forked from zhongyidian/super_video
feat: 对接各个视频数据
This commit is contained in:
1 parent
6103453e32
commit
6ab98c4764
339 files changed
+17316
-1531
No files matched your search
@@ -5,7 +5,12 @@
|
||||
|
||||
<template>
|
||||
<img
|
||||
class="cursor-pointer transition-all duration-300 hover:scale-105 hover:brightness-110"
|
||||
class="transition-all duration-300"
|
||||
:class="[
|
||||
props.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:scale-105 hover:brightness-110'
|
||||
]"
|
||||
draggable="false"
|
||||
:src="getStaticImage(image)"
|
||||
@click="handleClick"
|
||||
@@ -18,12 +23,22 @@ import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
interface Props {
|
||||
/** 按钮图片 */
|
||||
image: string
|
||||
/** 路由路径 */
|
||||
path?: RouteLocationRaw
|
||||
}>()
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
})
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (): void => {
|
||||
if (props.disabled) return
|
||||
if (props.path) {
|
||||
router.push(props.path)
|
||||
}
|
||||
|
||||
@@ -26,13 +26,9 @@ withDefaults(defineProps<Props>(), {
|
||||
totalSteps: 1
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
(e: 'continue'): void
|
||||
}>()
|
||||
|
||||
const handleContinue = () => {
|
||||
emit('continue')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -210,6 +210,9 @@ onMounted(() => {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* padding: 100px 0; */
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
@@ -237,13 +240,19 @@ onMounted(() => {
|
||||
rgba(20, 30, 45, 0.85) 100%
|
||||
); */
|
||||
width: 648px;
|
||||
height: 1080px;
|
||||
background-image: url('@/assets/images/vlog/专业剖析侧边.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* border-left: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
border-radius: 12px 0 0 12px;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3);
|
||||
/* border-radius: 12px 0 0 12px;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); */
|
||||
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@@ -356,17 +365,26 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.step-label--planning {
|
||||
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||
background: linear-gradient(135deg, #e38872, #e38872);
|
||||
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.step-label--post {
|
||||
/* 蓝色 */
|
||||
.step-label--blue {
|
||||
background: linear-gradient(135deg, #0ea5e9, #0284c7);
|
||||
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
/* 天蓝色 */
|
||||
.step-label--post {
|
||||
/* background: linear-gradient(180deg, #9ed5ee, #0ea5e9); */
|
||||
background-color: #70c7ef;
|
||||
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
.step-label--other {
|
||||
background: linear-gradient(135deg, #a855f7, #9333ea);
|
||||
/* background: linear-gradient(135deg, #a855f7, #9445de); */
|
||||
background-color: #5f15d7;
|
||||
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
import { processImage, type FilterOptions } from '@/utils/imageProcessing'
|
||||
|
||||
const options = ref<FilterOptions>({})
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const isRendering = ref(false)
|
||||
let imgObj: HTMLImageElement | null = null
|
||||
let renderRequestId: number | null = null
|
||||
let debounceTimer: number | null = null
|
||||
|
||||
// 更新参数方法
|
||||
const setOptions = (newOptions: FilterOptions) => {
|
||||
options.value = { ...options.value, ...newOptions }
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
const loadImage = () => {
|
||||
if (!props.src) return
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = props.src
|
||||
img.onload = () => {
|
||||
imgObj = img
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.src, loadImage)
|
||||
onMounted(loadImage)
|
||||
|
||||
// 渲染主逻辑
|
||||
const render = async () => {
|
||||
if (!canvasRef.value || !imgObj) return
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) return
|
||||
|
||||
isRendering.value = true
|
||||
|
||||
// 设置画布尺寸为图片原始尺寸
|
||||
if (canvas.width !== imgObj.naturalWidth || canvas.height !== imgObj.naturalHeight) {
|
||||
canvas.width = imgObj.naturalWidth
|
||||
canvas.height = imgObj.naturalHeight
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制原图
|
||||
ctx.drawImage(imgObj, 0, 0)
|
||||
|
||||
// 获取像素数据
|
||||
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 应用所有滤镜效果(像素级)
|
||||
// 使用优化的处理函数
|
||||
imageData = await processImage(imageData, options.value)
|
||||
|
||||
// 回写像素数据
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
|
||||
isRendering.value = false
|
||||
}
|
||||
|
||||
// 监听参数变化触发渲染(使用防抖优化性能)
|
||||
watch(
|
||||
options,
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
if (renderRequestId) cancelAnimationFrame(renderRequestId)
|
||||
renderRequestId = requestAnimationFrame(render)
|
||||
}, 100) // 100ms 防抖
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 生成图片方法
|
||||
const generate = async (): Promise<string> => {
|
||||
if (!canvasRef.value) throw new Error('Canvas not initialized')
|
||||
// 直接返回当前 Canvas 的内容,因为它是实时渲染的高清图
|
||||
return canvasRef.value.toDataURL('image/png')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
generate,
|
||||
setOptions,
|
||||
isRendering
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<canvas ref="canvasRef" class="max-w-full max-h-full object-contain"></canvas>
|
||||
|
||||
<!-- Loading 遮罩 -->
|
||||
<div v-if="isRendering" class="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg
|
||||
class="animate-spin"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -266,6 +266,9 @@ watch(
|
||||
z-index: 2;
|
||||
}
|
||||
.sv-ip-btn {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Overlay } from '@/components/Overlay'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
title: string
|
||||
desc: string
|
||||
images: string[]
|
||||
compositionKey: '1' | '2' | '3'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const imageMap = {
|
||||
'1': 'film/shooting/示意图-框架',
|
||||
'2': 'film/shooting/示意图-对称',
|
||||
'3': 'film/shooting/示意图-引导'
|
||||
}
|
||||
|
||||
const getImage = computed(() => {
|
||||
return getBackgroundImage(imageMap[props.compositionKey])
|
||||
})
|
||||
|
||||
// 覆盖图映射:根据构图类型和图片索引获取对应的覆盖图
|
||||
const getOverlayImage = (index: number) => {
|
||||
const overlayMap: Record<string, string[]> = {
|
||||
'1': ['film/shooting/05引导线构图_覆盖', 'film/shooting/06引导线构图_覆盖'], // 框架构图
|
||||
'2': ['film/shooting/03对称构图_覆盖', 'film/shooting/04对称构图_覆盖'], // 对称构图
|
||||
'3': ['film/shooting/01框架构图_覆盖', 'film/shooting/02框架构图_覆盖'] // 引导线构图
|
||||
}
|
||||
const images = overlayMap[props.compositionKey] || []
|
||||
return images[index] || ''
|
||||
}
|
||||
|
||||
// 每张图片的辅助线开关状态
|
||||
const showBoxLineMap = ref<Record<number, boolean>>({})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
v => {
|
||||
if (v) {
|
||||
showBoxLineMap.value = {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleToggle = (index: number) => {
|
||||
showBoxLineMap.value[index] = !showBoxLineMap.value[index]
|
||||
}
|
||||
|
||||
const show = computed(() => props.visible)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Overlay :visible="show" :clickable="false">
|
||||
<div
|
||||
class="relative w-[1752px] h-[932px]"
|
||||
:style="{ backgroundImage: getBackgroundImage('shooting/知识卡片底板') }"
|
||||
>
|
||||
<div
|
||||
class="absolute top-4 left-1/2 -translate-x-1/2 text-[45px] text-white font-bold tracking-[2px] drop-shadow-[2px_2px_4px_rgba(0,0,139,0.8)]"
|
||||
>
|
||||
{{ props.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute w-full h-[38px] top-32 left-1/2 -translate-x-1/2 bg-no-repeat bg-center"
|
||||
:style="{ backgroundImage: getBackgroundImage('shooting/文案1') }"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="absolute w-[191px] h-[131px] top-[30px] left-[120px] bg-no-repeat bg-center"
|
||||
:style="{ backgroundImage: getImage }"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="absolute top-[calc(50%-30px)] -translate-y-1/2"
|
||||
v-for="(image, index) in props.images"
|
||||
:key="index"
|
||||
:style="{ left: index === 0 ? '15%' : '55%' }"
|
||||
>
|
||||
<ImgButton
|
||||
class="absolute right-6 top-6 z-10"
|
||||
:image="showBoxLineMap[index] ? 'shooting/辅助线开' : 'shooting/辅助线关'"
|
||||
@click="handleToggle(index)"
|
||||
/>
|
||||
<div class="relative">
|
||||
<img class="w-[560px]" :src="getStaticImage(image)" alt="" />
|
||||
|
||||
<div
|
||||
v-if="showBoxLineMap[index]"
|
||||
class="absolute inset-0 pointer-events-none w-[560px] bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(getOverlayImage(index)) }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute w-full px-20 bottom-32 left-1/2 -translate-x-1/2 text-[#395E66] text-3xl font-bold indent-14 leading-[50px]"
|
||||
>
|
||||
{{ props.desc }}
|
||||
</div>
|
||||
|
||||
<ImgButton
|
||||
class="absolute right-2 top-4 translate-x-1/2 -translate-y-1/2 z-10"
|
||||
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</Overlay>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="loading-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<div class="text-white text-xl font-bold tracking-wider">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
text?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -85,7 +85,7 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleMaskClick = (event: MouseEvent) => {
|
||||
const handleMaskClick = () => {
|
||||
emit('mask-click')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,28 @@
|
||||
<!-- 标题区域 -->
|
||||
<div class="absolute top-0 left-40" v-if="currentStep > 0">
|
||||
<img
|
||||
v-if="moduleName === 'vlog'"
|
||||
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.TITLE)"
|
||||
alt="制作变焦镜-我的志愿者手记"
|
||||
class="title-image"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-else-if="moduleName === 'film'"
|
||||
:src="getStaticImage('film/制作变焦镜-这里是徐汇滨江')"
|
||||
alt="制作变焦镜-这里是徐汇滨江"
|
||||
class="title-image"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-else-if="moduleName === 'interview'"
|
||||
:src="getStaticImage('midterm/中期拍摄')"
|
||||
alt="制作变焦镜-我的志愿者手记"
|
||||
class="title-image"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-else
|
||||
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.TITLE)"
|
||||
alt="制作变焦镜-我的志愿者手记"
|
||||
class="title-image"
|
||||
@@ -29,7 +51,11 @@
|
||||
:src="currentStep === index ? steps[index].nameSelect : steps[index].nameselectNot"
|
||||
alt=""
|
||||
/> -->
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="canComplete ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||
@click="handleStepClick(index + 1)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'font-bold text-4xl ',
|
||||
@@ -67,6 +93,7 @@
|
||||
<div class="absolute top-0 left-40" v-else>
|
||||
<img
|
||||
:src="getStaticImage(IMAGE_PATHS.PROGRESS_BAR.UNLOCKED)"
|
||||
draggable="false"
|
||||
alt="未解锁"
|
||||
class="icon-image"
|
||||
/>
|
||||
@@ -93,24 +120,33 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useModel } from 'vue'
|
||||
import { getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
/** 当前步骤索引 (0-3) */
|
||||
currentStep?: number
|
||||
moduleName?: 'film' | 'vlog' | 'interview'
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
interface Emits {
|
||||
/** 点击完成按钮时触发 */
|
||||
complete: []
|
||||
/** 修改步骤时触发 */
|
||||
stepChange: [number]
|
||||
/** v-model:currentStep 双向绑定时触发 */
|
||||
'update:currentStep': [number]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentStep: 0
|
||||
currentStep: 0,
|
||||
moduleName: 'vlog'
|
||||
})
|
||||
|
||||
const [currentStep] = useModel(props, 'currentStep')
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 计算属性:是否可以完成(当前在最后一步)
|
||||
@@ -123,6 +159,12 @@ const handleComplete = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
if (canComplete.value) return
|
||||
currentStep.value = index
|
||||
emit('stepChange', index)
|
||||
}
|
||||
|
||||
// 步骤配置
|
||||
const steps = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getStaticImage } from '@/utils'
|
||||
import { Video } from '@/components'
|
||||
|
||||
export interface QuestionOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
options: QuestionOption[]
|
||||
correctOptionId: string // 单选题的正确答案ID,如果为空字符串,表示引导题,任意选择都可进入下一题
|
||||
correctOptionIds?: string[] // 多选题的正确答案ID数组
|
||||
isMultiple?: boolean // 是否为多选题,默认为 false
|
||||
mediaSrc?: string // 图片路径或视频 URL,通过文件后缀判断类型
|
||||
explanation?: string // 错误答案解析(可选)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: Question[]
|
||||
showQuestionNumber?: boolean // 是否显示题号,默认为 true
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showQuestionNumber: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const selectedOptionId = ref<string | null>(null) // 单选题选中的选项
|
||||
const selectedOptionIds = ref<string[]>([]) // 多选题选中的选项数组
|
||||
const showErrorExplanation = ref(false) // 是否显示错误解析
|
||||
|
||||
const total = computed(() => props.questions.length)
|
||||
const currentQuestion = computed(() => props.questions[currentIndex.value])
|
||||
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)
|
||||
})
|
||||
const correctOptions = computed(() => {
|
||||
if (!currentQuestion.value || !currentQuestion.value.correctOptionIds) return []
|
||||
return currentQuestion.value.options.filter(opt =>
|
||||
currentQuestion.value.correctOptionIds?.includes(opt.id)
|
||||
)
|
||||
})
|
||||
|
||||
// 判断选项数量是否超过4个
|
||||
const hasMoreThanFourOptions = computed(() => {
|
||||
return currentQuestion.value?.options.length > 4
|
||||
})
|
||||
|
||||
// 切换题目时重置选择状态
|
||||
watch(currentIndex, () => {
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
showErrorExplanation.value = false
|
||||
})
|
||||
|
||||
// 通过文件后缀判断媒体类型
|
||||
const isVideo = (src: string): boolean => {
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv', '.flv', '.wmv']
|
||||
const lowerSrc = src.toLowerCase()
|
||||
return videoExtensions.some(ext => lowerSrc.endsWith(ext))
|
||||
}
|
||||
|
||||
const isImage = (src: string): boolean => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp']
|
||||
const lowerSrc = src.toLowerCase()
|
||||
return imageExtensions.some(ext => lowerSrc.endsWith(ext))
|
||||
}
|
||||
|
||||
const handleOptionClick = (optionId: string) => {
|
||||
if (isMultiple.value) {
|
||||
// 多选题:切换选中状态
|
||||
const index = selectedOptionIds.value.indexOf(optionId)
|
||||
if (index > -1) {
|
||||
selectedOptionIds.value.splice(index, 1)
|
||||
} else {
|
||||
selectedOptionIds.value.push(optionId)
|
||||
}
|
||||
} else {
|
||||
// 单选题:直接选中
|
||||
selectedOptionId.value = optionId
|
||||
}
|
||||
// 选择新选项时隐藏错误解析
|
||||
showErrorExplanation.value = false
|
||||
}
|
||||
|
||||
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
|
||||
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
|
||||
const handleSubmit = () => {
|
||||
const q = currentQuestion.value
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:必须选择至少一个选项
|
||||
if (selectedOptionIds.value.length === 0) return
|
||||
|
||||
// 多选题:必须选择所有正确答案,且不能选择错误答案
|
||||
const correctIds = q.correctOptionIds || []
|
||||
const selectedSet = new Set(selectedOptionIds.value)
|
||||
const correctSet = new Set(correctIds)
|
||||
|
||||
// 检查是否选择了所有正确答案
|
||||
const hasAllCorrect = correctIds.every(id => selectedSet.has(id))
|
||||
// 检查是否只选择了正确答案(没有选择错误答案)
|
||||
const onlyCorrect = selectedOptionIds.value.every(id => correctSet.has(id))
|
||||
|
||||
const isCorrect =
|
||||
hasAllCorrect && onlyCorrect && selectedOptionIds.value.length === correctIds.length
|
||||
|
||||
if (!isCorrect) {
|
||||
// 答错时显示解析
|
||||
showErrorExplanation.value = true
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 单选题
|
||||
if (!selectedOptionId.value) return
|
||||
|
||||
const isGuideQuestion = q.correctOptionId === '' // 引导题判断
|
||||
const isCorrect = isGuideQuestion || selectedOptionId.value === q.correctOptionId
|
||||
|
||||
if (!isCorrect) {
|
||||
// 答错时显示解析
|
||||
showErrorExplanation.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 答对时重置错误解析状态
|
||||
showErrorExplanation.value = false
|
||||
|
||||
if (currentIndex.value < total.value - 1) {
|
||||
currentIndex.value += 1
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
} else {
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none z-30">
|
||||
<div
|
||||
class="bg-black/40 backdrop-blur-sm 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 class="text-3xl font-semibold">
|
||||
第
|
||||
<span class="text-4xl font-extrabold mx-1 text-emerald-300">{{
|
||||
currentQuestion.id
|
||||
}}</span>
|
||||
页
|
||||
<span class="text-2xl opacity-70 ml-2">/ 共 {{ total }} 页</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中部:左右布局 -->
|
||||
<div
|
||||
:class="[
|
||||
hasMedia ? 'grid grid-cols-[1.2fr,1.3fr] gap-8 items-stretch' : 'flex justify-center'
|
||||
]"
|
||||
>
|
||||
<!-- 左侧:题干 + 选项 -->
|
||||
<div :class="['flex flex-col gap-10', !hasMedia && 'max-w-2xl w-full']">
|
||||
<div class="space-y-8">
|
||||
<h2 class="text-3xl font-bold leading-relaxed">
|
||||
{{ currentQuestion.title }}
|
||||
</h2>
|
||||
<p class="text-2xl leading-7 opacity-90">
|
||||
{{ currentQuestion.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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)
|
||||
? showErrorExplanation &&
|
||||
(isMultiple
|
||||
? currentQuestion.correctOptionIds?.includes(opt.id)
|
||||
: opt.id === currentQuestion.correctOptionId)
|
||||
? 'border-emerald-400 bg-emerald-500/30 shadow-lg shadow-emerald-500/30'
|
||||
: showErrorExplanation &&
|
||||
(isMultiple
|
||||
? !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'
|
||||
: 'border-emerald-400 bg-emerald-500/20 shadow-lg shadow-emerald-500/20'
|
||||
: showErrorExplanation &&
|
||||
(isMultiple
|
||||
? 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'
|
||||
]"
|
||||
@click="handleOptionClick(opt.id)"
|
||||
>
|
||||
<span class="font-semibold mr-2">{{ opt.id.toUpperCase() }}.</span>
|
||||
<span>{{ opt.label }}</span>
|
||||
<!-- 多选题显示复选框样式 -->
|
||||
<span v-if="isMultiple" class="ml-auto text-emerald-400">
|
||||
{{ selectedOptionIds.includes(opt.id) ? '✓' : '' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误解析提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showErrorExplanation && currentQuestion.explanation"
|
||||
class="mt-4 p-6 rounded-xl bg-amber-500/20 border border-amber-400/30 space-y-3"
|
||||
>
|
||||
<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
|
||||
v-if="isMultiple && correctOptions.length > 0"
|
||||
class="text-lg text-amber-100 leading-relaxed"
|
||||
>
|
||||
{{
|
||||
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>
|
||||
<div class="text-xl font-semibold text-amber-300 mt-4">解析</div>
|
||||
<div class="text-lg text-amber-100 leading-relaxed">
|
||||
{{ currentQuestion.explanation }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div :class="!hasMedia && 'flex justify-center'">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="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"
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:参考图片 / 视频 -->
|
||||
<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"
|
||||
>
|
||||
<template v-if="isImage(currentQuestion.mediaSrc)">
|
||||
<img
|
||||
class="w-full h-full object-contain max-h-[500px]"
|
||||
:src="getStaticImage(currentQuestion.mediaSrc)"
|
||||
alt="题目参考画面"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="isVideo(currentQuestion.mediaSrc)">
|
||||
<Video
|
||||
class="w-full h-full object-cover"
|
||||
:src="currentQuestion.mediaSrc"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
></Video>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as QuestionQuiz } from './QuestionQuiz.vue'
|
||||
export type { Question, QuestionOption } from './QuestionQuiz.vue'
|
||||
@@ -0,0 +1 @@
|
||||
export { default as StarRating } from './star-rating.vue'
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { getStaticImage } from '@/utils'
|
||||
|
||||
interface Props {
|
||||
/** 评分数(1-5) */
|
||||
score: number
|
||||
/** 最大星数 */
|
||||
maxStars?: number
|
||||
/** 星星大小 */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** 是否只读(不能点击) */
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxStars: 5,
|
||||
size: 'md',
|
||||
readonly: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 评分变化事件 */
|
||||
change: [score: number]
|
||||
}>()
|
||||
|
||||
/** 当前打分值 */
|
||||
const currentScore = ref<number>(props.score)
|
||||
|
||||
/** 悬停时显示的评分 */
|
||||
const hoverScore = ref<number>(0)
|
||||
|
||||
/** 星星尺寸映射 */
|
||||
const sizeMap = {
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-14 h-14',
|
||||
lg: 'w-18 h-18'
|
||||
}
|
||||
|
||||
/** 显示的评分(优先显示悬停评分,否则显示当前评分) */
|
||||
const displayScore = computed<number>(() => hoverScore.value || currentScore.value)
|
||||
|
||||
/** 处理星星点击 */
|
||||
const handleStarClick = (star: number): void => {
|
||||
if (props.readonly) return
|
||||
currentScore.value = star
|
||||
emit('change', star)
|
||||
}
|
||||
|
||||
/** 处理鼠标进入 */
|
||||
const handleMouseEnter = (star: number): void => {
|
||||
if (props.readonly) return
|
||||
hoverScore.value = star
|
||||
}
|
||||
|
||||
/** 处理鼠标离开 */
|
||||
const handleMouseLeave = (): void => {
|
||||
hoverScore.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-1"
|
||||
:class="{ 'cursor-pointer': !props.readonly }"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<img
|
||||
v-for="star in props.maxStars"
|
||||
:key="star"
|
||||
:class="[sizeMap[props.size], !props.readonly && 'transition-transform hover:scale-110']"
|
||||
:src="
|
||||
getStaticImage(star <= displayScore ? 'vlog/practice/星星-亮' : 'vlog/practice/星星-灰')
|
||||
"
|
||||
alt=""
|
||||
@click="handleStarClick(star)"
|
||||
@mouseenter="handleMouseEnter(star)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, withDefaults, defineProps, defineEmits } from 'vue'
|
||||
import { ref, withDefaults } from 'vue'
|
||||
import arrowleft from '@/assets/images/equipment/按钮-收缩.png'
|
||||
import arrowright from '@/assets/images/equipment/按钮-展开.png'
|
||||
// 定义菜单项接口
|
||||
@@ -76,6 +76,7 @@ const selectItem = (index: number) => {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.equipment-arrow-left {
|
||||
position: absolute;
|
||||
top: 490px;
|
||||
@@ -101,6 +102,7 @@ const selectItem = (index: number) => {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
display: flex;
|
||||
// justify-content: space-between;
|
||||
|
||||
@@ -3,11 +3,25 @@
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:class="['img-switch-item', itemClass, `img-switch-item--${item.key}`]"
|
||||
:class="[
|
||||
itemClass,
|
||||
item.key == modelValue
|
||||
? 'translate-x-[-25%] brightness-100'
|
||||
: 'translate-x-[0] brightness-50',
|
||||
'img-switch-item transition-all duration-500',
|
||||
`img-switch-item--${item.key}`
|
||||
]"
|
||||
:data-key="item.key"
|
||||
:style="{ backgroundImage: getBackgroundImage(getItemImage(item)) }"
|
||||
@click="onSelect(item.key)"
|
||||
></div>
|
||||
>
|
||||
<span
|
||||
v-if="item.label"
|
||||
:class="['ml-6 text-white [text-shadow:5px_5px_5px_rgba(0,0,0,0.4)]', labelClass]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +33,7 @@ interface SwitchItem {
|
||||
key: string
|
||||
activeImage: string
|
||||
inactiveImage: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +41,7 @@ interface Props {
|
||||
modelValue?: string
|
||||
direction?: 'vertical' | 'horizontal'
|
||||
itemClass?: string
|
||||
labelClass?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -36,7 +52,8 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
direction: 'vertical',
|
||||
itemClass: ''
|
||||
itemClass: '',
|
||||
labelClass: 'text-5xl font-bold'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -57,7 +74,7 @@ const onSelect = (key: string) => {
|
||||
<style scoped>
|
||||
.img-switch-list {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 48px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -70,13 +87,16 @@ const onSelect = (key: string) => {
|
||||
}
|
||||
|
||||
.img-switch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* .img-switch-item:hover {
|
||||
transform: scale(1.05);
|
||||
} */
|
||||
.img-switch-item:hover {
|
||||
transform: translateX(-25%);
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,14 @@ interface VideoSource {
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface VideoAnchor {
|
||||
id: string
|
||||
time: number // 触发时间(秒)
|
||||
enabled?: boolean
|
||||
afterResume?: 'hide' | 'remove' | 'keep'
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// 源:字符串或多源
|
||||
src?: string
|
||||
@@ -23,12 +31,15 @@ interface Props {
|
||||
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>(), {
|
||||
@@ -40,8 +51,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hideNativeControls: true,
|
||||
autoHideControls: true,
|
||||
autoHideDelay: 2000,
|
||||
showCenterPlayIcon: true,
|
||||
playbackRate: 1,
|
||||
fit: 'contain'
|
||||
fit: 'contain',
|
||||
anchors: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -66,11 +79,21 @@ const isSeeking = ref(false)
|
||||
const currentTime = ref<TimeUnit>(0)
|
||||
const duration = ref<TimeUnit>(0)
|
||||
const buffered = ref<TimeUnit>(0)
|
||||
const internalVolume = ref(props.volume ?? 1)
|
||||
const internalVolume = ref(props.volume ?? 0)
|
||||
const internalRate = ref(props.playbackRate)
|
||||
const showRateOptions = ref(false)
|
||||
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5, 2]
|
||||
|
||||
// 锚点状态
|
||||
const hiddenAnchorIds = ref<Set<string>>(new Set())
|
||||
const removedAnchorIds = ref<Set<string>>(new Set())
|
||||
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
|
||||
)
|
||||
|
||||
// UI 可见性与计时器(控制条与所有插槽一起隐藏/显示)
|
||||
const showUi = ref(true)
|
||||
let hideTimer: number | undefined
|
||||
@@ -113,6 +136,31 @@ const bufferedPercent = computed(() =>
|
||||
duration.value ? (buffered.value / duration.value) * 100 : 0
|
||||
)
|
||||
|
||||
const handleAnchorsOnTimeUpdate = (t: number, el: HTMLVideoElement) => {
|
||||
if (!props.anchors.length || isHandlingAnchor.value) return
|
||||
|
||||
for (const anchor of props.anchors) {
|
||||
if (anchor.enabled === false) continue
|
||||
if (removedAnchorIds.value.has(anchor.id)) continue
|
||||
if (hiddenAnchorIds.value.has(anchor.id)) continue
|
||||
if (activeAnchorId.value === anchor.id) continue
|
||||
|
||||
const diff = t - anchor.time
|
||||
if (diff >= 0 && diff < 0.5) {
|
||||
// 命中锚点:先暂停视频
|
||||
el.pause()
|
||||
// Safari 兼容:下一帧再次确认暂停
|
||||
requestAnimationFrame(() => {
|
||||
if (!el.paused) el.pause()
|
||||
})
|
||||
|
||||
isHandlingAnchor.value = true
|
||||
activeAnchorId.value = anchor.id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = async () => {
|
||||
if (!videoRef.value) return
|
||||
if (videoRef.value.paused) {
|
||||
@@ -154,6 +202,29 @@ const handleRate = (r: number) => {
|
||||
videoRef.value.playbackRate = r
|
||||
}
|
||||
|
||||
const unmute = () => {
|
||||
if (!videoRef.value) return
|
||||
videoRef.value.muted = false
|
||||
}
|
||||
|
||||
const resumeFromAnchor = (anchor: VideoAnchor) => {
|
||||
const mode = anchor.afterResume ?? 'hide'
|
||||
|
||||
if (mode === 'hide') {
|
||||
hiddenAnchorIds.value = new Set(hiddenAnchorIds.value).add(anchor.id)
|
||||
activeAnchorId.value = null
|
||||
} else if (mode === 'remove') {
|
||||
removedAnchorIds.value = new Set(removedAnchorIds.value).add(anchor.id)
|
||||
activeAnchorId.value = null
|
||||
}
|
||||
// keep: 不修改 activeAnchorId,由外部自行控制 UI 关闭
|
||||
|
||||
isHandlingAnchor.value = false
|
||||
if (videoRef.value?.paused) {
|
||||
videoRef.value.play().catch(err => emit('error', err))
|
||||
}
|
||||
}
|
||||
|
||||
// 选择倍速并关闭下拉
|
||||
const selectRate = (r: number) => {
|
||||
handleRate(r)
|
||||
@@ -198,6 +269,8 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
let onSeeked: (() => void) | undefined
|
||||
|
||||
// 监听 video 事件
|
||||
onMounted(() => {
|
||||
const el = videoRef.value
|
||||
@@ -224,6 +297,7 @@ onMounted(() => {
|
||||
}
|
||||
duration.value = el.duration || duration.value
|
||||
emit('timeupdate', currentTime.value, duration.value)
|
||||
handleAnchorsOnTimeUpdate(currentTime.value, el)
|
||||
}
|
||||
const onLoaded = () => {
|
||||
duration.value = el.duration || 0
|
||||
@@ -233,6 +307,24 @@ onMounted(() => {
|
||||
const onRate = () => emit('ratechange', el.playbackRate)
|
||||
const onVolume = () => emit('volumechange', el.volume, el.muted)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
hiddenAnchorIds.value = next
|
||||
}
|
||||
|
||||
activeAnchorId.value = null
|
||||
isHandlingAnchor.value = false
|
||||
}
|
||||
|
||||
el.addEventListener('play', onPlay)
|
||||
el.addEventListener('pause', onPause)
|
||||
el.addEventListener('ended', onEnded)
|
||||
@@ -241,6 +333,7 @@ onMounted(() => {
|
||||
el.addEventListener('error', onError)
|
||||
el.addEventListener('ratechange', onRate)
|
||||
el.addEventListener('volumechange', onVolume)
|
||||
el.addEventListener('seeked', onSeeked)
|
||||
|
||||
// 初始配置
|
||||
el.autoplay = props.autoplay
|
||||
@@ -254,6 +347,9 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
if (onSeeked) {
|
||||
el.removeEventListener('seeked', onSeeked)
|
||||
}
|
||||
el.src = ''
|
||||
el.load()
|
||||
})
|
||||
@@ -270,6 +366,18 @@ const videoStyle = computed(() => ({
|
||||
@mousemove="revealTemporarily"
|
||||
@touchstart.passive="revealTemporarily"
|
||||
>
|
||||
<!-- 锚点遮罩插槽:优先叠在最上层,由业务侧自定义内容 -->
|
||||
<Transition name="sv-fade-fast">
|
||||
<div v-if="activeAnchor" class="pointer-events-auto absolute inset-0 z-20">
|
||||
<slot
|
||||
name="anchor"
|
||||
:anchor="activeAnchor"
|
||||
:resume="() => activeAnchor && resumeFromAnchor(activeAnchor)"
|
||||
:unmute="unmute"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="!hasMultipleSources ? src : undefined"
|
||||
@@ -289,6 +397,28 @@ const videoStyle = computed(() => ({
|
||||
您的浏览器暂不支持 video 标签
|
||||
</video>
|
||||
|
||||
<!-- 中间播放图标 -->
|
||||
<Transition name="sv-fade-fast">
|
||||
<div
|
||||
v-if="showCenterPlayIcon && !isPlaying"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
|
||||
@click.stop="togglePlay"
|
||||
>
|
||||
<div
|
||||
class="w-40 h-40 pointer-events-auto rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-22 h-22 text-white ml-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 覆盖层插槽:叠在视频之上 -->
|
||||
<Transition name="sv-fade-fast">
|
||||
<div v-show="showUi" class="pointer-events-none absolute inset-0">
|
||||
@@ -369,6 +499,15 @@ const videoStyle = computed(() => ({
|
||||
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"
|
||||
|
||||
@@ -25,3 +25,6 @@ export * from './Overlay'
|
||||
|
||||
// 视频组件
|
||||
export * from './Video'
|
||||
|
||||
// 答题组件
|
||||
export * from './QuestionQuiz'
|
||||
Reference in new issue
Block a user