forked from zhongyidian/super_video
feat: 完善访谈与vlog模块页面,重组设备与图片公共资源,新增音频管理与一班对接工具
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4c9be9d35
commit
fc9633f371
159 files changed
+4823
-2535
No files matched your search
@@ -36,11 +36,16 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleClick = (): void => {
|
||||
if (props.disabled) return
|
||||
if (props.path) {
|
||||
router.push(props.path)
|
||||
}
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
@@ -2,18 +2,18 @@
|
||||
<teleport to="body">
|
||||
<div class="guide-steps-container" v-if="show">
|
||||
<SpotlightOverlay
|
||||
:visible="Boolean(highlightSelector)"
|
||||
:target-selector="highlightSelector"
|
||||
:padding="highlightPadding"
|
||||
:border-radius="highlightRadius"
|
||||
:highlight-mode="highlightMode"
|
||||
:visible="Boolean(currentHighlightSelector)"
|
||||
:target-selector="currentHighlightSelector"
|
||||
:padding="currentHighlightPadding"
|
||||
:border-radius="currentHighlightRadius"
|
||||
:highlight-mode="currentHighlightMode"
|
||||
/>
|
||||
<div class="guide-align" :style="alignStyle">
|
||||
<GuideDragon
|
||||
:show-skip="currentStep < totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:position-offset-y="props.positionOffsetY"
|
||||
:position-offset-y="currentPositionOffsetY"
|
||||
@next="handleNext"
|
||||
@skip="handleSkip"
|
||||
@complete="handleComplete"
|
||||
@@ -23,6 +23,7 @@
|
||||
<div v-if="useSlotMode">
|
||||
<component
|
||||
:is="currentStepComponent"
|
||||
:key="currentStepIndex"
|
||||
@continue="handleNext"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
@@ -32,9 +33,14 @@
|
||||
<!-- 兼容原有的配置形式:也包裹 GuideStepsItem,获得继续按钮 -->
|
||||
<GuideStepsItem
|
||||
v-else
|
||||
:key="currentStepIndex"
|
||||
:is-last-step="currentStep >= totalSteps"
|
||||
:current-step="currentStep"
|
||||
:total-steps="totalSteps"
|
||||
:audio-src="currentStepData?.audioSrc"
|
||||
:audio-mode="currentStepData?.audioMode"
|
||||
:audio-channel-id="currentStepData?.audioChannelId"
|
||||
:audio-autoplay="currentStepData?.audioAutoplay"
|
||||
@continue="handleNext"
|
||||
>
|
||||
<!-- 支持组件形式的 content -->
|
||||
@@ -51,18 +57,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useSlots, onMounted, onUnmounted, reactive } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { GuideStep } from './types'
|
||||
import SpotlightOverlay from './SpotlightOverlay.vue'
|
||||
import GuideDragon from './GuideDragon.vue'
|
||||
import GuideStepsItem from './GuideStepsItem.vue'
|
||||
|
||||
interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 引导步骤配置 - 当使用插槽模式时可以为空 */
|
||||
steps?: GuideStep[]
|
||||
@@ -129,14 +128,11 @@ const alignStyle = reactive<{
|
||||
width: '1920px',
|
||||
height: '1080px'
|
||||
})
|
||||
// 验证并清理选择器,空字符串或只包含空格的选择器视为无效
|
||||
const highlightSelector = computed(() => {
|
||||
if (!props.highlightSelector) return null
|
||||
const trimmed = props.highlightSelector.trim()
|
||||
const normalizeSelector = (selector?: string | null) => {
|
||||
if (!selector) return null
|
||||
const trimmed = selector.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
})
|
||||
const highlightPadding = computed(() => props.highlightPadding || 0)
|
||||
const highlightRadius = computed(() => props.highlightRadius || 0)
|
||||
}
|
||||
|
||||
const useSlotMode = computed(() => {
|
||||
return slots.default && slots.default().length > 0
|
||||
@@ -161,6 +157,23 @@ const currentStepData = computed(() => {
|
||||
return props.steps[currentStepIndex.value] || null
|
||||
})
|
||||
|
||||
const currentStepConfig = computed(() => props.steps[currentStepIndex.value] || null)
|
||||
const currentHighlightSelector = computed(() => {
|
||||
return normalizeSelector(currentStepConfig.value?.highlightSelector ?? props.highlightSelector)
|
||||
})
|
||||
const currentHighlightPadding = computed(
|
||||
() => currentStepConfig.value?.highlightPadding ?? props.highlightPadding
|
||||
)
|
||||
const currentHighlightRadius = computed(
|
||||
() => currentStepConfig.value?.highlightRadius ?? props.highlightRadius
|
||||
)
|
||||
const currentHighlightMode = computed(
|
||||
() => currentStepConfig.value?.highlightMode ?? props.highlightMode
|
||||
)
|
||||
const currentPositionOffsetY = computed(
|
||||
() => currentStepConfig.value?.positionOffsetY ?? props.positionOffsetY
|
||||
)
|
||||
|
||||
const currentStepComponent = computed(() => {
|
||||
if (!useSlotMode.value) return null
|
||||
return slotSteps.value[currentStepIndex.value] || null
|
||||
|
||||
@@ -5,20 +5,53 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useAudioManager } from '@/composables/useAudioManager'
|
||||
import { IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
// 步骤项容器组件,通过插槽传递内容
|
||||
|
||||
type AudioMode = 'global' | 'channel'
|
||||
|
||||
interface Props {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLastStep: false,
|
||||
currentStep: 1,
|
||||
totalSteps: 1
|
||||
totalSteps: 1,
|
||||
audioSrc: undefined,
|
||||
audioMode: 'global',
|
||||
audioChannelId: undefined,
|
||||
audioAutoplay: true
|
||||
})
|
||||
|
||||
const { play, stop } = useAudioManager({
|
||||
mode: computed(() => props.audioMode),
|
||||
channelId: computed(() => props.audioChannelId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.audioSrc, props.audioMode, props.audioChannelId, props.audioAutoplay] as const,
|
||||
([audioSrc]) => {
|
||||
stop()
|
||||
|
||||
if (props.audioAutoplay && audioSrc) {
|
||||
play(audioSrc)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
<template>
|
||||
<!-- 抽屉遮罩层 -->
|
||||
<div v-if="visible" class="drawer-overlay" @click="handleClose">
|
||||
<!-- 抽屉内容 -->
|
||||
<div
|
||||
class="drawer-container"
|
||||
:class="{
|
||||
'drawer-container--visible': visible && !isClosing,
|
||||
'drawer-container--closing': isClosing
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 右侧步骤面板 -->
|
||||
<div class="steps-panel">
|
||||
<!-- 关闭按钮 -->
|
||||
<!-- <button class="close-button" @click="handleClose">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12 4L4 12M4 4L12 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button> -->
|
||||
<Transition name="step-navigation" appear>
|
||||
<!-- 抽屉遮罩层 -->
|
||||
<div v-if="visible" class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md">
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-[10]"
|
||||
image="components/close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 顶部导航箭头 -->
|
||||
<div class="panel-header">
|
||||
<button class="nav-arrow nav-arrow-up" @click="scrollUp" :disabled="!canScrollUp">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 9L3 6L6 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤列表 -->
|
||||
<div class="steps-list" ref="stepsListRef">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
:class="{ 'step-item--active': index === currentStepIndex }"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<!-- 播放图标(仅当前步骤显示) -->
|
||||
<!-- <div v-if="index === currentStepIndex" class="play-icon">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 0.5L6.5 4L1.5 7.5V0.5Z" fill="currentColor" />
|
||||
<!-- 抽屉内容 -->
|
||||
<div
|
||||
class="step-navigation-drawer relative flex h-full w-full justify-end"
|
||||
@click="handleClose"
|
||||
>
|
||||
<!-- 右侧步骤面板 -->
|
||||
<div
|
||||
class="relative flex h-full w-[648px] flex-col py-[100px]"
|
||||
:class="'steps-panel-bg'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 顶部分页箭头(上一页) -->
|
||||
<div class="relative z-10 flex items-center justify-center pointer-events-none">
|
||||
<button
|
||||
class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goPrevPage"
|
||||
:disabled="!canPrevPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 30L35 30L20 6Z" fill="white" />
|
||||
</svg>
|
||||
</div> -->
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤标签 -->
|
||||
<div class="step-label" :class="getTagColor(step.label)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
<!-- 步骤列表(分页,每页最多5条) -->
|
||||
<div class="scrollbar-hide z-10 h-[800px] flex-1 px-6 py-4">
|
||||
<div class="h-auto space-y-4">
|
||||
<div
|
||||
v-for="(step, index) in pagedSteps"
|
||||
:key="step.id"
|
||||
class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]"
|
||||
:class="{
|
||||
'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<!-- 步骤标签 -->
|
||||
<div
|
||||
class="mr-4 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
|
||||
:class="getTagColor(step.label)"
|
||||
>
|
||||
{{ step.label }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤内容 -->
|
||||
<div class="step-content">
|
||||
{{ step.content }}
|
||||
</div>
|
||||
<!-- 步骤内容 -->
|
||||
<div class="mr-4 flex-1 text-3xl font-bold leading-[1.5]">
|
||||
{{ step.content }}
|
||||
</div>
|
||||
|
||||
<!-- 步骤数字 -->
|
||||
<div class="step-number" :class="{ 'step-number--active': index === currentStepIndex }">
|
||||
{{ index + 1 }}
|
||||
<!-- 步骤数字(全局序号) -->
|
||||
<div
|
||||
class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300"
|
||||
:class="{
|
||||
'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]':
|
||||
currentPageStartIndex + index === currentStepIndex
|
||||
}"
|
||||
>
|
||||
{{ currentPageStartIndex + index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<div class="panel-footer">
|
||||
<button class="nav-arrow nav-arrow-down" @click="scrollDown" :disabled="!canScrollDown">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 3L9 6L6 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 底部分页箭头(下一页) -->
|
||||
<div class="relative z-10 flex flex-col items-center justify-center">
|
||||
<button
|
||||
class="flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
|
||||
@click="goNextPage"
|
||||
:disabled="!canNextPage"
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M5 10L35 10L20 34Z" fill="white" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ImgButton } from '@/components'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getTagColor } from '@/utils'
|
||||
|
||||
export interface StepItem {
|
||||
@@ -127,294 +127,101 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const stepsListRef = ref<HTMLElement>()
|
||||
const scrollPosition = ref(0)
|
||||
const maxScroll = ref(0)
|
||||
const isClosing = ref(false)
|
||||
// 分页:每页最多 5 条
|
||||
const pageSize = 5
|
||||
const currentPage = ref(0)
|
||||
|
||||
// 计算是否可以滚动
|
||||
const canScrollUp = computed(() => scrollPosition.value > 0)
|
||||
const canScrollDown = computed(() => scrollPosition.value < maxScroll.value)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)))
|
||||
const currentPageStartIndex = computed(() => currentPage.value * pageSize)
|
||||
const pagedSteps = computed(() =>
|
||||
props.steps.slice(currentPageStartIndex.value, currentPageStartIndex.value + pageSize)
|
||||
)
|
||||
|
||||
// 滚动到指定位置
|
||||
const scrollTo = (position: number) => {
|
||||
if (!stepsListRef.value) return
|
||||
stepsListRef.value.scrollTop = position
|
||||
scrollPosition.value = position
|
||||
const canPrevPage = computed(() => currentPage.value > 0)
|
||||
const canNextPage = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const goPrevPage = () => {
|
||||
if (!canPrevPage.value) return
|
||||
currentPage.value -= 1
|
||||
}
|
||||
|
||||
// 向上滚动
|
||||
const scrollUp = () => {
|
||||
if (!canScrollUp.value) return
|
||||
const newPosition = Math.max(0, scrollPosition.value - 100)
|
||||
scrollTo(newPosition)
|
||||
}
|
||||
|
||||
// 向下滚动
|
||||
const scrollDown = () => {
|
||||
if (!canScrollDown.value) return
|
||||
const newPosition = Math.min(maxScroll.value, scrollPosition.value + 100)
|
||||
scrollTo(newPosition)
|
||||
const goNextPage = () => {
|
||||
if (!canNextPage.value) return
|
||||
currentPage.value += 1
|
||||
}
|
||||
|
||||
// 处理步骤点击
|
||||
const handleStepClick = (index: number) => {
|
||||
emit('step-click', index, props.steps[index])
|
||||
const globalIndex = currentPageStartIndex.value + index
|
||||
emit('step-click', globalIndex, props.steps[globalIndex])
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
isClosing.value = true
|
||||
// 延迟关闭,让动画完成
|
||||
setTimeout(() => {
|
||||
emit('close')
|
||||
isClosing.value = false
|
||||
}, 300)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听visible变化,重置关闭状态
|
||||
// 当外部当前步骤改变时,自动定位到对应页
|
||||
watch(
|
||||
() => props.visible,
|
||||
() => props.currentStepIndex,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
isClosing.value = false
|
||||
const idx = newVal ?? 0
|
||||
const targetPage = Math.floor(idx / pageSize)
|
||||
if (targetPage !== currentPage.value) {
|
||||
currentPage.value = Math.min(Math.max(targetPage, 0), totalPages.value - 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 更新滚动状态
|
||||
const updateScrollState = () => {
|
||||
if (!stepsListRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = stepsListRef.value
|
||||
scrollPosition.value = scrollTop
|
||||
maxScroll.value = scrollHeight - clientHeight
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateScrollState()
|
||||
if (stepsListRef.value) {
|
||||
stepsListRef.value.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
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; */
|
||||
.step-navigation-enter-active,
|
||||
.step-navigation-leave-active {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
@apply absolute right-0 top-0 w-1/2 h-full flex justify-end;
|
||||
.step-navigation-enter-from,
|
||||
.step-navigation-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.step-navigation-enter-active .step-navigation-drawer,
|
||||
.step-navigation-leave-active .step-navigation-drawer {
|
||||
transition: transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.step-navigation-enter-from .step-navigation-drawer,
|
||||
.step-navigation-leave-to .step-navigation-drawer {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.drawer-container--visible {
|
||||
.step-navigation-enter-to .step-navigation-drawer,
|
||||
.step-navigation-leave-from .step-navigation-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-container--closing {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.steps-panel {
|
||||
@apply h-full relative;
|
||||
/* background: linear-gradient(
|
||||
180deg,
|
||||
rgba(20, 30, 45, 0.85) 0%,
|
||||
rgba(10, 15, 25, 0.9) 30%,
|
||||
rgba(15, 20, 30, 0.85) 70%,
|
||||
rgba(20, 30, 45, 0.85) 100%
|
||||
); */
|
||||
width: 648px;
|
||||
height: 1080px;
|
||||
.steps-panel-bg {
|
||||
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); */
|
||||
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
|
||||
.step-item-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(59, 130, 246, 1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 25px rgba(59, 130, 246, 0.8);
|
||||
.step-item-active-bg {
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex justify-center items-center;
|
||||
padding: 3rem 0 1rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
@apply w-6 h-6 flex items-center justify-center;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
@apply flex-1 px-6 py-4;
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.steps-list::-webkit-scrollbar {
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@apply flex items-center py-4 px-8 mb-4 cursor-pointer transition-all duration-300;
|
||||
position: relative;
|
||||
width: 831px;
|
||||
height: 122px;
|
||||
/* min-height: 4rem; */
|
||||
/* background: rgba(255, 255, 255, 0.05); */
|
||||
background-image: url('@/assets/images/vlog/侧边-底板未选中.png');
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.1); */
|
||||
/* border-radius: 20px; */
|
||||
/* backdrop-filter: blur(10px); */
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
/* background: rgba(255, 255, 255, 0.08); */
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-item--active {
|
||||
width: 1034px;
|
||||
height: 230px;
|
||||
padding-left: 180px;
|
||||
/* background: linear-gradient(90deg, rgba(59, 130, 246, 0.3) 0%, rgba(59, 130, 246, 0.1) 100%); */
|
||||
/* border-color: rgba(59, 130, 246, 0.6);
|
||||
box-shadow:
|
||||
0 0 30px rgba(59, 130, 246, 0.5),
|
||||
inset 0 0 20px rgba(59, 130, 246, 0.1); */
|
||||
background-image: url('@/assets/images/vlog/侧边-选中底板.png');
|
||||
transform: translateX(-23%);
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
@apply w-5 h-5 flex items-center justify-center mr-3;
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
@apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap;
|
||||
min-width: 5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
@apply flex-1 text-3xl font-bold;
|
||||
line-height: 1.5;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@apply flex items-center justify-center text-6xl font-semibold;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.step-number--active {
|
||||
margin-bottom: 80px;
|
||||
font-size: 150px;
|
||||
color: white;
|
||||
text-shadow: 5px 10px 5px rgba(0, 0, 0, 0.1);
|
||||
/* transform: scale(1.2);
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); */
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
padding: 1rem 0 2rem 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.guide-button {
|
||||
@apply px-8 py-3 rounded-full text-sm font-semibold;
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
|
||||
.guide-button:hover {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type AudioMode = 'global' | 'channel'
|
||||
export type HighlightMode = 'border' | 'gradient'
|
||||
|
||||
// 引导步骤类型定义
|
||||
export interface GuideStep {
|
||||
id: string
|
||||
title?: string
|
||||
content: string | Component
|
||||
nextText?: string
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
}
|
||||
|
||||
// 引导组件Props类型
|
||||
@@ -23,8 +36,13 @@ export interface GuideStepsProps {
|
||||
autoStart?: boolean
|
||||
keyboardNavigation?: boolean
|
||||
autoPlayInterval?: number
|
||||
highlightSelector?: string | null
|
||||
highlightPadding?: number
|
||||
highlightRadius?: number
|
||||
highlightMode?: HighlightMode
|
||||
/** 垂直偏移(仅Y轴),支持 number(px) 或 '10%'/'120px' */
|
||||
positionOffsetY?: number | string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
// 引导组件事件类型
|
||||
@@ -45,6 +63,10 @@ export interface GuideStepsItemProps {
|
||||
isLastStep?: boolean
|
||||
currentStep?: number
|
||||
totalSteps?: number
|
||||
audioSrc?: string
|
||||
audioMode?: AudioMode
|
||||
audioChannelId?: string
|
||||
audioAutoplay?: boolean
|
||||
}
|
||||
|
||||
// GuideStepsItem Emits类型
|
||||
|
||||
@@ -51,14 +51,27 @@
|
||||
:src="currentStep === index ? steps[index].nameSelect : steps[index].nameselectNot"
|
||||
alt=""
|
||||
/> -->
|
||||
<div class="flex items-center cursor-pointer" @click="handleStepClick(index + 1)">
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center',
|
||||
isStepClickable(index + 1)
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-45 grayscale'
|
||||
]"
|
||||
@click="handleStepClick(index + 1)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'font-bold text-4xl ',
|
||||
currentStep === index + 1 ? 'text-[#fbbf24]' : 'text-white'
|
||||
!isStepClickable(index + 1)
|
||||
? 'text-white/60'
|
||||
: currentStep === index + 1
|
||||
? 'text-[#fbbf24]'
|
||||
: 'text-white'
|
||||
]"
|
||||
>{{ step.name }}</span
|
||||
>
|
||||
{{ step.name }}
|
||||
</span>
|
||||
<!-- 图标 -->
|
||||
<img v-if="isStepCompleted(index)" src="@/assets/images/midterm/完成.png" alt="" />
|
||||
<img
|
||||
@@ -79,7 +92,7 @@
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="step-ellipsis"
|
||||
:class="['step-ellipsis', !isStepClickable(index + 2) && 'opacity-45 grayscale']"
|
||||
src="@/assets/images/midterm/进度装饰(未完成.png"
|
||||
alt=""
|
||||
/>
|
||||
@@ -174,7 +187,15 @@ const handleComplete = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isStepClickable = (index: number) => index <= props.completedStep + 1
|
||||
|
||||
const handleStepClick = (index: number) => {
|
||||
// 自由切换规则:
|
||||
// - 已完成的步骤(index <= completedStep)可自由切换
|
||||
// - 下一步(index === completedStep + 1)允许进入学习
|
||||
// - 其他情况(跳关)忽略
|
||||
if (!isStepClickable(index)) return
|
||||
|
||||
currentStep.value = index
|
||||
emit('stepChange', index)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const currentIndex = ref(0)
|
||||
const selectedOptionId = ref<string | null>(null) // 单选题选中的选项
|
||||
const selectedOptionIds = ref<string[]>([]) // 多选题选中的选项数组
|
||||
const showErrorExplanation = ref(false) // 是否显示错误解析
|
||||
const showCorrectFeedback = ref(false)
|
||||
|
||||
const total = computed(() => props.questions.length)
|
||||
const currentQuestion = computed(() => props.questions[currentIndex.value])
|
||||
@@ -63,6 +64,7 @@ watch(currentIndex, () => {
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
})
|
||||
|
||||
// 通过文件后缀判断媒体类型
|
||||
@@ -79,6 +81,8 @@ const isImage = (src: string): boolean => {
|
||||
}
|
||||
|
||||
const handleOptionClick = (optionId: string) => {
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:切换选中状态
|
||||
const index = selectedOptionIds.value.indexOf(optionId)
|
||||
@@ -93,13 +97,16 @@ const handleOptionClick = (optionId: string) => {
|
||||
}
|
||||
// 选择新选项时隐藏错误解析
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = false
|
||||
}
|
||||
|
||||
// 交互规则:选对进入下一步,选错显示解析;最后一题选对触发 complete 事件
|
||||
// 如果 correctOptionId 为空字符串,表示引导题,任意选择都可进入下一题
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
const q = currentQuestion.value
|
||||
|
||||
if (showCorrectFeedback.value) return
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选题:必须选择至少一个选项
|
||||
if (selectedOptionIds.value.length === 0) return
|
||||
@@ -138,12 +145,15 @@ const handleSubmit = () => {
|
||||
|
||||
// 答对时重置错误解析状态
|
||||
showErrorExplanation.value = false
|
||||
showCorrectFeedback.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
if (currentIndex.value < total.value - 1) {
|
||||
currentIndex.value += 1
|
||||
selectedOptionId.value = null
|
||||
selectedOptionIds.value = []
|
||||
} else {
|
||||
showCorrectFeedback.value = false
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
@@ -158,9 +168,9 @@ const handleSubmit = () => {
|
||||
<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-4xl font-extrabold mx-1 text-emerald-300">
|
||||
{{ currentQuestion.id }}
|
||||
</span>
|
||||
页
|
||||
<span class="text-2xl opacity-70 ml-2">/ 共 {{ total }} 页</span>
|
||||
</div>
|
||||
@@ -222,6 +232,16 @@ const handleSubmit = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 正确反馈提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showCorrectFeedback"
|
||||
class="mt-4 p-6 rounded-xl bg-emerald-500/20 border border-emerald-400/30 text-2xl font-semibold text-emerald-100"
|
||||
>
|
||||
回答正确
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 错误解析提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@@ -255,7 +275,10 @@ const handleSubmit = () => {
|
||||
<div :class="!hasMedia && 'flex justify-center'">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isMultiple ? selectedOptionIds.length === 0 : !selectedOptionId"
|
||||
:disabled="
|
||||
showCorrectFeedback ||
|
||||
(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"
|
||||
>
|
||||
|
||||
@@ -71,6 +71,30 @@ const emit = defineEmits<{
|
||||
(e: 'seeked', currentTime: TimeUnit): 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
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
|
||||
// 本地状态
|
||||
@@ -174,6 +198,43 @@ const togglePlay = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// src 切换时的稳健自动播放:
|
||||
// - 如果外部 modelValue 为 true(或当前正在播放),切换资源后会尝试继续播放
|
||||
// - 某些浏览器在切换 src 后立即 play 可能失败(还未 ready / load 过程中),这里做轻量重试
|
||||
const playAfterSourceChange = (el: HTMLVideoElement) => {
|
||||
const shouldPlay = !!props.modelValue || isPlaying.value
|
||||
if (!shouldPlay) return
|
||||
|
||||
// 尝试在下一帧播放;失败则再延迟重试一次
|
||||
requestAnimationFrame(() => {
|
||||
el.play().catch(err1 => {
|
||||
// 再给一次机会:资源 load/seek 状态可能还没就绪
|
||||
window.setTimeout(() => {
|
||||
el.play().catch(err2 => emit('error', err2 ?? err1))
|
||||
}, 50)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.src, props.sources],
|
||||
() => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
|
||||
// 切源:重置并重新加载
|
||||
try {
|
||||
el.pause()
|
||||
el.currentTime = 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.load()
|
||||
playAfterSourceChange(el)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const handleTimeInput = (e: Event) => {
|
||||
if (!videoRef.value) return
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -466,8 +527,30 @@ const videoStyle = computed(() => ({
|
||||
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
|
||||
@click.stop="togglePlay"
|
||||
>
|
||||
<span v-if="!isPlaying">▶</span>
|
||||
<span v-else>⏸</span>
|
||||
<!-- 播放图标 -->
|
||||
<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>
|
||||
|
||||
<!-- 左侧扩展位 -->
|
||||
@@ -594,6 +677,7 @@ const videoStyle = computed(() => ({
|
||||
transition: opacity 0.2s var(--sv-ease);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.sv-fade-fast-enter-from,
|
||||
.sv-fade-fast-leave-to {
|
||||
opacity: 0;
|
||||
@@ -607,6 +691,7 @@ const videoStyle = computed(() => ({
|
||||
transform 0.25s var(--sv-ease);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.sv-slide-up-enter-from,
|
||||
.sv-slide-up-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
export { default as Video } from './Video.vue'
|
||||
export type {} from './Video.vue'
|
||||
import type { ComponentOptionsMixin, DefineComponent, PublicProps, SlotsType } from 'vue'
|
||||
import VideoSfc from './Video.vue'
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
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 VideoSlots = SlotsType<{
|
||||
default?: () => any
|
||||
anchor?: (props: { anchor: VideoAnchor; resume: () => void; unmute: () => void }) => any
|
||||
overlay?: (props: VideoSlotProps) => any
|
||||
controls?: (props: VideoSlotProps) => any
|
||||
'controls-left'?: (props: VideoSlotProps) => any
|
||||
'controls-right'?: (props: VideoSlotProps) => any
|
||||
}>
|
||||
|
||||
type VideoComponent = DefineComponent<
|
||||
VideoProps,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
ComponentOptionsMixin,
|
||||
ComponentOptionsMixin,
|
||||
{
|
||||
'update:modelValue': (playing: boolean) => void
|
||||
play: () => void
|
||||
pause: () => void
|
||||
ended: () => void
|
||||
timeupdate: (currentTime: TimeUnit, duration: TimeUnit) => void
|
||||
error: (error: unknown) => void
|
||||
loadedmetadata: (duration: TimeUnit) => void
|
||||
ratechange: (rate: number) => void
|
||||
volumechange: (volume: number, muted: boolean) => void
|
||||
seeking: (currentTime: TimeUnit) => void
|
||||
seeked: (currentTime: TimeUnit) => void
|
||||
},
|
||||
string,
|
||||
PublicProps,
|
||||
Readonly<VideoProps>,
|
||||
Record<never, never>,
|
||||
VideoSlots
|
||||
>
|
||||
|
||||
const Video = VideoSfc as VideoComponent
|
||||
|
||||
export { Video }
|
||||
export default Video
|
||||
export type { VideoAnchor, VideoProps, VideoSlotProps }
|
||||
Reference in new issue
Block a user