forked from zhongyidian/super_video
feat: 完善课程学习与技能树体验
完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f289959be
commit
3e258b87d9
314 files changed
+7020
-3386
No files matched your search
@@ -1,159 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { getStaticImage } from '@/utils'
|
||||
import type { AnchoredProgressBarProps, ProgressAnchor } from './types'
|
||||
import { computeSegmentLayout } from './computeSegmentLayout'
|
||||
import type { AnchoredProgressBarProps } from './types'
|
||||
|
||||
defineOptions({
|
||||
name: 'AnchoredProgressBar'
|
||||
name: 'AnchoredProgressBar',
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<AnchoredProgressBarProps>(), {
|
||||
min: 0,
|
||||
max: 100,
|
||||
showPercent: true,
|
||||
nodeImage: '',
|
||||
segments: () => [],
|
||||
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: () => ({})
|
||||
segmentClass: '',
|
||||
showDividers: true,
|
||||
})
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
const clamp = (value: number, lower: number, upper: number) =>
|
||||
Math.min(Math.max(value, lower), upper)
|
||||
|
||||
const toPercent = (value: number) => {
|
||||
const progressPercent = computed(() => {
|
||||
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 }))
|
||||
return clamp(((props.progress - props.min) / range) * 100, 0, 100)
|
||||
})
|
||||
|
||||
const anchorWithPercent = computed(() =>
|
||||
props.anchors.map(anchor => ({
|
||||
...anchor,
|
||||
left: toPercent(anchor.value),
|
||||
children: anchor.children?.map(child => ({ ...child, left: toPercent(child.value) }))
|
||||
}))
|
||||
const segmentLayout = computed(() =>
|
||||
computeSegmentLayout(props.segments, props.min, props.max),
|
||||
)
|
||||
</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="absolute inset-0 overflow-hidden"
|
||||
: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-for="segment in segmentLayout"
|
||||
:key="segment.id"
|
||||
class="absolute top-0 h-full"
|
||||
:class="[segmentClass, segment.className]"
|
||||
:style="{
|
||||
left: `${segment.left}%`,
|
||||
width: `${segment.width}%`,
|
||||
...(segment.style ?? {}),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-700 ease-out"
|
||||
:class="fillClass"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<template v-if="showDividers">
|
||||
<div
|
||||
v-for="segment in segmentLayout.slice(0, -1)"
|
||||
:key="`${segment.id}-divider`"
|
||||
class="pointer-events-none absolute -translate-x-1/2 rounded-[3px] bg-white"
|
||||
:style="{
|
||||
left: `${segment.left + segment.width}%`,
|
||||
top: '-15%',
|
||||
height: '130%',
|
||||
width: '8px',
|
||||
}"
|
||||
></div>
|
||||
</template>
|
||||
<slot
|
||||
:segments="segmentLayout"
|
||||
:progress="progressPercent"
|
||||
/>
|
||||
<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,21 @@
|
||||
import type { ProgressSegment, ProgressSegmentLayout } from './types'
|
||||
|
||||
const clamp = (value: number, lower: number, upper: number) =>
|
||||
Math.min(Math.max(value, lower), upper)
|
||||
|
||||
export const computeSegmentLayout = (
|
||||
segments: ProgressSegment[],
|
||||
min = 0,
|
||||
max = 100,
|
||||
): ProgressSegmentLayout[] => {
|
||||
const range = max - min
|
||||
const toPercent = (value: number) =>
|
||||
range <= 0 ? 0 : clamp(((value - min) / range) * 100, 0, 100)
|
||||
|
||||
return segments.map((segment) => {
|
||||
const left = toPercent(segment.start)
|
||||
const right = toPercent(segment.end)
|
||||
const width = Math.max(0, right - left)
|
||||
return { ...segment, left, width, center: left + width / 2 }
|
||||
})
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AnchoredProgressBar } from './AnchoredProgressBar.vue'
|
||||
export { computeSegmentLayout } from './computeSegmentLayout'
|
||||
export type * from './types'
|
||||
@@ -1,38 +1,25 @@
|
||||
export type ProgressAnchor = {
|
||||
export type ProgressSegment = {
|
||||
id: string
|
||||
label: string
|
||||
value: number
|
||||
labelPosition?: 'top' | 'bottom'
|
||||
children?: ProgressAnchor[]
|
||||
label?: string
|
||||
start: number
|
||||
end: number
|
||||
className?: string
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ProgressSegmentLayout = ProgressSegment & {
|
||||
left: number
|
||||
width: number
|
||||
center: number
|
||||
}
|
||||
|
||||
export type AnchoredProgressBarProps = {
|
||||
progress: number
|
||||
anchors: ProgressAnchor[]
|
||||
min?: number
|
||||
max?: number
|
||||
showPercent?: boolean
|
||||
nodeImage?: string
|
||||
segments?: ProgressSegment[]
|
||||
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>
|
||||
segmentClass?: string
|
||||
showDividers?: boolean
|
||||
}
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
<template>
|
||||
<img
|
||||
class="transition-all duration-300"
|
||||
class="transition-[transform,filter] duration-300 [will-change:transform,filter]"
|
||||
:class="[
|
||||
props.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:scale-105 hover:brightness-110'
|
||||
: 'cursor-pointer hover:scale-105 hover:brightness-110',
|
||||
]"
|
||||
draggable="false"
|
||||
:src="getStaticImage(image)"
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Overlay } from '@/components/Overlay'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video } from '@/components/Video'
|
||||
import { GuideSteps, type GuideStep } from '@/components/Guide'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import {
|
||||
getBackgroundImage,
|
||||
getStaticImage,
|
||||
IMAGE_PATHS,
|
||||
getVideoPoster,
|
||||
} from '@/utils'
|
||||
|
||||
interface CompositionBtn {
|
||||
key: string
|
||||
@@ -133,6 +138,7 @@ const handleItemClick = (item: CompositionBtn) => {
|
||||
>
|
||||
<Video
|
||||
:src="videoSrc"
|
||||
:poster="getVideoPoster(videoSrc)"
|
||||
controls
|
||||
:volume="1"
|
||||
:muted="false"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getStaticImage } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import ImagePreview from '@/components/ImagePreview/service'
|
||||
|
||||
type TabKey = 'overall' | 'newbie' | 'vlog' | 'interview' | 'film'
|
||||
|
||||
type Props = {
|
||||
designWidth?: number
|
||||
designHeight?: number
|
||||
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
designWidth: 1920,
|
||||
designHeight: 1080,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const activeTab = ref<TabKey>('overall')
|
||||
|
||||
const tabs: Array<{
|
||||
key: TabKey
|
||||
light: string
|
||||
dark: string
|
||||
cover: string
|
||||
hd: string
|
||||
}> = [
|
||||
{
|
||||
key: 'overall',
|
||||
light: 'home/framework/总课程框架-亮',
|
||||
dark: 'home/framework/总课程框架-暗',
|
||||
cover: 'home/framework/封面-总',
|
||||
hd: 'home/framework/open-img/短视频达人养成记',
|
||||
},
|
||||
{
|
||||
key: 'newbie',
|
||||
light: 'home/framework/新手导航-亮',
|
||||
dark: 'home/framework/新手导航-暗',
|
||||
cover: 'home/framework/封面-新手导航',
|
||||
hd: 'home/framework/open-img/新手导航',
|
||||
},
|
||||
{
|
||||
key: 'vlog',
|
||||
light: 'home/framework/vlog-亮',
|
||||
dark: 'home/framework/vlog-暗',
|
||||
cover: 'home/framework/封面-vlog',
|
||||
hd: 'home/framework/open-img/Vlog-发现与记录',
|
||||
},
|
||||
{
|
||||
key: 'interview',
|
||||
light: 'home/framework/访谈-聆听与对话-亮',
|
||||
dark: 'home/framework/访谈-聆听与对话-暗',
|
||||
cover: 'home/framework/封面-访谈',
|
||||
hd: 'home/framework/open-img/访谈-聆听与对话',
|
||||
},
|
||||
{
|
||||
key: 'film',
|
||||
light: 'home/framework/电影-创造与表达-亮',
|
||||
dark: 'home/framework/电影-创造与表达-暗',
|
||||
cover: 'home/framework/封面-电影',
|
||||
hd: 'home/framework/open-img/电影-创造与表达',
|
||||
},
|
||||
]
|
||||
|
||||
const currentTab = computed(
|
||||
() => tabs.find((tab) => tab.key === activeTab.value) ?? tabs[0],
|
||||
)
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
const { scale, offsetX, offsetY } = props.getScaleMetrics()
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
width: `${props.designWidth}px`,
|
||||
height: `${props.designHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
zIndex: '3000',
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
function emitClose() {
|
||||
emit('close')
|
||||
if (props.onClose) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function openHdPreview() {
|
||||
ImagePreview({
|
||||
images: [getStaticImage(currentTab.value.hd)],
|
||||
maskClosable: true,
|
||||
getScaleMetrics: props.getScaleMetrics,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-black/75"
|
||||
:style="rootStyle"
|
||||
>
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
class="absolute inset-0 w-full h-full object-contain"
|
||||
:src="getStaticImage('home/intro/背景底图')"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<div class="absolute top-[94px] left-[260px] flex -space-x-3 z-10">
|
||||
<ImgButton
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:image="activeTab === tab.key ? tab.light : tab.dark"
|
||||
@click="activeTab = tab.key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-[185px] left-[265px] w-[1390px] h-[817px] z-20 p-6',
|
||||
'flex items-center justify-center bg-[#D7E4F8] rounded-[35px]',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
:src="getStaticImage(currentTab.cover)"
|
||||
class="h-full object-cover rounded-3xl cursor-pointer"
|
||||
draggable="false"
|
||||
@click="openHdPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img
|
||||
class="absolute bottom-[218px] left-[128px] z-20"
|
||||
:src="getStaticImage('home/intro/手机')"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<ImgButton
|
||||
class="absolute top-0 right-0 z-30"
|
||||
image="components/close"
|
||||
@click="emitClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CourseFrameworkPopup } from './service.ts'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import CourseFrameworkPopupVue from './CourseFrameworkPopup.vue'
|
||||
|
||||
type PopupOptions = {
|
||||
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
|
||||
designWidth?: number
|
||||
designHeight?: number
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
function getAppContext() {
|
||||
const appRoot = document.getElementById('app')
|
||||
const app = (appRoot as any)?.__vue_app__
|
||||
return app?._context
|
||||
}
|
||||
|
||||
export default function openCourseFrameworkPopup(options: PopupOptions) {
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
const vnode = createVNode(CourseFrameworkPopupVue, {
|
||||
...options,
|
||||
onClose: () => {
|
||||
if (options.onClose) {
|
||||
options.onClose()
|
||||
}
|
||||
render(null, container!)
|
||||
},
|
||||
})
|
||||
|
||||
vnode.appContext = getAppContext()
|
||||
|
||||
render(vnode, container)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/utils'
|
||||
import { ImgButton, Video } from '@/components'
|
||||
|
||||
type TabType = 'intro' | 'guide'
|
||||
|
||||
type Props = {
|
||||
designWidth?: number
|
||||
designHeight?: number
|
||||
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
designWidth: 1920,
|
||||
designHeight: 1080,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const activeTab = ref<TabType>('intro')
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
const { scale, offsetX, offsetY } = props.getScaleMetrics()
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: `${offsetX}px`,
|
||||
top: `${offsetY}px`,
|
||||
width: `${props.designWidth}px`,
|
||||
height: `${props.designHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
zIndex: '3000',
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
const videoSrc = computed(
|
||||
() =>
|
||||
activeTab.value === 'intro'
|
||||
? getPublicPath('video/start.mp4')
|
||||
: getPublicPath('video/guide.mp4'), // TODO: 操作指南视频
|
||||
)
|
||||
|
||||
function emitClose() {
|
||||
emit('close')
|
||||
if (props.onClose) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-black/75"
|
||||
:style="rootStyle"
|
||||
>
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
class="absolute inset-0 w-full h-full object-contain"
|
||||
:src="getStaticImage('home/intro/背景底图')"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<div class="absolute top-[94px] left-[268px] flex z-10">
|
||||
<ImgButton
|
||||
:image="
|
||||
activeTab === 'intro'
|
||||
? 'home/intro/课程简介亮'
|
||||
: 'home/intro/课程简介暗'
|
||||
"
|
||||
@click="activeTab = 'intro'"
|
||||
/>
|
||||
<ImgButton
|
||||
:image="
|
||||
activeTab === 'guide'
|
||||
? 'home/intro/操作指南亮'
|
||||
: 'home/intro/操作指南暗'
|
||||
"
|
||||
@click="activeTab = 'guide'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-[180px] left-[255px] w-[1408px] h-[817px] bg-cover bg-center bg-no-repeat z-20 p-7"
|
||||
:style="{
|
||||
backgroundImage: getBackgroundImage('home/intro/视频底板'),
|
||||
}"
|
||||
>
|
||||
<div class="bg-black/50 rounded-2xl overflow-hidden w-full h-full">
|
||||
<Video
|
||||
:src="videoSrc"
|
||||
:volume="1"
|
||||
:muted="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
class="absolute bottom-[218px] left-[128px] z-20"
|
||||
:src="getStaticImage('home/intro/手机')"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<ImgButton
|
||||
class="absolute top-0 right-0 z-10"
|
||||
image="components/close"
|
||||
@click="emitClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CourseGuidePopup } from './service.ts'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import CourseGuidePopupVue from './CourseGuidePopup.vue'
|
||||
|
||||
type PopupOptions = {
|
||||
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
|
||||
designWidth?: number
|
||||
designHeight?: number
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
/** 获取主应用的 appContext,使命令式挂载的组件能访问 router 等注入 */
|
||||
function getAppContext() {
|
||||
const appRoot = document.getElementById('app')
|
||||
const app = (appRoot as any)?.__vue_app__
|
||||
return app?._context
|
||||
}
|
||||
|
||||
export default function openCourseGuidePopup(options: PopupOptions) {
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
const vnode = createVNode(CourseGuidePopupVue, {
|
||||
...options,
|
||||
onClose: () => {
|
||||
if (options.onClose) {
|
||||
options.onClose()
|
||||
}
|
||||
render(null, container!)
|
||||
},
|
||||
})
|
||||
|
||||
// 让 vnode 继承主应用的注入上下文(router、pinia 等)
|
||||
vnode.appContext = getAppContext()
|
||||
|
||||
render(vnode, container)
|
||||
}
|
||||
@@ -69,7 +69,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted } from 'vue'
|
||||
import { useScale } from '@/composables/useScale'
|
||||
import type { GuideStep } from './types'
|
||||
import SpotlightOverlay from './SpotlightOverlay.vue'
|
||||
import GuideDragon from './GuideDragon.vue'
|
||||
@@ -101,6 +102,7 @@ interface Props {
|
||||
interface Emits {
|
||||
(e: 'complete'): void
|
||||
(e: 'skip'): void
|
||||
(e: 'close'): void
|
||||
(e: 'step-change', step: GuideStep | null, index: number): void
|
||||
}
|
||||
|
||||
@@ -122,25 +124,13 @@ const emit = defineEmits<Emits>()
|
||||
const slots = useSlots()
|
||||
const internalShow = ref(true)
|
||||
const currentStepIndex = ref(0)
|
||||
const { scaleStyle: alignStyle } = useScale()
|
||||
|
||||
// 合并外部 visible 和内部 show 状态
|
||||
// 只有当外部 visible 为 true 且内部 internalShow 也为 true 时才显示
|
||||
const show = computed(() => {
|
||||
return props.visible && internalShow.value
|
||||
})
|
||||
const alignStyle = reactive<{
|
||||
left: string
|
||||
top: string
|
||||
transform: string
|
||||
width: string
|
||||
height: string
|
||||
}>({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
transform: 'scale(1)',
|
||||
width: '1920px',
|
||||
height: '1080px',
|
||||
})
|
||||
const normalizeSelector = (selector?: string | null) => {
|
||||
if (!selector) return null
|
||||
const trimmed = selector.trim()
|
||||
@@ -244,12 +234,14 @@ const handleNext = () => {
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
emit('skip')
|
||||
handleComplete()
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
internalShow.value = false
|
||||
emit('complete')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const goToStep = (index: number) => {
|
||||
@@ -335,34 +327,11 @@ onMounted(() => {
|
||||
if (props.autoStart && props.autoPlayInterval > 0) {
|
||||
startAutoPlay()
|
||||
}
|
||||
// 同步到 .app-content 的缩放与偏移,使引导面板与页面对齐
|
||||
const syncAlign = () => {
|
||||
const el = document.querySelector('.app-content') as HTMLElement | null
|
||||
if (!el) return
|
||||
const style = window.getComputedStyle(el)
|
||||
const transform = style.transform
|
||||
let scale = 1
|
||||
if (transform && transform !== 'none') {
|
||||
const match = transform.match(/matrix\(([^)]+)\)/)
|
||||
if (match) {
|
||||
const parts = match[1].split(',').map((s) => parseFloat(s.trim()))
|
||||
if (!Number.isNaN(parts[0])) scale = parts[0]
|
||||
}
|
||||
}
|
||||
alignStyle.transform = `scale(${scale})`
|
||||
alignStyle.left = style.left || '0px'
|
||||
alignStyle.top = style.top || '0px'
|
||||
alignStyle.width = style.width || '1920px'
|
||||
alignStyle.height = style.height || '1080px'
|
||||
}
|
||||
syncAlign()
|
||||
window.addEventListener('resize', syncAlign)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
stopAutoPlay()
|
||||
window.removeEventListener('resize', () => {})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useScale } from '@/composables/useScale'
|
||||
|
||||
interface Props {
|
||||
/** 是否可见 */
|
||||
@@ -51,6 +52,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const rect = reactive({ top: 0, left: 0, width: 0, height: 0 })
|
||||
const isElementReady = ref(false)
|
||||
const { windowWidth, windowHeight } = useScale()
|
||||
const hasHighlightTarget = computed(
|
||||
() => isElementReady.value && rect.width > 0 && rect.height > 0,
|
||||
)
|
||||
@@ -62,6 +64,7 @@ const RETRY_TIMEOUT = 2000 // 超时时间(毫秒)
|
||||
let retryTimer: number | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let retryStartTime = 0
|
||||
let measureTimer: number | undefined
|
||||
|
||||
// 验证 CSS 选择器是否有效
|
||||
// 主要检查:不能以纯数字开头(除非是 ID 选择器 #1)
|
||||
@@ -135,10 +138,6 @@ const measure = () => {
|
||||
// 停止重试
|
||||
stopRetry()
|
||||
} else {
|
||||
// 元素未找到或尺寸无效,启动重试机制
|
||||
isElementReady.value = false
|
||||
rect.width = 0
|
||||
rect.height = 0
|
||||
startRetry(selector)
|
||||
}
|
||||
}
|
||||
@@ -218,27 +217,46 @@ const stopRetry = () => {
|
||||
retryCount = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.targetSelector, props.padding],
|
||||
() => {
|
||||
stopRetry() // 停止之前的重试
|
||||
const scheduleMeasure = (delay = 0) => {
|
||||
if (measureTimer) clearTimeout(measureTimer)
|
||||
|
||||
measureTimer = window.setTimeout(() => {
|
||||
measureTimer = undefined
|
||||
requestAnimationFrame(measure)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.visible,
|
||||
props.targetSelector,
|
||||
props.padding,
|
||||
windowWidth.value,
|
||||
windowHeight.value,
|
||||
],
|
||||
() => {
|
||||
stopRetry()
|
||||
scheduleMeasure()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const onResize = () => measure()
|
||||
const onResize = () => scheduleMeasure()
|
||||
const onOrientationChange = () => scheduleMeasure(350)
|
||||
const onScroll = () => measure()
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('orientationchange', onOrientationChange)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
measure()
|
||||
scheduleMeasure()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('orientationchange', onOrientationChange)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
if (measureTimer) clearTimeout(measureTimer)
|
||||
stopRetry()
|
||||
})
|
||||
|
||||
|
||||
@@ -142,8 +142,6 @@ export interface StepItem {
|
||||
interface Props {
|
||||
/** 是否显示抽屉 */
|
||||
visible: boolean
|
||||
/** 背景图片路径 */
|
||||
backgroundImage: string
|
||||
/** 步骤列表 */
|
||||
steps: StepItem[]
|
||||
/** 当前步骤索引 */
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
<template>
|
||||
<div v-show="visible" class="sv-image-preview-root" :style="rootStyle" @click.self="onMaskClick">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="sv-image-preview-root"
|
||||
:style="rootStyle"
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<div class="sv-image-preview-toolbar">
|
||||
<button class="sv-ip-btn" @click="emitClose">×</button>
|
||||
<div class="sv-ip-page" v-if="images.length > 1">
|
||||
<button
|
||||
class="sv-ip-btn"
|
||||
@click="emitClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div
|
||||
class="sv-ip-page"
|
||||
v-if="images.length > 1"
|
||||
>
|
||||
{{ currentIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,30 +29,55 @@
|
||||
@mousemove.prevent="onMouseMove"
|
||||
@mouseup.prevent="onMouseUp"
|
||||
@mouseleave.prevent="onMouseUp"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchstart.prevent="onTouchStart"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
>
|
||||
<LoadingOverlay
|
||||
:visible="isImageLoading && !hasImageLoadError"
|
||||
text="图片加载中..."
|
||||
/>
|
||||
<div
|
||||
v-if="hasImageLoadError"
|
||||
class="sv-image-error"
|
||||
>
|
||||
图片加载失败,请稍后重试
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<img
|
||||
v-if="visible"
|
||||
:key="currentIndex"
|
||||
v-if="visible && currentImage"
|
||||
:key="currentImage"
|
||||
class="sv-image"
|
||||
:class="{ 'no-transition': state.isPanning }"
|
||||
:src="images[currentIndex]"
|
||||
:class="{ 'no-transition': state.isPanning, loaded: !isImageLoading }"
|
||||
:src="currentImage"
|
||||
:style="imageStyle"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
@dblclick="toggleZoom"
|
||||
:draggable="false"
|
||||
/>
|
||||
</transition>
|
||||
<button v-if="images.length > 1" class="sv-ip-nav prev" @click.stop="prev">‹</button>
|
||||
<button v-if="images.length > 1" class="sv-ip-nav next" @click.stop="next">›</button>
|
||||
<button
|
||||
v-if="images.length > 1"
|
||||
class="sv-ip-nav prev"
|
||||
@click.stop="prev"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
v-if="images.length > 1"
|
||||
class="sv-ip-nav next"
|
||||
@click.stop="next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import LoadingOverlay from '../Loading/LoadingOverlay.vue'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
@@ -59,7 +97,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
maskClosable: true,
|
||||
zIndex: 3000,
|
||||
designWidth: 1920,
|
||||
designHeight: 1080
|
||||
designHeight: 1080,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
@@ -76,7 +114,8 @@ const state = reactive({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
pinchStartDistance: 0,
|
||||
pinchStartScale: 1
|
||||
pinchStartScale: 1,
|
||||
isPinching: false,
|
||||
})
|
||||
|
||||
// rAF batching for smoother pan
|
||||
@@ -95,6 +134,24 @@ function schedulePan(x: number, y: number) {
|
||||
}
|
||||
|
||||
const images = computed(() => props.images)
|
||||
const currentImage = computed(() => images.value[currentIndex.value] ?? '')
|
||||
const isImageLoading = ref(Boolean(currentImage.value))
|
||||
const hasImageLoadError = ref(false)
|
||||
|
||||
function resetImageLoading() {
|
||||
isImageLoading.value = Boolean(currentImage.value)
|
||||
hasImageLoadError.value = false
|
||||
}
|
||||
|
||||
function onImageLoad() {
|
||||
isImageLoading.value = false
|
||||
hasImageLoadError.value = false
|
||||
}
|
||||
|
||||
function onImageError() {
|
||||
isImageLoading.value = false
|
||||
hasImageLoadError.value = true
|
||||
}
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
const { scale, offsetX, offsetY } = props.getScaleMetrics()
|
||||
@@ -107,7 +164,7 @@ const rootStyle = computed(() => {
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
zIndex: String(props.zIndex),
|
||||
backgroundColor: 'rgba(0,0,0,0.8)'
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
@@ -115,7 +172,10 @@ const imageStyle = computed(() => ({
|
||||
transform: `translate(${state.translateX}px, ${state.translateY}px) scale(${state.imgScale})`,
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
userSelect: 'none' as const
|
||||
userSelect: 'none' as const,
|
||||
WebkitUserSelect: 'none' as const,
|
||||
WebkitTouchCallout: 'none' as const,
|
||||
touchAction: 'none' as const,
|
||||
}))
|
||||
|
||||
function emitClose() {
|
||||
@@ -166,13 +226,27 @@ function onTouchStart(e: TouchEvent) {
|
||||
state.startY = t.clientY - state.translateY
|
||||
} else if (e.touches.length === 2) {
|
||||
state.isPanning = false
|
||||
state.isPinching = true
|
||||
state.pinchStartDistance = distance(e.touches[0], e.touches[1])
|
||||
state.pinchStartScale = state.imgScale
|
||||
}
|
||||
}
|
||||
|
||||
let lastTap = 0
|
||||
function onTouchEnd() {
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
if (state.isPinching) {
|
||||
state.isPinching = e.touches.length >= 2
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0]
|
||||
state.isPanning = true
|
||||
state.startX = t.clientX - state.translateX
|
||||
state.startY = t.clientY - state.translateY
|
||||
} else {
|
||||
state.isPanning = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastTap < 300) {
|
||||
toggleZoom()
|
||||
@@ -186,6 +260,7 @@ function onTouchMove(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
schedulePan(t.clientX - state.startX, t.clientY - state.startY)
|
||||
} else if (e.touches.length === 2) {
|
||||
state.isPinching = true
|
||||
const d = distance(e.touches[0], e.touches[1])
|
||||
const factor = d / (state.pinchStartDistance || d)
|
||||
state.imgScale = clamp(state.pinchStartScale * factor, 0.5, 5)
|
||||
@@ -217,14 +292,17 @@ function resetTransform() {
|
||||
|
||||
function prev() {
|
||||
if (images.value.length <= 1) return
|
||||
currentIndex.value = (currentIndex.value - 1 + images.value.length) % images.value.length
|
||||
currentIndex.value =
|
||||
(currentIndex.value - 1 + images.value.length) % images.value.length
|
||||
resetTransform()
|
||||
resetImageLoading()
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (images.value.length <= 1) return
|
||||
currentIndex.value = (currentIndex.value + 1) % images.value.length
|
||||
resetTransform()
|
||||
resetImageLoading()
|
||||
}
|
||||
|
||||
// Close on Esc
|
||||
@@ -245,8 +323,14 @@ onUnmounted(() => {
|
||||
watch(
|
||||
() => props.images,
|
||||
() => {
|
||||
currentIndex.value = clamp(props.startIndex ?? 0, 0, Math.max(0, props.images.length - 1))
|
||||
}
|
||||
currentIndex.value = clamp(
|
||||
props.startIndex ?? 0,
|
||||
0,
|
||||
Math.max(0, props.images.length - 1),
|
||||
)
|
||||
resetTransform()
|
||||
resetImageLoading()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -292,10 +376,32 @@ watch(
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
.sv-image-stage :deep(.loading-overlay) {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
background: transparent;
|
||||
}
|
||||
.sv-image-error {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.sv-image {
|
||||
opacity: 0;
|
||||
will-change: transform;
|
||||
transition: transform 0.1s ease-out;
|
||||
transition:
|
||||
transform 0.1s ease-out,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.sv-image.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
.sv-image.no-transition {
|
||||
transition: none !important;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LoadingOverlay } from './LoadingOverlay.vue'
|
||||
@@ -8,9 +8,15 @@
|
||||
<!-- 主进度条 -->
|
||||
<div class="progress-bar">
|
||||
<!-- 中间步骤指示器 -->
|
||||
<div class="steps-section" v-if="currentStep > 0 || completedStep > 0">
|
||||
<div
|
||||
class="steps-section"
|
||||
v-if="currentStep > 0 || completedStep > 0"
|
||||
>
|
||||
<!-- 标题区域 -->
|
||||
<div class="absolute top-0 left-40" v-if="currentStep > 0 || completedStep > 0">
|
||||
<div
|
||||
class="absolute top-0 left-40"
|
||||
v-if="currentStep > 0 || completedStep > 0"
|
||||
>
|
||||
<img
|
||||
v-if="moduleName === 'vlog'"
|
||||
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.TITLE)"
|
||||
@@ -56,7 +62,7 @@
|
||||
'flex items-center',
|
||||
isStepClickable(index + 1)
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-45 grayscale'
|
||||
: 'cursor-not-allowed opacity-45 grayscale',
|
||||
]"
|
||||
@click="handleStepClick(index + 1)"
|
||||
>
|
||||
@@ -67,13 +73,17 @@
|
||||
? 'text-white/60'
|
||||
: currentStep === index + 1
|
||||
? 'text-[#fbbf24]'
|
||||
: 'text-white'
|
||||
: 'text-white',
|
||||
]"
|
||||
>
|
||||
{{ step.name }}
|
||||
</span>
|
||||
<!-- 图标 -->
|
||||
<img v-if="isStepCompleted(index)" src="@/assets/images/midterm/完成.png" alt="" />
|
||||
<img
|
||||
v-if="isStepCompleted(index)"
|
||||
src="@/assets/images/midterm/完成.png"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:class="['ml-2', currentStep === index + 1 && 'filter-blue']"
|
||||
@@ -92,7 +102,10 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:class="['step-ellipsis', !isStepClickable(index + 2) && 'opacity-45 grayscale']"
|
||||
:class="[
|
||||
'step-ellipsis',
|
||||
!isStepClickable(index + 2) && 'opacity-45 grayscale',
|
||||
]"
|
||||
src="@/assets/images/midterm/进度装饰(未完成.png"
|
||||
alt=""
|
||||
/>
|
||||
@@ -101,7 +114,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 left-40" v-else>
|
||||
<div
|
||||
class="absolute top-0 left-40"
|
||||
v-else
|
||||
>
|
||||
<img
|
||||
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.UNLOCKED)"
|
||||
draggable="false"
|
||||
@@ -112,15 +128,12 @@
|
||||
|
||||
<!-- 右侧完成按钮 -->
|
||||
<div class="right-section">
|
||||
<button @click="handleComplete" :disabled="!canComplete">
|
||||
<button
|
||||
@click="handleComplete"
|
||||
:disabled="!canComplete"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
getStaticImage(
|
||||
canComplete
|
||||
? IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON
|
||||
: IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON_DISABLED
|
||||
)
|
||||
"
|
||||
:src="getStaticImage(completeButtonImage)"
|
||||
alt="完成前置-即可开启实战"
|
||||
class="button-image"
|
||||
/>
|
||||
@@ -158,7 +171,7 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
completedStep: 0,
|
||||
currentStep: 1,
|
||||
moduleName: 'vlog'
|
||||
moduleName: 'vlog',
|
||||
})
|
||||
|
||||
const [currentStep] = useModel(props, 'currentStep')
|
||||
@@ -170,6 +183,14 @@ const emit = defineEmits<Emits>()
|
||||
// 步骤:1=前期准备, 2=中期拍摄, 3=后期剪辑, 4=实战操作台
|
||||
// 当 completedStep >= 3(即完成第3步)时,解锁右侧完成按钮
|
||||
const canComplete = computed(() => props.completedStep >= 3)
|
||||
const isPracticeCompleted = computed(() => props.completedStep >= 4)
|
||||
const completeButtonImage = computed(() => {
|
||||
if (!canComplete.value)
|
||||
return IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON_DISABLED
|
||||
return isPracticeCompleted.value
|
||||
? IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON_COMPLETED
|
||||
: IMAGE_PATHS.PROGRESS_BAR.COMPLETE_BUTTON
|
||||
})
|
||||
|
||||
// 判断步骤是否已完成
|
||||
const isStepCompleted = (stepIndex: number) => {
|
||||
@@ -206,20 +227,20 @@ const steps = [
|
||||
name: '前期准备',
|
||||
icon: getStaticImage('midterm/准备图标.png'),
|
||||
nameSelect: getStaticImage('midterm/前期准备-选中.png'),
|
||||
nameselectNot: getStaticImage('midterm/前期准备-未选中.png')
|
||||
nameselectNot: getStaticImage('midterm/前期准备-未选中.png'),
|
||||
},
|
||||
{
|
||||
name: '中期拍摄',
|
||||
icon: getStaticImage('midterm/拍摄图标.png'),
|
||||
nameSelect: getStaticImage('midterm/中期拍摄-选中.png'),
|
||||
nameselectNot: getStaticImage('midterm/中期拍摄-未选中.png')
|
||||
nameselectNot: getStaticImage('midterm/中期拍摄-未选中.png'),
|
||||
},
|
||||
{
|
||||
name: '后期剪辑',
|
||||
icon: getStaticImage('midterm/剪辑图标.png'),
|
||||
nameSelect: getStaticImage('midterm/后期剪辑-选中.png'),
|
||||
nameselectNot: getStaticImage('midterm/后期剪辑-未选中.png')
|
||||
}
|
||||
nameselectNot: getStaticImage('midterm/后期剪辑-未选中.png'),
|
||||
},
|
||||
]
|
||||
|
||||
// 获取步骤状态
|
||||
@@ -339,7 +360,7 @@ const getStepStatus = (stepIndex: number) => {
|
||||
|
||||
.right-section {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
right: 10px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@@ -369,8 +390,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.filter-blue {
|
||||
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
|
||||
brightness(102%) contrast(98%);
|
||||
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
|
||||
hue-rotate(330deg) brightness(102%) contrast(98%);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getStaticImage } from '@/utils'
|
||||
import { getStaticImage, getVideoPoster } from '@/utils'
|
||||
import { Video } from '@/components'
|
||||
|
||||
export interface QuestionOption {
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showQuestionNumber: true
|
||||
showQuestionNumber: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -45,12 +45,15 @@ const hasMedia = computed(() => !!currentQuestion.value?.mediaSrc)
|
||||
const isMultiple = computed(() => currentQuestion.value?.isMultiple ?? false)
|
||||
const correctOption = computed(() => {
|
||||
if (!currentQuestion.value) return null
|
||||
return currentQuestion.value.options.find(opt => opt.id === currentQuestion.value.correctOptionId)
|
||||
return currentQuestion.value.options.find(
|
||||
(opt) => opt.id === currentQuestion.value.correctOptionId,
|
||||
)
|
||||
})
|
||||
const correctOptions = computed(() => {
|
||||
if (!currentQuestion.value || !currentQuestion.value.correctOptionIds) return []
|
||||
return currentQuestion.value.options.filter(opt =>
|
||||
currentQuestion.value.correctOptionIds?.includes(opt.id)
|
||||
if (!currentQuestion.value || !currentQuestion.value.correctOptionIds)
|
||||
return []
|
||||
return currentQuestion.value.options.filter((opt) =>
|
||||
currentQuestion.value.correctOptionIds?.includes(opt.id),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -69,15 +72,31 @@ watch(currentIndex, () => {
|
||||
|
||||
// 通过文件后缀判断媒体类型
|
||||
const isVideo = (src: string): boolean => {
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv', '.flv', '.wmv']
|
||||
const videoExtensions = [
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.avi',
|
||||
'.webm',
|
||||
'.mkv',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
]
|
||||
const lowerSrc = src.toLowerCase()
|
||||
return videoExtensions.some(ext => lowerSrc.endsWith(ext))
|
||||
return videoExtensions.some((ext) => lowerSrc.endsWith(ext))
|
||||
}
|
||||
|
||||
const isImage = (src: string): boolean => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']
|
||||
const imageExtensions = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.bmp',
|
||||
]
|
||||
const lowerSrc = src.toLowerCase()
|
||||
return imageExtensions.some(ext => lowerSrc.endsWith(ext))
|
||||
return imageExtensions.some((ext) => lowerSrc.endsWith(ext))
|
||||
}
|
||||
|
||||
const handleOptionClick = (optionId: string) => {
|
||||
@@ -117,12 +136,16 @@ const handleSubmit = async () => {
|
||||
const correctSet = new Set(correctIds)
|
||||
|
||||
// 检查是否选择了所有正确答案
|
||||
const hasAllCorrect = correctIds.every(id => selectedSet.has(id))
|
||||
const hasAllCorrect = correctIds.every((id) => selectedSet.has(id))
|
||||
// 检查是否只选择了正确答案(没有选择错误答案)
|
||||
const onlyCorrect = selectedOptionIds.value.every(id => correctSet.has(id))
|
||||
const onlyCorrect = selectedOptionIds.value.every((id) =>
|
||||
correctSet.has(id),
|
||||
)
|
||||
|
||||
const isCorrect =
|
||||
hasAllCorrect && onlyCorrect && selectedOptionIds.value.length === correctIds.length
|
||||
hasAllCorrect &&
|
||||
onlyCorrect &&
|
||||
selectedOptionIds.value.length === correctIds.length
|
||||
|
||||
if (!isCorrect) {
|
||||
// 答错时显示解析
|
||||
@@ -134,7 +157,8 @@ const handleSubmit = async () => {
|
||||
if (!selectedOptionId.value) return
|
||||
|
||||
const isGuideQuestion = q.correctOptionId === '' // 引导题判断
|
||||
const isCorrect = isGuideQuestion || selectedOptionId.value === q.correctOptionId
|
||||
const isCorrect =
|
||||
isGuideQuestion || selectedOptionId.value === q.correctOptionId
|
||||
|
||||
if (!isCorrect) {
|
||||
// 答错时显示解析
|
||||
@@ -146,7 +170,7 @@ const handleSubmit = async () => {
|
||||
// 答对时重置错误解析状态
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
if (currentIndex.value < total.value - 1) {
|
||||
currentIndex.value += 1
|
||||
@@ -160,12 +184,17 @@ const handleSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30"
|
||||
>
|
||||
<div
|
||||
class="bg-black/80 backdrop-blur-lg rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
||||
>
|
||||
<!-- 顶部:题号 -->
|
||||
<div v-if="showQuestionNumber" class="flex items-center justify-between mb-2">
|
||||
<div
|
||||
v-if="showQuestionNumber"
|
||||
class="flex items-center justify-between mb-2"
|
||||
>
|
||||
<div class="text-3xl font-semibold">
|
||||
第
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">
|
||||
@@ -179,7 +208,9 @@ const handleSubmit = async () => {
|
||||
<!-- 中部:左右布局 -->
|
||||
<div
|
||||
:class="[
|
||||
hasMedia ? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch' : 'flex justify-center'
|
||||
hasMedia
|
||||
? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch'
|
||||
: 'flex justify-center',
|
||||
]"
|
||||
>
|
||||
<!-- 左侧:题干 + 选项 -->
|
||||
@@ -193,14 +224,22 @@ const handleSubmit = async () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="hasMoreThanFourOptions ? 'grid grid-cols-2 gap-6' : 'space-y-10'">
|
||||
<div
|
||||
:class="
|
||||
hasMoreThanFourOptions ? 'grid grid-cols-2 gap-6' : 'space-y-10'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-for="opt in currentQuestion.options"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="w-full text-left px-5 py-4 rounded-xl border transition-all duration-200 text-2xl leading-6"
|
||||
:class="[
|
||||
(isMultiple ? selectedOptionIds.includes(opt.id) : selectedOptionId === opt.id)
|
||||
(
|
||||
isMultiple
|
||||
? selectedOptionIds.includes(opt.id)
|
||||
: selectedOptionId === opt.id
|
||||
)
|
||||
? showErrorExplanation &&
|
||||
(isMultiple
|
||||
? currentQuestion.correctOptionIds?.includes(opt.id)
|
||||
@@ -208,8 +247,9 @@ const handleSubmit = async () => {
|
||||
? 'border-emerald-400 bg-emerald-500/30 shadow-lg shadow-emerald-500/30'
|
||||
: showErrorExplanation &&
|
||||
(isMultiple
|
||||
? !currentQuestion.correctOptionIds?.includes(opt.id) &&
|
||||
selectedOptionIds.includes(opt.id)
|
||||
? !currentQuestion.correctOptionIds?.includes(
|
||||
opt.id,
|
||||
) && selectedOptionIds.includes(opt.id)
|
||||
: opt.id === selectedOptionId &&
|
||||
opt.id !== currentQuestion.correctOptionId)
|
||||
? 'border-red-400 bg-red-500/20 shadow-lg shadow-red-500/20'
|
||||
@@ -219,14 +259,19 @@ const handleSubmit = async () => {
|
||||
? currentQuestion.correctOptionIds?.includes(opt.id)
|
||||
: opt.id === currentQuestion.correctOptionId)
|
||||
? 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 hover:border-white/25'
|
||||
: 'border-white/15 bg-white/5 hover:bg-white/10 hover:border-white/25',
|
||||
]"
|
||||
@click="handleOptionClick(opt.id)"
|
||||
>
|
||||
<span class="font-semibold mr-2">{{ opt.id.toUpperCase() }}.</span>
|
||||
<span class="font-semibold mr-2">
|
||||
{{ opt.id.toUpperCase() }}.
|
||||
</span>
|
||||
<span>{{ opt.label }}</span>
|
||||
<!-- 多选题显示复选框样式 -->
|
||||
<span v-if="isMultiple" class="ml-auto text-emerald-400">
|
||||
<span
|
||||
v-if="isMultiple"
|
||||
class="ml-auto text-emerald-400"
|
||||
>
|
||||
{{ selectedOptionIds.includes(opt.id) ? '✓' : '' }}
|
||||
</span>
|
||||
</button>
|
||||
@@ -251,19 +296,29 @@ const handleSubmit = async () => {
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl">💡</span>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="text-xl font-semibold text-amber-300">参考答案</div>
|
||||
<div class="text-xl font-semibold text-amber-300">
|
||||
参考答案
|
||||
</div>
|
||||
<div
|
||||
v-if="isMultiple && correctOptions.length > 0"
|
||||
class="text-lg text-amber-100 leading-relaxed"
|
||||
>
|
||||
{{
|
||||
correctOptions.map(opt => `${opt.id.toUpperCase()}. ${opt.label}`).join('、')
|
||||
correctOptions
|
||||
.map((opt) => `${opt.id.toUpperCase()}. ${opt.label}`)
|
||||
.join('、')
|
||||
}}
|
||||
</div>
|
||||
<div v-else-if="correctOption" class="text-lg text-amber-100 leading-relaxed">
|
||||
{{ correctOption.id.toUpperCase() }}. {{ correctOption.label }}
|
||||
<div
|
||||
v-else-if="correctOption"
|
||||
class="text-lg text-amber-100 leading-relaxed"
|
||||
>
|
||||
{{ correctOption.id.toUpperCase() }}.
|
||||
{{ correctOption.label }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-amber-300 mt-4">
|
||||
解析
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-amber-300 mt-4">解析</div>
|
||||
<div class="text-lg text-amber-100 leading-relaxed">
|
||||
{{ currentQuestion.explanation }}
|
||||
</div>
|
||||
@@ -277,7 +332,9 @@ const handleSubmit = async () => {
|
||||
type="button"
|
||||
:disabled="
|
||||
showCorrectFeedback ||
|
||||
(isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId)
|
||||
(isMultiple
|
||||
? selectedOptionIds.length === 0
|
||||
: !selectedOptionId)
|
||||
"
|
||||
class="inline-flex items-center justify-center px-10 py-3 rounded-full bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-white text-2xl font-bold shadow-lg shadow-emerald-500/40 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 hover:shadow-xl hover:shadow-emerald-500/50 hover:scale-105 active:scale-100"
|
||||
@click="handleSubmit"
|
||||
@@ -288,7 +345,11 @@ const handleSubmit = async () => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧:参考图片 / 视频 -->
|
||||
<div class="flex flex-col gap-3" v-if="currentQuestion.mediaSrc" :key="currentQuestion.id">
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
v-if="currentQuestion.mediaSrc"
|
||||
:key="currentQuestion.id"
|
||||
>
|
||||
<!-- <div class="text-2xl font-semibold text-white/80 px-1">题目参考画面</div> -->
|
||||
<div
|
||||
class="relative rounded-3xl overflow-hidden border border-white/20 bg-black/50 min-h-[500px] shadow-lg"
|
||||
@@ -304,6 +365,7 @@ const handleSubmit = async () => {
|
||||
<Video
|
||||
class="w-full h-full object-cover"
|
||||
:src="currentQuestion.mediaSrc"
|
||||
:poster="getVideoPoster(currentQuestion.mediaSrc)"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
></Video>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
? 'translate-x-[-25%] brightness-100'
|
||||
: 'translate-x-[0] brightness-50',
|
||||
'img-switch-item transition-all duration-500',
|
||||
`img-switch-item--${item.key}`
|
||||
`img-switch-item--${item.key}`,
|
||||
]"
|
||||
:data-key="item.key"
|
||||
:style="{ backgroundImage: getBackgroundImage(getItemImage(item)) }"
|
||||
@@ -17,23 +17,33 @@
|
||||
>
|
||||
<span
|
||||
v-if="item.label"
|
||||
:class="['ml-6 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)]', labelClass]"
|
||||
:class="[
|
||||
'ml-6 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)]',
|
||||
labelClass,
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<img
|
||||
v-if="item.statusImage"
|
||||
class="ml-3 h-[34px] w-[34px] object-contain"
|
||||
:src="getStaticImage(item.statusImage)"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getBackgroundImage } from '@/utils'
|
||||
import { getBackgroundImage, getStaticImage } from '@/utils'
|
||||
|
||||
interface SwitchItem {
|
||||
key: string
|
||||
activeImage: string
|
||||
inactiveImage: string
|
||||
label?: string
|
||||
statusImage?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -53,12 +63,14 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
direction: 'vertical',
|
||||
itemClass: '',
|
||||
labelClass: 'text-5xl font-bold'
|
||||
labelClass: 'text-5xl font-bold',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const directionClass = computed(() => (props.direction === 'vertical' ? 'vertical' : 'horizontal'))
|
||||
const directionClass = computed(() =>
|
||||
props.direction === 'vertical' ? 'vertical' : 'horizontal',
|
||||
)
|
||||
|
||||
const getItemImage = (item: SwitchItem) => {
|
||||
return item.key === props.modelValue ? item.activeImage : item.inactiveImage
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VideoAnchor, VideoSlotProps, TimeUnit } from './types'
|
||||
|
||||
interface Props {
|
||||
slotProps: VideoSlotProps
|
||||
anchors: VideoAnchor[]
|
||||
rateOptions: number[]
|
||||
showRateOptions: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:showRateOptions', value: boolean): void
|
||||
(e: 'seek-input', event: Event): void
|
||||
(e: 'seek-commit'): void
|
||||
(e: 'rate-select', rate: number): void
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>()
|
||||
|
||||
const fmt = (s: TimeUnit) => {
|
||||
const sec = Math.floor(s % 60)
|
||||
const min = Math.floor((s / 60) % 60)
|
||||
const hr = Math.floor(s / 3600)
|
||||
const two = (n: number) => (n < 10 ? `0${n}` : `${n}`)
|
||||
return hr > 0 ? `${hr}:${two(min)}:${two(sec)}` : `${two(min)}:${two(sec)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sv-controls absolute left-0 right-0 bottom-0 p-3 bg-gradient-to-t from-black/60 to-transparent text-white"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
|
||||
@click.stop="slotProps.togglePlay"
|
||||
>
|
||||
<svg
|
||||
v-if="!slotProps.playing"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 4.75v14.5c0 .7.76 1.14 1.36.78l9.25-5.5a.9.9 0 0 0 0-1.56l-9.25-5.5A.9.9 0 0 0 7 4.75z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 5.25c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1H7zm7.5 0c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1h-2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<slot
|
||||
name="controls-left"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
|
||||
<div class="text-xs tabular-nums min-w-[60px]">
|
||||
{{ fmt(slotProps.currentTime) }}
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 h-2">
|
||||
<div class="absolute inset-0 rounded bg-white/20"></div>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded bg-white/40"
|
||||
:style="{ width: slotProps.bufferedPercent + '%' }"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded bg-blue-500"
|
||||
:style="{ width: slotProps.progressPercent + '%' }"
|
||||
></div>
|
||||
<template v-if="slotProps.duration && anchors.length">
|
||||
<div
|
||||
v-for="anchor in anchors"
|
||||
:key="anchor.id"
|
||||
class="absolute -top-0.5 w-[2px] h-3 bg-yellow-300"
|
||||
:style="{ left: (anchor.time / slotProps.duration) * 100 + '%' }"
|
||||
></div>
|
||||
</template>
|
||||
<input
|
||||
class="absolute inset-0 w-full opacity-0 cursor-pointer"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
:value="slotProps.progressPercent"
|
||||
@input="emit('seek-input', $event)"
|
||||
@change="emit('seek-commit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs tabular-nums min-w-[60px] text-right">
|
||||
{{ fmt(slotProps.duration) }}
|
||||
</div>
|
||||
|
||||
<div class="ml-2 flex items-center gap-2">
|
||||
<span class="text-sm">🔊</span>
|
||||
<input
|
||||
class="w-24 cursor-pointer"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="slotProps.volume"
|
||||
@input="
|
||||
slotProps.setVolume(
|
||||
Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-2 relative">
|
||||
<button
|
||||
class="bg-white/10 rounded px-2 py-1 text-xs hover:bg-white/20"
|
||||
@click.stop="emit('update:showRateOptions', !showRateOptions)"
|
||||
>
|
||||
{{ slotProps.rate }}x
|
||||
</button>
|
||||
<div
|
||||
v-if="showRateOptions"
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/80 text-white rounded shadow-lg p-1 min-w-[72px] z-10"
|
||||
>
|
||||
<button
|
||||
v-for="rate in rateOptions"
|
||||
:key="rate"
|
||||
class="block w-full text-left px-2 py-1 text-xs hover:bg-white/10 rounded"
|
||||
@click.stop="emit('rate-select', rate)"
|
||||
>
|
||||
{{ rate }}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="controls-right"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+303
-328
@@ -1,48 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import DefaultControls from './DefaultControls.vue'
|
||||
import type {
|
||||
PlayAttemptReason,
|
||||
PlayFailedPayload,
|
||||
TimeUnit,
|
||||
VideoAnchor,
|
||||
VideoProps,
|
||||
VideoSlotProps,
|
||||
} from './types'
|
||||
|
||||
type TimeUnit = number
|
||||
|
||||
interface VideoSource {
|
||||
src: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface VideoAnchor {
|
||||
id: string
|
||||
time: number // 触发时间(秒)
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// 源:字符串或多源
|
||||
src?: string
|
||||
sources?: VideoSource[]
|
||||
// 初始配置
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
poster?: string
|
||||
// UI 配置
|
||||
controls?: boolean // 是否显示自定义控件
|
||||
hideNativeControls?: boolean // 隐藏原生控件
|
||||
autoHideControls?: boolean // 播放时自动隐藏控制条与插槽
|
||||
autoHideDelay?: number // 自动隐藏延迟(毫秒)
|
||||
showCenterPlayIcon?: boolean // 是否显示中间播放图标
|
||||
// 行为:受控/回调
|
||||
modelValue?: boolean // 外部受控播放状态
|
||||
volume?: number // 0~1
|
||||
playbackRate?: number
|
||||
// 尺寸与适配
|
||||
fit?: 'contain' | 'cover' | 'fill'
|
||||
// 锚点配置
|
||||
anchors?: VideoAnchor[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<VideoProps>(), {
|
||||
autoplay: false,
|
||||
muted: true,
|
||||
loop: false,
|
||||
@@ -54,7 +22,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showCenterPlayIcon: true,
|
||||
playbackRate: 1,
|
||||
fit: 'contain',
|
||||
anchors: () => []
|
||||
anchors: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -65,30 +33,23 @@ const emit = defineEmits<{
|
||||
(e: 'timeupdate', currentTime: TimeUnit, duration: TimeUnit): void
|
||||
(e: 'error', error: unknown): void
|
||||
(e: 'loadedmetadata', duration: TimeUnit): void
|
||||
(e: 'loadeddata'): void
|
||||
(e: 'canplay'): void
|
||||
(e: 'canplaythrough'): void
|
||||
(e: 'ratechange', rate: number): void
|
||||
(e: 'volumechange', volume: number, muted: boolean): void
|
||||
(e: 'seeking', currentTime: TimeUnit): void
|
||||
(e: 'seeked', currentTime: TimeUnit): void
|
||||
(e: 'playfailed', payload: PlayFailedPayload): void
|
||||
}>()
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
anchor?: (props: {
|
||||
anchor: VideoAnchor
|
||||
resume: () => void
|
||||
unmute: () => void
|
||||
}) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
@@ -115,7 +76,9 @@ const activeAnchorId = ref<string | null>(null)
|
||||
const isHandlingAnchor = ref(false)
|
||||
|
||||
const activeAnchor = computed(() =>
|
||||
activeAnchorId.value ? (props.anchors.find(a => a.id === activeAnchorId.value) ?? null) : null
|
||||
activeAnchorId.value
|
||||
? (props.anchors.find((a) => a.id === activeAnchorId.value) ?? null)
|
||||
: null,
|
||||
)
|
||||
|
||||
// UI 可见性与计时器(控制条与所有插槽一起隐藏/显示)
|
||||
@@ -143,21 +106,15 @@ const revealTemporarily = () => {
|
||||
scheduleHideIfNeeded()
|
||||
}
|
||||
|
||||
const hasMultipleSources = computed(() => !!props.sources && props.sources.length > 0)
|
||||
|
||||
const fmt = (s: number) => {
|
||||
const sec = Math.floor(s % 60)
|
||||
const min = Math.floor((s / 60) % 60)
|
||||
const hr = Math.floor(s / 3600)
|
||||
const two = (n: number) => (n < 10 ? `0${n}` : `${n}`)
|
||||
return hr > 0 ? `${hr}:${two(min)}:${two(sec)}` : `${two(min)}:${two(sec)}`
|
||||
}
|
||||
const hasMultipleSources = computed(
|
||||
() => !!props.sources && props.sources.length > 0,
|
||||
)
|
||||
|
||||
const progressPercent = computed(() =>
|
||||
duration.value ? (currentTime.value / duration.value) * 100 : 0
|
||||
duration.value ? (currentTime.value / duration.value) * 100 : 0,
|
||||
)
|
||||
const bufferedPercent = computed(() =>
|
||||
duration.value ? (buffered.value / duration.value) * 100 : 0
|
||||
duration.value ? (buffered.value / duration.value) * 100 : 0,
|
||||
)
|
||||
|
||||
const handleAnchorsOnTimeUpdate = (t: number, el: HTMLVideoElement) => {
|
||||
@@ -185,14 +142,29 @@ const handleAnchorsOnTimeUpdate = (t: number, el: HTMLVideoElement) => {
|
||||
}
|
||||
}
|
||||
|
||||
const attemptPlay = async (reason: PlayAttemptReason, emitFailure = true) => {
|
||||
const el = videoRef.value
|
||||
if (!el) return false
|
||||
|
||||
applyPlaybackRate(el)
|
||||
try {
|
||||
await el.play()
|
||||
applyPlaybackRate(el)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (emitFailure) {
|
||||
const payload = { reason, error, src: el.currentSrc || props.src }
|
||||
emit('error', error)
|
||||
emit('playfailed', payload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (!videoRef.value) return
|
||||
if (videoRef.value.paused) {
|
||||
try {
|
||||
await videoRef.value.play()
|
||||
} catch (err) {
|
||||
emit('error', err)
|
||||
}
|
||||
await attemptPlay('toggle')
|
||||
} else {
|
||||
videoRef.value.pause()
|
||||
}
|
||||
@@ -201,38 +173,89 @@ const togglePlay = async () => {
|
||||
// src 切换时的稳健自动播放:
|
||||
// - 如果外部 modelValue 为 true(或当前正在播放),切换资源后会尝试继续播放
|
||||
// - 某些浏览器在切换 src 后立即 play 可能失败(还未 ready / load 过程中),这里做轻量重试
|
||||
const playAfterSourceChange = (el: HTMLVideoElement) => {
|
||||
const shouldPlay = !!props.modelValue || isPlaying.value
|
||||
if (!shouldPlay) return
|
||||
|
||||
// 尝试在下一帧播放;失败则再延迟重试一次
|
||||
const playAfterSourceChange = () => {
|
||||
requestAnimationFrame(() => {
|
||||
el.play().catch(err1 => {
|
||||
// 再给一次机会:资源 load/seek 状态可能还没就绪
|
||||
attemptPlay('source-change', false).then((played) => {
|
||||
if (played) return
|
||||
window.setTimeout(() => {
|
||||
el.play().catch(err2 => emit('error', err2 ?? err1))
|
||||
attemptPlay('source-change')
|
||||
}, 50)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let suppressNextPauseEmit = false
|
||||
let clearSuppressPauseTimer: number | undefined
|
||||
|
||||
const suppressNextInternalPause = () => {
|
||||
suppressNextPauseEmit = true
|
||||
if (clearSuppressPauseTimer !== undefined) {
|
||||
window.clearTimeout(clearSuppressPauseTimer)
|
||||
}
|
||||
clearSuppressPauseTimer = window.setTimeout(() => {
|
||||
suppressNextPauseEmit = false
|
||||
clearSuppressPauseTimer = undefined
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const consumeSuppressedPause = () => {
|
||||
if (!suppressNextPauseEmit) return false
|
||||
suppressNextPauseEmit = false
|
||||
if (clearSuppressPauseTimer !== undefined) {
|
||||
window.clearTimeout(clearSuppressPauseTimer)
|
||||
clearSuppressPauseTimer = undefined
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const resetSessionState = () => {
|
||||
isPlaying.value = false
|
||||
isSeeking.value = false
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
buffered.value = 0
|
||||
activeAnchorId.value = null
|
||||
isHandlingAnchor.value = false
|
||||
hiddenAnchorIds.value = new Set()
|
||||
removedAnchorIds.value = new Set()
|
||||
showUi.value = true
|
||||
showRateOptions.value = false
|
||||
clearHideTimer()
|
||||
}
|
||||
|
||||
const applyPlaybackRate = (el: HTMLVideoElement) => {
|
||||
el.defaultPlaybackRate = internalRate.value
|
||||
el.playbackRate = internalRate.value
|
||||
}
|
||||
|
||||
const resetForSourceChange = (el: HTMLVideoElement) => {
|
||||
const shouldPlay = !!props.modelValue || isPlaying.value
|
||||
resetSessionState()
|
||||
|
||||
try {
|
||||
if (!el.paused) suppressNextInternalPause()
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch {
|
||||
consumeSuppressedPause()
|
||||
}
|
||||
el.load()
|
||||
applyPlaybackRate(el)
|
||||
nextTick(() => {
|
||||
if (!videoRef.value) return
|
||||
applyPlaybackRate(videoRef.value)
|
||||
if (shouldPlay) playAfterSourceChange()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.src, props.sources],
|
||||
() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
|
||||
// 切源:重置并重新加载
|
||||
try {
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.load()
|
||||
playAfterSourceChange(el)
|
||||
resetForSourceChange(el)
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const handleTimeInput = (e: Event) => {
|
||||
@@ -260,7 +283,7 @@ const handleVolume = (v: number) => {
|
||||
const handleRate = (r: number) => {
|
||||
if (!videoRef.value) return
|
||||
internalRate.value = r
|
||||
videoRef.value.playbackRate = r
|
||||
applyPlaybackRate(videoRef.value)
|
||||
}
|
||||
|
||||
const unmute = () => {
|
||||
@@ -282,7 +305,7 @@ const resumeFromAnchor = (anchor: VideoAnchor) => {
|
||||
|
||||
isHandlingAnchor.value = false
|
||||
if (videoRef.value?.paused) {
|
||||
videoRef.value.play().catch(err => emit('error', err))
|
||||
attemptPlay('anchor-resume')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,120 +326,195 @@ const seek = (time: number) => {
|
||||
// 外部受控播放
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
(val) => {
|
||||
if (val == null || !videoRef.value) return
|
||||
if (val && videoRef.value.paused) {
|
||||
videoRef.value.play().catch(err => emit('error', err))
|
||||
attemptPlay('model')
|
||||
} else if (!val && !videoRef.value.paused) {
|
||||
videoRef.value.pause()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 同步外部 volume、rate
|
||||
watch(
|
||||
() => props.volume,
|
||||
v => {
|
||||
(v) => {
|
||||
if (v == null || !videoRef.value) return
|
||||
handleVolume(v)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.playbackRate,
|
||||
r => {
|
||||
(r) => {
|
||||
if (r == null || !videoRef.value) return
|
||||
handleRate(r)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
let onSeeked: (() => void) | undefined
|
||||
|
||||
// 监听 video 事件
|
||||
onMounted(() => {
|
||||
const handleNativePlay = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
const onPlay = () => {
|
||||
isPlaying.value = true
|
||||
emit('update:modelValue', true)
|
||||
emit('play')
|
||||
revealTemporarily()
|
||||
}
|
||||
const onPause = () => {
|
||||
isPlaying.value = false
|
||||
if (el) applyPlaybackRate(el)
|
||||
isPlaying.value = true
|
||||
emit('update:modelValue', true)
|
||||
emit('play')
|
||||
revealTemporarily()
|
||||
}
|
||||
|
||||
const handleNativePause = () => {
|
||||
isPlaying.value = false
|
||||
if (!consumeSuppressedPause()) {
|
||||
emit('update:modelValue', false)
|
||||
emit('pause')
|
||||
clearHideTimer()
|
||||
showUi.value = true
|
||||
}
|
||||
const onEnded = () => emit('ended')
|
||||
const onTime = () => {
|
||||
currentTime.value = el.currentTime
|
||||
// 计算缓冲
|
||||
if (el.buffered.length) {
|
||||
buffered.value = el.buffered.end(el.buffered.length - 1)
|
||||
}
|
||||
duration.value = el.duration || duration.value
|
||||
emit('timeupdate', currentTime.value, duration.value)
|
||||
handleAnchorsOnTimeUpdate(currentTime.value, el)
|
||||
}
|
||||
const onLoaded = () => {
|
||||
duration.value = el.duration || 0
|
||||
emit('loadedmetadata', duration.value)
|
||||
}
|
||||
const onError = (e: Event) => emit('error', e)
|
||||
const onRate = () => emit('ratechange', el.playbackRate)
|
||||
const onVolume = () => emit('volumechange', el.volume, el.muted)
|
||||
clearHideTimer()
|
||||
showUi.value = true
|
||||
}
|
||||
|
||||
onSeeked = () => {
|
||||
const cur = el.currentTime
|
||||
// 清理当前时间之后的 hidden 状态,允许重新触发
|
||||
if (props.anchors.length && hiddenAnchorIds.value.size) {
|
||||
const next = new Set(hiddenAnchorIds.value)
|
||||
for (const id of next) {
|
||||
const anchor = props.anchors.find(a => a.id === id)
|
||||
if (anchor && anchor.time > cur) {
|
||||
next.delete(id)
|
||||
}
|
||||
const handleNativeEnded = () => emit('ended')
|
||||
|
||||
const handleNativeTimeUpdate = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
currentTime.value = el.currentTime
|
||||
if (el.buffered.length) {
|
||||
buffered.value = el.buffered.end(el.buffered.length - 1)
|
||||
}
|
||||
duration.value = el.duration || duration.value
|
||||
emit('timeupdate', currentTime.value, duration.value)
|
||||
handleAnchorsOnTimeUpdate(currentTime.value, el)
|
||||
}
|
||||
|
||||
const handleNativeLoadedMetadata = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
applyPlaybackRate(el)
|
||||
duration.value = el.duration || 0
|
||||
emit('loadedmetadata', duration.value)
|
||||
}
|
||||
|
||||
const handleNativeLoadedData = () => {
|
||||
const el = videoRef.value
|
||||
if (el) applyPlaybackRate(el)
|
||||
emit('loadeddata')
|
||||
}
|
||||
const handleNativeCanPlay = () => {
|
||||
const el = videoRef.value
|
||||
if (el) applyPlaybackRate(el)
|
||||
emit('canplay')
|
||||
}
|
||||
const handleNativeCanPlayThrough = () => {
|
||||
const el = videoRef.value
|
||||
if (el) applyPlaybackRate(el)
|
||||
emit('canplaythrough')
|
||||
}
|
||||
const handleNativeError = (e: Event) => emit('error', e)
|
||||
|
||||
const handleNativeRateChange = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
emit('ratechange', el.playbackRate)
|
||||
}
|
||||
|
||||
const handleNativeVolumeChange = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
emit('volumechange', el.volume, el.muted)
|
||||
}
|
||||
|
||||
const handleNativeSeeked = () => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
const cur = el.currentTime
|
||||
if (props.anchors.length && hiddenAnchorIds.value.size) {
|
||||
const next = new Set(hiddenAnchorIds.value)
|
||||
for (const id of next) {
|
||||
const anchor = props.anchors.find((a) => a.id === id)
|
||||
if (anchor && anchor.time > cur) {
|
||||
next.delete(id)
|
||||
}
|
||||
hiddenAnchorIds.value = next
|
||||
}
|
||||
|
||||
activeAnchorId.value = null
|
||||
isHandlingAnchor.value = false
|
||||
hiddenAnchorIds.value = next
|
||||
}
|
||||
|
||||
el.addEventListener('play', onPlay)
|
||||
el.addEventListener('pause', onPause)
|
||||
el.addEventListener('ended', onEnded)
|
||||
el.addEventListener('timeupdate', onTime)
|
||||
el.addEventListener('loadedmetadata', onLoaded)
|
||||
el.addEventListener('error', onError)
|
||||
el.addEventListener('ratechange', onRate)
|
||||
el.addEventListener('volumechange', onVolume)
|
||||
el.addEventListener('seeked', onSeeked)
|
||||
activeAnchorId.value = null
|
||||
isHandlingAnchor.value = false
|
||||
}
|
||||
|
||||
// 初始配置
|
||||
const videoEventHandlers = {
|
||||
play: handleNativePlay,
|
||||
pause: handleNativePause,
|
||||
ended: handleNativeEnded,
|
||||
timeupdate: handleNativeTimeUpdate,
|
||||
loadedmetadata: handleNativeLoadedMetadata,
|
||||
loadeddata: handleNativeLoadedData,
|
||||
canplay: handleNativeCanPlay,
|
||||
canplaythrough: handleNativeCanPlayThrough,
|
||||
error: handleNativeError,
|
||||
ratechange: handleNativeRateChange,
|
||||
volumechange: handleNativeVolumeChange,
|
||||
seeked: handleNativeSeeked,
|
||||
}
|
||||
|
||||
const bindVideoEvents = (el: HTMLVideoElement) => {
|
||||
for (const [eventName, handler] of Object.entries(videoEventHandlers)) {
|
||||
el.addEventListener(eventName, handler as EventListener)
|
||||
}
|
||||
}
|
||||
|
||||
const unbindVideoEvents = (el: HTMLVideoElement) => {
|
||||
for (const [eventName, handler] of Object.entries(videoEventHandlers)) {
|
||||
el.removeEventListener(eventName, handler as EventListener)
|
||||
}
|
||||
}
|
||||
|
||||
const applyInitialMediaProps = (el: HTMLVideoElement) => {
|
||||
el.autoplay = props.autoplay
|
||||
el.muted = props.muted
|
||||
el.loop = props.loop
|
||||
el.preload = props.preload
|
||||
el.playbackRate = internalRate.value
|
||||
applyPlaybackRate(el)
|
||||
el.volume = internalVolume.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
bindVideoEvents(el)
|
||||
applyInitialMediaProps(el)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
if (onSeeked) {
|
||||
el.removeEventListener('seeked', onSeeked)
|
||||
clearHideTimer()
|
||||
if (clearSuppressPauseTimer !== undefined) {
|
||||
window.clearTimeout(clearSuppressPauseTimer)
|
||||
clearSuppressPauseTimer = undefined
|
||||
}
|
||||
if (!el) return
|
||||
unbindVideoEvents(el)
|
||||
el.src = ''
|
||||
el.load()
|
||||
})
|
||||
|
||||
const slotProps = computed<VideoSlotProps>(() => ({
|
||||
video: videoRef.value,
|
||||
playing: isPlaying.value,
|
||||
currentTime: currentTime.value,
|
||||
duration: duration.value,
|
||||
progressPercent: progressPercent.value,
|
||||
bufferedPercent: bufferedPercent.value,
|
||||
volume: internalVolume.value,
|
||||
rate: internalRate.value,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume: handleVolume,
|
||||
setRate: handleRate,
|
||||
}))
|
||||
|
||||
const videoStyle = computed(() => ({
|
||||
objectFit: props.fit
|
||||
objectFit: props.fit,
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -429,7 +527,10 @@ const videoStyle = computed(() => ({
|
||||
>
|
||||
<!-- 锚点遮罩插槽:优先叠在最上层,由业务侧自定义内容 -->
|
||||
<Transition name="sv-fade-fast">
|
||||
<div v-if="activeAnchor" class="pointer-events-auto absolute inset-0 z-20">
|
||||
<div
|
||||
v-if="activeAnchor"
|
||||
class="pointer-events-auto absolute inset-0 z-20"
|
||||
>
|
||||
<slot
|
||||
name="anchor"
|
||||
:anchor="activeAnchor"
|
||||
@@ -448,12 +549,20 @@ const videoStyle = computed(() => ({
|
||||
:loop="loop"
|
||||
:autoplay="autoplay"
|
||||
:controls="!hideNativeControls"
|
||||
playsinline
|
||||
webkit-playsinline="true"
|
||||
x5-playsinline="true"
|
||||
:style="videoStyle"
|
||||
@click="togglePlay"
|
||||
class="w-full h-full block bg-black"
|
||||
>
|
||||
<template v-if="hasMultipleSources">
|
||||
<source v-for="s in sources" :key="s.src" :src="s.src" :type="s.type" />
|
||||
<source
|
||||
v-for="s in sources"
|
||||
:key="s.src"
|
||||
:src="s.src"
|
||||
:type="s.type"
|
||||
/>
|
||||
</template>
|
||||
您的浏览器暂不支持 video 标签
|
||||
</video>
|
||||
@@ -482,183 +591,49 @@ const videoStyle = computed(() => ({
|
||||
|
||||
<!-- 覆盖层插槽:叠在视频之上 -->
|
||||
<Transition name="sv-fade-fast">
|
||||
<div v-show="showUi" class="pointer-events-none absolute inset-0">
|
||||
<div
|
||||
v-show="showUi"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<slot
|
||||
name="overlay"
|
||||
:video="videoRef"
|
||||
:playing="isPlaying"
|
||||
:current-time="currentTime"
|
||||
:duration="duration"
|
||||
:progress-percent="progressPercent"
|
||||
:buffered-percent="bufferedPercent"
|
||||
:volume="internalVolume"
|
||||
:rate="internalRate"
|
||||
:toggle-play="togglePlay"
|
||||
:seek="seek"
|
||||
:set-volume="handleVolume"
|
||||
:set-rate="handleRate"
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 控制条插槽(可完全自定义),提供默认实现作为后备 -->
|
||||
<Transition name="sv-slide-up">
|
||||
<div v-if="controls" v-show="showUi">
|
||||
<div
|
||||
v-if="controls"
|
||||
v-show="showUi"
|
||||
>
|
||||
<slot
|
||||
name="controls"
|
||||
:video="videoRef"
|
||||
:playing="isPlaying"
|
||||
:current-time="currentTime"
|
||||
:duration="duration"
|
||||
:progress-percent="progressPercent"
|
||||
:buffered-percent="bufferedPercent"
|
||||
:volume="internalVolume"
|
||||
:rate="internalRate"
|
||||
:toggle-play="togglePlay"
|
||||
:seek="seek"
|
||||
:set-volume="handleVolume"
|
||||
:set-rate="handleRate"
|
||||
v-bind="slotProps"
|
||||
>
|
||||
<div
|
||||
class="sv-controls absolute left-0 right-0 bottom-0 p-3 bg-gradient-to-t from-black/60 to-transparent text-white"
|
||||
<DefaultControls
|
||||
:slot-props="slotProps"
|
||||
:anchors="props.anchors"
|
||||
:rate-options="rateOptions"
|
||||
v-model:show-rate-options="showRateOptions"
|
||||
@seek-input="handleTimeInput"
|
||||
@seek-commit="commitSeek"
|
||||
@rate-select="selectRate"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
|
||||
@click.stop="togglePlay"
|
||||
>
|
||||
<!-- 播放图标 -->
|
||||
<svg
|
||||
v-if="!isPlaying"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 4.75v14.5c0 .7.76 1.14 1.36.78l9.25-5.5a.9.9 0 0 0 0-1.56l-9.25-5.5A.9.9 0 0 0 7 4.75z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- 暂停图标 -->
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 5.25c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1H7zm7.5 0c-.55 0-1 .45-1 1v11.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V6.25c0-.55-.45-1-1-1h-2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 左侧扩展位 -->
|
||||
<template #controls-left="leftSlotProps">
|
||||
<slot
|
||||
name="controls-left"
|
||||
:video="videoRef"
|
||||
:playing="isPlaying"
|
||||
:current-time="currentTime"
|
||||
:duration="duration"
|
||||
:progress-percent="progressPercent"
|
||||
:buffered-percent="bufferedPercent"
|
||||
:volume="internalVolume"
|
||||
:rate="internalRate"
|
||||
:toggle-play="togglePlay"
|
||||
:seek="seek"
|
||||
:set-volume="handleVolume"
|
||||
:set-rate="handleRate"
|
||||
v-bind="leftSlotProps"
|
||||
/>
|
||||
|
||||
<div class="text-xs tabular-nums min-w-[60px]">{{ fmt(currentTime) }}</div>
|
||||
|
||||
<div class="relative flex-1 h-2">
|
||||
<div class="absolute inset-0 rounded bg-white/20"></div>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded bg-white/40"
|
||||
:style="{ width: bufferedPercent + '%' }"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded bg-blue-500"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
></div>
|
||||
<!-- 锚点标记(可选增强) -->
|
||||
<template v-if="duration && props.anchors.length">
|
||||
<div
|
||||
v-for="a in props.anchors"
|
||||
:key="a.id"
|
||||
class="absolute -top-0.5 w-[2px] h-3 bg-yellow-300"
|
||||
:style="{ left: (a.time / duration) * 100 + '%' }"
|
||||
></div>
|
||||
</template>
|
||||
<input
|
||||
class="absolute inset-0 w-full opacity-0 cursor-pointer"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
:value="progressPercent"
|
||||
@input="handleTimeInput"
|
||||
@change="commitSeek"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs tabular-nums min-w-[60px] text-right">{{ fmt(duration) }}</div>
|
||||
|
||||
<div class="ml-2 flex items-center gap-2">
|
||||
<span class="text-sm">🔊</span>
|
||||
<input
|
||||
class="w-24 cursor-pointer"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="internalVolume"
|
||||
@input="(e: Event) => handleVolume(Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 倍速:向上展开的下拉菜单(dropup) -->
|
||||
<div class="ml-2 relative">
|
||||
<button
|
||||
class="bg-white/10 rounded px-2 py-1 text-xs hover:bg-white/20"
|
||||
@click.stop="showRateOptions = !showRateOptions"
|
||||
>
|
||||
{{ internalRate }}x
|
||||
</button>
|
||||
<div
|
||||
v-if="showRateOptions"
|
||||
class="absolute bottom-full right-0 mb-2 bg-black/80 text-white rounded shadow-lg p-1 min-w-[72px] z-10"
|
||||
>
|
||||
<button
|
||||
v-for="r in rateOptions"
|
||||
:key="r"
|
||||
class="block w-full text-left px-2 py-1 text-xs hover:bg-white/10 rounded"
|
||||
@click.stop="selectRate(r)"
|
||||
>
|
||||
{{ r }}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧扩展位 -->
|
||||
</template>
|
||||
<template #controls-right="rightSlotProps">
|
||||
<slot
|
||||
name="controls-right"
|
||||
:video="videoRef"
|
||||
:playing="isPlaying"
|
||||
:current-time="currentTime"
|
||||
:duration="duration"
|
||||
:progress-percent="progressPercent"
|
||||
:buffered-percent="bufferedPercent"
|
||||
:volume="internalVolume"
|
||||
:rate="internalRate"
|
||||
:toggle-play="togglePlay"
|
||||
:seek="seek"
|
||||
:set-volume="handleVolume"
|
||||
:set-rate="handleRate"
|
||||
v-bind="rightSlotProps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DefaultControls>
|
||||
</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -5,57 +5,33 @@ import type {
|
||||
SlotsType,
|
||||
} from 'vue'
|
||||
import VideoSfc from './Video.vue'
|
||||
import type {
|
||||
PlayFailedPayload,
|
||||
TimeUnit,
|
||||
VideoAnchor,
|
||||
VideoProps,
|
||||
VideoSlotProps,
|
||||
} from './types'
|
||||
|
||||
type TimeUnit = number
|
||||
|
||||
interface VideoSource {
|
||||
src: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface VideoAnchor {
|
||||
id: string
|
||||
time: number
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface VideoProps {
|
||||
src?: string
|
||||
sources?: VideoSource[]
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
poster?: string
|
||||
controls?: boolean
|
||||
hideNativeControls?: boolean
|
||||
autoHideControls?: boolean
|
||||
autoHideDelay?: number
|
||||
showCenterPlayIcon?: boolean
|
||||
modelValue?: boolean
|
||||
volume?: number
|
||||
playbackRate?: number
|
||||
fit?: 'contain' | 'cover' | 'fill'
|
||||
anchors?: VideoAnchor[]
|
||||
type VideoEventProps = {
|
||||
onPlay?: () => void
|
||||
onPause?: () => void
|
||||
onEnded?: () => void
|
||||
onTimeupdate?: (currentTime: TimeUnit, duration: TimeUnit) => void
|
||||
onError?: (error: unknown) => void
|
||||
onLoadedmetadata?: (duration: TimeUnit) => void
|
||||
onLoadeddata?: () => void
|
||||
onCanplay?: () => void
|
||||
onCanplaythrough?: () => void
|
||||
onRatechange?: (rate: number) => void
|
||||
onVolumechange?: (volume: number, muted: boolean) => void
|
||||
onSeeking?: (currentTime: TimeUnit) => void
|
||||
onSeeked?: (currentTime: TimeUnit) => void
|
||||
onPlayfailed?: (payload: PlayFailedPayload) => void
|
||||
'onUpdate:modelValue'?: (playing: boolean) => void
|
||||
}
|
||||
|
||||
interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
type VideoComponentProps = VideoProps & VideoEventProps
|
||||
|
||||
type VideoSlots = SlotsType<{
|
||||
default?: () => any
|
||||
@@ -71,7 +47,7 @@ type VideoSlots = SlotsType<{
|
||||
}>
|
||||
|
||||
type VideoComponent = DefineComponent<
|
||||
VideoProps,
|
||||
VideoComponentProps,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
@@ -86,14 +62,18 @@ type VideoComponent = DefineComponent<
|
||||
timeupdate: (currentTime: TimeUnit, duration: TimeUnit) => void
|
||||
error: (error: unknown) => void
|
||||
loadedmetadata: (duration: TimeUnit) => void
|
||||
loadeddata: () => void
|
||||
canplay: () => void
|
||||
canplaythrough: () => void
|
||||
ratechange: (rate: number) => void
|
||||
volumechange: (volume: number, muted: boolean) => void
|
||||
seeking: (currentTime: TimeUnit) => void
|
||||
seeked: (currentTime: TimeUnit) => void
|
||||
playfailed: (payload: PlayFailedPayload) => void
|
||||
},
|
||||
string,
|
||||
PublicProps,
|
||||
Readonly<VideoProps>,
|
||||
Readonly<VideoComponentProps>,
|
||||
Record<never, never>,
|
||||
VideoSlots
|
||||
>
|
||||
@@ -102,4 +82,4 @@ const Video = VideoSfc as VideoComponent
|
||||
|
||||
export { Video }
|
||||
export default Video
|
||||
export type { VideoAnchor, VideoProps, VideoSlotProps }
|
||||
export type { VideoAnchor, VideoProps, VideoSlotProps, PlayFailedPayload }
|
||||
@@ -0,0 +1,60 @@
|
||||
export type TimeUnit = number
|
||||
export type PlayAttemptReason =
|
||||
| 'toggle'
|
||||
| 'model'
|
||||
| 'anchor-resume'
|
||||
| 'source-change'
|
||||
|
||||
export interface PlayFailedPayload {
|
||||
reason: PlayAttemptReason
|
||||
error: unknown
|
||||
src?: string
|
||||
}
|
||||
|
||||
export interface VideoSource {
|
||||
src: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface VideoAnchor {
|
||||
id: string
|
||||
time: number
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface VideoProps {
|
||||
src?: string
|
||||
sources?: VideoSource[]
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
preload?: 'auto' | 'metadata' | 'none'
|
||||
poster?: string
|
||||
controls?: boolean
|
||||
hideNativeControls?: boolean
|
||||
autoHideControls?: boolean
|
||||
autoHideDelay?: number
|
||||
showCenterPlayIcon?: boolean
|
||||
modelValue?: boolean
|
||||
volume?: number
|
||||
playbackRate?: number
|
||||
fit?: 'contain' | 'cover' | 'fill'
|
||||
anchors?: VideoAnchor[]
|
||||
}
|
||||
|
||||
export interface VideoSlotProps {
|
||||
video: HTMLVideoElement | undefined
|
||||
playing: boolean
|
||||
currentTime: TimeUnit
|
||||
duration: TimeUnit
|
||||
progressPercent: number
|
||||
bufferedPercent: number
|
||||
volume: number
|
||||
rate: number
|
||||
togglePlay: () => Promise<void>
|
||||
seek: (time: number) => void
|
||||
setVolume: (volume: number) => void
|
||||
setRate: (rate: number) => void
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export * from './Switcher'
|
||||
// 图片预览(函数式)
|
||||
export * from './ImagePreview'
|
||||
|
||||
// 课程指南弹窗(函数式)
|
||||
export * from './CourseGuidePopup'
|
||||
|
||||
// 课程框架弹窗(函数式)
|
||||
export * from './CourseFrameworkPopup'
|
||||
|
||||
// 弹窗组件
|
||||
export * from './Modal'
|
||||
export * from './UnfinishedLearningPrompt'
|
||||
@@ -25,6 +31,9 @@ export * from './UnfinishedLearningPrompt'
|
||||
// 遮罩层组件
|
||||
export * from './Overlay'
|
||||
|
||||
// Loading 加载组件
|
||||
export * from './Loading'
|
||||
|
||||
// 视频组件
|
||||
export * from './Video'
|
||||
|
||||
|
||||
Reference in new issue
Block a user