feat: 完善课程学习与技能树体验

完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-24 10:30:18 +08:00
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'
+15 -28
View File
@@ -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
}
+3 -3
View File
@@ -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<{
+7 -1
View File
@@ -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>
+1
View File
@@ -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)
}
+6 -37
View File
@@ -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({
+28 -10
View File
@@ -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()
})
-2
View File
@@ -142,8 +142,6 @@ export interface StepItem {
interface Props {
/** 是否显示抽屉 */
visible: boolean
/** 背景图片路径 */
backgroundImage: string
/** 步骤列表 */
steps: StepItem[]
/** 当前步骤索引 */
+125 -19
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { default as LoadingOverlay } from './LoadingOverlay.vue'
+44 -23
View File
@@ -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%);
}
/* 响应式设计 */
+94 -32
View File
@@ -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>
+17 -5
View File
@@ -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
+155
View File
@@ -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
View File
@@ -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>
+30 -50
View File
@@ -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 }
+60
View File
@@ -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
}
+9
View File
@@ -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'