feat: 对接各个视频数据

This commit is contained in:
zhongyidian committed 2025-12-30 19:42:47 +08:00
1 parent 6103453e32
commit 6ab98c4764
339 files changed
+17316 -1531

No files matched your search

+19 -4
View File
@@ -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)
}
+1 -5
View File
@@ -26,13 +26,9 @@ withDefaults(defineProps<Props>(), {
totalSteps: 1
})
const emit = defineEmits<{
defineEmits<{
(e: 'continue'): void
}>()
const handleContinue = () => {
emit('continue')
}
</script>
<style scoped>
+23 -5
View File
@@ -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);
}
+137
View File
@@ -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>
+20
View File
@@ -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>
+1 -1
View File
@@ -85,7 +85,7 @@ const handleClose = () => {
emit('close')
}
const handleMaskClick = (event: MouseEvent) => {
const handleMaskClick = () => {
emit('mask-click')
}
</script>
+45 -3
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
export { default as QuestionQuiz } from './QuestionQuiz.vue'
export type { Question, QuestionOption } from './QuestionQuiz.vue'
+1
View File
@@ -0,0 +1 @@
export { default as StarRating } from './star-rating.vue'
+80
View File
@@ -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>
+3 -1
View File
@@ -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;
+27 -7
View File
@@ -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>
+141 -2
View File
@@ -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"
+3
View File
@@ -25,3 +25,6 @@ export * from './Overlay'
// 视频组件
export * from './Video'
// 答题组件
export * from './QuestionQuiz'