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-08 09:44:59 +08:00
1 parent fc9633f371
commit 6f8252472f
114 files changed
+2677 -1717

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-container" :style="containerStyle">
<div class="app-content" :style="scaleStyle">
<div class="app-content overflow-hidden" :style="scaleStyle">
<router-view v-slot="{ Component }">
<KeepAlive :include="['Action', 'Skill']">
<component :is="Component" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getStaticImage } from '@/utils'
import type { AnchoredProgressBarProps, ProgressAnchor } from './types'
defineOptions({
name: 'AnchoredProgressBar'
})
const props = withDefaults(defineProps<AnchoredProgressBarProps>(), {
min: 0,
max: 100,
showPercent: true,
nodeImage: '',
trackClass: 'bg-black/20',
fillClass: 'bg-[#31effa] shadow-[0_0_26px_rgba(49,239,250,0.95)]',
markerClass: 'bg-white shadow-[0_0_12px_rgba(255,255,255,0.9)]',
labelClass: 'text-white font-[1000] tracking-wider',
childLabelClass: 'text-[#43f3f2] font-[1000] tracking-wider',
percentClass: 'text-white font-[1000] italic',
labelTop: '0%',
childLabelTop: '26%',
barTop: '56%',
barHeight: '26%',
markerTop: '48%',
markerHeight: '42%',
markerWidth: '0.55%',
childMarkerTop: '56%',
childMarkerHeight: '26%',
childMarkerWidth: '0.22%',
nodeTop: '39%',
nodeHeight: '58%',
percentTop: '76%',
labelStyle: () => ({}),
childLabelStyle: () => ({}),
percentStyle: () => ({})
})
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const toPercent = (value: number) => {
const range = props.max - props.min
if (range <= 0) return 0
return clamp(((value - props.min) / range) * 100, 0, 100)
}
const progressPercent = computed(() => toPercent(props.progress))
const nodeSrc = computed(() => (props.nodeImage ? getStaticImage(props.nodeImage) : ''))
const markerAnchors = computed(() => {
const anchorsByValue = new Map<number, ProgressAnchor & { markerType: 'major' | 'minor' }>()
props.anchors.forEach(anchor => {
anchorsByValue.set(toPercent(anchor.value), { ...anchor, markerType: 'major' })
anchor.children?.forEach(child => {
const left = toPercent(child.value)
if (!anchorsByValue.has(left)) {
anchorsByValue.set(left, { ...child, markerType: 'minor' })
}
})
})
anchorsByValue.set(0, { id: 'start', label: '', value: props.min, markerType: 'major' })
anchorsByValue.set(100, { id: 'end', label: '', value: props.max, markerType: 'major' })
return [...anchorsByValue.entries()]
.sort(([leftA], [leftB]) => leftA - leftB)
.map(([left, anchor]) => ({ ...anchor, left }))
})
const anchorWithPercent = computed(() =>
props.anchors.map(anchor => ({
...anchor,
left: toPercent(anchor.value),
children: anchor.children?.map(child => ({ ...child, left: toPercent(child.value) }))
}))
)
</script>
<template>
<div class="relative h-full w-full">
<template v-for="anchor in anchorWithPercent" :key="anchor.id">
<div
v-if="anchor.label"
class="absolute whitespace-nowrap font-[1000]"
:class="[labelClass, anchor.left >= 98 ? '-translate-x-full' : '-translate-x-0']"
:style="{
...labelStyle,
top: anchor.labelPosition === 'bottom' ? percentTop : labelTop,
left: `${anchor.left}%`
}"
>
{{ anchor.label }}
</div>
<div
v-for="child in anchor.children"
:key="child.id"
class="absolute -translate-x-1/2 whitespace-nowrap font-bold leading-none"
:class="childLabelClass"
:style="{ ...childLabelStyle, top: childLabelTop, left: `${child.left}%` }"
>
{{ child.label }}
</div>
</template>
<div
class="absolute left-0 w-full"
:class="trackClass"
:style="{ top: barTop, height: barHeight }"
></div>
<div
class="absolute left-0 rounded-full transition-all duration-700 ease-out"
:class="fillClass"
:style="{ top: barTop, height: barHeight, width: `${progressPercent}%` }"
></div>
<div
v-for="anchor in markerAnchors"
:key="anchor.id"
class="absolute rounded-full"
:class="markerClass"
:style="{
top: anchor.markerType === 'major' ? markerTop : childMarkerTop,
height: anchor.markerType === 'major' ? markerHeight : childMarkerHeight,
width: anchor.markerType === 'major' ? markerWidth : childMarkerWidth,
left: `${anchor.left}%`
}"
></div>
<img
v-if="nodeSrc"
class="absolute w-auto -translate-x-1/2 transition-all duration-700 ease-out"
:src="nodeSrc"
:style="{ top: nodeTop, height: nodeHeight, left: `${progressPercent}%` }"
alt=""
/>
<div
v-else
class="absolute aspect-square -translate-x-1/2 rounded-full border-[6px] border-[#31effa] bg-white/20 transition-all duration-700 ease-out"
:style="{ top: nodeTop, height: nodeHeight, left: `${progressPercent}%` }"
></div>
<div
v-if="showPercent"
class="absolute -translate-x-1/2 whitespace-nowrap"
:class="percentClass"
:style="{ ...percentStyle, top: percentTop, left: `${progressPercent}%` }"
>
{{ Math.round(progressPercent) }}%
</div>
<div
v-if="showPercent"
class="absolute right-0"
:class="percentClass"
:style="{ ...percentStyle, top: percentTop }"
>
100%
</div>
</div>
</template>
@@ -0,0 +1,2 @@
export { default as AnchoredProgressBar } from './AnchoredProgressBar.vue'
export type * from './types'
@@ -0,0 +1,38 @@
export type ProgressAnchor = {
id: string
label: string
value: number
labelPosition?: 'top' | 'bottom'
children?: ProgressAnchor[]
}
export type AnchoredProgressBarProps = {
progress: number
anchors: ProgressAnchor[]
min?: number
max?: number
showPercent?: boolean
nodeImage?: string
trackClass?: string
fillClass?: string
markerClass?: string
labelClass?: string
childLabelClass?: string
percentClass?: string
labelTop?: string
childLabelTop?: string
barTop?: string
barHeight?: string
markerTop?: string
markerHeight?: string
markerWidth?: string
childMarkerTop?: string
childMarkerHeight?: string
childMarkerWidth?: string
nodeTop?: string
nodeHeight?: string
percentTop?: string
labelStyle?: Record<string, string>
childLabelStyle?: Record<string, string>
percentStyle?: Record<string, string>
}
+1 -1
View File
@@ -27,7 +27,7 @@
<img
v-else-if="moduleName === 'interview'"
:src="getStaticImage('midterm/中期拍摄')"
:src="getStaticImage('interview/制作变焦镜-讲述我的育人故事')"
alt="制作变焦镜-我的志愿者手记"
class="title-image"
/>
+1
View File
@@ -10,6 +10,7 @@ export * from './Guide'
// 进度条组件
export * from './ProgressBar'
export * from './AnchoredProgressBar'
// 切换组件
export * from './Switcher'
+1 -9
View File
@@ -97,7 +97,7 @@ const Interview: RouteRecordRaw[] = [
},
{
path: '/interview/filmingLocation-b',
name: 'InterviewFilmingLocationA',
name: 'InterviewFilmingLocationB',
component: () =>
import('@/views/interview/midterm-shooting/shooting/filmingLocation-b/index.vue'),
meta: {
@@ -113,14 +113,6 @@ const Interview: RouteRecordRaw[] = [
title: '剪辑'
}
},
// {
// path: '/interview/adjustmentColor',
// name: 'InterviewAdjustmentColor',
// component: () => import('@/views/interview/after-editing/adjustmentColor.vue'),
// meta: {
// title: '调色'
// }
// },
{
path: '/interview/color-practice',
name: 'InterviewColorPractice',
+8 -8
View File
@@ -159,14 +159,6 @@ const Vlog: RouteRecordRaw[] = [
title: '后期剪辑-剪辑'
}
},
// {
// path: '/vlog/adjustmentColor',
// name: 'VlogAdjustmentColor',
// component: () => import('@/views/vlog/after-editing/adjustmentColor.vue'),
// meta: {
// title: '后期剪辑-调色'
// }
// },
{
path: '/vlog/color',
name: 'VlogColor',
@@ -175,6 +167,14 @@ const Vlog: RouteRecordRaw[] = [
title: '后期剪辑-调色'
}
},
{
path: '/vlog/color-practice',
name: 'VlogColorPractice',
component: () => import('@/views/vlog/after-editing/color-practice.vue'),
meta: {
title: '后期剪辑-调色实践'
}
},
{
path: '/vlog/export-publish',
name: 'VlogExportPublish',
+15 -2
View File
@@ -9,6 +9,7 @@ interface BasicElementsSavePayload {
isGuideBasicElementsTask?: boolean
isBasicElementsUnlock?: boolean
basicElementsUnlockProgress?: number
guideShown?: Record<string, boolean>
}
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -36,6 +37,7 @@ export const useBasicElementsStore = defineStore(
const isBasicElementsUnlock = ref<boolean>(false)
//basic-elements-发现与记录解锁进度
const basicElementsUnlockProgress = ref<number>(0)
const guideShown = ref<Record<string, boolean>>({})
//Actions
function saveBasicElementsStore(): void {
@@ -43,7 +45,8 @@ export const useBasicElementsStore = defineStore(
isGuideNewUser: isGuideNewUser.value,
isGuideBasicElementsTask: isGuideBasicElementsTask.value,
isBasicElementsUnlock: isBasicElementsUnlock.value,
basicElementsUnlockProgress: basicElementsUnlockProgress.value
basicElementsUnlockProgress: basicElementsUnlockProgress.value,
guideShown: guideShown.value
}
getYibanApi()
@@ -66,6 +69,7 @@ export const useBasicElementsStore = defineStore(
isGuideBasicElementsTask.value = data.isGuideBasicElementsTask ?? true
isBasicElementsUnlock.value = data.isBasicElementsUnlock ?? false
basicElementsUnlockProgress.value = data.basicElementsUnlockProgress ?? 0
guideShown.value = data.guideShown ?? {}
} catch (error) {
console.error('Failed to load Basic Elements store', error)
}
@@ -91,11 +95,18 @@ export const useBasicElementsStore = defineStore(
saveBasicElementsStore()
}
function markGuideShown(key: string): void {
if (!key) return
guideShown.value[key] = true
saveBasicElementsStore()
}
function resetBasicElementsState(): void {
isGuideNewUser.value = true
isGuideBasicElementsTask.value = true
isBasicElementsUnlock.value = false
basicElementsUnlockProgress.value = 0
guideShown.value = {}
saveBasicElementsStore()
}
@@ -105,6 +116,7 @@ export const useBasicElementsStore = defineStore(
setIsGuideBasicElementsTask,
setIsBasicElementsUnlock,
setBasicElementsUnlockProgress,
markGuideShown,
saveBasicElementsStore,
loadBasicElementsStore,
resetBasicElementsState,
@@ -112,7 +124,8 @@ export const useBasicElementsStore = defineStore(
isGuideNewUser,
isGuideBasicElementsTask,
isBasicElementsUnlock,
basicElementsUnlockProgress
basicElementsUnlockProgress,
guideShown
}
},
{
+105 -10
View File
@@ -8,11 +8,26 @@ export const FILM_KEY = 4
interface FilmSavePayload {
viewed?: Record<string, boolean>
isGuideFilmTask?: boolean
hasShownGuideFilmTask?: boolean
isGuideFilmReturn?: boolean
isGuideFilmStage2?: boolean
isGuideFilmStage3?: boolean
isFilmUnlock?: boolean
FilmUnlockProgress?: number
filmMenuVisible?: boolean
editingVideosWatched?: Record<string, boolean>
isGuideFilmEditingComplete?: boolean
guideShown?: Record<string, boolean>
}
export const FILM_EDITING_VIDEO_KEYS = [
'start',
'rhythm',
'soundTransition',
'cut',
'association',
'continuity'
] as const
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
try {
return await import('@/utils/yiban')
@@ -38,20 +53,38 @@ export const useFilmStore = defineStore(
//Film-任务取景框引导
const isGuideFilmTask = ref<boolean>(true)
const hasShownGuideFilmTask = ref<boolean>(false)
//Film-思路提点完成回到首页的一次性引导
const isGuideFilmReturn = ref<boolean>(true)
//Film-中期拍摄阶段一次性引导
const isGuideFilmStage2 = ref<boolean>(true)
//Film-后期剪辑阶段一次性引导
const isGuideFilmStage3 = ref<boolean>(true)
//Film-是否解锁完成前置(观看视频)
const isFilmUnlock = ref<boolean>(false)
//Film-发现与记录解锁进度
const FilmUnlockProgress = ref<number>(0)
//Film-菜单 Overlay 是否可见
const filmMenuVisible = ref<boolean>(false)
//Film-后期剪辑6个子视频已观看记录
const editingVideosWatched = ref<Record<string, boolean>>({})
//Film-后期剪辑6个视频学完后的一次性引导
const isGuideFilmEditingComplete = ref<boolean>(true)
const guideShown = ref<Record<string, boolean>>({})
//Actions
function saveFilmStore(): void {
const payload: FilmSavePayload = {
viewed: viewed.value,
isGuideFilmTask: isGuideFilmTask.value,
hasShownGuideFilmTask: hasShownGuideFilmTask.value,
isGuideFilmReturn: isGuideFilmReturn.value,
isGuideFilmStage2: isGuideFilmStage2.value,
isGuideFilmStage3: isGuideFilmStage3.value,
isFilmUnlock: isFilmUnlock.value,
FilmUnlockProgress: FilmUnlockProgress.value
FilmUnlockProgress: FilmUnlockProgress.value,
filmMenuVisible: filmMenuVisible.value,
editingVideosWatched: editingVideosWatched.value,
isGuideFilmEditingComplete: isGuideFilmEditingComplete.value,
guideShown: guideShown.value
}
getYibanApi()
@@ -70,9 +103,15 @@ export const useFilmStore = defineStore(
const data = JSON.parse(raw) as FilmSavePayload
viewed.value = data.viewed ?? {}
isGuideFilmTask.value = data.isGuideFilmTask ?? true
hasShownGuideFilmTask.value = data.hasShownGuideFilmTask ?? false
isGuideFilmReturn.value = data.isGuideFilmReturn ?? true
isGuideFilmStage2.value = data.isGuideFilmStage2 ?? true
isGuideFilmStage3.value = data.isGuideFilmStage3 ?? true
isFilmUnlock.value = data.isFilmUnlock ?? false
FilmUnlockProgress.value = data.FilmUnlockProgress ?? 0
filmMenuVisible.value = data.filmMenuVisible ?? false
editingVideosWatched.value = data.editingVideosWatched ?? {}
isGuideFilmEditingComplete.value = data.isGuideFilmEditingComplete ?? true
guideShown.value = data.guideShown ?? {}
} catch (error) {
console.error('Failed to load Film store', error)
}
@@ -104,11 +143,48 @@ export const useFilmStore = defineStore(
saveFilmStore()
}
function setHasShownGuideFilmTask(value: boolean): void {
hasShownGuideFilmTask.value = value
function setIsGuideFilmReturn(value: boolean): void {
isGuideFilmReturn.value = value
saveFilmStore()
}
function setIsGuideFilmStage2(value: boolean): void {
isGuideFilmStage2.value = value
saveFilmStore()
}
function setIsGuideFilmStage3(value: boolean): void {
isGuideFilmStage3.value = value
saveFilmStore()
}
function setFilmMenuVisible(value: boolean): void {
filmMenuVisible.value = value
saveFilmStore()
}
function markEditingVideoWatched(key: string): void {
if (!key) return
if (editingVideosWatched.value[key]) return
editingVideosWatched.value[key] = true
saveFilmStore()
}
function setIsGuideFilmEditingComplete(value: boolean): void {
isGuideFilmEditingComplete.value = value
saveFilmStore()
}
function markGuideShown(key: string): void {
if (!key) return
guideShown.value[key] = true
saveFilmStore()
}
const allEditingVideosWatched = computed<boolean>(() =>
FILM_EDITING_VIDEO_KEYS.every(key => editingVideosWatched.value[key] === true)
)
// 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表)
const stepRouteNames = moduleStepMap.film
@@ -135,9 +211,15 @@ export const useFilmStore = defineStore(
function resetFilmState(): void {
viewed.value = {}
isGuideFilmTask.value = true
hasShownGuideFilmTask.value = false
isGuideFilmReturn.value = true
isGuideFilmStage2.value = true
isGuideFilmStage3.value = true
isFilmUnlock.value = false
FilmUnlockProgress.value = 0
filmMenuVisible.value = false
editingVideosWatched.value = {}
isGuideFilmEditingComplete.value = true
guideShown.value = {}
saveFilmStore()
}
@@ -147,7 +229,13 @@ export const useFilmStore = defineStore(
setIsGuideFilmTask,
setIsFilmUnlock,
setFilmUnlockProgress,
setHasShownGuideFilmTask,
setIsGuideFilmReturn,
setIsGuideFilmStage2,
setIsGuideFilmStage3,
setFilmMenuVisible,
markEditingVideoWatched,
setIsGuideFilmEditingComplete,
markGuideShown,
saveFilmStore,
loadFilmStore,
resetFilmState,
@@ -158,7 +246,14 @@ export const useFilmStore = defineStore(
isGuideFilmTask,
isFilmUnlock,
FilmUnlockProgress,
hasShownGuideFilmTask
isGuideFilmReturn,
isGuideFilmStage2,
isGuideFilmStage3,
filmMenuVisible,
editingVideosWatched,
isGuideFilmEditingComplete,
guideShown,
allEditingVideosWatched
}
},
{
+67 -2
View File
@@ -8,8 +8,13 @@ export const INTERVIEW_KEY = 3
interface InterviewSavePayload {
viewed?: Record<string, boolean>
isGuideInterviewTask?: boolean
isGuideInterviewReturn?: boolean
isGuideInterviewStage2?: boolean
isGuideInterviewStage3?: boolean
isInterviewUnlock?: boolean
InterviewUnlockProgress?: number
interviewMenuVisible?: boolean
guideShown?: Record<string, boolean>
}
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -37,18 +42,32 @@ export const useInterviewStore = defineStore(
//Interview-任务取景框引导
const isGuideInterviewTask = ref<boolean>(true)
//Interview-思路提点完成回到首页的一次性引导
const isGuideInterviewReturn = ref<boolean>(true)
//Interview-中期拍摄阶段菜单一次性引导
const isGuideInterviewStage2 = ref<boolean>(true)
//Interview-后期剪辑阶段菜单一次性引导
const isGuideInterviewStage3 = ref<boolean>(true)
//Interview-是否解锁完成前置(观看视频)
const isInterviewUnlock = ref<boolean>(false)
//Interview-发现与记录解锁进度
const InterviewUnlockProgress = ref<number>(0)
//Interview-菜单 Overlay 是否可见
const interviewMenuVisible = ref<boolean>(false)
const guideShown = ref<Record<string, boolean>>({})
//Actions
function saveInterviewStore(): void {
const payload: InterviewSavePayload = {
viewed: viewed.value,
isGuideInterviewTask: isGuideInterviewTask.value,
isGuideInterviewReturn: isGuideInterviewReturn.value,
isGuideInterviewStage2: isGuideInterviewStage2.value,
isGuideInterviewStage3: isGuideInterviewStage3.value,
isInterviewUnlock: isInterviewUnlock.value,
InterviewUnlockProgress: InterviewUnlockProgress.value
InterviewUnlockProgress: InterviewUnlockProgress.value,
interviewMenuVisible: interviewMenuVisible.value,
guideShown: guideShown.value
}
getYibanApi()
@@ -69,8 +88,13 @@ export const useInterviewStore = defineStore(
const data = JSON.parse(raw) as InterviewSavePayload
viewed.value = data.viewed ?? {}
isGuideInterviewTask.value = data.isGuideInterviewTask ?? true
isGuideInterviewReturn.value = data.isGuideInterviewReturn ?? true
isGuideInterviewStage2.value = data.isGuideInterviewStage2 ?? true
isGuideInterviewStage3.value = data.isGuideInterviewStage3 ?? true
isInterviewUnlock.value = data.isInterviewUnlock ?? false
InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0
interviewMenuVisible.value = data.interviewMenuVisible ?? false
guideShown.value = data.guideShown ?? {}
} catch (error) {
console.error('Failed to load Interview store', error)
}
@@ -112,6 +136,21 @@ export const useInterviewStore = defineStore(
saveInterviewStore()
}
function setIsGuideInterviewReturn(value: boolean): void {
isGuideInterviewReturn.value = value
saveInterviewStore()
}
function setIsGuideInterviewStage2(value: boolean): void {
isGuideInterviewStage2.value = value
saveInterviewStore()
}
function setIsGuideInterviewStage3(value: boolean): void {
isGuideInterviewStage3.value = value
saveInterviewStore()
}
function setIsInterviewUnlock(value: boolean): void {
isInterviewUnlock.value = value
saveInterviewStore()
@@ -123,11 +162,27 @@ export const useInterviewStore = defineStore(
saveInterviewStore()
}
function setInterviewMenuVisible(value: boolean): void {
interviewMenuVisible.value = value
saveInterviewStore()
}
function markGuideShown(key: string): void {
if (!key) return
guideShown.value[key] = true
saveInterviewStore()
}
function resetInterviewState(): void {
viewed.value = {}
isGuideInterviewTask.value = true
isGuideInterviewReturn.value = true
isGuideInterviewStage2.value = true
isGuideInterviewStage3.value = true
isInterviewUnlock.value = false
InterviewUnlockProgress.value = 0
interviewMenuVisible.value = false
guideShown.value = {}
saveInterviewStore()
}
@@ -135,8 +190,13 @@ export const useInterviewStore = defineStore(
//State
markViewed,
setIsGuideInterviewTask,
setIsGuideInterviewReturn,
setIsGuideInterviewStage2,
setIsGuideInterviewStage3,
setIsInterviewUnlock,
setInterviewUnlockProgress,
setInterviewMenuVisible,
markGuideShown,
resetInterviewState,
loadInterviewStore,
saveInterviewStore,
@@ -145,8 +205,13 @@ export const useInterviewStore = defineStore(
progressPercent,
viewed,
isGuideInterviewTask,
isGuideInterviewReturn,
isGuideInterviewStage2,
isGuideInterviewStage3,
isInterviewUnlock,
InterviewUnlockProgress
InterviewUnlockProgress,
interviewMenuVisible,
guideShown
}
},
{
+15 -2
View File
@@ -15,6 +15,7 @@ interface VlogSavePayload {
isVlogUnlock?: boolean
vlogUnlockProgress?: number
vlogMenuVisible?: boolean
guideShown?: Record<string, boolean>
}
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -58,6 +59,7 @@ export const useVlogStore = defineStore(
//vlog-发现与记录解锁进度
const vlogUnlockProgress = ref<number>(0)
const guideShown = ref<Record<string, boolean>>({})
//Actions
function saveVlogStore(): void {
@@ -70,7 +72,8 @@ export const useVlogStore = defineStore(
isGuideVlogStage3: isGuideVlogStage3.value,
isVlogUnlock: isVlogUnlock.value,
vlogUnlockProgress: vlogUnlockProgress.value,
vlogMenuVisible: vlogMenuVisible.value
vlogMenuVisible: vlogMenuVisible.value,
guideShown: guideShown.value
}
getYibanApi()
@@ -96,6 +99,7 @@ export const useVlogStore = defineStore(
isVlogUnlock.value = data.isVlogUnlock ?? false
vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0
vlogMenuVisible.value = data.vlogMenuVisible ?? false
guideShown.value = data.guideShown ?? {}
} catch (error) {
console.error('Failed to load Vlog store', error)
}
@@ -152,6 +156,12 @@ export const useVlogStore = defineStore(
saveVlogStore()
}
function markGuideShown(key: string): void {
if (!key) return
guideShown.value[key] = true
saveVlogStore()
}
// 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表)
const stepRouteNames = moduleStepMap.vlog
@@ -184,6 +194,7 @@ export const useVlogStore = defineStore(
isGuideVlogStage3.value = true
isVlogUnlock.value = false
vlogMenuVisible.value = false
guideShown.value = {}
saveVlogStore()
}
@@ -201,6 +212,7 @@ export const useVlogStore = defineStore(
loadVlogStore,
resetVlogState,
setVlogUnlockProgress,
markGuideShown,
//Actions
completedStep,
progressPercent,
@@ -212,7 +224,8 @@ export const useVlogStore = defineStore(
isGuideVlogStage3,
isVlogUnlock,
vlogUnlockProgress,
vlogMenuVisible
vlogMenuVisible,
guideShown
}
},
{
+72 -30
View File
@@ -1,12 +1,7 @@
import { useUserStore } from '@/stores'
declare const $: {
extend(value: Record<string, unknown>): void
toJSON(value: unknown): string
parseJSON(value: string): unknown
getUrlVars?: () => string[] & Record<string, string | undefined>
getUrlVar?: (name: string) => string | undefined
}
// 声明外部依赖(由运行环境提供)
declare const $: any
declare const iscallyb: boolean
declare const emykey: string
declare function videoAjax(): VideoAjax
@@ -14,6 +9,11 @@ declare function getFutureDa(days: number): string
declare function getCurrentDa(str: string): void
declare function formatDateFromMilliseconds(date: Date): string
// 本地或非易班环境下,外部全局变量可能未注入;统一用安全访问
const isYibanRuntime = (): boolean => {
return typeof iscallyb !== 'undefined' && iscallyb === true
}
interface UserDefineParam {
lessonId: string
key?: string | number
@@ -46,44 +46,48 @@ interface VideoAjax {
finishCourse(param: { order: number; co: string; process: number }): void
}
// 扩展 jQuery,获取 URL 参数
$.extend({
getUrlVars(): string[] & Record<string, string | undefined> {
const vars = [] as unknown as string[] & Record<string, string | undefined>
const vars: any[] & Record<string, string | undefined> = [] as any
let hash: string[]
const queryIndex = window.location.href.indexOf('?')
if (queryIndex === -1) return vars
const hashes = window.location.href
.slice(queryIndex + 1)
.split('&')
.filter(Boolean)
for (const item of hashes) {
const [keyRaw = '', valueRaw = ''] = item.split('=')
for (let i = 0; i < hashes.length; i++) {
const item = hashes[i]
if (!item) continue
hash = item.split('=')
const keyRaw = hash[0] ?? ''
const valueRaw = hash[1] ?? ''
const key = decodeURIComponent(keyRaw)
const value = decodeURIComponent(valueRaw)
vars.push(key)
vars[key] = value
}
return vars
},
getUrlVar(name: string): string | undefined {
return $.getUrlVars?.()[name]
return ($ as any).getUrlVars()[name]
}
})
const LESSON_PREFIX = 'dsp'
const videoajax: VideoAjax = videoAjax()
videoajax.setHost('/')
const videoajax: VideoAjax | null = typeof videoAjax !== 'undefined' ? videoAjax() : null
videoajax?.setHost('/')
function normalizeLessonId(lessonId: string | number): string {
const raw = String(lessonId)
return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}`
}
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key: string | number, value: string): void {
if (iscallyb) {
if (isYibanRuntime()) {
setUserDefine(1, key, value)
} else {
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
@@ -92,46 +96,57 @@ function setLtDefine(key: string | number, value: string): void {
let mycstr9 = ''
// 获取定义数据(由于底层是异步接口,这里返回 Promise)
async function getLtDefine(key: string | number): Promise<string> {
if (iscallyb) {
if (isYibanRuntime()) {
if (mycstr9 === '') {
const all = await getUsersDefine(1, emykey)
if (all === undefined || all === null || all === 'null' || all === '') {
mycstr9 = getFutureDa(10)
setLtDefine(emykey, mycstr9)
} else {
mycstr9 = all as string
mycstr9 = all as string // 这里是 emykey,对应上面 getUsersDefine 的 string 分支
}
}
const str = mycstr9 === undefined || mycstr9 === null || mycstr9 === 'null' ? '' : mycstr9
getCurrentDa(str)
const objitem = await getUserDefine(1, key)
return objitem === undefined || objitem === null || objitem === 'null' ? '' : objitem
}
} else {
const itemvalue = sessionStorage.getItem(`${LESSON_PREFIX}_${key}`)
return itemvalue === undefined || itemvalue === null || itemvalue === 'null' ? '' : itemvalue
}
}
export function setCourseNotepad(collect: string[]): void {
// 设置课程笔记10002
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function setCourseNotepad(collect: string[]): void {
// 保持和原逻辑一致:序列化为 JSON 存储
setLtDefine(10002, $.toJSON(collect))
}
export async function getCourseNotepad(): Promise<string[]> {
// 获取课程笔记10002(异步)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getCourseNotepad(): Promise<string[]> {
const raw = await getLtDefine(10002)
if (raw === undefined || raw === null || raw === '') {
// 原逻辑:返回长度为 9 的空字符串数组
return ['', '', '', '', '', '', '', '', '']
}
return $.parseJSON(raw) as string[]
}
// lessonId(课时唯一ID)
export function setUserDefine(
lessonId: string | number,
key: string | number,
value: string
): void {
if (!isYibanRuntime() || !videoajax) {
sessionStorage.setItem(`${normalizeLessonId(lessonId)}_${key}`, value)
return
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
key,
@@ -140,22 +155,31 @@ export function setUserDefine(
videoajax.addUserDefine(param)
}
// 内部:将回调形式封装为 Promise
function getUserDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string> {
if (!isYibanRuntime() || !videoajax) {
const value = sessionStorage.getItem(`${normalizeLessonId(lessonId)}_${key}`)
return Promise.resolve(value === null || value === 'null' ? '' : value)
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
order: key
}
const ajax = videoajax
return new Promise(resolve => {
let content = ''
videoajax.getUserDefineInfoByUser(param, res => {
ajax.getUserDefineInfoByUser(param, res => {
// 保留原有 console 行为
console.log(res)
if (res && res.code === 200) {
const json = (res.data.value || []) as VideoAjaxResponseItem[]
content = json[0]?.content || ''
const contents = json[0]?.content || ''
content = contents
} else {
content = ''
}
@@ -164,6 +188,7 @@ function getUserDefineAsyncInternal(
})
}
// Promise 版本:获取用户自定义数据
export async function getUserDefine(
lessonId: string | number,
key: string | number
@@ -171,18 +196,24 @@ export async function getUserDefine(
return getUserDefineAsyncInternal(lessonId, key)
}
// 内部:获取【所有】用户自定义数据(Promise 封装)
function getUsersDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string | VideoAjaxResponseItem[]> {
if (!isYibanRuntime() || !videoajax) {
return Promise.resolve('')
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
order: key
}
const ajax = videoajax
return new Promise(resolve => {
let content: string | VideoAjaxResponseItem[] = ''
videoajax.getUserDefineInfo(param, data => {
ajax.getUserDefineInfo(param, data => {
if (data && data.code === 200) {
const json = (data.data.value || []) as VideoAjaxResponseItem[]
if (json.length > 1) {
@@ -208,6 +239,7 @@ function getUsersDefineAsyncInternal(
})
}
// Promise 版本:获取【所有】用户自定义数据
export async function getUsersDefine(
lessonId: string | number,
key: string | number
@@ -215,12 +247,22 @@ export async function getUsersDefine(
return getUsersDefineAsyncInternal(lessonId, key)
}
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
export async function setCourseTimeProgress(order: number): Promise<void> {
const courseid = useUserStore().courseId
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
// const courseid = await getLtDefine('10000')
console.info(`courseid:${courseid} order:${order}`)
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
if (
isYibanRuntime() &&
videoajax &&
courseid !== undefined &&
courseid !== null &&
courseid !== ''
) {
if (order > 0) {
videoajax.finishCourse({ order, co: courseid, process: 100 })
const param2 = { order, co: courseid, process: 100 }
videoajax.finishCourse(param2)
}
}
}
@@ -5,21 +5,16 @@ import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
// 视频导入
// 手机摄像头介绍
import video1 from '/video/new/xinshou_shoujishexiangtoujieshao.mp4'
// 景别概念介绍 - 暂时用其他视频代替
import video2 from '/video/new/xsdh_jbgnjs_batch.mp4'
// 手机慢动作 - 暂时用其他视频代替
import video3 from '/video/new/xsdh_sjmdz_batch.mp4'
// 延时摄影 - 暂时用其他视频代替
import video4 from '/video/new/xsdh_yssy_batch.mp4'
// 神奇的微距摄影
import video5 from '/video/new/xsdh_sqdwjsy_batch.mp4'
// 夜景摄影 - 暂时用其他视频代替
import video6 from '/video/new/xsdh_djmldyjsy_batch.mp4'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-shooting-skills-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
@@ -32,36 +27,42 @@ const rawSteps = [
id: 'step2',
label: '摄影基础',
content: '手机摄像头介绍',
videoUrl: getPublicPath('video/new/shooting-skills/xinshou_shoujishexiangtoujieshao.mp4'),
type: 'post' as const
},
{
id: 'step3',
label: '摄影基础',
content: '景别概念介绍',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_jbgnjs_batch.mp4'),
type: 'post' as const
},
{
id: 'step4',
label: '摄影基础',
content: '手机慢动作',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sjmdz_batch.mp4'),
type: 'post' as const
},
{
id: 'step5',
label: '摄影基础',
content: '延时摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_yssy_batch.mp4'),
type: 'post' as const
},
{
id: 'step6',
label: '摄影基础',
content: '神奇的微距摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_sqdwjsy_batch.mp4'),
type: 'post' as const
},
{
id: 'step7',
label: '摄影基础',
content: '独具魅力的夜景摄影',
videoUrl: getPublicPath('video/new/shooting-skills/xsdh_djmldyjsy_batch.mp4'),
type: 'post' as const
}
]
@@ -73,9 +74,11 @@ const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2, video3, video4, video5, video6]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
// 题目数据:景别概念介绍(index 1)、手机慢动作(index 2)、独具魅力的夜景摄影(index 5)
const questions = ref<Question[]>([])
@@ -172,9 +175,8 @@ const getQuestionsForVideo = (index: number): Question[] => {
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
@@ -297,7 +299,11 @@ const handleQuestionsComplete = () => {
@close="handleStepNavigationClose"
/>
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@complete="handleIntroGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_018.MP3')">
欢迎来到基础拍摄训练区!
</GuideStepsItem>
@@ -4,14 +4,19 @@ import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
import video1 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
import video2 from '/video/new/xinshou_zhengquedeshoujipaishe.mp4'
import video3 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-camera-movement-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
const props = defineProps<{
label: string
}>()
@@ -43,18 +48,21 @@ const navigationSteps = ref([
// id: 'step1',
// label: '视觉素养',
// content: '运镜初识',
// videoUrl: getPublicPath('video/new/movement/xxx.mp4'),
// type: 'planning' as const
// },
{
id: 'step2',
label: '视觉素养',
content: '正确的手机拍摄持机姿势',
videoUrl: getPublicPath('video/new/movement/xinshou_zhengquedeshoujipaishe.mp4'),
type: 'planning' as const
},
{
id: 'step3',
label: '视觉素养',
content: '运镜的基本方式',
videoUrl: getPublicPath('video/new/movement/xinshou_yunjingdejibenfangshi.mp4'),
type: 'planning' as const
}
])
@@ -64,13 +72,12 @@ const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video2, video3]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
@@ -170,7 +177,11 @@ const handleStepNavigationClose = () => {
@close="handleStepNavigationClose"
/>
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@complete="handleIntroGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_014.MP3')">
想拍出好的视频效果,画面稳定性是最基本的要求。
</GuideStepsItem>
+14 -7
View File
@@ -4,11 +4,17 @@ import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import video1 from '/video/new/xsdh_rhxzgt_batch.mp4'
import video2 from '/video/new/xsdh_jzgtf_batch.mp4'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-composition-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
const props = defineProps<{
label: string
}>()
@@ -25,12 +31,14 @@ const navigationSteps = ref([
id: 'step1',
label: '视觉素养',
content: '如何选择构图',
videoUrl: getPublicPath('video/new/composition/xsdh_rhxzgt_batch.mp4'),
type: 'planning' as const
},
{
id: 'step2',
label: '视觉素养',
content: '九种经典构图方式',
videoUrl: getPublicPath('video/new/composition/xsdh_jzgtf_batch.mp4'),
type: 'planning' as const
}
])
@@ -40,13 +48,12 @@ const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
@@ -149,7 +156,7 @@ onMounted(() => {})
/>
<GuideSteps
:visible="true"
:visible="showIntroGuide"
:highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''"
position-offset-y="85%"
@step-change="(_: any, index: number) => (guideStepIndex = index)"
+16 -8
View File
@@ -6,20 +6,24 @@ import { StepNavigation } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps } from '@/components/Guide'
// 光初识 - 暂时用光的基础认知代替
import video1 from '/video/new/xinshou_guangdejichurenzhi.mp4'
import video2 from '/video/new/xinshou_guangdesidashuxing.mp4'
import video3 from '/video/new/xsdh_psdhjsj_batch.mp4'
import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-light-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
const isGuide = ref(true)
const isGuide = showIntroGuide
const currentGuideStep = ref(0)
const showGoldenHourGuide = ref(false)
const goldenHourGuideShown = ref(false)
@@ -48,18 +52,21 @@ const navigationSteps = ref([
id: 'step2',
label: '视觉素养',
content: '光的基础认知',
videoUrl: getPublicPath('video/new/light/xinshou_guangdejichurenzhi.mp4'),
type: 'planning' as const
},
{
id: 'step3',
label: '视觉素养',
content: '光的四大属性',
videoUrl: getPublicPath('video/new/light/xinshou_guangdesidashuxing.mp4'),
type: 'planning' as const
},
{
id: 'step4',
label: '视觉素养',
content: '拍摄的黄金时间',
videoUrl: getPublicPath('video/new/light/xsdh_psdhjsj_batch.mp4'),
type: 'planning' as const
}
])
@@ -69,9 +76,11 @@ const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2, video3]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
// 题目数据:拍摄的黄金时间(index 2)
const questions = ref<Question[]>([])
@@ -107,9 +116,8 @@ const getQuestionsForVideo = (index: number): Question[] => {
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
@@ -5,8 +5,16 @@ import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation, GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const router = useRouter()
const basicElementsStore = useBasicElementsStore()
const introGuideKey = 'basic-short-video-editing-skills-intro'
const showIntroGuide = ref(!basicElementsStore.guideShown[introGuideKey])
if (showIntroGuide.value) {
basicElementsStore.markGuideShown(introGuideKey)
}
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
@@ -18,70 +26,70 @@ const rawSteps = [
id: 'step2',
label: '剪映基础操作',
content: '初识剪映专业版界面和功能',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_csjyzybjmhgn_batch.mp4'),
type: 'other' as const
},
{
id: 'step3',
label: '剪映基础操作',
content: '时间线的用法详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_sjxdyfxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step4',
label: '剪映基础操作',
content: '转场特效贴纸功能详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_zctxtzgnxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step5',
label: '剪映基础操作',
content: '如何给视频快速上字幕',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_rhgspksszm_batch.mp4'),
type: 'other' as const
},
{
id: 'step6',
label: '剪映基础操作',
content: '音频的功能应用和处理',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_ypdgnyyhcl_batch.mp4'),
type: 'other' as const
},
{
id: 'step7',
label: '剪映基础操作',
content: '关键帧功能用法详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_gjzgnyfxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step8',
label: '剪映基础操作',
content: '蒙版功能用法详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_mbgnyfxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step9',
label: '剪映基础操作',
content: '混合模式功能用法详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_hhmsgnyfxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step10',
label: '剪映基础操作',
content: '曲线变速功能用法详解',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_qxbsgnyfxj_batch.mp4'),
type: 'other' as const
},
{
id: 'step11',
label: '剪映基础操作',
content: '如何给画面调色',
videoUrl: getPublicPath('video/new/xsdh_CapCut_Tutorial.mp4'),
videoUrl: getPublicPath('video/new/editing-skills/xsdh_rhghmts_batch.mp4'),
type: 'other' as const
}
]
@@ -196,7 +204,11 @@ const handleIntroGuideComplete = () => {
@close="handleStepNavigationClose"
/>
<GuideSteps :visible="true" position-offset-y="85%" @complete="handleIntroGuideComplete">
<GuideSteps
:visible="showIntroGuide"
position-offset-y="85%"
@complete="handleIntroGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_021.MP3')">
拍摄工作完成以后还需要剪辑来赋予节奏与表达。
</GuideStepsItem>
+291 -34
View File
@@ -1,54 +1,311 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { getPublicPath } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
const router = useRouter()
const back = () => {
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
router.push({
name: 'Film'
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
@click="handleClose"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/调色.png')" alt="" />
<div class="absolute top-[6250px] left-36 w-[1670px] h-[1000px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/editing/color_jianji_batch.mp4')"
playsinline
controls
:muted="false"
:volume="1"
/>
</div>
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/film_img')" />
<!-- 开启对比 -->
<ImgButton
class="absolute top-[7830px] left-[51.5%] -translate-x-1/2"
image="after-editing/color/调色实践"
@click="router.push({ name: 'FilmColor' })"
/>
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<span class="text-white text-2xl font-bold">
推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
+30 -291
View File
@@ -1,310 +1,49 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useFilmStore } from '@/stores'
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { getPublicPath } from '@/utils'
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const router = useRouter()
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
router.push({
name: 'Film'
})
}
const handleClose = () => {
const back = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-10"
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/film_img')" />
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/调色.png')" alt="" />
<div class="absolute top-[6250px] left-36 w-[1670px] h-[1000px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/film/editing/color_jianji_batch.mp4')"
playsinline
controls
:muted="false"
:volume="1"
/>
</div>
<!-- 开启对比 -->
<ImgButton
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
class="absolute top-[7830px] left-[51.5%] -translate-x-1/2"
image="after-editing/color/调色实践"
@click="router.push({ name: 'FilmColorPractice' })"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<span class="text-white text-2xl font-bold"
>推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0</span
>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -3,31 +3,30 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
}
// 关联剪辑视频 URL(暂时用其他视频代替)
const associationVideoUrl = getPublicPath('video/on-site/dygmk_gljj_batch.mp4')
const associationVideoUrl = getPublicPath('video/film/editing/dygmk_gljj_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
const handleVideoEnded = () => {
film.markEditingVideoWatched('association')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="relative w-full h-full overflow-hidden">
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="associationVideoUrl"
@@ -38,8 +37,8 @@ const videoPlaying = ref(false)
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
@@ -3,16 +3,18 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
}
// 连续剪辑视频 URL(暂时用其他视频代替)
const continuityVideoUrl = getPublicPath('video/on-site/dygmk_lxjj_batch.mp4')
const continuityVideoUrl = getPublicPath('video/film/editing/dygmk_lxjj_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
@@ -23,19 +25,13 @@ const showSummary = ref(false)
const handleVideoEnded = () => {
videoEnded.value = true
showSummary.value = true
film.markEditingVideoWatched('continuity')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="relative w-full h-full overflow-hidden">
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="continuityVideoUrl"
@@ -47,9 +43,7 @@ const handleVideoEnded = () => {
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
>
</Video>
</div>
></Video>
<!-- 返回按钮 -->
<ImgButton
+10 -11
View File
@@ -3,31 +3,30 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
}
// 切块视频 URL(暂时用其他视频代替)
const cutVideoUrl = getPublicPath('video/on-site/dygmk_kq_batch.mp4')
const cutVideoUrl = getPublicPath('video/film/editing/dygmk_kq_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
const handleVideoEnded = () => {
film.markEditingVideoWatched('cut')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="relative w-full h-full overflow-hidden">
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="cutVideoUrl"
@@ -38,8 +37,8 @@ const videoPlaying = ref(false)
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
+39 -5
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { Video } from '@/components'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleClose = () => {
router.back()
@@ -47,6 +49,33 @@ const goAssociation = () => {
const goContinuity = () => {
router.push({ name: 'FilmEditingContinuity' })
}
// 6 个剪辑子视频学习完成后的一次性引导
const editingCompleteSteps: GuideStep[] = [
{
id: 'editing-complete-step1',
content:
'刚才我们提到的剪辑方式,并不是孤立存在的技巧,而是针对不同问题的解决方案。所以,理解剪辑逻辑才是最重要的!',
audioSrc: getPublicPath('audio/py_4_018.MP3')
}
]
const showEditingCompleteGuide = ref(false)
watch(
() => film.allEditingVideosWatched,
watched => {
if (watched && film.isGuideFilmEditingComplete) {
showEditingCompleteGuide.value = true
}
},
{ immediate: true }
)
const handleEditingCompleteGuideDone = () => {
showEditingCompleteGuide.value = false
film.setIsGuideFilmEditingComplete(false)
}
</script>
<template>
@@ -157,9 +186,14 @@ const goContinuity = () => {
@click="handleClose"
/>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>选择任意图片按钮,进入对应拍摄场景!</GuideStepsItem>
</GuideSteps>
<!-- 6 个剪辑视频学完后的一次性引导 -->
<GuideSteps
v-if="showEditingCompleteGuide"
:steps="editingCompleteSteps"
position-offset-y="85%"
@complete="handleEditingCompleteGuideDone"
@skip="handleEditingCompleteGuideDone"
/>
</div>
</template>
+10 -11
View File
@@ -3,31 +3,30 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
}
// 节奏型剪辑视频 URL(暂时用其他视频代替)
const rhythmVideoUrl = getPublicPath('video/on-site/dygmk_jzxjj_batch.mp4')
const rhythmVideoUrl = getPublicPath('video/film/editing/dygmk_jzxjj_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
const handleVideoEnded = () => {
film.markEditingVideoWatched('rhythm')
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="relative w-full h-full overflow-hidden">
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="rhythmVideoUrl"
@@ -38,8 +37,8 @@ const videoPlaying = ref(false)
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
@@ -4,8 +4,10 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
@@ -13,11 +15,11 @@ const handleBack = () => {
// 声音转场视频 URL(暂时用其他视频代替)
const soundTransitionVideoUrl = getPublicPath(
'video/on-site/xianchangpaishe_shengyingzhuanchang1.mp4'
'video/film/editing/xianchangpaishe_shengyingzhuanchang1.mp4'
)
// const soundTransitionVideoUrl2 = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const soundTransitionVideoUrl3 = getPublicPath(
'video/on-site/xianchangpaishe_shengyingzhuanchang3.mp4'
'video/film/editing/xianchangpaishe_shengyingzhuanchang3.mp4'
)
// 视频播放状态
@@ -63,6 +65,7 @@ const questions = ref<Question[]>([
const handleQuestionsComplete = () => {
showQuiz.value = false
showVerification.value = true
film.markEditingVideoWatched('soundTransition')
}
</script>
@@ -93,8 +96,8 @@ const handleQuestionsComplete = () => {
>
<template #overlay>
</template>
</Video>
</div> -->
</Video>
</div> -->
<QuestionQuiz
v-if="!showVerification"
:questions="questions"
@@ -4,8 +4,10 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
const handleBack = () => {
router.back()
@@ -21,6 +23,7 @@ const videoPlaying = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
film.markEditingVideoWatched('start')
}
</script>
@@ -45,8 +48,7 @@ const handleVideoEnded = () => {
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
>
</Video>
></Video>
</div>
<!-- 返回按钮 -->
@@ -1,39 +1,42 @@
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const guideKey = 'film-export-publish-intro'
const handleBack = () => {
router.back()
}
// 创作随机性视频 URL(暂时用其他视频代替)
const randomVideoUrl = getPublicPath('video/on-site/dygmk_zlsxhbj.mp4')
const randomVideoUrl = getPublicPath('video/film/publish/dygmk_zlsxhbj.mp4')
// 视频播放状态
const videoPlaying = ref(false)
const showGuide = ref(true)
const showGuide = ref(!filmStore.guideShown[guideKey])
if (showGuide.value) {
filmStore.markGuideShown(guideKey)
}
const handleComplete = () => {
showGuide.value = false
nextTick(() => {
videoPlaying.value = true
})
}
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="relative w-full h-full overflow-hidden">
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="randomVideoUrl"
@@ -43,7 +46,6 @@ const handleComplete = () => {
:controls="true"
:hideNativeControls="true"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
@@ -52,8 +54,15 @@ const handleComplete = () => {
@click="handleBack"
/>
<GuideSteps v-if="showGuide" position-offset-y="85%" @complete="handleComplete">
<GuideStepsItem>现在一起来看看导出后的影片效果吧!</GuideStepsItem>
<GuideSteps
v-if="showGuide"
position-offset-y="85%"
@complete="handleComplete"
@skip="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_019.MP3')">
现在一起来看看导出后的影片效果吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
@@ -3,7 +3,6 @@ import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -60,9 +59,5 @@ const back = () => {
/>
</div>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</div>
</template>
-4
View File
@@ -4,7 +4,6 @@ import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -46,8 +45,5 @@ const handleOpenImages = () => {
@click="handleOpenImages"
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</div>
</template>
+122 -14
View File
@@ -1,29 +1,111 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getStaticImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useFilmStore } from '@/stores'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import FilmMenu from './components/menu.vue'
const route = useRoute()
const router = useRouter()
const film = useFilmStore()
const visible = ref(false)
const status = ref(true)
const visible = computed({
get: () => film.filmMenuVisible,
set: value => film.setFilmMenuVisible(value)
})
// const status = ref(true)
const currentStep = computed(() => film.FilmUnlockProgress)
const progress = computed(() => film.progressPercent)
const guideIndex = ref<number>(0)
const taskGuideKey = 'film-task-guide'
const hasShownTaskGuide = computed(() => film.guideShown[taskGuideKey] === true)
const showTaskGuide = computed(
() => currentStep.value === 1 && guideIndex.value < 2 && film.isGuideFilmTask
)
// 思路提点完成回到首页的一次性引导
const showReturnGuide = ref(route.query.guide === 'return' && film.isGuideFilmReturn)
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content: '现在,我们把镜头带到了徐汇滨江。',
audioSrc: getPublicPath('audio/py_4_010.MP3')
},
{
id: 'return-step2',
content: '这里既保留着工业时代的痕迹,也承载着当代生活的节奏。',
audioSrc: getPublicPath('audio/py_4_011.MP3')
}
]
const handleReturnGuideComplete = () => {
showReturnGuide.value = false
film.setIsGuideFilmReturn(false)
router.replace({ name: 'Film' })
}
// 阶段菜单的一次性引导(中期拍摄 / 后期剪辑)
const stageGuideMap = {
2: {
flagKey: 'isGuideFilmStage2',
setter: 'setIsGuideFilmStage2',
steps: [
{
id: 'stage2-step1',
content: '拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。',
audioSrc: getPublicPath('audio/py_4_012.MP3')
},
{
id: 'stage2-step2',
content: '我们一起看看在这个模块中会用到哪些常见、实用的拍摄工具。',
audioSrc: getPublicPath('audio/py_4_013.MP3')
}
]
},
3: {
flagKey: 'isGuideFilmStage3',
setter: 'setIsGuideFilmStage3',
steps: [
{
id: 'stage3-step1',
content: '拍摄完成啦!接下来进入剪辑环节。',
audioSrc: getPublicPath('audio/py_4_017.MP3')
}
]
}
} as const
type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : []
) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => {
if (step !== 2 && step !== 3) return
const stage = step as StageKey
const config = stageGuideMap[stage]
if (!(film as any)[config.flagKey]) return
stageGuideStep.value = stage
}
const handleStageGuideComplete = () => {
if (!stageGuideStep.value) return
const config = stageGuideMap[stageGuideStep.value]
;(film as any)[config.setter](false)
stageGuideStep.value = null
}
const handleGuideClick = () => {
// Mark guide as completed after the second click and hide permanently
if (guideIndex.value >= 1) {
@@ -55,21 +137,28 @@ const handleClose = () => {
}
onMounted(() => {
if (film.hasShownGuideFilmTask) {
if (hasShownTaskGuide.value) {
film.setIsGuideFilmTask(false)
}
if (currentStep.value !== 0 && currentStep.value !== 4) {
visible.value = true
}
})
// Cache the guide after it pops up once so it does not re-appear
watch(
showTaskGuide,
val => {
if (val && !film.hasShownGuideFilmTask) {
film.setHasShownGuideFilmTask(true)
if (val && !hasShownTaskGuide.value) {
film.markGuideShown(taskGuideKey)
}
},
{ immediate: true }
)
// 阶段引导触发:当 Overlay 菜单打开且当前步骤变化时检查是否需要弹阶段引导
watch(
[visible, currentStep],
([isVisible, step]) => {
if (isVisible && step < 4) {
triggerStageGuide(step)
}
},
{ immediate: true }
@@ -134,6 +223,7 @@ watch(
<!-- 引导任务 -->
<ImgButton
v-if="currentStep !== 4"
v-show="!showReturnGuide"
class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON"
/>
@@ -181,7 +271,25 @@ watch(
/>
</div>
<!-- 思路提点完成回首页的一次性引导 -->
<GuideSteps
v-if="showReturnGuide"
:steps="returnSteps"
position-offset-y="85%"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
/>
<!-- 阶段菜单一次性引导(中期拍摄 / 后期剪辑) -->
<GuideSteps
v-if="stageGuideStep"
:steps="stageGuideSteps"
position-offset-y="85%"
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
<!-- <GuideSteps
:visible="status && currentStep === 0"
highlight-selector="1"
highlight-mode="gradient"
@@ -195,6 +303,6 @@ watch(
进入任务取景框开始学习!
</div>
</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
@@ -1,7 +1,7 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps } from '@/components/Guide'
import { ref, computed } from 'vue'
import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
@@ -14,20 +14,15 @@ const back = () => {
const steps = ref([
{
id: 'step1',
content:
'拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。接下来,我们就一起看看在这个模块中会用到哪些常见、实用的拍摄工具。'
},
{
id: 'step2',
content: '点击右滑按钮,查看下一张拍摄工具',
highlightSelector: '.equipment-rightbutton'
},
{
id: 'step3',
content: '滚动鼠标滚轮,查看拍摄工具详细介绍',
highlightSelector: '.plate'
content: '设备调试完毕,我们如约抵达徐汇滨江,一起开启拍摄之旅吧!',
audioSrc: getPublicPath('audio/py_4_014.MP3')
}
])
const showGuide = ref(false)
const guideTriggered = ref(false)
const guideTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const viewedMenuKeys = ref<Set<string>>(new Set())
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
@@ -116,6 +111,31 @@ categories.value = categories.value.map(c => ({
disabled: (c.products?.length ?? 0) === 0
}))
// 默认选中的菜单也算作已查看
if (categories.value[0] && !categories.value[0].disabled) {
viewedMenuKeys.value.add(categories.value[0].id)
}
const maybeTriggerGuide = () => {
if (guideTriggered.value) return
const enabledIds = categories.value.filter(c => !c.disabled).map(c => c.id)
if (enabledIds.length === 0) return
const allViewed = enabledIds.every(id => viewedMenuKeys.value.has(id))
if (!allViewed) return
guideTriggered.value = true
guideTimer.value = setTimeout(() => {
showGuide.value = true
guideTimer.value = null
}, 3000)
}
onBeforeUnmount(() => {
if (guideTimer.value) {
clearTimeout(guideTimer.value)
guideTimer.value = null
}
})
const currentCategory = computed(
() => categories.value[selectedMenuIndex.value] ?? categories.value[0]
)
@@ -133,6 +153,8 @@ const selectMenuItem = (index: number, item: { id: string; name: string; disable
}
selectedMenuIndex.value = index
currentIndex.value = 0
viewedMenuKeys.value.add(item.id)
maybeTriggerGuide()
}
// 下一张图片
@@ -245,7 +267,7 @@ const prevPhone = () => {
<!-- 引导步骤 -->
<GuideSteps
:visible="true"
:visible="showGuide"
:steps="steps"
:highlight-padding="12"
:highlight-radius="20"
@@ -1,10 +1,19 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const guideKey = 'film-onsite-intro'
const showGuide = ref(!filmStore.guideShown[guideKey])
if (showGuide.value) {
filmStore.markGuideShown(guideKey)
}
const handleClose = () => {
router.back()
@@ -133,8 +142,10 @@ const goRandom = () => {
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>选择任意图片按钮,进入对应拍摄场景!</GuideStepsItem>
<GuideSteps :visible="showGuide" position-offset-y="85%">
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_016.MP3')">
接下来进入核心拍摄环节,我们依次来学习!
</GuideStepsItem>
</GuideSteps>
</template>
@@ -36,7 +36,7 @@ const questions = ref<Question[]>([
{ id: 'd', label: '让画面信息量变得更多' }
],
correctOptionId: 'b',
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_dijiaodu.mp4')
mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_dijiaodu.mp4')
}
])
@@ -101,7 +101,7 @@ const handleQuestionsComplete = () => {
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
@@ -12,7 +12,7 @@ const handleBack = () => {
}
// 创作随机性视频 URL(暂时用其他视频代替)
const randomVideoUrl = getPublicPath('video/on-site/dygmk_czsjx_batch.mp4')
const randomVideoUrl = getPublicPath('video/film/on-site/dygmk_czsjx_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
@@ -20,14 +20,8 @@ const videoPlaying = ref(false)
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<div class="w-full h-full">
<Video
class="w-full h-full"
:src="randomVideoUrl"
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
@@ -12,7 +12,7 @@ const handleBack = () => {
}
// 滑轨拍摄视频 URL
const sliderVideoUrl = getPublicPath('video/on-site/dygmk_hgydjt_batch.mp4')
const sliderVideoUrl = getPublicPath('video/film/on-site/dygmk_hgydjt_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
@@ -53,14 +53,7 @@ const handleQuestionsComplete = () => {
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="sliderVideoUrl"
@@ -81,7 +74,6 @@ const handleQuestionsComplete = () => {
/>
</template>
</Video>
</div>
<!-- 返回按钮 -->
<ImgButton
@@ -36,7 +36,7 @@ const questions = ref<Question[]>([
{ id: 'c', label: '使用连拍模式' }
],
correctOptionId: 'b',
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_baitianmanmen.mp4')
mediaSrc: getPublicPath('video/film/on-site/xianchangpaishe_baitianmanmen.mp4')
}
])
@@ -12,14 +12,14 @@ const handleBack = () => {
}
// 慢门拍摄夜晚视频 URL(暂时用其他视频代替)
const slowShutterNightVideoUrl = getPublicPath('video/on-site/dygmk_mmps_batch.mp4')
const slowShutterNightVideoUrl = getPublicPath('video/film/on-site/dygmk_mmps_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
@@ -27,7 +27,7 @@ const videoPlaying = ref(false)
></div>
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<div class="w-full h-full">
<Video
class="w-full h-full"
:src="slowShutterNightVideoUrl"
@@ -8,7 +8,7 @@ import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 延时拍摄视频 URL(占位,后续可替换)
const timeLapseVideoUrl = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const timeLapseVideoUrl = getPublicPath('video/film/on-site/xianchangpaishe_yanshipaishe.mp4')
const handleBack = () => {
router.back()
@@ -81,15 +81,32 @@ const handleQuestionsComplete = () => {
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
<p>在这个延时画面中,注意天空变化与光影移动的节奏,它们在短时间内被连续呈现出来。</p>
<p><span>延时拍摄的主要目的</span>,<span>是交代时间流逝与环境状态</span>。</p>
<p>
<span>这里的拍摄对象是江边的建筑群与天空</span
>。<span>通过固定机位的连续拍摄</span>,<span>将原本需要较长时间才能观察到的</span
><span>天空变化和光影移动</span>,<span>压缩到一个较短的镜头中呈现</span>。
<span>延时拍摄的主要目的</span>
,
<span>是交代时间流逝与环境状态</span>
。
</p>
<p>
<span>这种处理方式</span
>,<span>非常适合放置在视频片头阶段</span>,<span>用于快速建立时间感与空间环境</span>,<span>而不会占用过多时长</span>。
<span>这里的拍摄对象是江边的建筑群与天空</span>
。
<span>通过固定机位的连续拍摄</span>
,
<span>将原本需要较长时间才能观察到的</span>
<span>天空变化和光影移动</span>
,
<span>压缩到一个较短的镜头中呈现</span>
。
</p>
<p>
<span>这种处理方式</span>
,
<span>非常适合放置在视频片头阶段</span>
,
<span>用于快速建立时间感与空间环境</span>
,
<span>而不会占用过多时长</span>
。
</p>
</div>
<div
@@ -1,17 +1,15 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { GuideSteps } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/new/xsdh_jzgtf_batch.mp4'
import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue'
import { Video } from '@/components'
import { useFilmStore } from '@/stores'
const film = useFilmStore()
import { useFilmStore } from '@/stores/modules/film'
type Btn = {
key: string
@@ -22,22 +20,22 @@ type Btn = {
}
const router = useRouter()
const filmStore = useFilmStore()
const introGuideKey = 'film-shooting-intro'
const compositionGuideKey = 'film-shooting-composition'
const showIntroGuide = ref(!filmStore.guideShown[introGuideKey])
const showCompositionGuide = ref(!filmStore.guideShown[compositionGuideKey])
if (showIntroGuide.value) {
filmStore.markGuideShown(introGuideKey)
}
const steps = ref([
{
id: '1',
audioSrc: getPublicPath('audio/py_4_015.MP3'),
content: '哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~',
highlightSelector: '.btn-1'
},
{
id: '2',
content: '跟随箭头指引,一键解锁取景地',
highlightSelector: '.btn-2'
},
{
id: '3',
content: '跟随箭头指引,一键解锁取景地',
highlightSelector: '.btn-3'
}
])
@@ -98,6 +96,13 @@ const handleModalClose = () => {
showModal.value = false
}
const openCompositionGuide = () => {
showModal.value = true
if (showCompositionGuide.value) {
filmStore.markGuideShown(compositionGuideKey)
}
}
const handleClick = (item: any) => {
currentItem.value = item
showModal2.value = true
@@ -142,12 +147,12 @@ const back = () => {
<ImgButton class="btn-3" image="film/shooting/油罐艺术公园入口.png" @click="handleClickB" />
</div>
<div class="shooting-composition btn-1">
<ImgButton image="shooting/构图指南.png" @click="showModal = true" />
<ImgButton image="shooting/构图指南.png" @click="openCompositionGuide" />
</div>
<!-- 引导步骤 -->
<GuideSteps
:visible="true"
:visible="showIntroGuide"
:steps="steps"
:highlight-padding="12"
:highlight-radius="20"
@@ -196,7 +201,7 @@ const back = () => {
</div>
<GuideSteps
:visible="true"
:visible="showCompositionGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-padding="12"
+21 -6
View File
@@ -6,8 +6,10 @@ import { StarRating } from '@/components/Rating'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/svg/back-to-top.svg'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const TOTAL_PAGES = 3
const PAGE_HEIGHT = 1080
@@ -34,6 +36,7 @@ const showSittingPerson = computed<boolean>(() => {
})
const visible = ref<boolean>(false)
const guideKeyPrefix = 'film-practice-page'
//每一页的引导词
const steps = ref([
@@ -42,10 +45,18 @@ const steps = ref([
'点击【查看】按钮,欣赏他人实战作品~'
])
const currentGuideKey = computed(() => `${guideKeyPrefix}-${currentPage.value}`)
const contentSteps = computed(() => {
return steps.value[currentPage.value]
})
const showPageGuide = (): void => {
if (filmStore.guideShown[currentGuideKey.value]) return
visible.value = true
filmStore.markGuideShown(currentGuideKey.value)
}
/** 他人实战作品列表 */
interface WorkItem {
id: number
@@ -62,7 +73,7 @@ const worksList = ref<WorkItem[]>([
image: getStaticImage('film/practice/学生案例'),
title: '学生案例',
stars: 4,
path: '/video/practice/film_anli1.mp4',
path: '/video/film/practice/film_anli1.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
}
@@ -115,7 +126,7 @@ const smoothScrollTo = (toPage: number): void => {
} else {
isAnimating.value = false
currentPage.value = toPage
visible.value = true
showPageGuide()
}
}
@@ -158,7 +169,7 @@ const handleWheel = (event: WheelEvent): void => {
}
onMounted(() => {
visible.value = true
showPageGuide()
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
})
@@ -333,11 +344,15 @@ onUnmounted(() => {
<style scoped>
/* 隐藏滚动条但保持可滚动 - 兼容主流浏览器 */
.task-panel {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE/Edge */
}
.task-panel::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
display: none;
/* Chrome/Safari/Opera */
}
/* 第一页人物动画容器 */
+9 -1
View File
@@ -7,7 +7,15 @@ import { IMAGE_PATHS, getBackgroundImage, getPublicPath } from '@/utils'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter()
const filmStore = useFilmStore()
const guideKey = 'film-practice-video-rating'
const showGuide = ref(!filmStore.guideShown[guideKey])
if (showGuide.value) {
filmStore.markGuideShown(guideKey)
}
const props = defineProps({
path: {
@@ -125,7 +133,7 @@ const handleBack = (): void => {
</div>
</div>
</Overlay>
<GuideSteps :visible="true" :highlight-selector="'.btn-1'" position-offset-y="60%">
<GuideSteps :visible="showGuide" :highlight-selector="'.btn-1'" position-offset-y="60%">
<GuideStepsItem>点击这里可以给作品评分噢~</GuideStepsItem>
</GuideSteps>
</div>
-5
View File
@@ -2,7 +2,6 @@
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -28,9 +27,5 @@ const back = () => {
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/资源筹备.png')" alt="" />
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</div>
</template>
+1 -5
View File
@@ -5,7 +5,6 @@ import { Video } from '@/components'
import { useRouter } from 'vue-router'
import { ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -44,7 +43,7 @@ const handleOpenImages = () => {
<div class="absolute top-60 left-36 w-[1000px] h-[700px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/jiaoben.mp4')"
:src="getPublicPath('video/film/script-writing/jiaoben.mp4')"
playsinline
controls
:volume="1"
@@ -57,8 +56,5 @@ const handleOpenImages = () => {
@click="handleOpenImages"
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</div>
</template>
+3 -3
View File
@@ -4,7 +4,7 @@ import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const { scale, offsetX, offsetY } = useScale()
@@ -46,8 +46,8 @@ const handleOpenImages = () => {
@click="handleOpenImages"
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
+33 -33
View File
@@ -25,48 +25,50 @@ const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
//引导步骤
const steps = ref([
{
id: 'step1',
content: '试着用这些方法记录身边的小事,你会发现,原来平凡的日子里有这么多值得捕捉的瞬间。'
}
])
const steps = ref<{ id: string; content: string; audioSrc?: string }[]>([])
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '后期制作',
videoUrl: getPublicPath('video/film/work/dygmk_zf_batch.mp4'),
content: '遮幅'
},
{
id: 'step2',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_gx_batch.mp4'),
content: '光线'
},
{
id: 'step3',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_xs_batch.mp4'),
content: '虚实'
},
{
id: 'step4',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_txjt_batch.mp4'),
content: '特写镜头'
},
{
id: 'step5',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_fjt_batch.mp4'),
content: '分镜头'
},
{
id: 'step5',
label: '后期制作',
videoUrl: getPublicPath('video/film/work/dygmk_lj_batch.mp4'),
content: '滤镜'
},
{
id: 'step5',
label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'),
content: '同期声'
}
])
@@ -75,16 +77,6 @@ const navigationSteps = ref([
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [
getPublicPath('video/film/dygmk_zf_batch.mp4'),
getPublicPath('video/film/dygmk_gx_batch.mp4'),
getPublicPath('video/film/dygmk_xs_batch.mp4'),
getPublicPath('video/film/dygmk_txjt_batch.mp4'),
getPublicPath('video/film/dygmk_fjt_batch.mp4'),
getPublicPath('video/film/dygmk_lj_batch.mp4'),
getPublicPath('video/film/dygmk_tqs_batch.mp4')
]
const currentVideoIndex = ref(0)
// 题目数据:虚实(index 2)和分镜头(index 4)
@@ -141,14 +133,14 @@ const getQuestionsForVideo = (index: number): Question[] => {
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
const resolvedSrc = computed(() => {
if (key.value === 'case') return getPublicPath('video/film/dygmk_zbbwg_batch.mp4')
if (key.value === 'case') return getPublicPath('video/film/work/dygmk_zbbwg_batch.mp4')
if (key.value === 'profession') return currentVideo.value
return ''
})
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
@@ -171,7 +163,8 @@ const handleClose = () => {
if (key.value === 'idea') {
film.setFilmUnlockProgress(1)
router.push({
name: 'Film'
name: 'Film',
query: { guide: 'return' }
})
return
}
@@ -234,11 +227,15 @@ const handleQuestionsComplete = () => {
handleVideoChange()
}
onMounted(() => {
if (key.value === 'idea') {
status.value = true
}
})
// 专业剖析引导结束后自动播放视频
const handleProfessionGuideComplete = () => {
visibleGuide.value = false
nextTick(() => {
playing.value = true
})
}
onMounted(() => {})
</script>
<template>
@@ -316,14 +313,16 @@ onMounted(() => {
v-if="visibleGuide && key === 'profession'"
position-offset-y="85%"
@step-change="(_: any, index: number) => (guideStepIndex = index)"
@complete="handleProfessionGuideComplete"
@skip="handleProfessionGuideComplete"
>
<GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_004.MP3')">
从刚才的案例可以看出,电影感画面自带一种独特的艺术魅力——或大气恢弘,扑朔迷离或充满故事感。
</GuideStepsItem>
<GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_005.MP3')">
作为短视频创作者,我们可以借助这些方法,在拍摄与剪辑中打造更有质感的画面。
</GuideStepsItem>
<GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_006.MP3')">
<div class="flex flex-wrap items-center justify-start">
<div>接下来,让我们一起看看打造电影感的几种关键技巧吧。</div>
<div class="text-yellow-400">
@@ -354,13 +353,14 @@ onMounted(() => {
position-offset-y="85%"
@complete="handleComplete"
>
<GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_007.MP3')">
一个好镜头,既要好看,又要好懂 —— 既有视觉吸引力,又能把信息清晰地传达出来。
</GuideStepsItem>
<GuideStepsItem>当这些条件被满足,一个镜头就有了“高级感”。</GuideStepsItem>
<GuideStepsItem>而优秀的短视频,就是靠无数这样的好镜头累积而成的。</GuideStepsItem>
<GuideStepsItem>
接下来,我们就以徐汇滨江为例,一起动手练习如何拍出属于它的意义与故事吧!
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_008.MP3')">
当这些条件被满足,一个镜头就有了“高级感”。
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_009.MP3')">
而优秀的短视频,就是靠无数这样的好镜头累积而成的。
</GuideStepsItem>
</GuideSteps>
</template>
+9 -12
View File
@@ -1,7 +1,7 @@
<script lang="tsx" name="FilmWork" setup>
defineOptions({ name: 'FilmWork' })
import { ref, computed } from 'vue'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage, getPublicPath } from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
@@ -50,9 +50,7 @@ const currentItem = computed(() => {
return sideSwitcherItems.value.find(item => item.key === selected.value)
})
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--case' : '.work-border'
)
const currentHighlight = computed(() => (stepIndex.value === 2 ? '.work-border' : null))
const handleStepChange = (_: any, index: number) => {
stepIndex.value = index
@@ -194,15 +192,14 @@ function openImages(urls: string[], startIndex = 0) {
@skip="handleGuideComplete"
position-offset-y="85%"
>
<GuideStepsItem>
点击右侧【案例赏析】、【专业剖析】、【思路提点】,可以切换不同内容
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_001.MP3')">
你是否也听过这样的评价——“这个镜头,看起来很高级。”
</GuideStepsItem>
<GuideStepsItem>
<div class="flex items-center justify-start">
请点击 &nbsp;
<img class="w-16" :src="getStaticImage('film/sx')" alt="" />
&nbsp;,进入案例赏析!
</div>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_002.MP3')">
但所谓高级感,到底来自哪里?
</GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_003.MP3')">
带着这个问题,我们一起来看看“钟表博物馆”的案例,看看普通画面是如何被赋予质感与故事感的。
</GuideStepsItem>
</GuideSteps>
+36 -19
View File
@@ -5,6 +5,7 @@ defineOptions({
import { getBackgroundImage, getPublicPath, IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import StudyProgressPopup from './components/StudyProgressPopup.vue'
import { computed, ref, onMounted } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router'
import {
@@ -35,10 +36,9 @@ type GuideNode = {
children?: GuideChildNode[]
}
const homeGuideShow = ref(true)
const unlockedHomeGuideShow = ref(false)
const elementGuideShow = ref(false)
const unlockedHomeGuideCompleted = ref(false)
const actionGuideKey = 'home-action-guide'
const activeActionGuide = ref<'home' | 'unlocked' | 'element' | null>(null)
const studyProgressVisible = ref(false)
const homeGuideSteps: GuideStep[] = [
{
@@ -109,16 +109,16 @@ const elementGuideSteps: GuideStep[] = [
]
const handleHomeGuideComplete = () => {
homeGuideShow.value = false
activeActionGuide.value = null
}
const handleUnlockedHomeGuideComplete = () => {
unlockedHomeGuideShow.value = false
activeActionGuide.value = null
}
const handleElementGuideComplete = () => {
elementGuideShow.value = false
unlockedHomeGuideCompleted.value = true
activeActionGuide.value = null
basicElements.markGuideShown(actionGuideKey)
}
const router = useRouter()
@@ -142,6 +142,7 @@ const imageMap = {
//获取新手导航引导状态
const isGuideNewUser = computed(() => basicElements.isGuideNewUser)
const hasFinishedActionGuide = computed(() => basicElements.guideShown[actionGuideKey] === true)
// 预加载所有图片
const preloadImages = () => {
@@ -155,8 +156,8 @@ const preloadImages = () => {
// 事件处理函数
const handleOpenGuide = () => {
guideShow.value = true
if (!homeGuideShow.value && isGuideNewUser.value) {
unlockedHomeGuideShow.value = true
if (activeActionGuide.value !== 'home' && isGuideNewUser.value && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'unlocked'
}
}
@@ -193,8 +194,11 @@ const selectedImage = computed(() => {
return getBackgroundImage(imageMap[selectedModule.value])
})
const hasFinishedAllHomeGuides = computed(() => unlockedHomeGuideCompleted.value)
const isSkillDisabled = computed(() => !hasFinishedAllHomeGuides.value)
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
const handleOpenStudyProgress = () => {
studyProgressVisible.value = true
}
const handleSkill = () => {
if (isSkillDisabled.value) return
@@ -268,8 +272,8 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
}
showChildrenNodeId.value = node.id
if (!unlockedHomeGuideShow.value && !unlockedHomeGuideCompleted.value) {
elementGuideShow.value = true
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'element'
}
}
@@ -287,25 +291,28 @@ const handleClickOutside = () => {
// 生命周期
onMounted(() => {
preloadImages()
if (isGuideNewUser.value && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'home'
}
})
</script>
<template>
<div class="w-full h-full relative">
<GuideSteps
v-if="isGuideNewUser && homeGuideShow"
v-if="activeActionGuide === 'home'"
:steps="homeGuideSteps"
position-offset-y="82%"
@complete="handleHomeGuideComplete"
/>
<GuideSteps
v-if="unlockedHomeGuideShow"
v-if="activeActionGuide === 'unlocked'"
:steps="unlockedHomeGuideSteps"
position-offset-y="82%"
@complete="handleUnlockedHomeGuideComplete"
/>
<GuideSteps
v-if="elementGuideShow"
v-if="activeActionGuide === 'element'"
:steps="elementGuideSteps"
position-offset-y="82%"
@complete="handleElementGuideComplete"
@@ -317,6 +324,13 @@ onMounted(() => {
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION_BG) }"
></div>
<!-- 学习进度 -->
<div class="absolute top-0 left-0">
<ImgButton image="home/action/学习进度入口" @click="handleOpenStudyProgress" />
</div>
<StudyProgressPopup v-model="studyProgressVisible" />
<div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
>
@@ -355,8 +369,11 @@ onMounted(() => {
<!-- 新手指导遮罩 -->
<div
v-if="isGuideNewUser"
:class="['absolute inset-0 z-10', homeGuideShow ? 'bg-black/0' : 'bg-black/50']"
v-if="isGuideNewUser && !hasFinishedActionGuide"
:class="[
'absolute inset-0 z-10',
activeActionGuide === 'home' ? 'bg-black/0' : 'bg-black/50'
]"
></div>
<!-- 新手指导 -->
@@ -0,0 +1,137 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { useBasicElementsStore, useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
import { AnchoredProgressBar, ImgButton, type ProgressAnchor } from '@/components'
import { studyModuleAnchors, type StudyProgressModuleKey } from './studyProgressConfig'
import { getProductionModuleProgress } from './studyProgressUtils'
defineOptions({
name: 'StudyProgressPopup'
})
type StudyProgressItem = {
key: StudyProgressModuleKey
progress: number
anchors: ProgressAnchor[]
}
const visible = defineModel<boolean>({ default: false })
const basicElements = useBasicElementsStore()
const vlog = useVlogStore()
const interview = useInterviewStore()
const film = useFilmStore()
const rowPositions = {
basic: { height: 168 },
vlog: { height: 148 },
interview: { height: 146 },
film: { height: 146 }
} as const
const labelTextShadow =
'0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)'
const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)'
const childTextShadow = '0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const basicProgress = computed(() =>
basicElements.isBasicElementsUnlock ? 100 : basicElements.basicElementsUnlockProgress
)
const totalProgress = computed(() => {
const total =
basicProgress.value + vlog.progressPercent + interview.progressPercent + film.progressPercent
return Math.round(total / 4)
})
const progressItems = computed<StudyProgressItem[]>(() => [
{
key: 'basic',
progress: basicProgress.value,
anchors: studyModuleAnchors.basic
},
{
key: 'vlog',
progress: getProductionModuleProgress('vlog', vlog.viewed),
anchors: studyModuleAnchors.vlog
},
{
key: 'interview',
progress: getProductionModuleProgress('interview', interview.viewed),
anchors: studyModuleAnchors.interview
},
{
key: 'film',
progress: getProductionModuleProgress('film', film.viewed),
anchors: studyModuleAnchors.film
}
])
const handleClose = () => {
visible.value = false
}
</script>
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-95 blur-[8px]"
enter-to-class="opacity-100 scale-100 blur-none"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 scale-100 blur-none"
leave-to-class="opacity-0 scale-95 blur-[8px]"
>
<div
v-if="visible"
class="fixed inset-0 z-[100] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('home/stuy/学习进度底图') }"
>
<ImgButton
class="absolute right-0 top-0 z-10"
@click="handleClose"
:image="'components/close'"
/>
<div
class="absolute left-1/2 top-[14%] flex -translate-x-1/2 items-end text-white font-[1000] tracking-widest pr-10"
:style="{ textShadow: totalTextShadow }"
>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">{{ totalProgress }}%</span>
</div>
<div class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2">
<AnchoredProgressBar
v-for="item in progressItems"
:key="item.key"
class="relative w-full"
:style="{ height: `${rowPositions[item.key].height}px` }"
:progress="item.progress"
:anchors="item.anchors"
node-image="home/stuy/节点圆圈"
:label-class="
item.key === 'basic'
? 'text-[clamp(24px,2.2vw,42px)] text-white font-[1000] tracking-wider'
: 'text-[clamp(24px,2.1vw,40px)] text-white font-[1000] tracking-wider'
"
child-label-class="text-[clamp(20px,1.75vw,34px)] text-[#43f3f2] font-[1000] tracking-wider"
percent-class="text-[clamp(22px,2vw,38px)] text-white font-[1000] italic"
label-top="2%"
child-label-top="34%"
bar-top="58%"
bar-height="18%"
marker-top="50%"
marker-height="34%"
marker-width="1%"
child-marker-top="53%"
child-marker-height="28%"
child-marker-width="0.3%"
node-top="41%"
node-height="52%"
percent-top="78%"
:label-style="{ textShadow: labelTextShadow }"
:child-label-style="{ textShadow: childTextShadow }"
:percent-style="{ textShadow: labelTextShadow }"
/>
</div>
</div>
</Transition>
</template>
@@ -0,0 +1,31 @@
import type { ProgressAnchor } from '@/components'
export type StudyProgressModuleKey = 'basic' | 'vlog' | 'interview' | 'film'
const productionAnchors: ProgressAnchor[] = [
{ id: 'task', label: '任务取景框', value: 0 },
{
id: 'zoom',
label: '制作变焦镜',
value: 20,
children: [
{ id: 'prepare', label: '前期准备', value: 20 },
{ id: 'shooting', label: '中期拍摄', value: 42 },
{ id: 'editing', label: '后期剪辑', value: 64 }
]
},
{ id: 'practice', label: '实战操作台', value: 85 },
{ id: 'done', label: '', value: 100 }
]
export const studyModuleAnchors: Record<StudyProgressModuleKey, ProgressAnchor[]> = {
basic: [
{ id: 'three-elements', label: '三大基础要素', value: 0 },
{ id: 'shooting-skills', label: '基础拍摄技巧', value: 33.33 },
{ id: 'editing-skills', label: '短视频剪辑技巧', value: 66.66 },
{ id: 'done', label: '', value: 100 }
],
vlog: productionAnchors,
interview: productionAnchors,
film: productionAnchors
}
@@ -0,0 +1,41 @@
import { moduleStepMap } from '@/config/moduleStepMap'
type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3
const countViewedRoutes = (routes: readonly string[], viewed: Record<string, boolean>) => {
if (routes.length === 0) return 0
return routes.filter(routeName => viewed[routeName]).length
}
const getStepProgress = (routes: readonly string[], viewed: Record<string, boolean>) => {
if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length
}
const interpolate = (start: number, end: number, progress: number) =>
start + (end - start) * progress
export const getProductionModuleProgress = (
moduleKey: ProductionModuleKey,
viewed: Record<string, boolean>
) => {
const stepRoutes = moduleStepMap[moduleKey]
const hasAnyViewed = Object.values(stepRoutes).some(routes =>
routes.some(routeName => viewed[routeName])
)
if (!hasAnyViewed) return 0
const stepProgress = (step: StepKey) => getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1)
const step2Progress = stepProgress(2)
const step3Progress = stepProgress(3)
if (step1Progress < 1) return Math.round(interpolate(20, 42, step1Progress))
if (step2Progress < 1) return Math.round(interpolate(42, 64, step2Progress))
if (step3Progress < 1) return Math.round(interpolate(64, 85, step3Progress))
return 100
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '
import { ImgButton } from '@/components/Button'
import { GuideSteps, ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import { useBasicElementsStore } from '@/stores/modules/basic-elements'
const CourseFrameGuide = () => (
<div class="flex items-center justify-start">
@@ -64,11 +65,17 @@ export default defineComponent({
positionOffsetY: '40%'
}
])
const showGuide = ref(true)
const basicElementsStore = useBasicElementsStore()
const guideKey = 'home-intro'
const showGuide = ref(!basicElementsStore.guideShown[guideKey])
const { scale, offsetX, offsetY } = useScale()
if (showGuide.value) {
basicElementsStore.markGuideShown(guideKey)
}
const handleComplete = () => {
console.log('handleComplete')
showGuide.value = false
}
const openMindMap = () => {
@@ -1,98 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
// import { GuideSteps } from '@/components/Guide'
import { IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 导入视频文件
import videoadjustmentColor from '/video/vlog_renwuqujing_tiaose.mp4'
const videoList = [videoadjustmentColor]
const videoRef = ref<HTMLVideoElement>()
const currentVideoIndex = ref(0)
onMounted(() => {
// 确保视频元素加载并自动播放
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 尝试自动播放
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
// 自动播放失败,可能是浏览器策略阻止,需要用户交互
console.log('自动播放被阻止:', error)
})
}
// 监听视频结束事件
videoRef.value.addEventListener('ended', playNextVideo)
}
})
// 播放下一个视频
const playNextVideo = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 自动播放下一个视频
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log('自动播放被阻止:', error)
})
}
}
}
}
// 监听当前视频索引变化
watch(currentVideoIndex, newIndex => {
if (videoRef.value) {
videoRef.value.src = videoList[newIndex]
videoRef.value.load()
}
})
const handleClose = () => {
router.back()
}
</script>
<template>
<div>
<!--使用动态绑定和ref-->
<video
ref="videoRef"
:src="videoList[currentVideoIndex]"
type="video/mp4"
controls
preload="auto"
class="video-player"
autoplay
>
<p>你的浏览器不支持video标签.</p>
</video>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</div>
</template>
<style scoped>
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
@@ -1,38 +1,311 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
const router = useRouter()
const back = () => {
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
router.push({
name: 'Interview'
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-20"
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
@click="handleClose"
/>
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('interview/img/调色实践.png')" alt="" />
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('interview/color/访谈调色')" />
<!-- 开启对比 -->
<ImgButton
class="absolute top-[1180px] left-[51.5%] -translate-x-1/2"
image="after-editing/color/调色实践"
@click="router.push({ name: 'InterviewColor' })"
/>
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<span class="text-white text-2xl font-bold">
推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
+18 -289
View File
@@ -1,309 +1,38 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useRouter } from 'vue-router'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const router = useRouter()
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
router.push({
name: 'Film'
})
}
const handleClose = () => {
const back = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<div class="relative w-full h-full overflow-hidden">
<ImgButton
class="absolute right-0 top-0 z-10"
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
@click="back"
/>
<img class="absolute inset-0" :src="getStaticImage('img/背景.png')" alt="" />
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<img class="absolute top-5 z-10" :src="getStaticImage('img/调色-标题.png')" alt="" />
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/film_img')" />
<!-- 开启对比 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('interview/img/调色实践.png')" alt="" />
<ImgButton
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
class="absolute top-[1180px] left-[51.5%] -translate-x-1/2"
image="after-editing/color/调色实践"
@click="router.push({ name: 'InterviewColorPractice' })"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<span class="text-white text-2xl font-bold">
推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0
</span>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps> -->
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -1,23 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { Video, GuideSteps, GuideStepsItem } from '@/components'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { useInterviewStore } from '@/stores/modules/interview'
const router = useRouter()
const interviewStore = useInterviewStore()
const guideKey = 'interview-export-publish-intro'
const playing = ref(false)
const showGuide = ref(!interviewStore.guideShown[guideKey])
if (showGuide.value) {
interviewStore.markGuideShown(guideKey)
}
const handleBack = () => {
router.back()
}
const handleComplete = () => {
showGuide.value = false
nextTick(() => {
playing.value = true
})
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden bg-black">
<Video
class="w-full h-full"
:src="getPublicPath('video/on-site/dygmk_zlsxhbj.mp4')"
:src="getPublicPath('video/interview/publish/ftmk_jsyrgs_.mp4')"
v-model="playing"
:muted="false"
:volume="1"
@@ -29,5 +44,16 @@ const handleBack = () => {
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
<GuideSteps
v-if="showGuide"
position-offset-y="85%"
@complete="handleComplete"
@skip="handleComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_3_013.MP3')">
现在一起来看看导出后的影片效果吧!
</GuideStepsItem>
</GuideSteps>
</div>
</template>
+1 -1
View File
@@ -86,7 +86,7 @@ const menuItems = ref([
},
{
label: '调色',
routeName: 'InterviewColorPractice',
routeName: 'InterviewColor',
parentValue: 3,
disabled: false
},
+3 -3
View File
@@ -2,7 +2,7 @@
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const router = useRouter()
@@ -26,8 +26,8 @@ const back = () => {
<img :src="getStaticImage('interview/img/创意构想.png')" alt="" />
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
+109 -9
View File
@@ -1,17 +1,91 @@
<script lang="tsx" name="Interview" setup>
defineOptions({ name: 'Interview' })
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRouter } from 'vue-router'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores'
import InterviewMenu from './components/menu.vue'
const route = useRoute()
const router = useRouter()
const interview = useInterviewStore()
const visible = ref(false)
const showReturnGuide = ref(route.query.guide === 'return' && interview.isGuideInterviewReturn)
const returnSteps: GuideStep[] = [
{
id: 'return-step1',
content: '接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。',
audioSrc: getPublicPath('audio/py_3_007.MP3')
}
]
const handleReturnGuideComplete = () => {
showReturnGuide.value = false
interview.setIsGuideInterviewReturn(false)
router.replace({ name: 'Interview' })
}
const stageGuideMap = {
2: {
flagKey: 'isGuideInterviewStage2',
setter: 'setIsGuideInterviewStage2',
steps: [
{
id: 'stage2-step1',
content: '前期准备阶段,我们已经完成了主题确认、脚本结构与镜头规划。',
audioSrc: getPublicPath('audio/py_3_010.MP3')
},
{
id: 'stage2-step2',
content: '进入中期拍摄后就要按照既定方案,把后期需要用的素材尽可能拍完整。',
audioSrc: getPublicPath('audio/py_3_011.MP3')
}
]
},
3: {
flagKey: 'isGuideInterviewStage3',
setter: 'setIsGuideInterviewStage3',
steps: [
{
id: 'stage3-step1',
content: '拍摄完成啦!现在我们正式进入剪辑环节。',
audioSrc: getPublicPath('audio/py_3_012.MP3')
}
]
}
} as const
type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : []
) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => {
if (step !== 2 && step !== 3) return
const stage = step as StageKey
const config = stageGuideMap[stage]
if (!(interview as any)[config.flagKey]) return
stageGuideStep.value = stage
}
const handleStageGuideComplete = () => {
if (!stageGuideStep.value) return
const config = stageGuideMap[stageGuideStep.value]
;(interview as any)[config.setter](false)
stageGuideStep.value = null
}
const visible = computed({
get: () => interview.interviewMenuVisible,
set: value => interview.setInterviewMenuVisible(value)
})
const currentStep = computed(() => interview.InterviewUnlockProgress)
const progress = computed(() => interview.progressPercent)
@@ -37,11 +111,15 @@ const handleClose = () => {
router.push({ name: 'Action' })
}
onMounted(() => {
if (currentStep.value !== 0 && currentStep.value !== 4) {
visible.value = true
watch(
[visible, currentStep],
([isVisible, step]) => {
if (isVisible && step < 4) {
triggerStageGuide(step)
}
})
},
{ immediate: true }
)
</script>
<template>
@@ -92,7 +170,11 @@ onMounted(() => {
</div>
<!-- 引导任务 -->
<ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" />
<ImgButton
v-show="!showReturnGuide"
class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON"
/>
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<!-- 关闭按钮 -->
@@ -125,5 +207,23 @@ onMounted(() => {
@step-change="handleStepChange"
/>
</div>
<!-- 思路提点完成回首页的一次性引导 -->
<GuideSteps
v-if="showReturnGuide"
:steps="returnSteps"
position-offset-y="85%"
@complete="handleReturnGuideComplete"
@skip="handleReturnGuideComplete"
/>
<!-- 阶段菜单一次性引导 -->
<GuideSteps
v-if="stageGuideStep"
:steps="stageGuideSteps"
position-offset-y="85%"
@complete="handleStageGuideComplete"
@skip="handleStageGuideComplete"
/>
</div>
</template>
@@ -188,18 +188,6 @@ const prevPhone = () => {
<button @click="disableAllMenuItems" class="debug-btn">禁用全部</button>
<button @click="toggleMenuItem('bluetooth-remote')" class="debug-btn">切换蓝牙遥控器</button>
</div> -->
<!-- 引导步骤 -->
<GuideSteps
:steps="steps"
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
>
<GuideStepsItem>
<img src="@/assets/images/equipment/设备准备引导1.png" alt="" />
</GuideStepsItem>
</GuideSteps>
</div>
</template>
<style lang="less" scoped>
@@ -10,17 +10,19 @@ const playing = ref(false)
const showStepNavigation = ref(false)
const currentVideoIndex = ref(0)
const navigationSteps = [
const navigationSteps = ref([
{
id: 'q1-shoot-material',
label: '中期拍摄',
content: '素材怎么拍',
videoUrl: getPublicPath('video/interview/on-site/ftmk_sczmp_batch.mp4'),
type: 'shooting' as const
},
{
id: 'q1-required-shots',
label: '中期拍摄',
content: '必拍镜头有哪些',
videoUrl: getPublicPath('video/interview/on-site/ftmk_bpjtynx_batch.mp4'),
type: 'shooting' as const
},
{
@@ -35,16 +37,9 @@ const navigationSteps = [
content: '特写镜头的作用',
type: 'shooting' as const
}
]
])
const videoList = [
getPublicPath('video/on-site/dygmk_mmps_batch.mp4'),
getPublicPath('video/on-site/dygmk_hgydjt_batch.mp4'),
getPublicPath('video/on-site/dygmk_jzxjj_batch.mp4'),
getPublicPath('video/on-site/dygmk_czsjx_batch.mp4')
]
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
const back = () => {
router.back()
@@ -63,7 +58,7 @@ const handleStepNavigationClose = () => {
}
const handleEnded = () => {
if (currentVideoIndex.value >= videoList.length - 1) {
if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false
return
}
@@ -156,9 +156,12 @@ const capturePhoto = async () => {
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage('shooting/取景地模糊背景.png') }"
:style="{ backgroundImage: getBackgroundImage('interview/shooting/操场-模糊.png') }"
>
<ImgButton image="shooting/标题-取景地a.png" class="title-button absolute left-0" />
<ImgButton
image="interview/shooting/操场-侧边标题.png"
class="title-button absolute left-0"
/>
<ImgButton image="shooting/返回.png" class="back-button absolute right-0" @click="back" />
<StartOverlay v-if="!start" @start="start = true" />
@@ -186,7 +189,7 @@ const capturePhoto = async () => {
'cursor-grabbing': isDragging
}"
:style="{ transform: getTransform() }"
src="@/assets/images/shooting/取景大图.png"
src="@/assets/images/interview/shooting/操场-清晰.png"
alt=""
crossorigin="anonymous"
@mousedown="startDrag"
@@ -101,12 +101,15 @@ const handleComplete = () => {
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{
backgroundImage: start
? getBackgroundImage('shooting/取景大图.png')
: getBackgroundImage('shooting/取景地模糊背景.png')
? getBackgroundImage('interview/shooting/教学楼-清晰.png')
: getBackgroundImage('interview/shooting/教学楼-模糊.png')
}"
>
<!-- 标题 -->
<ImgButton image="shooting/标题-取景地b.png" class="title-button absolute left-0" />
<ImgButton
image="interview/shooting/教学楼-侧边标题.png"
class="title-button absolute left-0"
/>
<!-- 返回按钮 -->
<ImgButton
@@ -123,7 +126,7 @@ const handleComplete = () => {
<img
ref="backgroundImage"
class="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-0"
src="@/assets/images/shooting/取景大图.png"
src="@/assets/images/interview/shooting/教学楼-清晰.png"
alt=""
crossorigin="anonymous"
/>
@@ -4,10 +4,11 @@ import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue'
import { useInterviewStore } from '@/stores/modules/interview'
type Btn = {
key: string
@@ -18,6 +19,13 @@ type Btn = {
}
const router = useRouter()
const interviewStore = useInterviewStore()
const guideKey = 'interview-shooting-intro'
const showIntroGuide = ref(!interviewStore.guideShown[guideKey])
if (showIntroGuide.value) {
interviewStore.markGuideShown(guideKey)
}
const showModal = ref(false)
@@ -87,7 +95,7 @@ const back = () => {
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
:style="{ backgroundImage: getBackgroundImage('interview/shooting/实地取景底图') }"
>
<div class="shooting-title">
<img :src="shootingtitle" alt="" />
@@ -95,25 +103,28 @@ const back = () => {
<div class="shooting-return">
<ImgButton image="shooting/返回.png" @click="back" />
</div>
<div class="shooting-left">
<ImgButton image="shooting/按钮-取景地A.png" @click="handleClickA" />
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="interview/shooting/操场-入口.png" @click="handleClickA" />
</div>
<div class="shooting-right">
<ImgButton image="shooting/按钮-取景地B.png" @click="handleClickB" />
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="interview/shooting/教学楼-入口.png" @click="handleClickB" />
</div>
<div class="shooting-composition">
<ImgButton image="shooting/构图指南.png" @click="showModal = true" />
<ImgButton class="mybutton" image="shooting/构图指南.png" @click="showModal = true" />
</div>
</div>
<!-- 引导步骤 -->
<GuideSteps
v-if="showIntroGuide"
:highlight-selector="'.mybutton'"
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
position-offset-y="30%"
>
<GuideStepsItem> 在走进拍摄场景之前,我们先来查看一下构图指南。 </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_3_009.MP3')">
同一个地点,站位不同、构图不同,画面质感就完全不同。
</GuideStepsItem>
</GuideSteps>
<!-- 保存图片弹窗 -->
+16 -12
View File
@@ -43,15 +43,15 @@ interface WorkItem {
}
const worksList = ref<WorkItem[]>([
// {
// id: 1,
// image: 'https://picsum.photos/400/300?random=1',
// title: '作品名称',
// stars: 4,
// path: '/video/practice/vlog_anli1.mp4',
// description:
// '作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
// },
{
id: 1,
image: 'https://picsum.photos/400/300?random=1',
title: '作品名称',
stars: 4,
path: '/video/interview/practice/fangtan_cace1_batch.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
}
// {
// id: 2,
// image: 'https://picsum.photos/400/300?random=2',
@@ -321,11 +321,15 @@ onUnmounted(() => {
<style scoped>
/* 隐藏滚动条但保持可滚动 - 兼容主流浏览器 */
.task-panel {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE/Edge */
}
.task-panel::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
display: none;
/* Chrome/Safari/Opera */
}
/* 第一页人物动画容器 */
+2 -2
View File
@@ -26,8 +26,8 @@ const back = () => {
<img :src="getStaticImage('interview/img/资源筹备.png')" alt="" />
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
+21 -4
View File
@@ -2,14 +2,26 @@
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Video } from '@/components'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ImagePreview, Video } from '@/components'
import { useScale } from '@/composables/useScale'
const router = useRouter()
const back = () => {
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('interview/我的育人故事_脚本')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
@@ -35,10 +47,15 @@ const back = () => {
:volume="1"
/>
</div>
<button
class="absolute top-[2290px] left-1/2 -translate-x-1/2 w-[240px] h-[80px] ml-6"
@click="handleOpenImages"
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
+21 -3
View File
@@ -2,13 +2,26 @@
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
// import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
const router = useRouter()
const back = () => {
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('interview/我的育人故事_分镜')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
@@ -24,10 +37,15 @@ const back = () => {
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('interview/img/分镜制作.png')" alt="" />
<button
class="absolute top-[1590px] left-1/2 -translate-x-1/2 w-[480px] h-[80px] ml-6"
@click="handleOpenImages"
/>
</div>
<GuideSteps :visible="true" position-offset-y="85%">
<!-- <GuideSteps :visible="true" position-offset-y="85%">
<GuideStepsItem>这份长图文干货满满,滚动鼠标查看完整内容吧</GuideStepsItem>
</GuideSteps>
</GuideSteps> -->
</div>
</template>
+69 -31
View File
@@ -2,14 +2,9 @@
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, StepNavigation } from '@/components/Guide'
import { GuideSteps, StepNavigation, type GuideStep } from '@/components/Guide'
import { Video } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
import second from '/video/vlog_renwuqujing_jichujingbieyunyong.mp4'
import third from '/video/vlog_renwuqujing_huamiangoutu.mp4'
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
import five from '/video/vlog_renwuqujing_zhuapai.mp4'
import six from '/video/vlog_renwuqujing_jianjipingjie.mp4'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter()
@@ -22,44 +17,78 @@ const playing = ref(false)
const status = ref(false)
//引导步骤
const steps = ref([
const steps = ref<GuideStep[]>([
{
id: 'step1',
content: '试着用这些方法记录身边的小事,你会发现,原来平凡的日子里有这么多值得捕捉的瞬间。'
audioSrc: getPublicPath('audio/py_3_006.MP3'),
content: '刚才我们学习了访谈作品的核心拍摄技能,现在通过这张思维导图来回顾一下吧!'
}
])
const introGuideSteps = computed<GuideStep[]>(() => {
if (key.value === 'case') {
return [
{
id: 'case-intro',
audioSrc: getPublicPath('audio/py_3_003.MP3'),
content: '现在,让我们通过一段访谈案例,去看看同龄人心中的职业蓝图吧!'
}
]
}
if (key.value === 'profession') {
return [
{
id: 'profession-intro1',
audioSrc: getPublicPath('audio/py_3_004.MP3'),
content: '看完作品后,你可能已经感受到了,一条访谈视频,好像并不是“坐着讲话”这么简单。'
},
{
id: 'profession-intro2',
audioSrc: getPublicPath('audio/py_3_005.MP3'),
content: '那么,让我们一起来拆解访谈作品的核心技能吧!'
}
]
}
return []
})
const showIntroGuide = computed(() => introGuideSteps.value.length > 0)
const handleIntroGuideDone = () => {
playing.value = true
}
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '拍摄执行',
content: '基础景别应用',
content: '机位设置',
videoUrl: getPublicPath('video/interview/work/ftmk_jwsz_batch.mp4'),
type: 'shooting' as const
},
{
id: 'step2',
label: '拍摄执行',
content: '画面构图',
content: '轴线原则',
videoUrl: getPublicPath('video/interview/work/ftmk_zxyz_batch.mp4'),
type: 'shooting' as const
},
{
id: 'step3',
label: '内容策划',
content: '简单脚本策划',
content: '镜头组接逻辑',
videoUrl: getPublicPath('video/interview/work/ftmk_jtzjlj_batch.mp4'),
type: 'planning' as const
},
{
id: 'step4',
label: '拍摄执行',
content: '动态事件跟拍与抓拍',
content: '声音同步与收录',
videoUrl: getPublicPath('video/interview/work/ftmk_sytbysl_batch.mp4'),
type: 'shooting' as const
},
{
id: 'step5',
label: '后期制作',
content: '素材导入、剪辑拼接',
type: 'post' as const
}
])
@@ -68,28 +97,27 @@ const showStepNavigation = ref(false)
const currentNavigationStep = ref(1) // 对应视频索引,从0开始
// 视频列表
const videoList = [second, third, fourth, five, six]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const currentVideo = computed(() => navigationSteps.value[currentVideoIndex.value].videoUrl)
const resolvedSrc = computed(() => {
if (key.value === 'case') return '/video/anlishangxi_meishi_xiaoyuanzi.mp4'
if (key.value === 'case') return '/video/interview/work/ft_zylt.mp4'
if (key.value === 'profession') return currentVideo.value
return ''
})
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
if (currentVideoIndex.value < navigationSteps.value.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
steps.value = [
{
id: 'step1',
content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!'
}
]
// status.value = true
// steps.value = [
// {
// id: 'step1',
// content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!'
// }
// ]
return
}
nextTick(() => {
@@ -102,7 +130,8 @@ const handleClose = () => {
if (key.value === 'idea') {
interview.setInterviewUnlockProgress(1)
router.push({
name: 'Interview'
name: 'Interview',
query: { guide: 'return' }
})
return
}
@@ -202,6 +231,15 @@ onMounted(() => {
</div>
</div>
<!-- 进入案例赏析 / 专业剖析时显示引导 -->
<GuideSteps
v-if="showIntroGuide"
:steps="introGuideSteps"
position-offset-y="85%"
@complete="handleIntroGuideDone"
@skip="handleIntroGuideDone"
/>
<!-- 引导与高亮 -->
<GuideSteps v-if="status" :steps="steps" position-offset-y="85%" @complete="handleComplete" />
+5 -4
View File
@@ -1,7 +1,7 @@
<script lang="tsx" name="Work" setup>
defineOptions({ name: 'Work' })
import { ref, computed } from 'vue'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage, getPublicPath } from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps } from '@/components/Guide'
@@ -26,13 +26,14 @@ const isInterviewUnlock = computed(() => interview.isInterviewUnlock)
const steps = [
{
id: 'step1',
content:
'现在我们要一起走进 Vlog 的世界。简单说,Vlog 就是用视频记录生活的一种方式 —— 它像一本会动的日记,能把我们身边的小事、瞬间的心情,通过镜头变得生动又具体。',
audioSrc: getPublicPath('audio/py_3_001.MP3'),
content: '我们常常会思考,未来自己想成为什么样的人呢?',
highlightSelector: '.img-switch-item--case'
},
{
id: 'step2',
content: '接下来,我们就通过一个具体的案例,看看 Vlog 是怎么“捕捉美好” 的吧。',
audioSrc: getPublicPath('audio/py_3_002.MP3'),
content: '如果把这份思考勇敢地说出来,被镜头记录下来,你会如何表达呢?',
highlightSelector: '.work-border'
}
]
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more