feat: 完善课程引导与实战评价体验

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-09 09:53:06 +08:00
1 parent 6f8252472f
commit 1f8723aabd
122 files changed
+2595 -1325

No files matched your search

+4 -4
View File
@@ -3,13 +3,13 @@
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"useTabs": false, "useTabs": false,
"trailingComma": "none", "trailingComma": "all",
"printWidth": 100, "printWidth": 80,
"endOfLine": "lf", "endOfLine": "lf",
"arrowParens": "avoid", "arrowParens": "always",
"bracketSpacing": true, "bracketSpacing": true,
"bracketSameLine": false, "bracketSameLine": false,
"htmlWhitespaceSensitivity": "ignore", "htmlWhitespaceSensitivity": "ignore",
"vueIndentScriptAndStyle": false, "vueIndentScriptAndStyle": false,
"singleAttributePerLine": false "singleAttributePerLine": true
} }
-15
View File
@@ -1,15 +0,0 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"printWidth": 100,
"endOfLine": "lf",
"arrowParens": "avoid",
"bracketSpacing": true,
"bracketSameLine": false,
"htmlWhitespaceSensitivity": "ignore",
"vueIndentScriptAndStyle": false,
"singleAttributePerLine": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+46 -19
View File
@@ -1,14 +1,20 @@
<template> <template>
<teleport to="body"> <teleport to="body">
<div class="guide-steps-container" v-if="show"> <div
class="guide-steps-container"
v-if="show"
>
<SpotlightOverlay <SpotlightOverlay
:visible="Boolean(currentHighlightSelector)" :visible="show"
:target-selector="currentHighlightSelector" :target-selector="currentHighlightSelector"
:padding="currentHighlightPadding" :padding="currentHighlightPadding"
:border-radius="currentHighlightRadius" :border-radius="currentHighlightRadius"
:highlight-mode="currentHighlightMode" :highlight-mode="currentHighlightMode"
/> />
<div class="guide-align" :style="alignStyle"> <div
class="guide-align"
:style="alignStyle"
>
<GuideDragon <GuideDragon
:show-skip="currentStep < totalSteps" :show-skip="currentStep < totalSteps"
:current-step="currentStep" :current-step="currentStep"
@@ -44,9 +50,15 @@
@continue="handleNext" @continue="handleNext"
> >
<!-- 支持组件形式的 content --> <!-- 支持组件形式的 content -->
<component v-if="isCurrentStepComponent" :is="currentStepContentComponent" /> <component
v-if="isCurrentStepComponent"
:is="currentStepContentComponent"
/>
<!-- 支持字符串形式的 content --> <!-- 支持字符串形式的 content -->
<div v-else v-html="currentStepContent"></div> <div
v-else
v-html="currentStepContent"
></div>
</GuideStepsItem> </GuideStepsItem>
</template> </template>
</GuideDragon> </GuideDragon>
@@ -101,7 +113,7 @@ const props = withDefaults(defineProps<Props>(), {
highlightRadius: 16, highlightRadius: 16,
highlightMode: 'border', highlightMode: 'border',
positionOffsetY: '50%', positionOffsetY: '50%',
visible: true visible: true,
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
@@ -126,7 +138,7 @@ const alignStyle = reactive<{
top: '0px', top: '0px',
transform: 'scale(1)', transform: 'scale(1)',
width: '1920px', width: '1920px',
height: '1080px' height: '1080px',
}) })
const normalizeSelector = (selector?: string | null) => { const normalizeSelector = (selector?: string | null) => {
if (!selector) return null if (!selector) return null
@@ -157,21 +169,25 @@ const currentStepData = computed(() => {
return props.steps[currentStepIndex.value] || null return props.steps[currentStepIndex.value] || null
}) })
const currentStepConfig = computed(() => props.steps[currentStepIndex.value] || null) const currentStepConfig = computed(
() => props.steps[currentStepIndex.value] || null,
)
const currentHighlightSelector = computed(() => { const currentHighlightSelector = computed(() => {
return normalizeSelector(currentStepConfig.value?.highlightSelector ?? props.highlightSelector) return normalizeSelector(
currentStepConfig.value?.highlightSelector ?? props.highlightSelector,
)
}) })
const currentHighlightPadding = computed( const currentHighlightPadding = computed(
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding () => currentStepConfig.value?.highlightPadding ?? props.highlightPadding,
) )
const currentHighlightRadius = computed( const currentHighlightRadius = computed(
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius () => currentStepConfig.value?.highlightRadius ?? props.highlightRadius,
) )
const currentHighlightMode = computed( const currentHighlightMode = computed(
() => currentStepConfig.value?.highlightMode ?? props.highlightMode () => currentStepConfig.value?.highlightMode ?? props.highlightMode,
) )
const currentPositionOffsetY = computed( const currentPositionOffsetY = computed(
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY () => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY,
) )
const currentStepComponent = computed(() => { const currentStepComponent = computed(() => {
@@ -210,7 +226,9 @@ const currentStepContentComponent = computed(() => {
}) })
const handleNext = () => { const handleNext = () => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (maxSteps === 0) { if (maxSteps === 0) {
handleComplete() handleComplete()
return return
@@ -234,7 +252,9 @@ const handleComplete = () => {
} }
const goToStep = (index: number) => { const goToStep = (index: number) => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (maxSteps === 0) { if (maxSteps === 0) {
console.warn('GuideSteps goToStep: No steps available') console.warn('GuideSteps goToStep: No steps available')
return return
@@ -244,7 +264,12 @@ const goToStep = (index: number) => {
currentStepIndex.value = index currentStepIndex.value = index
emit('step-change', currentStepData.value, currentStepIndex.value) emit('step-change', currentStepData.value, currentStepIndex.value)
} else { } else {
console.warn('GuideSteps goToStep: Invalid step index', index, 'max:', maxSteps - 1) console.warn(
'GuideSteps goToStep: Invalid step index',
index,
'max:',
maxSteps - 1,
)
} }
} }
@@ -283,7 +308,9 @@ let autoPlayTimer: number | null = null
const startAutoPlay = () => { const startAutoPlay = () => {
if (props.autoPlayInterval > 0) { if (props.autoPlayInterval > 0) {
autoPlayTimer = window.setInterval(() => { autoPlayTimer = window.setInterval(() => {
const maxSteps = useSlotMode.value ? slotSteps.value.length : props.steps.length const maxSteps = useSlotMode.value
? slotSteps.value.length
: props.steps.length
if (currentStepIndex.value < maxSteps - 1) { if (currentStepIndex.value < maxSteps - 1) {
handleNext() handleNext()
} else { } else {
@@ -317,7 +344,7 @@ onMounted(() => {
if (transform && transform !== 'none') { if (transform && transform !== 'none') {
const match = transform.match(/matrix\(([^)]+)\)/) const match = transform.match(/matrix\(([^)]+)\)/)
if (match) { if (match) {
const parts = match[1].split(',').map(s => parseFloat(s.trim())) const parts = match[1].split(',').map((s) => parseFloat(s.trim()))
if (!Number.isNaN(parts[0])) scale = parts[0] if (!Number.isNaN(parts[0])) scale = parts[0]
} }
} }
@@ -343,7 +370,7 @@ defineExpose({
currentStep, currentStep,
totalSteps, totalSteps,
startAutoPlay, startAutoPlay,
stopAutoPlay stopAutoPlay,
}) })
</script> </script>
+28 -10
View File
@@ -1,10 +1,21 @@
<template> <template>
<div v-if="visible" class="spotlight-overlay"> <div
v-if="visible"
class="spotlight-overlay"
>
<div
v-if="!hasHighlightTarget"
class="spotlight-dim"
></div>
<!-- 渐变遮罩层 --> <!-- 渐变遮罩层 -->
<div v-if="props.highlightMode === 'gradient'" class="spotlight-mask" :style="maskStyle"></div> <div
v-else-if="props.highlightMode === 'gradient'"
class="spotlight-mask"
:style="maskStyle"
></div>
<!-- 画框模式:使用 box-shadow 遮罩 + 边框 --> <!-- 画框模式:使用 box-shadow 遮罩 + 边框 -->
<div <div
v-if="props.highlightMode === 'border' && rect.width > 0 && rect.height > 0" v-else-if="props.highlightMode === 'border'"
class="spotlight-hole" class="spotlight-hole"
:style="holeStyle" :style="holeStyle"
></div> ></div>
@@ -35,11 +46,14 @@ const props = withDefaults(defineProps<Props>(), {
padding: 8, padding: 8,
borderRadius: 16, borderRadius: 16,
gradientRange: 100, gradientRange: 100,
highlightMode: 'border' highlightMode: 'border',
}) })
const rect = reactive({ top: 0, left: 0, width: 0, height: 0 }) const rect = reactive({ top: 0, left: 0, width: 0, height: 0 })
const isElementReady = ref(false) const isElementReady = ref(false)
const hasHighlightTarget = computed(
() => isElementReady.value && rect.width > 0 && rect.height > 0,
)
// 重试相关变量 // 重试相关变量
let retryCount = 0 let retryCount = 0
@@ -148,7 +162,7 @@ const startRetry = (selector: string) => {
mutationObserver.observe(document.body, { mutationObserver.observe(document.body, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: false attributes: false,
}) })
// 同时使用 requestAnimationFrame 轮询检查(作为备用机制) // 同时使用 requestAnimationFrame 轮询检查(作为备用机制)
@@ -210,7 +224,7 @@ watch(
stopRetry() // 停止之前的重试 stopRetry() // 停止之前的重试
requestAnimationFrame(measure) requestAnimationFrame(measure)
}, },
{ immediate: true } { immediate: true },
) )
const onResize = () => measure() const onResize = () => measure()
@@ -233,7 +247,7 @@ const maskStyle = computed(() => {
if (!props.visible || rect.width === 0 || rect.height === 0) { if (!props.visible || rect.width === 0 || rect.height === 0) {
return { return {
maskImage: 'none', maskImage: 'none',
WebkitMaskImage: 'none' WebkitMaskImage: 'none',
} }
} }
@@ -281,7 +295,7 @@ const maskStyle = computed(() => {
return { return {
maskImage: gradient, maskImage: gradient,
WebkitMaskImage: gradient WebkitMaskImage: gradient,
} }
}) })
@@ -297,7 +311,7 @@ const holeStyle = computed(() => {
width: rect.width + 'px', width: rect.width + 'px',
height: rect.height + 'px', height: rect.height + 'px',
borderRadius: props.borderRadius + 'px', borderRadius: props.borderRadius + 'px',
boxShadow: `0 0 0 9999px rgba(0,0,0,0.6)` boxShadow: `0 0 0 9999px rgba(0,0,0,0.6)`,
} }
}) })
</script> </script>
@@ -310,16 +324,20 @@ const holeStyle = computed(() => {
z-index: 100000; z-index: 100000;
} }
.spotlight-dim,
.spotlight-mask { .spotlight-mask {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
pointer-events: none;
}
.spotlight-mask {
mask-image: radial-gradient(ellipse, transparent 0%, black 100%); mask-image: radial-gradient(ellipse, transparent 0%, black 100%);
-webkit-mask-image: radial-gradient(ellipse, transparent 0%, black 100%); -webkit-mask-image: radial-gradient(ellipse, transparent 0%, black 100%);
mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
pointer-events: none;
} }
.spotlight-hole { .spotlight-hole {
@@ -0,0 +1,135 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
interface Props {
visible: boolean
title: string
desc: string
images: string[]
compositionKey: '1' | '2' | '3'
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'close'): void }>()
const imageMap = {
'1': 'interview/composition/示意图-前景',
'2': 'interview/composition/示意图-留白',
'3': 'interview/composition/示意图-三角',
}
const getImage = computed(() => {
return getBackgroundImage(imageMap[props.compositionKey])
})
const getOverlayImage = (index: number) => {
const overlayMap: Record<string, string[]> = {
'1': [
'interview/composition/前景1-辅助线',
'interview/composition/前景2-辅助线',
],
'2': [
'interview/composition/留白1-辅助线',
'interview/composition/留白2-辅助线',
],
'3': [
'interview/composition/三角1-辅助线',
'interview/composition/三角2-辅助线',
],
}
const images = overlayMap[props.compositionKey] || []
return images[index] || ''
}
const showBoxLineMap = ref<Record<number, boolean>>({})
watch(
() => props.visible,
(v) => {
if (v) {
showBoxLineMap.value = {}
}
},
)
const handleToggle = (index: number) => {
showBoxLineMap.value[index] = !showBoxLineMap.value[index]
}
const show = computed(() => props.visible)
</script>
<template>
<Overlay
:visible="show"
:clickable="false"
>
<div
class="relative w-[1752px] h-[932px]"
:style="{ backgroundImage: getBackgroundImage('shooting/知识卡片底板') }"
>
<div
class="absolute top-4 left-1/2 -translate-x-1/2 text-[45px] text-white font-bold tracking-[2px] drop-shadow-[2px_2px_4px_rgba(0,0,139,0.8)]"
>
{{ props.title }}
</div>
<div
class="absolute w-full h-[38px] top-32 left-1/2 -translate-x-1/2 bg-no-repeat bg-center"
:style="{ backgroundImage: getBackgroundImage('shooting/文案1') }"
></div>
<div
class="absolute w-[191px] h-[131px] top-[30px] left-[120px] bg-no-repeat bg-center"
:style="{ backgroundImage: getImage }"
></div>
<div
class="absolute top-[calc(50%-30px)] -translate-y-1/2"
v-for="(image, index) in props.images"
:key="index"
:style="{ left: index === 0 ? '15%' : '55%' }"
>
<ImgButton
class="absolute right-6 top-6 z-10"
:image="
showBoxLineMap[index] ? 'shooting/辅助线开' : 'shooting/辅助线关'
"
@click="handleToggle(index)"
/>
<div class="relative">
<img
class="w-[560px]"
:src="getStaticImage(image)"
alt=""
/>
<div
v-if="showBoxLineMap[index]"
class="absolute inset-0 pointer-events-none w-[560px] bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: getBackgroundImage(getOverlayImage(index)),
}"
></div>
</div>
</div>
<div
class="absolute w-full px-20 bottom-32 left-1/2 -translate-x-1/2 text-[#395E66] text-3xl font-bold indent-14 leading-[50px]"
>
{{ props.desc }}
</div>
<ImgButton
class="absolute right-2 top-4 translate-x-1/2 -translate-y-1/2 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="emit('close')"
/>
</div>
</Overlay>
</template>
<style scoped></style>
@@ -0,0 +1,2 @@
export { default } from './index.vue'
export type { PracticeRatingItem } from './types'
@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating'
import { IMAGE_PATHS, getBackgroundImage } from '@/utils'
import type { PracticeRatingItem } from './types'
interface Props {
visible: boolean
items: PracticeRatingItem[]
backgroundImage: string
topTip?: string
bottomTip?: string
itemBackground?: string
saveButtonImage?: string
}
const props = withDefaults(defineProps<Props>(), {
itemBackground: 'vlog/practice/评分条',
saveButtonImage: 'vlog/practice/保存',
})
const emit = defineEmits<{
(e: 'close'): void
}>()
const hasTips = computed<boolean>(() =>
Boolean(props.topTip || props.bottomTip),
)
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
>
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage(backgroundImage) }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="emit('close')"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10">
<p
v-if="topTip"
class="glow-text"
>
{{ topTip }}
</p>
<div
v-for="(item, index) in items"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage(itemBackground) }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating
class="absolute -bottom-5 right-10"
:score="item.score"
size="lg"
/>
</div>
<p
v-if="bottomTip"
class="glow-text"
>
{{ bottomTip }}
</p>
<div
class="w-full flex justify-center"
:class="{ 'mt-16': !hasTips }"
>
<ImgButton
:image="saveButtonImage"
@click="emit('close')"
/>
</div>
</div>
</div>
</div>
</Overlay>
</template>
<style scoped>
.glow-text {
color: white;
font-size: 2rem;
font-weight: bold;
text-shadow:
-1px -1px 0 #0066ff,
1px -1px 0 #0066ff,
-1px 1px 0 #0066ff,
1px 1px 0 #0066ff,
-2px -2px 0 #0066ff,
2px -2px 0 #0066ff,
-2px 2px 0 #0066ff,
2px 2px 0 #0066ff,
0 0 10px #0066ff,
0 0 20px #0066ff,
0 0 30px #0066ff,
0 0 40px #0066ff,
0 0 50px #0066ff;
}
</style>
@@ -0,0 +1,5 @@
export interface PracticeRatingItem {
title: string
description: string
score: number
}
+12 -2
View File
@@ -1,4 +1,9 @@
import type { ComponentOptionsMixin, DefineComponent, PublicProps, SlotsType } from 'vue' import type {
ComponentOptionsMixin,
DefineComponent,
PublicProps,
SlotsType,
} from 'vue'
import VideoSfc from './Video.vue' import VideoSfc from './Video.vue'
type TimeUnit = number type TimeUnit = number
@@ -34,6 +39,7 @@ interface VideoProps {
playbackRate?: number playbackRate?: number
fit?: 'contain' | 'cover' | 'fill' fit?: 'contain' | 'cover' | 'fill'
anchors?: VideoAnchor[] anchors?: VideoAnchor[]
onEnded?: () => void
} }
interface VideoSlotProps { interface VideoSlotProps {
@@ -53,7 +59,11 @@ interface VideoSlotProps {
type VideoSlots = SlotsType<{ type VideoSlots = SlotsType<{
default?: () => any default?: () => any
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any anchor?: (props: {
anchor: VideoAnchor
resume: () => void
unmute: () => void
}) => any
overlay?: (props: VideoSlotProps) => any overlay?: (props: VideoSlotProps) => any
controls?: (props: VideoSlotProps) => any controls?: (props: VideoSlotProps) => any
'controls-left'?: (props: VideoSlotProps) => any 'controls-left'?: (props: VideoSlotProps) => any
+27 -7
View File
@@ -8,26 +8,46 @@ export type StepIndex = 1 | 2 | 3
* - 用于“停留≥阈值后自动计为已查看(viewed)”以及计算 completedStep。 * - 用于“停留≥阈值后自动计为已查看(viewed)”以及计算 completedStep。
* - 按需求:菜单中 disabled=true 的子项不计入完成条件,因此这里不包含 disabled 子项。 * - 按需求:菜单中 disabled=true 的子项不计入完成条件,因此这里不包含 disabled 子项。
*/ */
export const moduleStepMap: Record<ModuleName, Record<StepIndex, readonly string[]>> = { export const moduleStepMap: Record<
ModuleName,
Record<StepIndex, readonly string[]>
> = {
film: { film: {
1: ['FilmResource', 'FilmCreative', 'FilmScriptWriting', 'FilmStoryboard'], 1: ['FilmResource', 'FilmCreative', 'FilmScriptWriting', 'FilmStoryboard'],
2: ['FilmMidtermEquipment', 'FilmMidtermShooting', 'FilmOnsiteShooting'], 2: ['FilmMidtermEquipment', 'FilmMidtermShooting', 'FilmOnsiteShooting'],
3: ['FilmEditing', 'FilmColorPractice', 'FilmSoundEffect', 'FilmExportPublish'] 3: [
'FilmEditing',
'FilmColorPractice',
'FilmSoundEffect',
'FilmExportPublish',
],
}, },
vlog: { vlog: {
1: ['VlogResource', 'VlogCreative', 'VlogScriptWriting', 'VlogStoryboard'], 1: ['VlogResource', 'VlogCreative', 'VlogScriptWriting', 'VlogStoryboard'],
2: ['VlogMidtermEquipment', 'VlogMidtermShooting', 'VlogShooting'], 2: ['VlogMidtermEquipment', 'VlogMidtermShooting', 'VlogShooting'],
3: ['VlogEditing', 'VlogColor', 'VlogExportPublish'] 3: ['VlogEditing', 'VlogColor', 'VlogExportPublish'],
}, },
interview: { interview: {
1: ['InterviewResource', 'InterviewCreative', 'InterviewScriptWriting', 'InterviewStoryboard'], 1: [
'InterviewResource',
'InterviewCreative',
'InterviewScriptWriting',
'InterviewStoryboard',
],
2: ['InterviewEquipment', 'InterviewShooting', 'InterviewOnsiteShooting'], 2: ['InterviewEquipment', 'InterviewShooting', 'InterviewOnsiteShooting'],
3: ['InterviewEditing', 'InterviewColorPractice', 'InterviewAudio', 'InterviewExportPublish'] 3: [
} 'InterviewEditing',
'InterviewColor',
'InterviewAudio',
'InterviewExportPublish',
],
},
} as const } as const
export const allTrackedRouteNames = new Set<string>( export const allTrackedRouteNames = new Set<string>(
Object.values(moduleStepMap).flatMap(stepMap => Object.values(stepMap).flatMap(names => names)) Object.values(moduleStepMap).flatMap((stepMap) =>
Object.values(stepMap).flatMap((names) => names),
),
) )
export function getModuleNameByRouteName(routeName: string): ModuleName | null { export function getModuleNameByRouteName(routeName: string): ModuleName | null {
+72 -53
View File
@@ -8,8 +8,8 @@ const Interview: RouteRecordRaw[] = [
name: 'Interview', name: 'Interview',
component: () => import('@/views/interview/index.vue'), component: () => import('@/views/interview/index.vue'),
meta: { meta: {
title: '发现与记录' title: '发现与记录',
} },
}, },
// interview 发现与记录 // interview 发现与记录
{ {
@@ -17,16 +17,16 @@ const Interview: RouteRecordRaw[] = [
name: 'InterviewWork', name: 'InterviewWork',
component: () => import('@/views/interview/work/index.vue'), component: () => import('@/views/interview/work/index.vue'),
meta: { meta: {
title: '发现与记录' title: '发现与记录',
} },
}, },
{ {
path: '/interview/video', path: '/interview/video',
name: 'InterviewVideo', name: 'InterviewVideo',
component: () => import('@/views/interview/video/index.vue'), component: () => import('@/views/interview/video/index.vue'),
meta: { meta: {
title: '发现与记录-视频' title: '发现与记录-视频',
} },
}, },
//前期准备 //前期准备
{ {
@@ -34,75 +34,82 @@ const Interview: RouteRecordRaw[] = [
name: 'InterviewResource', name: 'InterviewResource',
component: () => import('@/views/interview/resource/index.vue'), component: () => import('@/views/interview/resource/index.vue'),
meta: { meta: {
title: '资源筹备' title: '资源筹备',
} },
}, },
{ {
path: '/interview/creative', path: '/interview/creative',
name: 'InterviewCreative', name: 'InterviewCreative',
component: () => import('@/views/interview/creative/index.vue'), component: () => import('@/views/interview/creative/index.vue'),
meta: { meta: {
title: '创意构想' title: '创意构想',
} },
}, },
{ {
path: '/interview/script-writing', path: '/interview/script-writing',
name: 'InterviewScriptWriting', name: 'InterviewScriptWriting',
component: () => import('@/views/interview/script-writing/index.vue'), component: () => import('@/views/interview/script-writing/index.vue'),
meta: { meta: {
title: '脚本撰写' title: '脚本撰写',
} },
}, },
{ {
path: '/interview/storyboard', path: '/interview/storyboard',
name: 'InterviewStoryboard', name: 'InterviewStoryboard',
component: () => import('@/views/interview/storyboard/index.vue'), component: () => import('@/views/interview/storyboard/index.vue'),
meta: { meta: {
title: '分镜制作' title: '分镜制作',
} },
}, },
//中期拍摄 //中期拍摄
{ {
path: '/interview/equipment', path: '/interview/equipment',
name: 'InterviewEquipment', name: 'InterviewEquipment',
component: () => import('@/views/interview/midterm-shooting/equipment/index.vue'), component: () =>
import('@/views/interview/midterm-shooting/equipment/index.vue'),
meta: { meta: {
title: '设备准备' title: '设备准备',
} },
}, },
{ {
path: '/interview/shooting', path: '/interview/shooting',
name: 'InterviewShooting', name: 'InterviewShooting',
component: () => import('@/views/interview/midterm-shooting/shooting/index.vue'), component: () =>
import('@/views/interview/midterm-shooting/shooting/index.vue'),
meta: { meta: {
title: '实地取景' title: '实地取景',
} },
}, },
{ {
path: '/interview/on-site-shooting', path: '/interview/on-site-shooting',
name: 'InterviewOnsiteShooting', name: 'InterviewOnsiteShooting',
component: () => import('@/views/interview/midterm-shooting/on-site/index.vue'), component: () =>
import('@/views/interview/midterm-shooting/on-site/index.vue'),
meta: { meta: {
title: '现场拍摄' title: '现场拍摄',
} },
}, },
{ {
path: '/interview/filmingLocation-a', path: '/interview/filmingLocation-a',
name: 'InterviewFilmingLocationA', name: 'InterviewFilmingLocationA',
component: () => component: () =>
import('@/views/interview/midterm-shooting/shooting/filmingLocation-a/index.vue'), import(
'@/views/interview/midterm-shooting/shooting/filmingLocation-a/index.vue'
),
meta: { meta: {
title: '实地取景-取景地A' title: '实地取景-取景地A',
} },
}, },
{ {
path: '/interview/filmingLocation-b', path: '/interview/filmingLocation-b',
name: 'InterviewFilmingLocationB', name: 'InterviewFilmingLocationB',
component: () => component: () =>
import('@/views/interview/midterm-shooting/shooting/filmingLocation-b/index.vue'), import(
'@/views/interview/midterm-shooting/shooting/filmingLocation-b/index.vue'
),
meta: { meta: {
title: '实地取景-取景地B' title: '实地取景-取景地B',
} },
}, },
// 后期剪辑 // 后期剪辑
{ {
@@ -110,40 +117,42 @@ const Interview: RouteRecordRaw[] = [
name: 'InterviewEditing', name: 'InterviewEditing',
component: () => import('@/views/interview/after-editing/editing.vue'), component: () => import('@/views/interview/after-editing/editing.vue'),
meta: { meta: {
title: '剪辑' title: '剪辑',
} },
},
{
path: '/interview/color-practice',
name: 'InterviewColorPractice',
component: () => import('@/views/interview/after-editing/color-practice.vue'),
meta: {
title: '后期剪辑-调色实践'
}
}, },
{ {
path: '/interview/color', path: '/interview/color',
name: 'InterviewColor', name: 'InterviewColor',
component: () => import('@/views/interview/after-editing/color.vue'), component: () => import('@/views/interview/after-editing/color.vue'),
meta: { meta: {
title: '调色' title: '调色',
} },
},
{
path: '/interview/color-practice',
name: 'InterviewColorPractice',
component: () =>
import('@/views/interview/after-editing/color-practice.vue'),
meta: {
title: '后期剪辑-调色实践',
},
}, },
{ {
path: '/interview/audio', path: '/interview/audio',
name: 'InterviewAudio', name: 'InterviewAudio',
component: () => import('@/views/interview/after-editing/audio/index.vue'), component: () => import('@/views/interview/after-editing/audio/index.vue'),
meta: { meta: {
title: '音频' title: '音频',
} },
}, },
{ {
path: '/interview/export-publish', path: '/interview/export-publish',
name: 'InterviewExportPublish', name: 'InterviewExportPublish',
component: () => import('@/views/interview/after-editing/export-publish/index.vue'), component: () =>
import('@/views/interview/after-editing/export-publish/index.vue'),
meta: { meta: {
title: '导出发布' title: '导出发布',
} },
}, },
// 实战操作台 // 实战操作台
{ {
@@ -151,8 +160,8 @@ const Interview: RouteRecordRaw[] = [
name: 'InterviewPractice', name: 'InterviewPractice',
component: () => import('@/views/interview/practice/index.vue'), component: () => import('@/views/interview/practice/index.vue'),
meta: { meta: {
title: '实战操作台' title: '实战操作台',
} },
}, },
// 我的创作笔记 // 我的创作笔记
{ {
@@ -160,8 +169,18 @@ const Interview: RouteRecordRaw[] = [
name: 'InterviewMyNotes', name: 'InterviewMyNotes',
component: () => import('@/views/interview/practice/my-notes/index.vue'), component: () => import('@/views/interview/practice/my-notes/index.vue'),
meta: { meta: {
title: '我的创作笔记' title: '我的创作笔记',
} },
},
// 教师点评
{
path: '/interview/practice/teacher-comment',
name: 'InterviewTeacherComment',
component: () =>
import('@/views/interview/practice/teacher-comment/index.vue'),
meta: {
title: '教师点评',
},
}, },
// 他人实战作品 // 他人实战作品
{ {
@@ -170,9 +189,9 @@ const Interview: RouteRecordRaw[] = [
props: true, props: true,
component: () => import('@/views/interview/practice/video/index.vue'), component: () => import('@/views/interview/practice/video/index.vue'),
meta: { meta: {
title: '他人实战作品' title: '他人实战作品',
} },
} },
] ]
export default Interview export default Interview
+60 -43
View File
@@ -8,8 +8,8 @@ const Vlog: RouteRecordRaw[] = [
name: 'Vlog', name: 'Vlog',
component: () => import('@/views/vlog/index.vue'), component: () => import('@/views/vlog/index.vue'),
meta: { meta: {
title: '发现与记录' title: '发现与记录',
} },
}, },
// vlog 任务取景框 // vlog 任务取景框
{ {
@@ -17,16 +17,16 @@ const Vlog: RouteRecordRaw[] = [
name: 'VlogWork', name: 'VlogWork',
component: () => import('@/views/vlog/work/index.vue'), component: () => import('@/views/vlog/work/index.vue'),
meta: { meta: {
title: '发现与记录' title: '发现与记录',
} },
}, },
{ {
path: '/vlog/video', path: '/vlog/video',
name: 'VlogVideo', name: 'VlogVideo',
component: () => import('@/views/vlog/video/index.vue'), component: () => import('@/views/vlog/video/index.vue'),
meta: { meta: {
title: '发现与记录-视频' title: '发现与记录-视频',
} },
}, },
//前期准备 //前期准备
{ {
@@ -34,57 +34,58 @@ const Vlog: RouteRecordRaw[] = [
name: 'VlogResource', name: 'VlogResource',
component: () => import('@/views/vlog/resource/index.vue'), component: () => import('@/views/vlog/resource/index.vue'),
meta: { meta: {
title: '资源筹备' title: '资源筹备',
} },
}, },
{ {
path: '/vlog/creative', path: '/vlog/creative',
name: 'VlogCreative', name: 'VlogCreative',
component: () => import('@/views/vlog/creative/index.vue'), component: () => import('@/views/vlog/creative/index.vue'),
meta: { meta: {
title: '创意构想' title: '创意构想',
} },
}, },
{ {
path: '/vlog/script-writing', path: '/vlog/script-writing',
name: 'VlogScriptWriting', name: 'VlogScriptWriting',
component: () => import('@/views/vlog/script-writing/index.vue'), component: () => import('@/views/vlog/script-writing/index.vue'),
meta: { meta: {
title: '脚本撰写' title: '脚本撰写',
} },
}, },
{ {
path: '/vlog/storyboard', path: '/vlog/storyboard',
name: 'VlogStoryboard', name: 'VlogStoryboard',
component: () => import('@/views/vlog/storyboard/index.vue'), component: () => import('@/views/vlog/storyboard/index.vue'),
meta: { meta: {
title: '分镜制作' title: '分镜制作',
} },
}, },
//中期拍摄 //中期拍摄
{ {
path: '/vlog/equipment', path: '/vlog/equipment',
name: 'VlogMidtermEquipment', name: 'VlogMidtermEquipment',
component: () => import('@/views/vlog/midterm-shooting/equipment/index.vue'), component: () =>
import('@/views/vlog/midterm-shooting/equipment/index.vue'),
meta: { meta: {
title: '设备准备' title: '设备准备',
} },
}, },
{ {
path: '/vlog/shooting', path: '/vlog/shooting',
name: 'VlogMidtermShooting', name: 'VlogMidtermShooting',
component: () => import('@/views/vlog/midterm-shooting/shooting/index.vue'), component: () => import('@/views/vlog/midterm-shooting/shooting/index.vue'),
meta: { meta: {
title: '实地取景' title: '实地取景',
} },
}, },
{ {
path: '/vlog/on-site-shooting', path: '/vlog/on-site-shooting',
name: 'VlogShooting', name: 'VlogShooting',
component: () => import('@/views/vlog/midterm-shooting/on-site/index.vue'), component: () => import('@/views/vlog/midterm-shooting/on-site/index.vue'),
meta: { meta: {
title: '现场拍摄' title: '现场拍摄',
} },
}, },
// { // {
// path: '/vlog/on-site-shooting/time-lapse', // path: '/vlog/on-site-shooting/time-lapse',
@@ -137,18 +138,24 @@ const Vlog: RouteRecordRaw[] = [
{ {
path: '/vlog/filmingLocation-a', path: '/vlog/filmingLocation-a',
name: 'VlogFilmingLocationA', name: 'VlogFilmingLocationA',
component: () => import('@/views/vlog/midterm-shooting/shooting/filmingLocation-a/index.vue'), component: () =>
import(
'@/views/vlog/midterm-shooting/shooting/filmingLocation-a/index.vue'
),
meta: { meta: {
title: '实地取景-取景地A' title: '实地取景-取景地A',
} },
}, },
{ {
path: '/vlog/filmingLocation-b', path: '/vlog/filmingLocation-b',
name: 'VlogFilmingLocationB', name: 'VlogFilmingLocationB',
component: () => import('@/views/vlog/midterm-shooting/shooting/filmingLocation-b/index.vue'), component: () =>
import(
'@/views/vlog/midterm-shooting/shooting/filmingLocation-b/index.vue'
),
meta: { meta: {
title: '实地取景-取景地B' title: '实地取景-取景地B',
} },
}, },
// 后期剪辑 // 后期剪辑
{ {
@@ -156,32 +163,33 @@ const Vlog: RouteRecordRaw[] = [
name: 'VlogEditing', name: 'VlogEditing',
component: () => import('@/views/vlog/after-editing/editing.vue'), component: () => import('@/views/vlog/after-editing/editing.vue'),
meta: { meta: {
title: '后期剪辑-剪辑' title: '后期剪辑-剪辑',
} },
}, },
{ {
path: '/vlog/color', path: '/vlog/color',
name: 'VlogColor', name: 'VlogColor',
component: () => import('@/views/vlog/after-editing/color.vue'), component: () => import('@/views/vlog/after-editing/color.vue'),
meta: { meta: {
title: '后期剪辑-调色' title: '后期剪辑-调色',
} },
}, },
{ {
path: '/vlog/color-practice', path: '/vlog/color-practice',
name: 'VlogColorPractice', name: 'VlogColorPractice',
component: () => import('@/views/vlog/after-editing/color-practice.vue'), component: () => import('@/views/vlog/after-editing/color-practice.vue'),
meta: { meta: {
title: '后期剪辑-调色实践' title: '后期剪辑-调色实践',
} },
}, },
{ {
path: '/vlog/export-publish', path: '/vlog/export-publish',
name: 'VlogExportPublish', name: 'VlogExportPublish',
component: () => import('@/views/vlog/after-editing/export-publish/index.vue'), component: () =>
import('@/views/vlog/after-editing/export-publish/index.vue'),
meta: { meta: {
title: '后期剪辑-导出发布' title: '后期剪辑-导出发布',
} },
}, },
// 实战操作台 // 实战操作台
{ {
@@ -189,8 +197,8 @@ const Vlog: RouteRecordRaw[] = [
name: 'VlogPractice', name: 'VlogPractice',
component: () => import('@/views/vlog/practice/index.vue'), component: () => import('@/views/vlog/practice/index.vue'),
meta: { meta: {
title: '实战操作台' title: '实战操作台',
} },
}, },
// 我的创作笔记 // 我的创作笔记
{ {
@@ -198,8 +206,17 @@ const Vlog: RouteRecordRaw[] = [
name: 'VlogMyNotes', name: 'VlogMyNotes',
component: () => import('@/views/vlog/practice/my-notes/index.vue'), component: () => import('@/views/vlog/practice/my-notes/index.vue'),
meta: { meta: {
title: '我的创作笔记' title: '我的创作笔记',
} },
},
// 教师点评
{
path: '/vlog/practice/teacher-comment',
name: 'VlogTeacherComment',
component: () => import('@/views/vlog/practice/teacher-comment/index.vue'),
meta: {
title: '教师点评',
},
}, },
// 他人实战作品 // 他人实战作品
{ {
@@ -208,9 +225,9 @@ const Vlog: RouteRecordRaw[] = [
props: true, props: true,
component: () => import('@/views/vlog/practice/video/index.vue'), component: () => import('@/views/vlog/practice/video/index.vue'),
meta: { meta: {
title: '他人实战作品' title: '他人实战作品',
} },
} },
] ]
export default Vlog export default Vlog
+30 -13
View File
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { moduleStepMap } from '@/config/moduleStepMap' import { moduleStepMap } from '@/config/moduleStepMap'
export const FILM_LESSON_ID = 'film_rwqjk' export const FILM_LESSON_ID = 'dianying_rwqjk'
export const FILM_KEY = 4 export const FILM_KEY = 4
interface FilmSavePayload { interface FilmSavePayload {
@@ -17,6 +17,7 @@ interface FilmSavePayload {
editingVideosWatched?: Record<string, boolean> editingVideosWatched?: Record<string, boolean>
isGuideFilmEditingComplete?: boolean isGuideFilmEditingComplete?: boolean
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number
} }
export const FILM_EDITING_VIDEO_KEYS = [ export const FILM_EDITING_VIDEO_KEYS = [
@@ -25,7 +26,7 @@ export const FILM_EDITING_VIDEO_KEYS = [
'soundTransition', 'soundTransition',
'cut', 'cut',
'association', 'association',
'continuity' 'continuity',
] as const ] as const
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -70,6 +71,7 @@ export const useFilmStore = defineStore(
//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)
//Actions //Actions
function saveFilmStore(): void { function saveFilmStore(): void {
@@ -84,12 +86,15 @@ export const useFilmStore = defineStore(
filmMenuVisible: filmMenuVisible.value, filmMenuVisible: filmMenuVisible.value,
editingVideosWatched: editingVideosWatched.value, editingVideosWatched: editingVideosWatched.value,
isGuideFilmEditingComplete: isGuideFilmEditingComplete.value, isGuideFilmEditingComplete: isGuideFilmEditingComplete.value,
guideShown: guideShown.value guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value,
} }
getYibanApi() getYibanApi()
.then(api => api?.setUserDefine(FILM_LESSON_ID, FILM_KEY, JSON.stringify(payload))) .then((api) =>
.catch(error => console.error('Failed to save Film store', error)) api?.setUserDefine(FILM_LESSON_ID, FILM_KEY, JSON.stringify(payload)),
)
.catch((error) => console.error('Failed to save Film store', error))
} }
async function loadFilmStore(): Promise<void> { async function loadFilmStore(): Promise<void> {
@@ -110,8 +115,10 @@ 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 ?? {}
isGuideFilmEditingComplete.value = data.isGuideFilmEditingComplete ?? true isGuideFilmEditingComplete.value =
data.isGuideFilmEditingComplete ?? true
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0
} catch (error) { } catch (error) {
console.error('Failed to load Film store', error) console.error('Failed to load Film store', error)
} }
@@ -122,7 +129,7 @@ export const useFilmStore = defineStore(
viewed.value[key] = true viewed.value[key] = true
FilmUnlockProgress.value = Math.max( FilmUnlockProgress.value = Math.max(
FilmUnlockProgress.value, FilmUnlockProgress.value,
Math.min(completedStep.value + 1, 4) Math.min(completedStep.value + 1, 4),
) )
saveFilmStore() saveFilmStore()
} }
@@ -181,8 +188,15 @@ export const useFilmStore = defineStore(
saveFilmStore() saveFilmStore()
} }
function setPracticeScrollPage(value: number): void {
practiceScrollPage.value = value
saveFilmStore()
}
const allEditingVideosWatched = computed<boolean>(() => const allEditingVideosWatched = computed<boolean>(() =>
FILM_EDITING_VIDEO_KEYS.every(key => editingVideosWatched.value[key] === true) FILM_EDITING_VIDEO_KEYS.every(
(key) => editingVideosWatched.value[key] === true,
),
) )
// 基于“子页面是否已查看”的大步骤完成度(1..3) // 基于“子页面是否已查看”的大步骤完成度(1..3)
@@ -191,7 +205,7 @@ export const useFilmStore = defineStore(
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) => viewed.value[name] === true)
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
@@ -203,7 +217,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) => viewed.value[name]).length
return Math.round((viewedCount / allRoutes.length) * 100) return Math.round((viewedCount / allRoutes.length) * 100)
}) })
@@ -220,6 +234,7 @@ export const useFilmStore = defineStore(
editingVideosWatched.value = {} editingVideosWatched.value = {}
isGuideFilmEditingComplete.value = true isGuideFilmEditingComplete.value = true
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0
saveFilmStore() saveFilmStore()
} }
@@ -236,6 +251,7 @@ export const useFilmStore = defineStore(
markEditingVideoWatched, markEditingVideoWatched,
setIsGuideFilmEditingComplete, setIsGuideFilmEditingComplete,
markGuideShown, markGuideShown,
setPracticeScrollPage,
saveFilmStore, saveFilmStore,
loadFilmStore, loadFilmStore,
resetFilmState, resetFilmState,
@@ -253,10 +269,11 @@ export const useFilmStore = defineStore(
editingVideosWatched, editingVideosWatched,
isGuideFilmEditingComplete, isGuideFilmEditingComplete,
guideShown, guideShown,
allEditingVideosWatched practiceScrollPage,
allEditingVideosWatched,
} }
}, },
{ {
persist: true persist: true,
} },
) )
+28 -10
View File
@@ -15,6 +15,7 @@ interface InterviewSavePayload {
InterviewUnlockProgress?: number InterviewUnlockProgress?: number
interviewMenuVisible?: boolean interviewMenuVisible?: boolean
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number
} }
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -55,6 +56,7 @@ export const useInterviewStore = defineStore(
//Interview-菜单 Overlay 是否可见 //Interview-菜单 Overlay 是否可见
const interviewMenuVisible = ref<boolean>(false) const interviewMenuVisible = ref<boolean>(false)
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
const practiceScrollPage = ref<number>(0)
//Actions //Actions
function saveInterviewStore(): void { function saveInterviewStore(): void {
@@ -67,14 +69,21 @@ export const useInterviewStore = defineStore(
isInterviewUnlock: isInterviewUnlock.value, isInterviewUnlock: isInterviewUnlock.value,
InterviewUnlockProgress: InterviewUnlockProgress.value, InterviewUnlockProgress: InterviewUnlockProgress.value,
interviewMenuVisible: interviewMenuVisible.value, interviewMenuVisible: interviewMenuVisible.value,
guideShown: guideShown.value guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value,
} }
getYibanApi() getYibanApi()
.then(api => .then((api) =>
api?.setUserDefine(INTERVIEW_LESSON_ID, INTERVIEW_KEY, JSON.stringify(payload)) api?.setUserDefine(
INTERVIEW_LESSON_ID,
INTERVIEW_KEY,
JSON.stringify(payload),
),
)
.catch((error) =>
console.error('Failed to save Interview store', error),
) )
.catch(error => console.error('Failed to save Interview store', error))
} }
async function loadInterviewStore(): Promise<void> { async function loadInterviewStore(): Promise<void> {
@@ -95,6 +104,7 @@ export const useInterviewStore = defineStore(
InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0 InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0
interviewMenuVisible.value = data.interviewMenuVisible ?? false interviewMenuVisible.value = data.interviewMenuVisible ?? false
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0
} catch (error) { } catch (error) {
console.error('Failed to load Interview store', error) console.error('Failed to load Interview store', error)
} }
@@ -105,7 +115,7 @@ export const useInterviewStore = defineStore(
viewed.value[key] = true viewed.value[key] = true
InterviewUnlockProgress.value = Math.max( InterviewUnlockProgress.value = Math.max(
InterviewUnlockProgress.value, InterviewUnlockProgress.value,
Math.min(completedStep.value + 1, 4) Math.min(completedStep.value + 1, 4),
) )
saveInterviewStore() saveInterviewStore()
} }
@@ -116,7 +126,7 @@ export const useInterviewStore = defineStore(
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) => viewed.value[name] === true)
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
@@ -127,7 +137,7 @@ export const useInterviewStore = 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) => viewed.value[name]).length
return Math.round((viewedCount / allRoutes.length) * 100) return Math.round((viewedCount / allRoutes.length) * 100)
}) })
@@ -173,6 +183,11 @@ export const useInterviewStore = defineStore(
saveInterviewStore() saveInterviewStore()
} }
function setPracticeScrollPage(value: number): void {
practiceScrollPage.value = value
saveInterviewStore()
}
function resetInterviewState(): void { function resetInterviewState(): void {
viewed.value = {} viewed.value = {}
isGuideInterviewTask.value = true isGuideInterviewTask.value = true
@@ -183,6 +198,7 @@ export const useInterviewStore = defineStore(
InterviewUnlockProgress.value = 0 InterviewUnlockProgress.value = 0
interviewMenuVisible.value = false interviewMenuVisible.value = false
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0
saveInterviewStore() saveInterviewStore()
} }
@@ -197,6 +213,7 @@ export const useInterviewStore = defineStore(
setInterviewUnlockProgress, setInterviewUnlockProgress,
setInterviewMenuVisible, setInterviewMenuVisible,
markGuideShown, markGuideShown,
setPracticeScrollPage,
resetInterviewState, resetInterviewState,
loadInterviewStore, loadInterviewStore,
saveInterviewStore, saveInterviewStore,
@@ -211,10 +228,11 @@ export const useInterviewStore = defineStore(
isInterviewUnlock, isInterviewUnlock,
InterviewUnlockProgress, InterviewUnlockProgress,
interviewMenuVisible, interviewMenuVisible,
guideShown guideShown,
practiceScrollPage,
} }
}, },
{ {
persist: true persist: true,
} },
) )
+23 -9
View File
@@ -16,6 +16,7 @@ interface VlogSavePayload {
vlogUnlockProgress?: number vlogUnlockProgress?: number
vlogMenuVisible?: boolean vlogMenuVisible?: boolean
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number
} }
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -60,6 +61,7 @@ export const useVlogStore = defineStore(
//vlog-发现与记录解锁进度 //vlog-发现与记录解锁进度
const vlogUnlockProgress = ref<number>(0) const vlogUnlockProgress = ref<number>(0)
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
const practiceScrollPage = ref<number>(0)
//Actions //Actions
function saveVlogStore(): void { function saveVlogStore(): void {
@@ -73,12 +75,15 @@ export const useVlogStore = defineStore(
isVlogUnlock: isVlogUnlock.value, isVlogUnlock: isVlogUnlock.value,
vlogUnlockProgress: vlogUnlockProgress.value, vlogUnlockProgress: vlogUnlockProgress.value,
vlogMenuVisible: vlogMenuVisible.value, vlogMenuVisible: vlogMenuVisible.value,
guideShown: guideShown.value guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value,
} }
getYibanApi() getYibanApi()
.then(api => api?.setUserDefine(VLOG_LESSON_ID, VLOG_KEY, JSON.stringify(payload))) .then((api) =>
.catch(error => console.error('Failed to save Vlog store', error)) api?.setUserDefine(VLOG_LESSON_ID, VLOG_KEY, JSON.stringify(payload)),
)
.catch((error) => console.error('Failed to save Vlog store', error))
} }
async function loadVlogStore(): Promise<void> { async function loadVlogStore(): Promise<void> {
@@ -100,6 +105,7 @@ export const useVlogStore = defineStore(
vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0 vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0
vlogMenuVisible.value = data.vlogMenuVisible ?? false vlogMenuVisible.value = data.vlogMenuVisible ?? false
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0
} catch (error) { } catch (error) {
console.error('Failed to load Vlog store', error) console.error('Failed to load Vlog store', error)
} }
@@ -110,7 +116,7 @@ export const useVlogStore = defineStore(
viewed.value[key] = true viewed.value[key] = true
vlogUnlockProgress.value = Math.max( vlogUnlockProgress.value = Math.max(
vlogUnlockProgress.value, vlogUnlockProgress.value,
Math.min(completedStep.value + 1, 4) Math.min(completedStep.value + 1, 4),
) )
saveVlogStore() saveVlogStore()
} }
@@ -162,13 +168,18 @@ export const useVlogStore = defineStore(
saveVlogStore() saveVlogStore()
} }
function setPracticeScrollPage(value: number): void {
practiceScrollPage.value = value
saveVlogStore()
}
// 基于“子页面是否已查看”的大步骤完成度(1..3) // 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表) // disabled 子项不计入(在这里直接不放进列表)
const stepRouteNames = moduleStepMap.vlog const stepRouteNames = moduleStepMap.vlog
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) => viewed.value[name] === true)
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
@@ -180,7 +191,7 @@ export const useVlogStore = 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) => viewed.value[name]).length
return Math.round((viewedCount / allRoutes.length) * 100) return Math.round((viewedCount / allRoutes.length) * 100)
}) })
@@ -195,6 +206,7 @@ export const useVlogStore = defineStore(
isVlogUnlock.value = false isVlogUnlock.value = false
vlogMenuVisible.value = false vlogMenuVisible.value = false
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0
saveVlogStore() saveVlogStore()
} }
@@ -213,6 +225,7 @@ export const useVlogStore = defineStore(
resetVlogState, resetVlogState,
setVlogUnlockProgress, setVlogUnlockProgress,
markGuideShown, markGuideShown,
setPracticeScrollPage,
//Actions //Actions
completedStep, completedStep,
progressPercent, progressPercent,
@@ -225,10 +238,11 @@ export const useVlogStore = defineStore(
isVlogUnlock, isVlogUnlock,
vlogUnlockProgress, vlogUnlockProgress,
vlogMenuVisible, vlogMenuVisible,
guideShown guideShown,
practiceScrollPage,
} }
}, },
{ {
persist: true persist: true,
} },
) )
@@ -2,8 +2,12 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components' import StepNavigation from '@/components/Guide/StepNavigation.vue'
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 { useBasicElementsStore } from '@/stores/modules/basic-elements' import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -27,44 +31,48 @@ const rawSteps = [
id: 'step2', id: 'step2',
label: '摄影基础', label: '摄影基础',
content: '手机摄像头介绍', content: '手机摄像头介绍',
videoUrl: getPublicPath('video/new/shooting-skills/xinshou_shoujishexiangtoujieshao.mp4'), videoUrl: getPublicPath(
type: 'post' as const 'video/new/shooting-skills/xinshou_shoujishexiangtoujieshao.mp4',
),
type: 'post' as const,
}, },
{ {
id: 'step3', id: 'step3',
label: '摄影基础', label: '摄影基础',
content: '景别概念介绍', content: '景别概念介绍',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_jbgnjs_batch.mp4'), videoUrl: getPublicPath('video/new/shooting-skills/xsdh_jbgnjs_batch.mp4'),
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step4', id: 'step4',
label: '摄影基础', label: '摄影基础',
content: '手机慢动作', content: '手机慢动作',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sjmdz_batch.mp4'), videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sjmdz_batch.mp4'),
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step5', id: 'step5',
label: '摄影基础', label: '摄影基础',
content: '延时摄影', content: '延时摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_yssy_batch.mp4'), videoUrl: getPublicPath('video/new/shooting-skills/xsdh_yssy_batch.mp4'),
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step6', id: 'step6',
label: '摄影基础', label: '摄影基础',
content: '神奇的微距摄影', content: '神奇的微距摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sqdwjsy_batch.mp4'), videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sqdwjsy_batch.mp4'),
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step7', id: 'step7',
label: '摄影基础', label: '摄影基础',
content: '独具魅力的夜景摄影', content: '独具魅力的夜景摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_djmldyjsy_batch.mp4'), videoUrl: getPublicPath(
type: 'post' as const 'video/new/shooting-skills/xsdh_djmldyjsy_batch.mp4',
} ),
type: 'post' as const,
},
] ]
const navigationSteps = ref(rawSteps) const navigationSteps = ref(rawSteps)
@@ -77,7 +85,9 @@ const currentNavigationStep = ref(0) // 对应视频索引,从0开始
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
//按顺序播放 //按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
// 题目数据:景别概念介绍(index 1)、手机慢动作(index 2)、独具魅力的夜景摄影(index 5) // 题目数据:景别概念介绍(index 1)、手机慢动作(index 2)、独具魅力的夜景摄影(index 5)
const questions = ref<Question[]>([]) const questions = ref<Question[]>([])
@@ -90,24 +100,25 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 1, id: 1,
title: '', title: '',
description: '(单选)如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?', description:
'(单选)如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?',
options: [ options: [
{ {
id: 'a', id: 'a',
label: '面部特写视频' label: '面部特写视频',
}, },
{ {
id: 'b', id: 'b',
label: '中景人物视频' label: '中景人物视频',
}, },
{ {
id: 'c', id: 'c',
label: '全景环境视频' label: '全景环境视频',
} },
], ],
correctOptionId: 'c', correctOptionId: 'c',
explanation: '全景更适合交代故事发生在哪里。' explanation: '全景更适合交代故事发生在哪里。',
} },
] ]
} }
@@ -117,30 +128,32 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 2, id: 2,
title: '', title: '',
description: '(多选题)下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?', description:
'(多选题)下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?',
options: [ options: [
{ {
id: 'a', id: 'a',
label: '城市街道上车水马龙,车辆快速穿行' label: '城市街道上车水马龙,车辆快速穿行',
}, },
{ {
id: 'b', id: 'b',
label: '甩头发、转身、跳跃等人物动作瞬间' label: '甩头发、转身、跳跃等人物动作瞬间',
}, },
{ {
id: 'c', id: 'c',
label: '水花飞溅、雨滴落地、浪花拍岸' label: '水花飞溅、雨滴落地、浪花拍岸',
}, },
{ {
id: 'd', id: 'd',
label: '风吹动衣摆、窗帘或树叶缓慢摆动' label: '风吹动衣摆、窗帘或树叶缓慢摆动',
} },
], ],
isMultiple: true, isMultiple: true,
correctOptionIds: ['b', 'c', 'd'], correctOptionIds: ['b', 'c', 'd'],
correctOptionId: '', // 多选题时此字段不使用 correctOptionId: '', // 多选题时此字段不使用
explanation: '更适合用慢动作表现的是 B、C、D,它们都包含了"瞬间细节"和"节奏变化"。' explanation:
} '更适合用慢动作表现的是 B、C、D,它们都包含了"瞬间细节"和"节奏变化"。',
},
] ]
} }
@@ -154,20 +167,20 @@ const getQuestionsForVideo = (index: number): Question[] => {
options: [ options: [
{ {
id: 'a', id: 'a',
label: '哪里好看就拍哪里' label: '哪里好看就拍哪里',
}, },
{ {
id: 'b', id: 'b',
label: '设备越贵,画面越好' label: '设备越贵,画面越好',
}, },
{ {
id: 'c', id: 'c',
label: '在合适的时间,用合适的方式拍' label: '在合适的时间,用合适的方式拍',
} },
], ],
correctOptionId: 'c', correctOptionId: 'c',
explanation: '拍摄要学会判断时间、光线和方式。' explanation: '拍摄要学会判断时间、光线和方式。',
} },
] ]
} }
@@ -252,7 +265,12 @@ const handleQuestionsComplete = () => {
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -2,7 +2,7 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替 // 运镜初识 - 暂时用运镜的基本方式代替
@@ -55,16 +55,20 @@ const navigationSteps = ref([
id: 'step2', id: 'step2',
label: '视觉素养', label: '视觉素养',
content: '正确的手机拍摄持机姿势', content: '正确的手机拍摄持机姿势',
videoUrl: getPublicPath('video/new/movement/xinshou_zhengquedeshoujipaishe.mp4'), videoUrl: getPublicPath(
type: 'planning' as const 'video/new/movement/xinshou_zhengquedeshoujipaishe.mp4',
),
type: 'planning' as const,
}, },
{ {
id: 'step3', id: 'step3',
label: '视觉素养', label: '视觉素养',
content: '运镜的基本方式', content: '运镜的基本方式',
videoUrl: getPublicPath('video/new/movement/xinshou_yunjingdejibenfangshi.mp4'), videoUrl: getPublicPath(
type: 'planning' as const 'video/new/movement/xinshou_yunjingdejibenfangshi.mp4',
} ),
type: 'planning' as const,
},
]) ])
// 步骤导航相关状态 // 步骤导航相关状态
@@ -75,7 +79,9 @@ const currentNavigationStep = ref(0) // 对应视频索引,从0开始
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
//按顺序播放 //按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const handleVideoChange = () => { const handleVideoChange = () => {
if (currentVideoIndex.value < navigationSteps.value.length - 1) { if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++ currentVideoIndex.value++
@@ -137,7 +143,12 @@ const handleStepNavigationClose = () => {
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
+19 -7
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide' import StepNavigation from '@/components/Guide/StepNavigation.vue'
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'
@@ -32,15 +32,15 @@ const navigationSteps = ref([
label: '视觉素养', label: '视觉素养',
content: '如何选择构图', content: '如何选择构图',
videoUrl: getPublicPath('video/new/composition/xsdh_rhxzgt_batch.mp4'), videoUrl: getPublicPath('video/new/composition/xsdh_rhxzgt_batch.mp4'),
type: 'planning' as const type: 'planning' as const,
}, },
{ {
id: 'step2', id: 'step2',
label: '视觉素养', label: '视觉素养',
content: '九种经典构图方式', content: '九种经典构图方式',
videoUrl: getPublicPath('video/new/composition/xsdh_jzgtf_batch.mp4'), videoUrl: getPublicPath('video/new/composition/xsdh_jzgtf_batch.mp4'),
type: 'planning' as const type: 'planning' as const,
} },
]) ])
// 步骤导航相关状态 // 步骤导航相关状态
@@ -51,7 +51,9 @@ const currentNavigationStep = ref(0) // 对应视频索引,从0开始
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
//按顺序播放 //按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const handleVideoChange = () => { const handleVideoChange = () => {
if (currentVideoIndex.value < navigationSteps.value.length - 1) { if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++ currentVideoIndex.value++
@@ -115,7 +117,12 @@ onMounted(() => {})
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -180,7 +187,12 @@ onMounted(() => {})
class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200" class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
+32 -17
View File
@@ -2,8 +2,11 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { Video, QuestionQuiz, type Question } 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 { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue' import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue'
@@ -53,22 +56,22 @@ const navigationSteps = ref([
label: '视觉素养', label: '视觉素养',
content: '光的基础认知', content: '光的基础认知',
videoUrl: getPublicPath('video/new/light/xinshou_guangdejichurenzhi.mp4'), videoUrl: getPublicPath('video/new/light/xinshou_guangdejichurenzhi.mp4'),
type: 'planning' as const type: 'planning' as const,
}, },
{ {
id: 'step3', id: 'step3',
label: '视觉素养', label: '视觉素养',
content: '光的四大属性', content: '光的四大属性',
videoUrl: getPublicPath('video/new/light/xinshou_guangdesidashuxing.mp4'), videoUrl: getPublicPath('video/new/light/xinshou_guangdesidashuxing.mp4'),
type: 'planning' as const type: 'planning' as const,
}, },
{ {
id: 'step4', id: 'step4',
label: '视觉素养', label: '视觉素养',
content: '拍摄的黄金时间', content: '拍摄的黄金时间',
videoUrl: getPublicPath('video/new/light/xsdh_psdhjsj_batch.mp4'), videoUrl: getPublicPath('video/new/light/xsdh_psdhjsj_batch.mp4'),
type: 'planning' as const type: 'planning' as const,
} },
]) ])
// 步骤导航相关状态 // 步骤导航相关状态
@@ -79,7 +82,9 @@ const currentNavigationStep = ref(0) // 对应视频索引,从0开始
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
//按顺序播放 //按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
// 题目数据:拍摄的黄金时间(index 2) // 题目数据:拍摄的黄金时间(index 2)
const questions = ref<Question[]>([]) const questions = ref<Question[]>([])
@@ -92,24 +97,25 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 1, id: 1,
title: '', title: '',
description: '(单选)同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?', description:
'(单选)同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?',
options: [ options: [
{ {
id: 'a', id: 'a',
label: '正午直射光,阴影很硬,路面反光明显' label: '正午直射光,阴影很硬,路面反光明显',
}, },
{ {
id: 'b', id: 'b',
label: '傍晚低角度阳光,墙面被染成金色' label: '傍晚低角度阳光,墙面被染成金色',
}, },
{ {
id: 'c', id: 'c',
label: '夜晚路灯照明,光源复杂、色温混杂' label: '夜晚路灯照明,光源复杂、色温混杂',
} },
], ],
correctOptionId: 'b', correctOptionId: 'b',
explanation: '光线角度越低,画面柔和,情绪放大' explanation: '光线角度越低,画面柔和,情绪放大',
} },
] ]
} }
return [] return []
@@ -195,7 +201,12 @@ const handleQuestionsComplete = () => {
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -243,9 +254,13 @@ const handleQuestionsComplete = () => {
<!-- 引导与高亮 --> <!-- 引导与高亮 -->
<GuideSteps v-if="isGuide" position-offset-y="85%" @complete="handleComplete"> <GuideSteps
v-if="isGuide"
position-offset-y="85%"
@complete="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_009.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_1_009.MP3')">
光是摄影的灵魂,摄影高手一定是用光的高手 光是摄影的灵魂,摄影高手一定是用光的高手。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_010.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_1_010.MP3')">
现在, 让我们一起去认识光、读懂光。 现在, 让我们一起去认识光、读懂光。
@@ -2,7 +2,8 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue'
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'
@@ -26,72 +27,86 @@ const rawSteps = [
id: 'step2', id: 'step2',
label: '剪映基础操作', label: '剪映基础操作',
content: '初识剪映专业版界面和功能', content: '初识剪映专业版界面和功能',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_csjyzybjmhgn_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_csjyzybjmhgn_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step3', id: 'step3',
label: '剪映基础操作', label: '剪映基础操作',
content: '时间线的用法详解', content: '时间线的用法详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_sjxdyfxj_batch.mp4'), videoUrl: getPublicPath('video/new/editing-skills/xsdh_sjxdyfxj_batch.mp4'),
type: 'other' as const type: 'other' as const,
}, },
{ {
id: 'step4', id: 'step4',
label: '剪映基础操作', label: '剪映基础操作',
content: '转场特效贴纸功能详解', content: '转场特效贴纸功能详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_zctxtzgnxj_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_zctxtzgnxj_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step5', id: 'step5',
label: '剪映基础操作', label: '剪映基础操作',
content: '如何给视频快速上字幕', content: '如何给视频快速上字幕',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_rhgspksszm_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_rhgspksszm_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step6', id: 'step6',
label: '剪映基础操作', label: '剪映基础操作',
content: '音频的功能应用和处理', content: '音频的功能应用和处理',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_ypdgnyyhcl_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_ypdgnyyhcl_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step7', id: 'step7',
label: '剪映基础操作', label: '剪映基础操作',
content: '关键帧功能用法详解', content: '关键帧功能用法详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_gjzgnyfxj_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_gjzgnyfxj_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step8', id: 'step8',
label: '剪映基础操作', label: '剪映基础操作',
content: '蒙版功能用法详解', content: '蒙版功能用法详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_mbgnyfxj_batch.mp4'), videoUrl: getPublicPath('video/new/editing-skills/xsdh_mbgnyfxj_batch.mp4'),
type: 'other' as const type: 'other' as const,
}, },
{ {
id: 'step9', id: 'step9',
label: '剪映基础操作', label: '剪映基础操作',
content: '混合模式功能用法详解', content: '混合模式功能用法详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_hhmsgnyfxj_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_hhmsgnyfxj_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step10', id: 'step10',
label: '剪映基础操作', label: '剪映基础操作',
content: '曲线变速功能用法详解', content: '曲线变速功能用法详解',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_qxbsgnyfxj_batch.mp4'), videoUrl: getPublicPath(
type: 'other' as const 'video/new/editing-skills/xsdh_qxbsgnyfxj_batch.mp4',
),
type: 'other' as const,
}, },
{ {
id: 'step11', id: 'step11',
label: '剪映基础操作', label: '剪映基础操作',
content: '如何给画面调色', content: '如何给画面调色',
videoUrl: getPublicPath('video/new/editing-skills/xsdh_rhghmts_batch.mp4'), videoUrl: getPublicPath('video/new/editing-skills/xsdh_rhghmts_batch.mp4'),
type: 'other' as const type: 'other' as const,
} },
] ]
const navigationSteps = ref(rawSteps) const navigationSteps = ref(rawSteps)
@@ -163,7 +178,12 @@ const handleIntroGuideComplete = () => {
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -2,7 +2,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components' import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores' import { useFilmStore } from '@/stores'
@@ -15,11 +18,11 @@ const handleBack = () => {
// 声音转场视频 URL(暂时用其他视频代替) // 声音转场视频 URL(暂时用其他视频代替)
const soundTransitionVideoUrl = getPublicPath( const soundTransitionVideoUrl = getPublicPath(
'video/film/editing/xianchangpaishe_shengyingzhuanchang1.mp4' 'video/film/editing/xianchangpaishe_shengyingzhuanchang1.mp4',
) )
// const soundTransitionVideoUrl2 = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4') // const soundTransitionVideoUrl2 = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const soundTransitionVideoUrl3 = getPublicPath( const soundTransitionVideoUrl3 = getPublicPath(
'video/film/editing/xianchangpaishe_shengyingzhuanchang3.mp4' 'video/film/editing/xianchangpaishe_shengyingzhuanchang3.mp4',
) )
// 视频播放状态 // 视频播放状态
@@ -39,13 +42,14 @@ const questions = ref<Question[]>([
{ {
id: 1, id: 1,
title: '画面感受判断', title: '画面感受判断',
description: '播放视频,听听看,你觉得这个声音,像是用来结束一段画面,还是开启一段新的内容?', description:
'播放视频,听听看,你觉得这个声音,像是用来结束一段画面,还是开启一段新的内容?',
options: [ options: [
{ id: 'a', label: '开始' }, { id: 'a', label: '开始' },
{ id: 'b', label: '结束' } { id: 'b', label: '结束' },
], ],
correctOptionId: '', // 引导题,无对错 correctOptionId: '', // 引导题,无对错
mediaSrc: soundTransitionVideoUrl mediaSrc: soundTransitionVideoUrl,
}, },
{ {
id: 2, id: 2,
@@ -54,11 +58,11 @@ const questions = ref<Question[]>([
options: [ options: [
{ id: 'a', label: '直接切画面' }, { id: 'a', label: '直接切画面' },
{ id: 'b', label: '用相似声音连接' }, { id: 'b', label: '用相似声音连接' },
{ id: 'c', label: '用字幕说明' } { id: 'c', label: '用字幕说明' },
], ],
correctOptionId: 'b', correctOptionId: 'b',
mediaSrc: 'film/on-site/声音转场jh_2.png' mediaSrc: 'film/on-site/声音转场jh_2.png',
} },
]) ])
// 答题完成 // 答题完成
@@ -70,7 +74,9 @@ const handleQuestionsComplete = () => {
</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 <div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10" class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
@@ -113,7 +119,9 @@ const handleQuestionsComplete = () => {
class="max-w-[1100px] w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto" class="max-w-[1100px] w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
> >
<h2 class="text-4xl font-bold mb-4 leading-relaxed">结果验证</h2> <h2 class="text-4xl font-bold mb-4 leading-relaxed">结果验证</h2>
<div class="rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"> <div
class="rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"
>
<Video <Video
class="w-full h-full" class="w-full h-full"
:src="soundTransitionVideoUrl3" :src="soundTransitionVideoUrl3"
@@ -123,7 +131,9 @@ const handleQuestionsComplete = () => {
:volume="1" :volume="1"
/> />
</div> </div>
<div class="mt-6 p-6 rounded-xl bg-amber-500/20 border border-amber-400/30"> <div
class="mt-6 p-6 rounded-xl bg-amber-500/20 border border-amber-400/30"
>
<div class="text-xl font-semibold text-amber-300 mb-2">提示语:</div> <div class="text-xl font-semibold text-amber-300 mb-2">提示语:</div>
<div class="text-lg text-amber-100 leading-relaxed"> <div class="text-lg text-amber-100 leading-relaxed">
<p>相似的声音,</p> <p>相似的声音,</p>
@@ -2,8 +2,15 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { QuestionQuiz, type Question } from '@/components' import QuestionQuiz, {
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
const router = useRouter() const router = useRouter()
@@ -16,14 +23,15 @@ const questions = ref<Question[]>([
{ {
id: 1, id: 1,
title: '画面感受判断', title: '画面感受判断',
description: '如果你身处滨江岸边,看到一位正在打太极的老人,你会选择哪个角度拍摄?', description:
'如果你身处滨江岸边,看到一位正在打太极的老人,你会选择哪个角度拍摄?',
options: [ options: [
{ id: 'a', label: '平视拍摄' }, { id: 'a', label: '平视拍摄' },
{ id: 'b', label: '俯拍' }, { id: 'b', label: '俯拍' },
{ id: 'c', label: '低角度仰拍' } { id: 'c', label: '低角度仰拍' },
], ],
correctOptionId: '', // 引导题,没有正确答案,任意选择都可进入下一题 correctOptionId: '', // 引导题,没有正确答案,任意选择都可进入下一题
mediaSrc: 'film/on-site/低角度拍摄jh_1.png' mediaSrc: 'film/on-site/低角度拍摄jh_1.png',
}, },
{ {
id: 2, id: 2,
@@ -33,11 +41,11 @@ const questions = ref<Question[]>([
{ id: 'a', label: '放大了人物的真实身高' }, { id: 'a', label: '放大了人物的真实身高' },
{ id: 'b', label: '强化了人物动作的力量感与气势' }, { id: 'b', label: '强化了人物动作的力量感与气势' },
{ id: 'c', label: '让背景元素显得更加丰富' }, { id: 'c', label: '让背景元素显得更加丰富' },
{ id: 'd', label: '让画面信息量变得更多' } { id: 'd', label: '让画面信息量变得更多' },
], ],
correctOptionId: 'b', correctOptionId: 'b',
mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_dijiaodu.mp4') mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_dijiaodu.mp4'),
} },
]) ])
const showSummaryStep = ref(false) const showSummaryStep = ref(false)
@@ -2,7 +2,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components' import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, IMAGE_PATHS } from '@/utils' import { getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter() const router = useRouter()
@@ -12,7 +15,9 @@ const handleBack = () => {
} }
// 滑轨拍摄视频 URL // 滑轨拍摄视频 URL
const sliderVideoUrl = getPublicPath('video/film/on-site/dygmk_hgydjt_batch.mp4') const sliderVideoUrl = getPublicPath(
'video/film/on-site/dygmk_hgydjt_batch.mp4',
)
// 视频播放状态 // 视频播放状态
const videoEnded = ref(false) const videoEnded = ref(false)
@@ -34,15 +39,16 @@ const questions = ref<Question[]>([
options: [ options: [
{ {
id: 'a', id: 'a',
label: '人物正对镜头说话,背景简单,镜头静止人物正对镜头说话,背景简单,镜头静止' label:
'人物正对镜头说话,背景简单,镜头静止人物正对镜头说话,背景简单,镜头静止',
}, },
{ id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' }, { id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' },
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' } { id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' },
], ],
correctOptionId: 'b', correctOptionId: 'b',
explanation: explanation:
'滑轨镜头的价值,不在"动",而在稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感,而不是分散注意力。' '滑轨镜头的价值,不在"动",而在稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感,而不是分散注意力。',
} },
]) ])
// 答题完成 // 答题完成
@@ -52,7 +58,9 @@ const handleQuestionsComplete = () => {
</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"
>
<!-- 视频播放区域 --> <!-- 视频播放区域 -->
<Video <Video
class="w-full h-full" class="w-full h-full"
@@ -2,8 +2,15 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { QuestionQuiz, type Question } from '@/components' import QuestionQuiz, {
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
const router = useRouter() const router = useRouter()
@@ -19,11 +26,12 @@ const questions = ref<Question[]>([
description: '在这个白天场景中,画面里的运动变化,是否容易被直接观察到?', description: '在这个白天场景中,画面里的运动变化,是否容易被直接观察到?',
options: [ options: [
{ id: 'a', label: '是,变化本身已经很清楚' }, { id: 'a', label: '是,变化本身已经很清楚' },
{ id: 'b', label: '否,变化存在,但不容易被完整感知' } { id: 'b', label: '否,变化存在,但不容易被完整感知' },
], ],
correctOptionId: 'b', correctOptionId: 'b',
mediaSrc: 'film/on-site/白天慢门jh_1.png', mediaSrc: 'film/on-site/白天慢门jh_1.png',
explanation: '白天光照条件下,许多运动变化存在于连续时间中,并不容易被单帧画面完整呈现。' explanation:
'白天光照条件下,许多运动变化存在于连续时间中,并不容易被单帧画面完整呈现。',
}, },
{ {
id: 2, id: 2,
@@ -33,11 +41,13 @@ const questions = ref<Question[]>([
options: [ options: [
{ id: 'a', label: '提高 ISO' }, { id: 'a', label: '提高 ISO' },
{ id: 'b', label: '加装 ND 镜片,降低快门速度' }, { id: 'b', label: '加装 ND 镜片,降低快门速度' },
{ id: 'c', label: '使用连拍模式' } { id: 'c', label: '使用连拍模式' },
], ],
correctOptionId: 'b', correctOptionId: 'b',
mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_baitianmanmen.mp4') mediaSrc: getPublicPath(
} 'video/film/on-site/xianchangpaishe_baitianmanmen.mp4',
),
},
]) ])
const showSummaryStep = ref(false) const showSummaryStep = ref(false)
@@ -2,13 +2,18 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components' import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter() const router = useRouter()
// 延时拍摄视频 URL(占位,后续可替换) // 延时拍摄视频 URL(占位,后续可替换)
const timeLapseVideoUrl = getPublicPath('video/film/on-site/xianchangpaishe_yanshipaishe.mp4') const timeLapseVideoUrl = getPublicPath(
'video/film/on-site/xianchangpaishe_yanshipaishe.mp4',
)
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -19,29 +24,31 @@ const questions = ref<Question[]>([
{ {
id: 1, id: 1,
title: '观察判断', title: '观察判断',
description: '如果你只用一条普通视频来拍这个场景,你觉得画面最难被感知的是什么?', description:
'如果你只用一条普通视频来拍这个场景,你觉得画面最难被感知的是什么?',
options: [ options: [
{ id: 'a', label: '建筑的外形结构' }, { id: 'a', label: '建筑的外形结构' },
{ id: 'b', label: '天空的颜色' }, { id: 'b', label: '天空的颜色' },
{ id: 'c', label: '时间的变化' }, { id: 'c', label: '时间的变化' },
{ id: 'd', label: '画面的清晰度' } { id: 'd', label: '画面的清晰度' },
], ],
correctOptionId: 'c', correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片 mediaSrc: 'film/on-site/延时拍摄jh_1.png', // 占位:请按需替换为真实图片
}, },
{ {
id: 2, id: 2,
title: '方法选择', title: '方法选择',
description: '如果希望在很短时间内,让观众明显感知“时间正在流动”,你会选择哪种拍摄方式?', description:
'如果希望在很短时间内,让观众明显感知“时间正在流动”,你会选择哪种拍摄方式?',
options: [ options: [
{ id: 'a', label: '手持拍摄' }, { id: 'a', label: '手持拍摄' },
{ id: 'b', label: '慢动作拍摄' }, { id: 'b', label: '慢动作拍摄' },
{ id: 'c', label: '延时拍摄' }, { id: 'c', label: '延时拍摄' },
{ id: 'd', label: '推拉镜头' } { id: 'd', label: '推拉镜头' },
], ],
correctOptionId: 'c', correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片 mediaSrc: 'film/on-site/延时拍摄jh_1.png', // 占位:请按需替换为真实图片
} },
]) ])
const showObserveStep = ref(false) const showObserveStep = ref(false)
@@ -79,7 +86,9 @@ const handleQuestionsComplete = () => {
<!-- 总结文本内容 --> <!-- 总结文本内容 -->
<div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3"> <div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3">
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2> <h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
<p>在这个延时画面中,注意天空变化与光影移动的节奏,它们在短时间内被连续呈现出来。</p> <p>
在这个延时画面中,注意天空变化与光影移动的节奏,它们在短时间内被连续呈现出来。
</p>
<p> <p>
<span>延时拍摄的主要目的</span> <span>延时拍摄的主要目的</span>
@@ -4,7 +4,12 @@ import { useRouter } from 'vue-router'
import { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import { Overlay } from '@/components/Overlay' import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import {
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'
@@ -35,26 +40,26 @@ const steps = ref([
id: '1', id: '1',
audioSrc: getPublicPath('audio/py_4_015.MP3'), audioSrc: getPublicPath('audio/py_4_015.MP3'),
content: '哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~', content: '哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~',
highlightSelector: '.btn-1' highlightSelector: '.btn-1',
} },
]) ])
const guideSteps = ref([ const guideSteps = ref([
{ {
id: '1', id: '1',
content: '点击框架构图,查看构图方法', content: '点击框架构图,查看构图方法',
highlightSelector: '.btn-item-1' highlightSelector: '.btn-item-1',
}, },
{ {
id: '2', id: '2',
content: '点击对称构图,查看构图方法', content: '点击对称构图,查看构图方法',
highlightSelector: '.btn-item-2' highlightSelector: '.btn-item-2',
}, },
{ {
id: '3', id: '3',
content: '点击引导线构图,查看构图方法', content: '点击引导线构图,查看构图方法',
highlightSelector: '.btn-item-3' highlightSelector: '.btn-item-3',
} },
]) ])
const showModal = ref(false) const showModal = ref(false)
@@ -65,7 +70,7 @@ const currentItem = ref<Btn>({
name: '', name: '',
btnImage: '', btnImage: '',
images: [], images: [],
desc: '' desc: '',
}) })
const btnList = ref<Btn[]>([ const btnList = ref<Btn[]>([
@@ -74,22 +79,25 @@ const btnList = ref<Btn[]>([
name: '框架构图', name: '框架构图',
btnImage: 'film/shooting/框架构图', btnImage: 'film/shooting/框架构图',
desc: '框架构图是拍摄时用“框架”把主体框起来,可以让画面更加有空间感及层次感,并且使用也并不复杂,比如镜子、门窗、山洞等,也可以说是光影框等。', desc: '框架构图是拍摄时用“框架”把主体框起来,可以让画面更加有空间感及层次感,并且使用也并不复杂,比如镜子、门窗、山洞等,也可以说是光影框等。',
images: ['film/shooting/01框架构图_底图', 'film/shooting/02框架构图_底图'] images: ['film/shooting/01框架构图_底图', 'film/shooting/02框架构图_底图'],
}, },
{ {
key: '2', key: '2',
name: '对称构图', name: '对称构图',
btnImage: 'film/shooting/对称构图', btnImage: 'film/shooting/对称构图',
desc: '对称构图是通过画面中轴线(水平 / 垂直)两侧元素镜像分布的构图方式,属于平衡类构图。它借对称的视觉均衡感,让画面显规整、稳定,同时强化主体秩序与美感。拍摄可找天然对称景物(如建筑、倒影),或调整角度让元素对称;略有不对称可后期微调,适合展现建筑、对称静物等规整场景。', desc: '对称构图是通过画面中轴线(水平 / 垂直)两侧元素镜像分布的构图方式,属于平衡类构图。它借对称的视觉均衡感,让画面显规整、稳定,同时强化主体秩序与美感。拍摄可找天然对称景物(如建筑、倒影),或调整角度让元素对称;略有不对称可后期微调,适合展现建筑、对称静物等规整场景。',
images: ['film/shooting/03对称构图_底图', 'film/shooting/04对称构图_底图'] images: ['film/shooting/03对称构图_底图', 'film/shooting/04对称构图_底图'],
}, },
{ {
key: '3', key: '3',
name: '引导线构图', name: '引导线构图',
btnImage: 'film/shooting/引导线构图', btnImage: 'film/shooting/引导线构图',
desc: '引导线构图的关键在于引导线,借助环境中的引导线,把观者注意力集中到引导线上,营造画面纵深效果,我们日常拍摄中常见的引导线有:马路及其延伸(马路线、栏杆、走廊)', desc: '引导线构图的关键在于引导线,借助环境中的引导线,把观者注意力集中到引导线上,营造画面纵深效果,我们日常拍摄中常见的引导线有:马路及其延伸(马路线、栏杆、走廊)。',
images: ['film/shooting/05引导线构图_底图', 'film/shooting/06引导线构图_底图'] images: [
} 'film/shooting/05引导线构图_底图',
'film/shooting/06引导线构图_底图',
],
},
]) ])
const handleModalClose = () => { const handleModalClose = () => {
@@ -135,19 +143,36 @@ const back = () => {
></div> ></div>
<div class="shooting-title"> <div class="shooting-title">
<img :src="shootingtitle" alt="" /> <img
:src="shootingtitle"
alt=""
/>
</div> </div>
<div class="shooting-return"> <div class="shooting-return">
<ImgButton image="shooting/返回.png" @click="back" /> <ImgButton
image="shooting/返回.png"
@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 class="btn-2" image="film/shooting/龙美术馆入口.png" @click="handleClickA" /> <ImgButton
class="btn-2"
image="film/shooting/龙美术馆入口.png"
@click="handleClickA"
/>
</div> </div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]"> <div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton class="btn-3" image="film/shooting/油罐艺术公园入口.png" @click="handleClickB" /> <ImgButton
class="btn-3"
image="film/shooting/油罐艺术公园入口.png"
@click="handleClickB"
/>
</div> </div>
<div class="shooting-composition btn-1"> <div class="shooting-composition btn-1">
<ImgButton image="shooting/构图指南.png" @click="openCompositionGuide" /> <ImgButton
image="shooting/构图指南.png"
@click="openCompositionGuide"
/>
</div> </div>
<!-- 引导步骤 --> <!-- 引导步骤 -->
@@ -164,7 +189,7 @@ const back = () => {
:visible="showModal" :visible="showModal"
:clickable="false" :clickable="false"
:customStyle="{ :customStyle="{
backgroundImage: getBackgroundImage('film/shooting/构图指南底图') backgroundImage: getBackgroundImage('film/shooting/构图指南底图'),
}" }"
customClass="inset-0 bg-cover bg-center bg-no-repeat" customClass="inset-0 bg-cover bg-center bg-no-repeat"
contentClass="relative w-full h-full" contentClass="relative w-full h-full"
@@ -177,15 +202,25 @@ const back = () => {
/> />
<!-- 构图指南 --> <!-- 构图指南 -->
<ImgButton class="absolute left-0 top-0 z-10" image="shooting/标题-构图指南" /> <ImgButton
class="absolute left-0 top-0 z-10"
image="shooting/标题-构图指南"
/>
<!-- 新手导航视频 --> <!-- 新手导航视频 -->
<div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2"> <div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2">
<ImgButton image="shooting/新手导航-视频回顾" @click="showModal3 = true" /> <ImgButton
image="shooting/新手导航-视频回顾"
@click="showModal3 = true"
/>
<div <div
class="absolute left-[40%] top-[42%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none" 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="" /> <img
class="w-32"
:src="getStaticImage('work/小手')"
alt=""
/>
<span class="text-white text-2xl font-bold ml-5">点击进入</span> <span class="text-white text-2xl font-bold ml-5">点击进入</span>
</div> </div>
</div> </div>
@@ -220,8 +255,16 @@ const back = () => {
@close="showModal2 = false" @close="showModal2 = false"
/> />
<!-- 视频 --> <!-- 视频 -->
<Overlay :visible="showModal3" :clickable="false"> <Overlay
<Video :src="videoSrc" controls :volume="1" :muted="false"></Video> :visible="showModal3"
:clickable="false"
>
<Video
:src="videoSrc"
controls
:volume="1"
:muted="false"
></Video>
<!-- 返回 --> <!-- 返回 -->
<ImgButton <ImgButton
class="absolute right-0 top-0 z-10" class="absolute right-0 top-0 z-10"
+103 -25
View File
@@ -4,7 +4,7 @@ import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating' import { StarRating } from '@/components/Rating'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/svg/back-to-top.svg' import BackToTopIcon from '@/assets/images/components/置顶.png'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film' import { useFilmStore } from '@/stores/modules/film'
@@ -26,7 +26,9 @@ const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
/** 是否正在从第二页回到第一页 */ /** 是否正在从第二页回到第一页 */
const isScrollingBackToFirst = computed<boolean>(() => { const isScrollingBackToFirst = computed<boolean>(() => {
return isAnimating.value && currentPage.value === 1 && animatingToPage.value === 0 return (
isAnimating.value && currentPage.value === 1 && animatingToPage.value === 0
)
}) })
/** 第一页坐着的人物是否显示 */ /** 第一页坐着的人物是否显示 */
@@ -42,7 +44,7 @@ const guideKeyPrefix = 'film-practice-page'
const steps = ref([ const steps = ref([
'欢迎来到实战操作台! 这里的内容很丰富,滚动鼠标就能浏览到全部内容啦~', '欢迎来到实战操作台! 这里的内容很丰富,滚动鼠标就能浏览到全部内容啦~',
'点击【我的创作笔记】,编写创作思路~', '点击【我的创作笔记】,编写创作思路~',
'点击【查看】按钮,欣赏他人实战作品~' '点击【查看】按钮,欣赏他人实战作品~',
]) ])
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`) const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
@@ -75,8 +77,8 @@ const worksList = ref<WorkItem[]>([
stars: 4, stars: 4,
path: '/video/film/practice/film_anli1.mp4', path: '/video/film/practice/film_anli1.mp4',
description: description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介' '作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介',
} },
]) ])
/** 左滑动作品列表 */ /** 左滑动作品列表 */
@@ -168,18 +170,44 @@ const handleWheel = (event: WheelEvent): void => {
} }
} }
const restoreScrollPage = (): void => {
const savedPage = filmStore.practiceScrollPage
const targetPage = Math.max(0, Math.min(savedPage, TOTAL_PAGES - 1))
currentPage.value = targetPage
animatingToPage.value = targetPage
requestAnimationFrame(() => {
if (containerRef.value) {
containerRef.value.scrollTop = targetPage * PAGE_HEIGHT
}
})
}
const saveScrollPage = (): void => {
const page = containerRef.value
? Math.round(containerRef.value.scrollTop / PAGE_HEIGHT)
: currentPage.value
const targetPage = Math.max(0, Math.min(page, TOTAL_PAGES - 1))
filmStore.setPracticeScrollPage(targetPage)
}
onMounted(() => { onMounted(() => {
restoreScrollPage()
showPageGuide() showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false }) containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
}) })
onUnmounted(() => { onUnmounted(() => {
saveScrollPage()
containerRef.value?.removeEventListener('wheel', handleWheel) containerRef.value?.removeEventListener('wheel', handleWheel)
}) })
</script> </script>
<template> <template>
<div ref="containerRef" class="w-full h-full overflow-auto"> <div
ref="containerRef"
class="w-full h-full overflow-auto"
>
<div class="relative min-h-full overflow-hidden"> <div class="relative min-h-full overflow-hidden">
<!-- 关闭 --> <!-- 关闭 -->
<ImgButton <ImgButton
@@ -188,16 +216,19 @@ onUnmounted(() => {
@click="handleClose" @click="handleClose"
/> />
<!-- 实战操作台 --> <!-- 实战操作台 -->
<div <div class="w-full h-[1080px] bg-[#F5EFE6] relative">
class="w-full h-[1080px] bg-cover bg-no-repeat relative"
:style="{ backgroundImage: getBackgroundImage('film/practice/实战操作台首页- 电影感') }"
>
<ImgButton <ImgButton
class="absolute left-40 bottom-28 z-10" class="absolute left-40 bottom-14 z-10"
image="vlog/practice/查看任务要求" image="vlog/practice/查看任务要求"
@click="scrollToNextPage" @click="scrollToNextPage"
/> />
<!-- img --> <!-- img -->
<img
class="absolute left-0 top-0 z-10"
draggable="false"
:src="getStaticImage('film/practice/电影')"
alt=""
/>
<img <img
class="absolute right-0" class="absolute right-0"
draggable="false" draggable="false"
@@ -212,12 +243,18 @@ onUnmounted(() => {
> >
<ImgButton <ImgButton
class="absolute right-20 bottom-0 z-99" class="absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记" image="vlog/practice/我的创作笔记btn"
path="/film/practice/my-notes" path="/film/practice/my-notes"
/> />
<div class="task-panel w-[757px] h-full ml-10 overflow-y-auto" @wheel.stop> <div
<img :src="getStaticImage('film/practice/滑动任务面板-电影感')" alt="" /> class="task-panel w-[757px] h-full ml-10 overflow-y-auto"
@wheel.stop
>
<img
:src="getStaticImage('film/practice/任务要求')"
alt=""
/>
</div> </div>
<img <img
@@ -239,7 +276,9 @@ onUnmounted(() => {
<!-- 他人实战作品 --> <!-- 他人实战作品 -->
<div <div
class="w-full h-[1080px] bg-cover bg-no-repeat pt-44 px-16" class="w-full h-[1080px] bg-cover bg-no-repeat pt-44 px-16"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/他人实战作品') }" :style="{
backgroundImage: getBackgroundImage('vlog/practice/他人实战作品'),
}"
> >
<!-- 作品列表 --> <!-- 作品列表 -->
<div class="w-full overflow-hidden"> <div class="w-full overflow-hidden">
@@ -254,15 +293,27 @@ onUnmounted(() => {
> >
<!-- 作品图片 --> <!-- 作品图片 -->
<div class="w-full h-[280px] rounded-2xl overflow-hidden"> <div class="w-full h-[280px] rounded-2xl overflow-hidden">
<img class="w-full h-full object-cover" :src="work.image" :alt="work.title" /> <img
class="w-full h-full object-cover"
:src="work.image"
:alt="work.title"
/>
</div> </div>
<!-- 作品信息 --> <!-- 作品信息 -->
<div class="flex flex-col items-center pt-8 space-y-4"> <div class="flex flex-col items-center pt-8 space-y-4">
<h3 class="text-5xl font-bold text-gray-800 mb-2">{{ work.title }}</h3> <h3 class="text-5xl font-bold text-gray-800 mb-2">
{{ work.title }}
</h3>
<!-- 星星评分 --> <!-- 星星评分 -->
<StarRating :score="work.stars" size="lg" readonly /> <StarRating
:score="work.stars"
size="lg"
readonly
/>
<!-- 简介 --> <!-- 简介 -->
<p class="h-[110px] text-2xl text-gray-600 mb-3 line-clamp-3 leading-relaxed"> <p
class="h-[110px] text-2xl text-gray-600 mb-3 line-clamp-3 leading-relaxed"
>
{{ work.description }} {{ work.description }}
</p> </p>
<!-- 查看按钮 --> <!-- 查看按钮 -->
@@ -275,7 +326,7 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- 指示按钮 --> <!-- 指示按钮,暂时不要
<div class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-16"> <div class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-16">
<img <img
class="cursor-pointer hover:scale-110 transition-transform" class="cursor-pointer hover:scale-110 transition-transform"
@@ -286,19 +337,23 @@ onUnmounted(() => {
/> />
<img <img
class="cursor-pointer hover:scale-110 transition-transform rotate-180" class="cursor-pointer hover:scale-110 transition-transform rotate-180"
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex >= worksList.length - 3 }" :class="{
'opacity-50 cursor-not-allowed':
worksScrollIndex >= worksList.length - 3,
}"
:src="getStaticImage('vlog/practice/滑动左')" :src="getStaticImage('vlog/practice/滑动左')"
alt="向右" alt="向右"
@click="scrollWorksRight" @click="scrollWorksRight"
/> />
</div> </div>
-->
</div> </div>
</div> </div>
<!-- 下滑按钮 - 最后一页隐藏 --> <!-- 下滑按钮 - 最后一页隐藏 -->
<ImgButton <ImgButton
v-show="!isLastPage" v-show="!isLastPage"
class="absolute bottom-0 left-1/2 -translate-x-1/2" class="absolute bottom-0 left-1/2 down-scroll-hint"
:image="'vlog/practice/下滑'" :image="'vlog/practice/下滑'"
@click="scrollToNextPage" @click="scrollToNextPage"
/> />
@@ -306,13 +361,16 @@ onUnmounted(() => {
<!-- 回到顶部按钮 - 最后一页显示 --> <!-- 回到顶部按钮 - 最后一页显示 -->
<img <img
v-show="isLastPage" v-show="isLastPage"
class="absolute bottom-10 right-10 cursor-pointer transition-all duration-300 hover:scale-105" class="absolute bottom-10 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon" :src="BackToTopIcon"
@click="scrollToTop" @click="scrollToTop"
/> />
<!-- 跨页人物动画 --> <!-- 跨页人物动画 -->
<div v-show="showSittingPerson || isScrollingBackToFirst" class="character-container"> <div
v-show="showSittingPerson || isScrollingBackToFirst"
class="character-container"
>
<!-- 坐着的人物 - 第一页 --> <!-- 坐着的人物 - 第一页 -->
<Transition name="fade"> <Transition name="fade">
<img <img
@@ -335,7 +393,11 @@ onUnmounted(() => {
</Transition> </Transition>
</div> </div>
<GuideSteps v-if="visible" position-offset-y="70%" @complete="visible = false"> <GuideSteps
v-if="visible"
position-offset-y="70%"
@complete="visible = false"
>
<GuideStepsItem>{{ contentSteps }}</GuideStepsItem> <GuideStepsItem>{{ contentSteps }}</GuideStepsItem>
</GuideSteps> </GuideSteps>
</div> </div>
@@ -355,6 +417,22 @@ onUnmounted(() => {
/* Chrome/Safari/Opera */ /* Chrome/Safari/Opera */
} }
.down-scroll-hint {
transform: translateX(-50%);
animation: down-scroll-hint 1.4s ease-in-out infinite;
}
@keyframes down-scroll-hint {
0%,
100% {
transform: translate(-50%, 0);
}
50% {
transform: translate(-50%, 12px);
}
}
/* 第一页人物动画容器 */ /* 第一页人物动画容器 */
.character-container { .character-container {
position: fixed; position: fixed;
+30 -85
View File
@@ -3,8 +3,9 @@ import { computed, 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 { Overlay } from '@/components/Overlay' import PracticeRatingModal, {
import { StarRating } from '@/components/Rating' type PracticeRatingItem,
} from '@/components/PracticeRatingModal'
const router = useRouter() const router = useRouter()
@@ -21,42 +22,33 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '') const disabled = computed<boolean>(() => noteContent.value.trim() === '')
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([ const evaluationItems = ref<PracticeRatingItem[]>([
{ {
title: '画面氛围', title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一', description: '画面具有明确情绪或空间气质,整体风格统一',
score: 4 score: 4,
}, },
{ {
title: '拍摄方法', title: '拍摄方法',
description: '拍摄方式选择合理,镜头语言服务于画面表达', description: '拍摄方式选择合理,镜头语言服务于画面表达',
score: 2 score: 2,
}, },
{ {
title: '剪辑逻辑', title: '剪辑逻辑',
description: '剪辑节奏清晰,镜头衔接自然,有整体感', description: '剪辑节奏清晰,镜头衔接自然,有整体感',
score: 5 score: 5,
}, },
{ {
title: '创作意识', title: '创作意识',
description: '能体现主动构思,而非随拍拼接', description: '能体现主动构思,而非随拍拼接',
score: 0 score: 0,
}, },
{ {
title: '综合观感', title: '综合观感',
description: '作品具有“电影感”,完成度高', description: '作品具有“电影感”,完成度高',
score: 5 score: 5,
} },
]) ])
const handleSave = (): void => { const handleSave = (): void => {
console.log('保存笔记') console.log('保存笔记')
@@ -67,7 +59,9 @@ const handleSave = (): void => {
<template> <template>
<div <div
class="w-full h-full bg-no-repeat bg-cover px-8 pt-[400px]" class="w-full h-full bg-no-repeat bg-cover px-8 pt-[400px]"
:style="{ backgroundImage: getBackgroundImage('film/practice/创作笔记底图-电影感') }" :style="{
backgroundImage: getBackgroundImage('film/practice/我的创作笔记'),
}"
> >
<ImgButton <ImgButton
class="absolute top-0 right-0" class="absolute top-0 right-0"
@@ -83,53 +77,25 @@ const handleSave = (): void => {
></textarea> ></textarea>
<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 image="vlog/practice/保存" @click="handleSave" /> <ImgButton
<ImgButton image="vlog/practice/自评" :disabled="disabled" @click="showModal = true" /> image="vlog/practice/保存"
@click="handleSave"
/>
<ImgButton
image="vlog/practice/自评"
:disabled="disabled"
@click="showModal = true"
/>
</div> </div>
<!-- 保存图片弹窗 --> <PracticeRatingModal
<Overlay :visible="showModal" :clickable="false"> :visible="showModal"
<div :items="evaluationItems"
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]" background-image="film/practice/电影感短视频创作-作品评分表"
:style="{ backgroundImage: getBackgroundImage('film/practice/评分底板-电影感') }" top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
> bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
<ImgButton @close="showModal = false"
class="absolute -top-8 -right-14 z-10" />
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal = false"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10">
<p class="glow-text">
评分时要关注5个维度哦~每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~
</p>
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<p class="glow-text">
己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~
</p>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</Overlay>
</div> </div>
</template> </template>
@@ -146,25 +112,4 @@ textarea::placeholder {
color: #5d5b5b; color: #5d5b5b;
font-weight: 700; font-weight: 700;
} }
.glow-text {
color: white;
font-size: 2rem;
font-weight: bold;
text-shadow:
/* 描边效果 - 多层阴影模拟描边 */
-1px -1px 0 #0066ff,
1px -1px 0 #0066ff,
-1px 1px 0 #0066ff,
1px 1px 0 #0066ff,
-2px -2px 0 #0066ff,
2px -2px 0 #0066ff,
-2px 2px 0 #0066ff,
2px 2px 0 #0066ff,
/* 发光效果 - 蓝色光晕 */ 0 0 10px #0066ff,
0 0 20px #0066ff,
0 0 30px #0066ff,
0 0 40px #0066ff,
0 0 50px #0066ff;
}
</style> </style>
+36 -74
View File
@@ -2,9 +2,10 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Video } from '@/components' import { Video } from '@/components'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating' import PracticeRatingModal, {
import { IMAGE_PATHS, getBackgroundImage, getPublicPath } from '@/utils' type PracticeRatingItem,
import { Overlay } from '@/components/Overlay' } from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue' import { 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'
@@ -20,46 +21,37 @@ if (showGuide.value) {
const props = defineProps({ const props = defineProps({
path: { path: {
type: String, type: String,
required: true required: true,
} },
}) })
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([ const evaluationItems = ref<PracticeRatingItem[]>([
{ {
title: '主题与内容', title: '画面氛围',
description: '主题明确聚焦"身边美好",内容真实、有感染力,能让观众感受到日常闪光', description: '画面具有明确情绪或空间气质,整体风格统一',
score: 3 score: 4,
}, },
{ {
title: '拍摄技巧', title: '拍摄方法',
description: '运镜自然、光线运用合理、画面清晰饱和度、能辅助主题表达(至少1种运镜+1种光线)', description: '拍摄方式选择合理,镜头语言服务于画面表达',
score: 4 score: 2,
}, },
{ {
title: '剪辑与包装', title: '剪辑逻辑',
description: '素材拼接流畅,调色/滤镜适配、字幕清晰、音乐不盖人声、整体风格统一', description: '剪辑节奏清晰,镜头衔接自然,有整体感',
score: 5 score: 5,
}, },
{ {
title: '创意与独特性', title: '创作意识',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”,避免平淡复刻', description: '能体现主动构思,而非随拍拼接',
score: 4 score: 0,
}, },
{ {
title: '综合感受', title: '综合观感',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”避免平淡复刻', description: '作品具有“电影感”,完成度高',
score: 5 score: 5,
} },
]) ])
/** 保存弹窗 */ /** 保存弹窗 */
@@ -91,49 +83,19 @@ const handleBack = (): void => {
:loop="false" :loop="false"
/> />
<!-- 保存图片弹窗 --> <PracticeRatingModal
<Overlay :visible="showModal" :clickable="false"> :visible="showModal"
<div :items="evaluationItems"
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[80px]" background-image="film/practice/电影感短视频创作-作品评分表"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分底板') }" top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
> bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
<ImgButton @close="showModal = false"
class="absolute -top-8 -right-14 z-10" />
:image="IMAGE_PATHS.COMPONENTS.CLOSE" <GuideSteps
@click="showModal = false" :visible="showGuide"
/> :highlight-selector="'.btn-1'"
position-offset-y="60%"
<div class="relative h-full overflow-y-scroll px-8"> >
<div
class="w-full bg-no-repeat bg-cover bg-left py-44"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/滑动表内文字') }"
>
<div class="pl-10 space-y-16">
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</div>
</Overlay>
<GuideSteps :visible="showGuide" :highlight-selector="'.btn-1'" position-offset-y="60%">
<GuideStepsItem>点击这里可以给作品评分噢~</GuideStepsItem> <GuideStepsItem>点击这里可以给作品评分噢~</GuideStepsItem>
</GuideSteps> </GuideSteps>
</div> </div>
+66 -33
View File
@@ -2,8 +2,12 @@
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem, StepNavigation } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components' import StepNavigation from '@/components/Guide/StepNavigation.vue'
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 { useFilmStore } from '@/stores' import { useFilmStore } from '@/stores'
@@ -33,44 +37,44 @@ const navigationSteps = ref([
id: 'step1', id: 'step1',
label: '后期制作', label: '后期制作',
videoUrl: getPublicPath('video/film/work/dygmk_zf_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_zf_batch.mp4'),
content: '遮幅' content: '遮幅',
}, },
{ {
id: 'step2', id: 'step2',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_gx_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_gx_batch.mp4'),
content: '光线' content: '光线',
}, },
{ {
id: 'step3', id: 'step3',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_xs_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_xs_batch.mp4'),
content: '虚实' content: '虚实',
}, },
{ {
id: 'step4', id: 'step4',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_txjt_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_txjt_batch.mp4'),
content: '特写镜头' content: '特写镜头',
}, },
{ {
id: 'step5', id: 'step5',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_fjt_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_fjt_batch.mp4'),
content: '分镜头' content: '分镜头',
}, },
{ {
id: 'step5', id: 'step5',
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: 'step5',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'),
content: '同期声' content: '同期声',
} },
]) ])
// 步骤导航相关状态 // 步骤导航相关状态
@@ -94,17 +98,17 @@ const getQuestionsForVideo = (index: number): Question[] => {
options: [ options: [
{ {
id: 'a', id: 'a',
label: '前景、人物、背景全部清晰' label: '前景、人物、背景全部清晰',
}, },
{ {
id: 'b', id: 'b',
label: '前景虚化,人物清晰,背景有层次' label: '前景虚化,人物清晰,背景有层次',
} },
], ],
correctOptionId: 'b', correctOptionId: 'b',
explanation: explanation:
'电影感的画面,往往会主动模糊一部分信息。这是为了把观众的注意力,留给正在发生的情绪。' '电影感的画面,往往会主动模糊一部分信息。这是为了把观众的注意力,留给正在发生的情绪。',
} },
] ]
} }
// 分镜头(index 4) // 分镜头(index 4)
@@ -117,25 +121,28 @@ const getQuestionsForVideo = (index: number): Question[] => {
options: [ options: [
{ {
id: 'a', id: 'a',
label: '特写→中景→ 全景' label: '特写→中景→ 全景',
}, },
{ {
id: 'b', id: 'b',
label: '全景→ 中景→ 特写' label: '全景→ 中景→ 特写',
} },
], ],
correctOptionId: 'b', correctOptionId: 'b',
explanation: '电影里的镜头, 往往是从环境进入人物,再进入情绪。' explanation: '电影里的镜头, 往往是从环境进入人物,再进入情绪。',
} },
] ]
} }
return [] return []
} }
//按顺序播放 //按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const resolvedSrc = computed(() => { const resolvedSrc = computed(() => {
if (key.value === 'case') return getPublicPath('video/film/work/dygmk_zbbwg_batch.mp4') if (key.value === 'case')
return getPublicPath('video/film/work/dygmk_zbbwg_batch.mp4')
if (key.value === 'profession') return currentVideo.value if (key.value === 'profession') return currentVideo.value
return '' return ''
}) })
@@ -148,8 +155,9 @@ const handleVideoChange = () => {
steps.value = [ steps.value = [
{ {
id: 'step1', id: 'step1',
content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!' content:
} '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
},
] ]
return return
} }
@@ -164,7 +172,7 @@ const handleClose = () => {
film.setFilmUnlockProgress(1) film.setFilmUnlockProgress(1)
router.push({ router.push({
name: 'Film', name: 'Film',
query: { guide: 'return' } query: { guide: 'return' },
}) })
return return
} }
@@ -200,7 +208,7 @@ const handleComplete = () => {
if (key.value === 'profession') { if (key.value === 'profession') {
if (!film.isFilmUnlock) film.setIsFilmUnlock(true) if (!film.isFilmUnlock) film.setIsFilmUnlock(true)
router.push({ router.push({
name: 'FilmWork' name: 'FilmWork',
}) })
} }
} }
@@ -239,7 +247,10 @@ onMounted(() => {})
</script> </script>
<template> <template>
<div class="w-full h-full relative bg-white/10" @click="handleClick"> <div
class="w-full h-full relative bg-white/10"
@click="handleClick"
>
<template v-if="key !== 'case' && key !== 'profession'"> <template v-if="key !== 'case' && key !== 'profession'">
<ImgButton <ImgButton
class="absolute right-0 top-0 z-10" class="absolute right-0 top-0 z-10"
@@ -254,7 +265,12 @@ onMounted(() => {})
@click="showStepNavigation = true" @click="showStepNavigation = true"
title="步骤导航" title="步骤导航"
> >
<svg width="35" height="35" viewBox="0 0 20 20" fill="none"> <svg
width="35"
height="35"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -289,13 +305,24 @@ onMounted(() => {})
/> />
</template> </template>
</Video> </Video>
<div v-else-if="key === 'idea'" :src="image"> <div
<img :src="getStaticImage('film/思路提点全屏')" alt="" /> v-else-if="key === 'idea'"
:src="image"
>
<img
:src="getStaticImage('film/思路提点全屏')"
alt=""
/>
</div> </div>
</div> </div>
<!-- 引导与高亮 --> <!-- 引导与高亮 -->
<GuideSteps v-if="status" :steps="steps" position-offset-y="85%" @complete="handleComplete" /> <GuideSteps
v-if="status"
:steps="steps"
position-offset-y="85%"
@complete="handleComplete"
/>
<!-- 步骤导航抽屉 --> <!-- 步骤导航抽屉 -->
<StepNavigation <StepNavigation
@@ -331,7 +358,12 @@ onMounted(() => {})
class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200" class="w-10 h-10 bg-blue-500/80 hover:bg-blue-500 rounded-full inline-flex items-center justify-center text-white transition-all duration-200"
title="步骤导航" title="步骤导航"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
@@ -354,7 +386,8 @@ onMounted(() => {})
@complete="handleComplete" @complete="handleComplete"
> >
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_007.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_4_007.MP3')">
一个好镜头,既要好看,又要好懂 —— 既有视觉吸引力,又能把信息清晰地传达出来。 一个好镜头,既要好看,又要好懂 ——
既有视觉吸引力,又能把信息清晰地传达出来。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_008.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_4_008.MP3')">
当这些条件被满足,一个镜头就有了“高级感”。 当这些条件被满足,一个镜头就有了“高级感”。
+34 -15
View File
@@ -1,7 +1,12 @@
<script lang="tsx" name="FilmWork" setup> <script lang="tsx" name="FilmWork" setup>
defineOptions({ name: 'FilmWork' }) defineOptions({ name: 'FilmWork' })
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage, getPublicPath } from '@/utils' import {
getBackgroundImage,
IMAGE_PATHS,
getStaticImage,
getPublicPath,
} from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue' import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
@@ -29,28 +34,30 @@ const sideSwitcherItems = ref([
key: 'case', key: 'case',
name: '案例赏析', name: '案例赏析',
description: '物件春秋-上海钟表文化科普馆', description: '物件春秋-上海钟表文化科普馆',
image: 'film/案件赏析小屏' image: 'film/案件赏析小屏',
}, },
{ {
key: 'profession', key: 'profession',
name: '专业剖析', name: '专业剖析',
description: '物件春秋-上海钟表文化科普馆', description: '物件春秋-上海钟表文化科普馆',
image: 'film/专业剖析' image: 'film/专业剖析',
}, },
{ {
key: 'idea', key: 'idea',
name: '思路提点', name: '思路提点',
description: '物件春秋-上海钟表文化科普馆', description: '物件春秋-上海钟表文化科普馆',
image: 'film/思路提点小屏' image: 'film/思路提点小屏',
} },
]) ])
//当前选中的item //当前选中的item
const currentItem = computed(() => { const currentItem = computed(() => {
return sideSwitcherItems.value.find(item => item.key === selected.value) return sideSwitcherItems.value.find((item) => item.key === selected.value)
}) })
const currentHighlight = computed(() => (stepIndex.value === 2 ? '.work-border' : null)) const currentHighlight = computed(() =>
stepIndex.value === 2 ? '.work-border' : null,
)
const handleStepChange = (_: any, index: number) => { const handleStepChange = (_: any, index: number) => {
stepIndex.value = index stepIndex.value = index
@@ -61,14 +68,16 @@ const handleSideSwitcherChange = (value: WorkTab) => {
} }
const handleImageClick = () => { const handleImageClick = () => {
const currentItem = workSideSwitcherRef.value?.items.find(item => item.key === selected.value) const currentItem = workSideSwitcherRef.value?.items.find(
(item) => item.key === selected.value,
)
router.push({ router.push({
name: 'FilmVideo', name: 'FilmVideo',
query: { query: {
image: currentItem?.activeImage || '', image: currentItem?.activeImage || '',
key: selected.value, key: selected.value,
label: currentItem?.label || '' label: currentItem?.label || '',
} },
}) })
} }
@@ -96,7 +105,11 @@ function openImages(urls: string[], startIndex = 0) {
images: urls, images: urls,
startIndex, startIndex,
maskClosable: true, maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value }) getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
}) })
} }
</script> </script>
@@ -131,10 +144,14 @@ function openImages(urls: string[], startIndex = 0) {
/> />
<!-- 手 --> <!-- 手 -->
<div <div
class="absolute left-[46%] top-[40%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none" class="absolute left-[46%] top-[45%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none"
> >
<img class="w-40" :src="getStaticImage('work/小手')" alt="" /> <img
<span class="text-white text-2xl font-bold ml-5">点击进入</span> class="w-40"
:src="getStaticImage('work/小手')"
alt=""
/>
<!-- <span class="text-white text-2xl font-bold ml-5">点击进入</span> -->
</div> </div>
</div> </div>
<div <div
@@ -177,7 +194,9 @@ function openImages(urls: string[], startIndex = 0) {
<!-- 任务取景按钮 --> <!-- 任务取景按钮 -->
<ImgButton <ImgButton
class="absolute right-14 bottom-10" class="absolute right-14 bottom-10"
:image="isFilmUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN" :image="
isFilmUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN
"
@click="handleWorkClick" @click="handleWorkClick"
/> />
+140 -53
View File
@@ -1,8 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
defineOptions({ defineOptions({
name: 'Action' name: 'Action',
}) })
import { getBackgroundImage, getPublicPath, IMAGE_PATHS, getStaticImage } from '@/utils' import {
getBackgroundImage,
getPublicPath,
IMAGE_PATHS,
getStaticImage,
} from '@/utils'
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'
@@ -13,7 +18,7 @@ import {
useFilmStore, useFilmStore,
useVlogStore, useVlogStore,
useInterviewStore, useInterviewStore,
useBasicElementsStore useBasicElementsStore,
} 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'
@@ -46,66 +51,67 @@ const homeGuideSteps: GuideStep[] = [
content: '点击新手导航按钮,可以展开/收起新手导航面板!', content: '点击新手导航按钮,可以展开/收起新手导航面板!',
positionOffsetY: '50%', positionOffsetY: '50%',
highlightSelector: '.newUserGuide', highlightSelector: '.newUserGuide',
audioSrc: getPublicPath('audio/py_0_005.MP3') audioSrc: getPublicPath('audio/py_0_005.MP3'),
}, },
{ {
id: 'home-guide-locked-modules', id: 'home-guide-locked-modules',
content: '新手导航模块全部完成后才能解锁三大进阶模块哦~', content: '新手导航模块全部完成后才能解锁三大进阶模块哦~',
highlightSelector: '.newUserGuide', highlightSelector: '.newUserGuide',
positionOffsetY: '50%', positionOffsetY: '50%',
audioSrc: getPublicPath('audio/py_0_006.MP3') audioSrc: getPublicPath('audio/py_0_006.MP3'),
}, },
{ {
id: 'home-guide-module-switcher', id: 'home-guide-module-switcher',
content: '舞台划分为三大创作板块!点击 Vlog、访谈、电影的对应区域,右侧屏幕就会切换到对应板块!', content:
'舞台划分为三大创作板块!点击 Vlog、访谈、电影的对应区域,右侧屏幕就会切换到对应板块!',
highlightSelector: '.module-switcher-guide', highlightSelector: '.module-switcher-guide',
positionOffsetY: '85%', positionOffsetY: '85%',
audioSrc: getPublicPath('audio/py_0_007.MP3') audioSrc: getPublicPath('audio/py_0_007.MP3'),
}, },
{ {
id: 'home-guide-tv', id: 'home-guide-tv',
content: '再点击右侧屏幕就会开启对应的板块学习哦!', content: '再点击右侧屏幕就会开启对应的板块学习哦!',
highlightSelector: '.tvGuide', highlightSelector: '.tvGuide',
positionOffsetY: '80%', positionOffsetY: '80%',
audioSrc: getPublicPath('audio/py_0_008.MP3') audioSrc: getPublicPath('audio/py_0_008.MP3'),
}, },
{ {
id: 'home-guide-skill', id: 'home-guide-skill',
content: '点击技能树,可以一键回顾新手导航和三大创作板块所有的知识点哦!', content: '点击技能树,可以一键回顾新手导航和三大创作板块所有的知识点哦!',
highlightSelector: '.skill-tree', highlightSelector: '.skill-tree',
positionOffsetY: '50%', positionOffsetY: '50%',
audioSrc: getPublicPath('audio/py_0_009.MP3') audioSrc: getPublicPath('audio/py_0_009.MP3'),
} },
] ]
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'),
}, },
{ {
id: 'home-guide-map', id: 'home-guide-map',
content: '我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。', content:
audioSrc: getPublicPath('audio/py_1_002.MP3') '我们会读懂构图、用好光线、灵活运镜,了解基础拍摄技巧和剪映软件的基本操作。',
audioSrc: getPublicPath('audio/py_1_002.MP3'),
}, },
{ {
id: 'home-guide-start-node', id: 'home-guide-start-node',
content: '选择任意节点开始学习~', content: '选择任意节点开始学习~',
highlightSelector: '.guide-content', highlightSelector: '.guide-content',
positionOffsetY: '20%', positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_003.MP3') audioSrc: getPublicPath('audio/py_1_003.MP3'),
} },
] ]
const elementGuideSteps: GuideStep[] = [ const elementGuideSteps: GuideStep[] = [
{ {
id: 'home-guide-start-element', id: 'home-guide-start-element',
content: '选择任意要素开始学习~', content: '选择任意要素开始学习~',
highlightSelector: '.btn',
positionOffsetY: '20%', positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3') audioSrc: getPublicPath('audio/py_1_004.MP3'),
} },
] ]
const handleHomeGuideComplete = () => { const handleHomeGuideComplete = () => {
@@ -137,17 +143,21 @@ const isImageLoading = ref(false)
const imageMap = { const imageMap = {
vlog: IMAGE_PATHS.ACTION.VIDEO1, vlog: IMAGE_PATHS.ACTION.VIDEO1,
film: IMAGE_PATHS.ACTION.VIDEO2, film: IMAGE_PATHS.ACTION.VIDEO2,
interview: IMAGE_PATHS.ACTION.VIDEO3 interview: IMAGE_PATHS.ACTION.VIDEO3,
} }
//获取新手导航引导状态 //获取新手导航引导状态
const isGuideNewUser = computed(() => basicElements.isGuideNewUser) const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
const hasFinishedActionGuide = computed(() => basicElements.guideShown[actionGuideKey] === true) const hasFinishedActionGuide = computed(
() => basicElements.guideShown[actionGuideKey] === true,
)
// 预加载所有图片 // 预加载所有图片
const preloadImages = () => { const preloadImages = () => {
const imageUrls = Object.values(imageMap).map(path => getBackgroundImage(path)) const imageUrls = Object.values(imageMap).map((path) =>
imageUrls.forEach(url => { getBackgroundImage(path),
)
imageUrls.forEach((url) => {
const img = new Image() const img = new Image()
img.src = url img.src = url
}) })
@@ -156,7 +166,11 @@ const preloadImages = () => {
// 事件处理函数 // 事件处理函数
const handleOpenGuide = () => { const handleOpenGuide = () => {
guideShow.value = true guideShow.value = true
if (activeActionGuide.value !== 'home' && isGuideNewUser.value && !hasFinishedActionGuide.value) { if (
activeActionGuide.value !== 'home' &&
isGuideNewUser.value &&
!hasFinishedActionGuide.value
) {
activeActionGuide.value = 'unlocked' activeActionGuide.value = 'unlocked'
} }
} }
@@ -185,7 +199,9 @@ const handleClick = () => {
// console.log(selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1)) // console.log(selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1))
router.push({ router.push({
//首字母大写 //首字母大写
name: selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1) name:
selectedModule.value.charAt(0).toUpperCase() +
selectedModule.value.slice(1),
}) })
} }
@@ -204,7 +220,7 @@ const handleSkill = () => {
if (isSkillDisabled.value) return if (isSkillDisabled.value) return
router.push({ router.push({
name: 'Skill' name: 'Skill',
}) })
} }
@@ -227,10 +243,25 @@ const nodes: GuideNode[] = [
y: 30, y: 30,
hasChildren: true, hasChildren: true,
children: [ children: [
{ id: 1, label: '构图', icon: 'home/action/新手导航构图', routeName: 'Composition' }, {
{ id: 2, label: '光', icon: 'home/action/新手导航光', routeName: 'Light' }, id: 1,
{ id: 3, label: '运镜', icon: 'home/action/新手导航运镜', routeName: 'CameraMovement' } label: '构图',
] icon: 'home/action/新手导航构图',
routeName: 'Composition',
},
{
id: 2,
label: '光',
icon: 'home/action/新手导航光',
routeName: 'Light',
},
{
id: 3,
label: '运镜',
icon: 'home/action/新手导航运镜',
routeName: 'CameraMovement',
},
],
}, },
{ {
id: 2, id: 2,
@@ -239,7 +270,7 @@ const nodes: GuideNode[] = [
x: 50, x: 50,
y: 60, y: 60,
hasChildren: false, hasChildren: false,
routeName: 'BasicShootingSkills' routeName: 'BasicShootingSkills',
}, },
{ {
id: 3, id: 3,
@@ -248,8 +279,8 @@ const nodes: GuideNode[] = [
x: 80, x: 80,
y: 30, y: 30,
hasChildren: false, hasChildren: false,
routeName: 'ShortVideoEditingSkills' routeName: 'ShortVideoEditingSkills',
} },
] ]
// 显示子节点的节点ID // 显示子节点的节点ID
@@ -326,7 +357,10 @@ onMounted(() => {
<!-- 学习进度 --> <!-- 学习进度 -->
<div class="absolute top-0 left-0"> <div class="absolute top-0 left-0">
<ImgButton image="home/action/学习进度入口" @click="handleOpenStudyProgress" /> <ImgButton
image="home/action/学习进度入口"
@click="handleOpenStudyProgress"
/>
</div> </div>
<StudyProgressPopup v-model="studyProgressVisible" /> <StudyProgressPopup v-model="studyProgressVisible" />
@@ -344,11 +378,18 @@ onMounted(() => {
</div> </div>
<!-- 选择模块 --> <!-- 选择模块 -->
<div :class="['absolute left-0 top-[-140px] inset-0 z-[8] pointer-events-none']"> <div
:class="[
'absolute left-0 top-[-140px] inset-0 z-[8] pointer-events-none',
]"
>
<div <div
class="module-switcher-guide absolute w-[1380px] h-[600px] top-[460px] left-[calc(50%-690px)] pointer-events-none" class="module-switcher-guide absolute w-[1380px] h-[600px] top-[460px] left-[calc(50%-690px)] pointer-events-none"
></div> ></div>
<ModuleSwitcher v-model="selectedModule" @change="handleModuleChange" /> <ModuleSwitcher
v-model="selectedModule"
@change="handleModuleChange"
/>
</div> </div>
<!-- 电视机 --> <!-- 电视机 -->
@@ -372,14 +413,16 @@ onMounted(() => {
v-if="isGuideNewUser && !hasFinishedActionGuide" v-if="isGuideNewUser && !hasFinishedActionGuide"
:class="[ :class="[
'absolute inset-0 z-10', 'absolute inset-0 z-10',
activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50' activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50',
]" ]"
></div> ></div>
<!-- 新手指导 --> <!-- 新手指导 -->
<div <div
class="absolute bottom-0 left-0 w-full h-[497px] bg-cover bg-center bg-no-repeat z-20 pointer-events-none" class="absolute bottom-0 left-0 w-full h-[497px] bg-cover bg-center bg-no-repeat z-20 pointer-events-none"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BTN) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BTN),
}"
v-if="!guideShow" v-if="!guideShow"
> >
<div <div
@@ -390,7 +433,9 @@ onMounted(() => {
<div <div
class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40" class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG),
}"
v-else v-else
> >
<ImgButton <ImgButton
@@ -401,13 +446,20 @@ onMounted(() => {
<!-- 新手导航内容 --> <!-- 新手导航内容 -->
<div class="relative w-[880px] h-[400px] mt-20"> <div class="relative w-[880px] h-[400px] mt-20">
<div class="w-full h-full relative guide-content" @click="handleClickOutside"> <div
class="w-full h-full relative guide-content"
@click="handleClickOutside"
>
<!-- 标题区域 --> <!-- 标题区域 -->
<div class="text-center mt-10 mb-8"> <div class="text-center mt-10 mb-8">
<h2 class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"> <h2
class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"
>
新手导航 新手导航
</h2> </h2>
<p class="text-4xl text-white font-bold drop-shadow-md">点击节点即可开始学习</p> <p class="text-4xl text-white font-bold drop-shadow-md">
点击节点即可开始学习
</p>
</div> </div>
<!-- 节点图表 --> <!-- 节点图表 -->
@@ -492,24 +544,37 @@ onMounted(() => {
</div> </div>
<!-- 子节点图标(当点击三大基础要素时显示) --> <!-- 子节点图标(当点击三大基础要素时显示) -->
<div <Transition name="child-node-pop">
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="absolute -top-40 left-1/2 transform -translate-x-1/2 flex z-20 btn"
>
<div <div
v-for="childNode in node.children" v-if="node.hasChildren && showChildrenNodeId === node.id"
:key="childNode.id" class="child-node-buttons btn absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
class="flex flex-col items-center cursor-pointer"
@click.stop="handleChildNodeClick(childNode, $event)"
> >
<!-- 图标 -->
<div <div
class="w-36 flex items-center justify-center transition-all duration-300 hover:scale-110" 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)]"
> >
<img :src="getStaticImage(childNode.icon)" :alt="childNode.label" /> <div
v-for="childNode in node.children"
:key="childNode.id"
class="group/child relative z-10 flex flex-col items-center cursor-pointer"
@click.stop="handleChildNodeClick(childNode, $event)"
>
<!-- 图标 -->
<div
class="relative w-36 flex items-center justify-center transition-all duration-300 hover:scale-110"
>
<div
class="absolute inset-x-5 inset-y-4 rounded-full bg-[#7bfffd]/12 blur-xl opacity-60 transition-opacity duration-300 group-hover/child:opacity-100"
></div>
<img
class="relative z-10 drop-shadow-[0_0_10px_rgba(123,255,253,0.45)] transition-all duration-300 group-hover/child:brightness-125 group-hover/child:drop-shadow-[0_0_18px_rgba(123,255,253,0.8)]"
:src="getStaticImage(childNode.icon)"
:alt="childNode.label"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </Transition>
</div> </div>
</div> </div>
</div> </div>
@@ -517,3 +582,25 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.child-node-pop-enter-active,
.child-node-pop-leave-active {
transition:
opacity 0.22s ease,
transform 0.22s cubic-bezier(0.2, 0.9, 0.2, 1.1);
transform-origin: bottom center;
}
.child-node-pop-enter-from,
.child-node-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.86);
}
.child-node-pop-enter-to,
.child-node-pop-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
</style>
+82 -20
View File
@@ -1,30 +1,47 @@
import { defineComponent, ref } from 'vue' import { computed, defineComponent, ref } from 'vue'
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, ImagePreview } from '@/components' import { GuideSteps, ImagePreview, Video } from '@/components'
import { useScale } from '@/composables/useScale' import { useScale } from '@/composables/useScale'
import { useBasicElementsStore } from '@/stores/modules/basic-elements' import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const CourseFrameGuide = () => ( const CourseFrameGuide = () => (
<div class="flex items-center justify-start"> <div class="flex items-center justify-start">
点击 点击
<img class="w-20" src={getStaticImage(IMAGE_PATHS.BUTTONS.COURSE_FRAME)} alt="" /> <img
class="w-40"
src={getStaticImage(IMAGE_PATHS.BUTTONS.COURSE_FRAME)}
alt=""
/>
查看课程结构,快速了解每个模块的学习内容。 查看课程结构,快速了解每个模块的学习内容。
</div> </div>
) )
const OperateGuide = () => ( const OperateGuide = () => (
<div class="flex items-center justify-start"> <div class="flex items-center justify-start">
<img class="w-20 inline" src={getStaticImage(IMAGE_PATHS.BUTTONS.OPERATE_GUIDE)} alt="" /> <img
包含课程介绍和操作指南,观看指引视频,轻松上手课程。(资源建设中...) class="w-40 inline"
src={getStaticImage(IMAGE_PATHS.BUTTONS.OPERATE_GUIDE)}
alt=""
/>
包含课程介绍和操作指南,点击即可查看!
</div> </div>
) )
const StartFilmingGuide = () => ( const StartFilmingGuide = () => (
<div class="flex items-center justify-start"> <div class="flex items-center justify-start">
点击 点击
<img class="w-20" src={getStaticImage(IMAGE_PATHS.BUTTONS.START_FILMING)} alt="" /> <img
正式进入课程,开启你的短视频拍摄之旅。 class="w-40"
src={getStaticImage(IMAGE_PATHS.BUTTONS.START_FILMING)}
alt=""
/>
,一起我们的短视频学习之旅吧!
</div> </div>
) )
@@ -38,7 +55,7 @@ export default defineComponent({
content: '欢迎来到《短视频达人养成记》网络课程的学习!', content: '欢迎来到《短视频达人养成记》网络课程的学习!',
highlightSelector: null, highlightSelector: null,
audioSrc: getPublicPath('audio/py_0_001.MP3'), audioSrc: getPublicPath('audio/py_0_001.MP3'),
positionOffsetY: '80%' positionOffsetY: '80%',
}, },
{ {
id: 'step-2', id: 'step-2',
@@ -46,7 +63,7 @@ export default defineComponent({
content: CourseFrameGuide, content: CourseFrameGuide,
highlightSelector: '.kecheng-wrapper', highlightSelector: '.kecheng-wrapper',
audioSrc: getPublicPath('audio/py_0_002.MP3'), audioSrc: getPublicPath('audio/py_0_002.MP3'),
positionOffsetY: '40%' positionOffsetY: '40%',
}, },
{ {
id: 'step-3', id: 'step-3',
@@ -54,7 +71,7 @@ export default defineComponent({
content: OperateGuide, content: OperateGuide,
highlightSelector: '.zhiying-wrapper', highlightSelector: '.zhiying-wrapper',
audioSrc: getPublicPath('audio/py_0_003.MP3'), audioSrc: getPublicPath('audio/py_0_003.MP3'),
positionOffsetY: '40%' positionOffsetY: '40%',
}, },
{ {
id: 'step-4', id: 'step-4',
@@ -62,15 +79,27 @@ export default defineComponent({
content: StartFilmingGuide, content: StartFilmingGuide,
highlightSelector: '.start-filming-wrapper', highlightSelector: '.start-filming-wrapper',
audioSrc: getPublicPath('audio/py_0_004.MP3'), audioSrc: getPublicPath('audio/py_0_004.MP3'),
positionOffsetY: '40%' positionOffsetY: '40%',
} },
]) ])
const basicElementsStore = useBasicElementsStore() const basicElementsStore = useBasicElementsStore()
const guideKey = 'home-intro' const guideKey = 'home-intro'
const showGuide = ref(!basicElementsStore.guideShown[guideKey]) const isFirstVisit = !basicElementsStore.guideShown[guideKey]
const showGuide = ref(isFirstVisit)
const { scale, offsetX, offsetY } = useScale() const { scale, offsetX, offsetY } = useScale()
if (showGuide.value) { // 首次进入的视频播放序列
const introVideos = computed(() => [
getPublicPath('video/campus life.mp4'),
getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
])
const introVideoIndex = ref(0)
const showIntroVideo = ref(isFirstVisit)
const currentIntroSrc = computed(
() => introVideos.value[introVideoIndex.value] ?? '',
)
if (isFirstVisit) {
basicElementsStore.markGuideShown(guideKey) basicElementsStore.markGuideShown(guideKey)
} }
@@ -78,11 +107,24 @@ export default defineComponent({
showGuide.value = false showGuide.value = false
} }
const handleIntroVideoEnded = () => {
if (introVideoIndex.value < introVideos.value.length - 1) {
introVideoIndex.value++
} else {
showIntroVideo.value = false
// 引导步骤在视频播放结束后出现
}
}
const openMindMap = () => { const openMindMap = () => {
ImagePreview({ ImagePreview({
images: [getStaticImage('maps/dt')], images: [getStaticImage('maps/dt')],
maskClosable: true, maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value }) getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
}),
}) })
} }
@@ -94,19 +136,39 @@ export default defineComponent({
/> />
<div class="absolute right-[420px] bottom-[200px] start-filming-wrapper"> <div class="absolute right-[420px] bottom-[200px] start-filming-wrapper">
<ImgButton image="home/action-btn" path={{ name: 'Action' }} /> <ImgButton
image="home/action-btn"
path={{ name: 'Action' }}
/>
</div> </div>
<div class="absolute right-20 bottom-36 kecheng-wrapper"> <div class="absolute right-20 bottom-36 kecheng-wrapper">
<ImgButton image={IMAGE_PATHS.BUTTONS.COURSE_FRAME} onClick={openMindMap} /> <ImgButton
image={IMAGE_PATHS.BUTTONS.COURSE_FRAME}
onClick={openMindMap}
/>
</div> </div>
<div class="absolute right-20 bottom-10 zhiying-wrapper"> <div class="absolute right-20 bottom-10 zhiying-wrapper">
<ImgButton image={IMAGE_PATHS.BUTTONS.OPERATE_GUIDE} /> <ImgButton image={IMAGE_PATHS.BUTTONS.OPERATE_GUIDE} />
</div> </div>
{showIntroVideo.value && (
<div class="absolute inset-0 z-[10000] bg-black flex items-center justify-center">
<Video
src={currentIntroSrc.value}
autoplay={true}
muted={false}
controls={true}
volume={1}
fit="contain"
onEnded={handleIntroVideoEnded}
/>
</div>
)}
<GuideSteps <GuideSteps
visible={showGuide.value} visible={showGuide.value && !showIntroVideo.value}
steps={guideSteps.value} steps={guideSteps.value}
highlightPadding={12} highlightPadding={12}
highlightRadius={16} highlightRadius={16}
@@ -116,5 +178,5 @@ export default defineComponent({
/> />
</div> </div>
) )
} },
}) })
+11 -6
View File
@@ -2,7 +2,7 @@
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { IMAGE_PATHS } from '@/utils' import { IMAGE_PATHS } from '@/utils'
import { Video } from '@/components' import { Video } from '@/components'
@@ -19,20 +19,20 @@ const navigationSteps = [
id: 'step1', id: 'step1',
label: '后期制作', label: '后期制作',
content: '声画剪辑流程', content: '声画剪辑流程',
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step2', id: 'step2',
label: '后期制作', label: '后期制作',
content: 'J-cut/L-cut运用', content: 'J-cut/L-cut运用',
type: 'post' as const type: 'post' as const,
}, },
{ {
id: 'step3', id: 'step3',
label: '后期制作', label: '后期制作',
content: '【Q3】节奏剪辑技巧', content: '【Q3】节奏剪辑技巧',
type: 'post' as const type: 'post' as const,
} },
] ]
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
@@ -83,7 +83,12 @@ const handleClose = () => {
title="步骤导航" title="步骤导航"
@click="showStepNavigation = true" @click="showStepNavigation = true"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
+54 -31
View File
@@ -16,61 +16,62 @@ 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: 'InterviewResource', routeName: 'InterviewResource',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '创意构想', label: '创意构想',
routeName: 'InterviewCreative', routeName: 'InterviewCreative',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '脚本撰写', label: '脚本撰写',
routeName: 'InterviewScriptWriting', routeName: 'InterviewScriptWriting',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '分镜制作', label: '分镜制作',
routeName: 'InterviewStoryboard', routeName: 'InterviewStoryboard',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '设备准备', label: '设备准备',
routeName: 'InterviewEquipment', routeName: 'InterviewEquipment',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '实地取景', label: '实地取景',
routeName: 'InterviewShooting', routeName: 'InterviewShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '现场拍摄', label: '现场拍摄',
routeName: 'InterviewOnsiteShooting', routeName: 'InterviewOnsiteShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
// { // {
// label: '素材管理', // label: '素材管理',
@@ -82,26 +83,26 @@ const menuItems = ref([
label: '剪辑', label: '剪辑',
routeName: 'InterviewEditing', routeName: 'InterviewEditing',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '调色', label: '调色',
routeName: 'InterviewColor', routeName: 'InterviewColor',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '音频', label: '音频',
routeName: 'InterviewAudio', routeName: 'InterviewAudio',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '导出发布', label: '导出发布',
routeName: 'InterviewExportPublish', routeName: 'InterviewExportPublish',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
} },
]) ])
const props = defineProps<{ const props = defineProps<{
@@ -119,28 +120,36 @@ const currentParentValue = computed<number>(() => {
return props.currentStep return props.currentStep
}) // 当前选中的父级菜单项 }) // 当前选中的父级菜单项
const currentItemValue = computed<string>(() => { const currentItemValue = ref<string>('') // 当前选中的子级菜单项
return router.currentRoute.value.name?.toString() ?? ''
})
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 }) => {
if (item.disabled) return if (item.disabled) return
currentItemValue.value = item.routeName
router.push({ name: item.routeName }) router.push({ name: item.routeName })
} }
</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 +157,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,13 +175,22 @@ 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', isViewed(item.routeName) ? 'brightness-90' : 'brightness-50']" class="w-10"
src="@/assets/images/midterm/完成.png" :src="
isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png'
: '/src/assets/images/midterm/未完成.png'
"
alt="" alt=""
/> />
</span> </span>
@@ -179,8 +202,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 {
@@ -2,7 +2,7 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ImgButton, Video } from '@/components' import { ImgButton, Video } from '@/components'
import { StepNavigation } from '@/components/Guide' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils' import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
const router = useRouter() const router = useRouter()
@@ -16,30 +16,32 @@ const navigationSteps = ref([
label: '中期拍摄', label: '中期拍摄',
content: '素材怎么拍', content: '素材怎么拍',
videoUrl: getPublicPath('video/interview/on-site/ftmk_sczmp_batch.mp4'), videoUrl: getPublicPath('video/interview/on-site/ftmk_sczmp_batch.mp4'),
type: 'shooting' as const type: 'shooting' as const,
}, },
{ {
id: 'q1-required-shots', id: 'q1-required-shots',
label: '中期拍摄', label: '中期拍摄',
content: '必拍镜头有哪些', content: '必拍镜头有哪些',
videoUrl: getPublicPath('video/interview/on-site/ftmk_bpjtynx_batch.mp4'), videoUrl: getPublicPath('video/interview/on-site/ftmk_bpjtynx_batch.mp4'),
type: 'shooting' as const type: 'shooting' as const,
}, },
{ {
id: 'q2-moving-shot', id: 'q2-moving-shot',
label: '中期拍摄', label: '中期拍摄',
content: '移动镜头的作用', content: '移动镜头的作用',
type: 'shooting' as const type: 'shooting' as const,
}, },
{ {
id: 'q2-close-up', id: 'q2-close-up',
label: '中期拍摄', label: '中期拍摄',
content: '特写镜头的作用', content: '特写镜头的作用',
type: 'shooting' as const type: 'shooting' as const,
} },
]) ])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl) const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl,
)
const back = () => { const back = () => {
router.back() router.back()
@@ -92,7 +94,12 @@ const handleEnded = () => {
title="资源列表" title="资源列表"
@click="showStepNavigation = true" @click="showStepNavigation = true"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path <path
d="M3 4H17M3 8H17M3 12H17M3 16H17" d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor" stroke="currentColor"
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more