文章目录
- 分析
-
- 二、文件结构
- 三、代码
-
- [3.1 `<template>` 模板部分](#3.1
<template>模板部分) - [3.2 `<script setup lang="ts">` 逻辑部分](#3.2
<script setup lang="ts">逻辑部分) -
- [3.2.1 导入依赖](#3.2.1 导入依赖)
- [3.2.2 定义数据类型](#3.2.2 定义数据类型)
- [3.2.3 定义组件接收的参数(props)](#3.2.3 定义组件接收的参数(props))
- [3.2.4 定义响应式变量](#3.2.4 定义响应式变量)
- [3.2.5 Swiper 模块配置](#3.2.5 Swiper 模块配置)
- [3.2.6 数据标准化函数](#3.2.6 数据标准化函数)
- [3.2.7 存储视频 DOM 引用](#3.2.7 存储视频 DOM 引用)
- [3.2.8 图片自动切换定时器](#3.2.8 图片自动切换定时器)
- [3.2.9 Slide 切换时的核心调度(最重要的函数)](#3.2.9 Slide 切换时的核心调度(最重要的函数))
- [3.2.10 视频播放事件处理](#3.2.10 视频播放事件处理)
- [3.2.11 加载数据和生命周期](#3.2.11 加载数据和生命周期)
- [3.3 `<style scoped>` 样式部分](#3.3
<style scoped>样式部分)
- [3.1 `<template>` 模板部分](#3.1
- 四、完整运行流程(图文示例)
- 五、为什么要这样设计?
-
- [为什么不用 Swiper 自带的 autoplay?](#为什么不用 Swiper 自带的 autoplay?)
- [为什么需要 isVideoPlaying 锁?](#为什么需要 isVideoPlaying 锁?)
- 六、常⻅问题
-
- [Q: 为什么不直接让用户手动滑动?](#Q: 为什么不直接让用户手动滑动?)
- [Q: 视频为什么静音(muted)?](#Q: 视频为什么静音(muted)?)
- [Q: `setTimeout` 时间准吗?](#Q:
setTimeout时间准吗?) - [Q: 视频播放失败怎么办?](#Q: 视频播放失败怎么办?)
- 七、源代码
一、需求
有一个轮播区域,可以自动轮播图片和视频:
- 如果当前是一张图片 → 停留 3 秒,然后自动切换到下一个
- 如果当前是一个视频 → 等视频播放完,再自动切换到下一个
- 用户可以混合排列图片和视频,顺序任意

分析
二、文件结构
只涉及两个文件:
| 文件 | 作用 |
|---|---|
src/components/TopCarousel.vue |
轮播组件的代码文件(模板 + 逻辑 + 样式都在这里) |
src/utils/constants.ts |
常量文件,存放轮播间隔时间等配置 |
三、代码
3.1 <template> 模板部分
vue
<template>
<div class="carousel-wrapper">
<div class="carousel-inner">
<Swiper
ref="swiperRef"
:modules="modules"
:loop="true"
:allow-touch-move="false"
@slide-change="onSlideChange"
class="carousel-swiper"
>
<SwiperSlide v-for="(item, index) in mediaList" :key="index">
<!-- 视频 -->
<video
v-if="item.type === 'video'"
:src="item.url"
:ref="el => setVideoRef(index, el)"
muted
playsinline
preload="auto"
class="carousel-media"
@playing="onVideoPlaying"
@ended="onVideoEnded"
/>
<!-- 图片 -->
<img
v-else
:src="item.url"
:alt="item.alt || '轮播图片'"
class="carousel-media"
/>
</SwiperSlide>
</Swiper>
</div>
</div>
</template>
解释:
| 代码 | 含义 |
|---|---|
<div class="carousel-wrapper"> |
最外层容器,控制整个轮播区域的位置和大小 |
<div class="carousel-inner"> |
内层容器,限定轮播的可视区域尺寸(956×543) |
<Swiper> |
轮播库 Swiper 的根组件,它提供了滑动/切换的能力 |
ref="swiperRef" |
给 Swiper 取个名字,方便在 JS 代码里控制它(比如调用 slideNext()) |
:modules="modules" |
注册 Swiper 的插件模块(这里只用到了分页器 Pagination) |
:loop="true" |
开启循环模式,最后一张之后回到第一张 |
:allow-touch-move="false" |
禁止用户用手滑动切换,只能由程序自动控制 |
@slide-change="onSlideChange" |
每次切换完成时触发 onSlideChange 函数 |
<SwiperSlide v-for="..."> |
循环渲染每一个轮播项 |
v-if="item.type === 'video'" |
如果是视频类型,渲染 <video> 标签 |
:ref="el => setVideoRef(index, el)" |
把当前视频的 DOM 元素存起来,方便后面控制播放/暂停 |
muted |
视频静音(浏览器自动播放策略要求) |
playsinline |
在 iPhone 上不自动全屏播放 |
preload="auto" |
提前加载视频 |
@playing="onVideoPlaying" |
视频开始播放时触发 |
@ended="onVideoEnded" |
视频播放完毕时触发 |
v-else |
否则就是图片,渲染 <img> 标签 |
3.2 <script setup lang="ts"> 逻辑部分
3.2.1 导入依赖
ts
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Pagination } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/pagination'
import request from '../utils/request'
import { API, CAROUSEL_INTERVAL } from '../utils/constants'
| 导入 | 是什么 |
|---|---|
ref |
Vue 的"响应式数据",数据变了页面会自动更新 |
onMounted |
Vue 的生命周期钩子,组件挂载到页面后执行 |
onBeforeUnmount |
组件销毁前执行,用来清理定时器 |
Swiper, SwiperSlide |
Swiper 轮播库的两个核心组件 |
SwiperClass |
Swiper 的 TypeScript 类型定义 |
Pagination |
Swiper 的分页器插件(底部小圆点) |
CAROUSEL_INTERVAL |
常量,值为 3000,表示图片停留 3 秒 |
3.2.2 定义数据类型
ts
interface MediaItem {
type: 'video' | 'image'
url: string
alt?: string
}
这是 TypeScript 的接口,规定了每条轮播数据必须有的字段:
type:只能是'video'或'image'url:媒体资源的地址(字符串)alt?:可选的图片描述文字
3.2.3 定义组件接收的参数(props)
ts
const props = defineProps({
mediaData: {
type: Array as () => MediaItem[] | null,
default: null
}
})
defineProps 声明这个组件可以从外部接收什么数据。
这里允许外部(比如通过 WebSocket)传一个新的轮播列表进来替换当前列表。
3.2.4 定义响应式变量
ts
const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let imageTimer: ReturnType<typeof setTimeout> | null = null
let isVideoPlaying = false
| 变量 | 类型 | 作用 |
|---|---|---|
swiperRef |
引用 Swiper 实例 | 用来调用 swiper.slideNext() 等方法 |
mediaList |
数组 | 存储所有轮播项(图片+视频) |
videoRefs |
字典对象 | { 0: <video元素>, 1: <video元素> },存每个视频的 DOM 引用 |
imageTimer |
定时器 ID | 图片自动切换的计时器 |
isVideoPlaying |
布尔值 | 开关锁 ,true 表示正在放视频,禁止定时器切换 |
3.2.5 Swiper 模块配置
ts
const modules = [Pagination]
告诉 Swiper 只启用分页器插件(底部小圆点),不启用 autoplay,因为我们要手动控制切换时机。
3.2.6 数据标准化函数
ts
function normalizeItem(item: string | MediaItem): MediaItem {
// 情况1:传入的是纯字符串(URL)
if (typeof item === 'string') {
const path = item.split('?')[0].split('#')[0] // 去掉 URL 参数和锚点
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path) // 判断扩展名是否为视频格式
return { type: isVideo ? 'video' : 'image', url: item }
}
// 情况2:传入的是对象但没有 type 字段
if (!item.type) {
const path = item.url.split('?')[0].split('#')[0]
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
item.type = isVideo ? 'video' : 'image'
}
return item
}
为什么需要这个函数?
因为接口返回的数据可能是以下两种格式:
ts
// 格式1:纯 URL 字符串数组
['https://xxx.com/1.jpg', 'https://xxx.com/2.mp4']
// 格式2:对象数组
[{ url: '...', type: 'video' }, { url: '...' }]
normalizeItem 将两种情况统一为 { url, type } 格式。
判断方法很简单:看 URL 的文件后缀 ,以 .mp4/.webm/.ogg/.mov 结尾的就是视频,否则是图片。
split('?')[0] 是为了去掉 URL 后面的查询参数(如 ?x-oss=xxx),防止干扰后缀判断。
3.2.7 存储视频 DOM 引用
ts
function setVideoRef(index: number, el: any) {
if (el) {
videoRefs.value[index] = el as HTMLVideoElement
}
}
当 Vue 渲染 <video> 时,会把 video 的 DOM 元素传给这个函数。
videoRefs 最终长这样:
ts
{
0: <video元素>, // 第 0 个 slide 的视频
2: <video元素> // 第 2 个 slide 的视频
}
只存视频(图片没有 :ref),且 index 只在视频项有值。
为什么要存? 因为后面需要手动调用 video.play() 和 video.pause() ------ 必须拿到视频的 DOM 元素才能操作。
3.2.8 图片自动切换定时器
ts
function scheduleImageNext() {
stopImageTimer() // 先清除上一次的定时器
imageTimer = setTimeout(() => { // 设置新的定时器
if (isVideoPlaying) return // 如果正在播放视频,就不切换
const swiper = swiperRef.value?.$el?.swiper
if (swiper) {
swiper.slideNext() // 切换到下一个 slide
}
}, CAROUSEL_INTERVAL) // 3000ms = 3秒
}
function stopImageTimer() {
if (imageTimer) {
clearTimeout(imageTimer) // 清除定时器
imageTimer = null
}
}
执行流程:
调用 scheduleImageNext()
→ 清除旧的定时器(防止重复)
→ 启动新定时器
→ 3 秒后:
→ 检查 isVideoPlaying:
→ 如果 true(视频播放中)→ 跳过,不切换
→ 如果 false → 调用 swiper.slideNext() 切换到下一张
为什么要先清除旧的? 防止快速切换时多个定时器同时存在,导致时间混乱。
3.2.9 Slide 切换时的核心调度(最重要的函数)
ts
function onSlideChange(swiper: SwiperClass) {
const currentIndex = swiper.realIndex // 当前 slide 的索引
const item = mediaList.value[currentIndex] // 当前 slide 的数据
// 第一步:暂停所有正在播放的视频
Object.values(videoRefs.value).forEach(video => {
if (video && !video.paused) video.pause()
})
// 第二步:根据类型做不同处理
if (item && item.type === 'video') {
// ====== 视频分支 ======
stopImageTimer() // 取消图片定时器(视频不需要定时器)
const videoEl = videoRefs.value[currentIndex] // 拿到当前视频的 DOM
if (videoEl) {
videoEl.currentTime = 0 // 从头开始播放
videoEl.play().catch((err: any) => {
console.warn('[Carousel] 视频自动播放失败:', err.message)
// 如果播放失败(被浏览器拦截等),5秒后备自动切换
setTimeout(() => {
swiper.slideNext()
}, 5000)
})
}
} else {
// ====== 图片分支 ======
isVideoPlaying = false // 解除视频播放锁定
scheduleImageNext() // 启动3秒定时器
}
}
完整逻辑流程图:
用户打开页面 / 切换 slide
│
▼
onSlideChange 被触发
│
├── 1. 暂停所有视频(防止上一个视频还在后台播放)
│
├── 2. 判断当前 slide 的类型
│
├── 如果是【视频】:
│ │
│ ├── 取消图片定时器(stopImageTimer)
│ ├── 把视频进度归零(currentTime = 0)
│ └── 播放视频
│ ├── 成功 → 等待 @ended 事件
│ └── 失败 → 5秒后备切换
│
└── 如果是【图片】:
│
├── 解除视频锁定(isVideoPlaying = false)
└── 启动3秒定时器(scheduleImageNext)
└── 3秒后 → 检查 isVideoPlaying
├── false → 切换下一张
└── true → 不切换(视频正在播放)
3.2.10 视频播放事件处理
ts
function onVideoPlaying() {
isVideoPlaying = true
}
function onVideoEnded() {
isVideoPlaying = false
const swiper = swiperRef.value?.$el?.swiper
if (swiper) {
swiper.slideNext()
}
}
这两个函数由 <video> 标签的 @playing 和 @ended 事件触发。
onVideoPlaying --- 视频开始播放时:
视频开始播放
→ 触发 <video> 的 @playing 事件
→ 调用 onVideoPlaying()
→ isVideoPlaying = true(锁住,不让定时器切换)
onVideoEnded --- 视频播放完毕时:
视频播放到最后一帧
→ 触发 <video> 的 @ended 事件
→ 调用 onVideoEnded()
→ isVideoPlaying = false(解除锁)
→ swiper.slideNext() 切换到下一张
注意:slideNext() 会再次触发 onSlideChange,从而进入下一个循环(图片或视频)。
3.2.11 加载数据和生命周期
ts
async function fetchCarouselData() {
mediaList.value = ([
'https://www.dkifly.com/ifly-api/admin-api/infra/file/25/get/20260518/怀远楼l_1779069971962.jpg',
'https://zkdk.oss-cn-hangzhou.aliyuncs.com/20260528/input1_1779518143479_merged_1779954141584.mp4?xxx',
'https://www.dkifly.com/ifly-api/admin-api/infra/file/25/get/20260518/石跳桥l_1779069989230.jpg'
] as unknown as MediaItem[]).map(normalizeItem)
}
onMounted(() => {
fetchCarouselData() // 组件挂载后立即加载数据
})
onBeforeUnmount(() => {
stopImageTimer() // 组件销毁前清理定时器,防止内存泄漏
})
onMounted:组件第一次出现在页面上时执行,用来初始化数据onBeforeUnmount:组件被销毁(比如用户离开页面)时执行,用来清理资源fetchCarouselData目前使用的是静态示例数据 ,通过.map(normalizeItem)标准化为{ type, url }格式
标准化后的数据长这样:
ts
[
{ type: 'image', url: '...怀远楼l_xxx.jpg' },
{ type: 'video', url: '...merged_xxx.mp4?...' },
{ type: 'image', url: '...石跳桥l_xxx.jpg' }
]
3.3 <style scoped> 样式部分
css
.carousel-wrapper {
width: 100%;
height: 600px;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.carousel-inner {
width: 956px;
height: 543px;
border-radius: 4px;
overflow: hidden;
}
.carousel-swiper {
width: 100%;
height: 100%;
}
.carousel-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
scoped 表示这些样式只对这个组件生效,不会影响页面的其他部分。
布局示意:
┌───────────────────────────────────┐ ← .carousel-wrapper (100%宽度, 600px高)
│ │ 水平垂直居中
│ ┌─────────────────────────┐ │ ← .carousel-inner (956×543, 圆角)
│ │ │ │
│ │ 轮播内容 (图片/视频) │ │ ← .carousel-media (填满容器)
│ │ │ │ object-fit: cover 保证内容不变形裁剪填充
│ └─────────────────────────┘ │
└───────────────────────────────────┘
四、完整运行流程(图文示例)
假设数据为:[图片A, 视频B, 图片C]
页面加载
│
▼
fetchCarouselData() → 加载 [图片A, 视频B, 图片C]
│
▼
Swiper 展示第 0 张(图片A)
│
▼
onSlideChange 被触发
├── 暂停所有视频(无)
└── 图片分支 → isVideoPlaying = false → scheduleImageNext()
│
▼
3 秒后 → slideNext()
│
▼
─────────────────
展示第 1 张(视频B)
│
▼
onSlideChange
├── 暂停所有视频
└── 视频分支
├── stopImageTimer()
├── video.play()
└── @playing → isVideoPlaying = true
视频播放中...
视频播放中...
视频播放到最后一帧
│
▼
@ended → onVideoEnded()
├── isVideoPlaying = false
└── slideNext()
│
▼
─────────────────
展示第 2 张(图片C)
onSlideChange
└── 图片分支 → 3 秒后 slideNext()
│
▼
回到第 0 张(loop 循环)
五、为什么要这样设计?
为什么不用 Swiper 自带的 autoplay?
Swiper 的 autoplay 只能设置固定间隔(比如每 3 秒切一次),但视频的时长是不固定的:
- 有的视频 5 秒
- 有的视频 30 秒
- 无法提前知道
所以不能用一个固定定时器覆盖所有情况。解决方案是:
| 类型 | 切换触发方式 |
|---|---|
| 图片 | 手动 setTimeout 定时器(固定间隔) |
| 视频 | 原生 @ended 事件触发(时长由视频决定) |
为什么需要 isVideoPlaying 锁?
在极少数情况下,可能出现这样的时序问题:
第 0 秒:切换到视频,启动播放
第 2 秒:一个旧的图片定时器刚好到时间了(因为 setTimeout 是异步的)
→ 如果没有锁,它会直接切换,打断视频播放
isVideoPlaying 这面"锁"就是用来挡这种意外情况的。当它 true 时,任何定时器的回调都会提前 return,不会执行切换。
六、常⻅问题
Q: 为什么不直接让用户手动滑动?
A: 本项目是一个公共展示屏/广告机场景,轮播是自动播放的,不需要交互。
Q: 视频为什么静音(muted)?
A: 浏览器(尤其是 Chrome)规定,不允许自动播放有声视频,必须静音才能自动播放。
Q: setTimeout 时间准吗?
A: 不太准(JavaScript 是单线程),但对 3 秒切换来说,几十毫秒的误差不影响体验。
Q: 视频播放失败怎么办?
A: 在 video.play().catch() 中做了 5 秒后备切换,不会卡死。
七、源代码
javascript
<template>
<!-- 顶部轮播区域 -->
<div class="carousel-wrapper">
<div class="carousel-inner">
<Swiper
ref="swiperRef"
:modules="modules"
:loop="true"
:allow-touch-move="false"
@slide-change="onSlideChange"
class="carousel-swiper"
>
<SwiperSlide v-for="(item, index) in mediaList" :key="index">
<!-- 视频 -->
<video
v-if="item.type === 'video'"
:src="item.url"
:ref="el => setVideoRef(index, el)"
muted
playsinline
preload="auto"
class="carousel-media"
@playing="onVideoPlaying"
@ended="onVideoEnded"
/>
<!-- 图片 -->
<img
v-else
:src="item.url"
:alt="item.alt || '轮播图片'"
class="carousel-media"
/>
</SwiperSlide>
</Swiper>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Pagination } from 'swiper/modules'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css/pagination'
import request from '../utils/request'
import { API, CAROUSEL_INTERVAL } from '../utils/constants'
interface MediaItem {
type: 'video' | 'image'
url: string
alt?: string
}
const props = defineProps({
// 允许外部通过 WebSocket 推送更新轮播列表
mediaData: {
type: Array as () => MediaItem[] | null,
default: null
}
})
const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let imageTimer: ReturnType<typeof setTimeout> | null = null
let isVideoPlaying = false
// Swiper 模块(不启用 autoplay,全部手动控制切换)
const modules = [Pagination]
/** 将 URL 字符串标准化为 { type, url } 对象 */
function normalizeItem(item: string | MediaItem): MediaItem {
if (typeof item === 'string') {
const path = item.split('?')[0].split('#')[0]
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
return { type: isVideo ? 'video' : 'image', url: item }
}
// 已经是对象格式,补充缺省 type
if (!item.type) {
const path = item.url.split('?')[0].split('#')[0]
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
item.type = isVideo ? 'video' : 'image'
}
return item
}
/** 存储视频 DOM 引用 */
function setVideoRef(index: number, el: any) {
if (el) {
videoRefs.value[index] = el as HTMLVideoElement
}
}
/** 图片自动轮播:延迟后切到下一张(视频播放中不切换) */
function scheduleImageNext() {
stopImageTimer()
imageTimer = setTimeout(() => {
if (isVideoPlaying) return
const swiper = swiperRef.value?.$el?.swiper
if (swiper) {
swiper.slideNext()
}
}, CAROUSEL_INTERVAL)
}
function stopImageTimer() {
if (imageTimer) {
clearTimeout(imageTimer)
imageTimer = null
}
}
/** Slide 切换时的处理 */
function onSlideChange(swiper: SwiperClass) {
const currentIndex = swiper.realIndex
const item = mediaList.value[currentIndex]
// 暂停所有视频
Object.values(videoRefs.value).forEach(video => {
if (video && !video.paused) video.pause()
})
if (item && item.type === 'video') {
// 视频:取消图片定时器,播放视频
stopImageTimer()
const videoEl = videoRefs.value[currentIndex]
if (videoEl) {
videoEl.currentTime = 0
videoEl.play().catch((err: any) => {
console.warn('[Carousel] 视频自动播放失败:', err.message)
// 视频播放失败则 5 秒后自动切到下一张
setTimeout(() => {
swiper.slideNext()
}, 5000)
})
}
} else {
// 图片:解除视频锁定,启动计时器
isVideoPlaying = false
scheduleImageNext()
}
}
/** 视频开始播放,锁定禁止自动切换 */
function onVideoPlaying() {
isVideoPlaying = true
}
/** 视频播放结束,解除锁定并切换到下一个 */
function onVideoEnded() {
isVideoPlaying = false
const swiper = swiperRef.value?.$el?.swiper
if (swiper) {
swiper.slideNext()
}
}
/** 从接口加载轮播数据 */
async function fetchCarouselData() {
// 默认示例数据
mediaList.value = ([
'https://62.jpg',
'https://V.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260529T012706Z&X-Amz-SignedHeaders=host&X-Amz-Credential=LTAI5t94A2wGtZdZZx4n9BZD%2F20260529%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Expires=86400&X-Amz-Signature=df43e28b08df7a53ec32801bc4911078584d660fd588720ec4734298b13440f4',
'https://30.jpg'
] as unknown as MediaItem[]).map(normalizeItem)
// try {
// const res = await request.get<{ data: MediaItem[] }>({ url: API.CAROUSEL_LIST })
// if (res && Array.isArray(res.data)) {
// mediaList.value = res.data.map(normalizeItem)
// }
// } catch (err: any) {
// console.warn('[Carousel] 接口加载失败,使用默认数据:', err.message)
// }
}
/** 监听外部传入的媒体数据(WebSocket 更新) */
function updateMediaList(data: MediaItem[]) {
if (Array.isArray(data)) {
mediaList.value = data.map(normalizeItem)
}
}
onMounted(() => {
fetchCarouselData()
})
onBeforeUnmount(() => {
stopImageTimer()
})
// 对外暴露更新方法
defineExpose({ updateMediaList })
</script>
<style scoped>
.carousel-wrapper {
width: 100%;
height: 600px;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.carousel-inner {
width: 956px;
height: 543px;
border-radius: 4px;
overflow: hidden;
}
.carousel-swiper {
width: 100%;
height: 100%;
}
.carousel-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
组件封装+优化
js
<template>
<!-- 顶部轮播区域 -->
<div class="carousel-wrapper">
<div class="carousel-inner">
<Swiper
ref="swiperRef"
:modules="modules"
:loop="true"
:allow-touch-move="false"
:autoplay="{ delay: CAROUSEL_INTERVAL, disableOnInteraction: false, pauseOnMouseEnter: false }"
@slide-change="onSlideChange"
class="carousel-swiper"
>
<SwiperSlide v-for="(item, index) in mediaList" :key="index">
<!-- 视频 -->
<video
v-if="item.type === 'video'"
:src="item.url"
:ref="el => setVideoRef(index, el)"
muted
playsinline
preload="auto"
class="carousel-media"
@playing="onVideoPlaying"
@ended="onVideoEnded"
/>
<!-- 图片 -->
<img
v-else
:src="item.url"
:alt="item.alt || '轮播图片'"
class="carousel-media"
/>
</SwiperSlide>
</Swiper>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import type { Swiper as SwiperClass } from 'swiper/types'
import { Autoplay, Pagination } from 'swiper/modules'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css'
// @ts-expect-error swiper ships CSS files without type declarations
import 'swiper/css/pagination'
import { CAROUSEL_INTERVAL } from '../utils/constants'
import type { MediaItem } from '../types/media'
const props = defineProps({
// 由父组件传入的轮播列表数据
mediaData: {
type: Array as () => (string | MediaItem)[] | null,
default: null
}
})
const swiperRef = ref<{ $el: { swiper: SwiperClass } } | null>(null)
const mediaList = ref<MediaItem[]>([])
const videoRefs = ref<Record<number, HTMLVideoElement | null>>({})
let isVideoPlaying = false
// Autoplay 模块驱动图片轮播,视频播放时暂停 autoplay
const modules = [Autoplay, Pagination]
/** 将 URL 字符串标准化为 { type, url } 对象 */
function normalizeItem(item: string | MediaItem): MediaItem {
if (typeof item === 'string') {
const path = item.split('?')[0].split('#')[0]
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
return { type: isVideo ? 'video' : 'image', url: item }
}
if (!item.type) {
const path = item.url.split('?')[0].split('#')[0]
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(path)
item.type = isVideo ? 'video' : 'image'
}
return item
}
/** 父组件数据变化时自动同步 */
watch(() => props.mediaData, (val) => {
if (Array.isArray(val)) {
mediaList.value = val.map(normalizeItem)
}
}, { immediate: true })
/** 获取 Swiper 实例 */
function getSwiper(): SwiperClass | null {
return swiperRef.value?.$el?.swiper ?? null
}
/** 启停 autoplay */
function stopAutoplay() {
getSwiper()?.autoplay?.stop()
}
function startAutoplay() {
getSwiper()?.autoplay?.start()
}
/** 存储视频 DOM 引用 */
function setVideoRef(index: number, el: any) {
if (el) {
videoRefs.value[index] = el as HTMLVideoElement
}
}
/** Slide 切换时的处理 */
function onSlideChange(swiper: SwiperClass) {
const currentIndex = swiper.realIndex
const item = mediaList.value[currentIndex]
// 暂停所有正在播放的视频
Object.values(videoRefs.value).forEach(video => {
if (video && !video.paused) video.pause()
})
if (item && item.type === 'video') {
stopAutoplay()
isVideoPlaying = true
const videoEl = videoRefs.value[currentIndex]
if (videoEl) {
videoEl.currentTime = 0
videoEl.play().catch(() => {
// 视频播放失败,恢复 autoplay 继续轮播
isVideoPlaying = false
startAutoplay()
})
} else {
isVideoPlaying = false
startAutoplay()
}
} else {
isVideoPlaying = false
startAutoplay()
}
}
function onVideoPlaying() {
// autoplay 已在 onSlideChange 中停止
}
function onVideoEnded() {
isVideoPlaying = false
const swiper = getSwiper()
if (swiper) {
swiper.slideNext()
}
startAutoplay()
}
/** 外部(WebSocket)推送更新 */
function updateMediaList(data: (string | MediaItem)[]) {
if (Array.isArray(data)) {
mediaList.value = data.map(normalizeItem)
}
}
onBeforeUnmount(() => {
stopAutoplay()
})
defineExpose({ updateMediaList })
</script>
<style scoped>
.carousel-wrapper {
width: 100%;
height: 600px;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.carousel-inner {
width: 956px;
height: 543px;
border-radius: 4px;
overflow: hidden;
}
.carousel-swiper {
width: 100%;
height: 100%;
}
.carousel-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>