Skip to Content
@anker-in/headless-ui 2.0 is released 🎉

MediaSceneSwitcher (场景切换器)

多场景切换展示组件,支持图片/视频和场景切换动画【✅ 已发布】

加载中...
当前视口: 1920px × 600px场景: 浅色主题场景切换
打开链接

功能特性

  • 桌面端自动轮播 - 3秒间隔自动切换场景,提供流畅的视觉体验
  • 点击切换与定时器重置 - 用户点击场景后重置自动播放定时器
  • 移动端 Swiper 集成 - 支持手势滑动,提供原生体验
  • CSS 进度条动画 - 线性渐变进度指示器,可视化自动播放进度
  • 视频/图片混合媒体 - 支持视频和图片混合展示,灵活配置
  • 响应式资源管理 - 桌面端和移动端独立的媒体资源配置
  • 布局方向配置 - 支持左侧和右侧预览区域布局
  • 主题切换 - 支持亮色/暗色主题,适应不同页面风格
  • 促销标签展示 - 场景项支持可选的促销标签显示
  • 曝光追踪集成 - 内置曝光埋点支持,便于数据分析
  • 无障碍性支持 - 完整的键盘导航和屏幕阅读器支持

Props 参数

MediaSceneSwitcherProps

Prop类型默认值必需说明
dataMediaSceneSwitcherData-组件的完整配置数据
classNamestring''自定义容器样式类
childrenReact.ReactNode-子元素内容

MediaSceneSwitcherData

字段类型默认值必需说明
titlestring-主标题文案
subtitlestring-副标题文案,支持换行
theme'light' | 'dark''light'主题模式
layout'left' | 'right''left'桌面端预览区域位置(左侧/右侧)
itemsMediaSceneSwitcherItem[][]场景项数组

MediaSceneSwitcherItem

字段类型必需说明
idstring场景项唯一标识符
titlestring场景标题文案
tagstring促销标签文案(如 “20% OFF”)
videoUrlstring | null桌面端视频资源 URL,null 表示使用图片
imageUrlImg桌面端图片资源(视频封面或主图)
mobImageUrlImg移动端图片资源

Img 类型

interface Img { url: string // 图片 URL alt: string // 替代文本(无障碍必需) }

类型定义

interface MediaSceneSwitcherProps { data: MediaSceneSwitcherData className?: string children?: React.ReactNode } interface MediaSceneSwitcherData { title: string subtitle?: string theme?: 'light' | 'dark' layout?: 'left' | 'right' items: MediaSceneSwitcherItem[] } interface MediaSceneSwitcherItem { id: string title: string tag?: string videoUrl: string | null imageUrl: Img mobImageUrl: Img } interface Img { url: string alt: string }

使用示例

示例 1: 基础用法(3个场景 - 清洁主题)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function BasicExample() { return ( <MediaSceneSwitcher data={{ title: '360 Times/Minute', subtitle: 'Self-Cleaning Robot Vacuum', theme: 'dark', layout: 'left', items: [ { id: 'scene-1', title: 'Scrapes Off Dirt', tag: '20% OFF', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=824&h=640&q=80&fit=crop', alt: 'Scraping off dirt from floor', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=296&h=360&q=80&fit=crop', alt: 'Scraping off dirt mobile', }, }, { id: 'scene-2', title: 'Cleans the Mop with Water and Detergent', tag: '20% OFF', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=824&h=640&q=80&fit=crop', alt: 'Cleaning mop with water and detergent', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=296&h=360&q=80&fit=crop', alt: 'Cleaning mop mobile', }, }, { id: 'scene-3', title: 'Squeezes Out Dirty Water', tag: '20% OFF', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1527515637462-cff94eecc1ac?w=824&h=640&q=80&fit=crop', alt: 'Squeezing out dirty water', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1527515637462-cff94eecc1ac?w=296&h=360&q=80&fit=crop', alt: 'Squeezing water mobile', }, }, ], }} /> ) }

示例 2: 视频场景(混合视频和图片)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function VideoExample() { return ( <MediaSceneSwitcher data={{ title: 'Advanced Features', subtitle: 'Experience the Power', theme: 'light', layout: 'right', items: [ { id: 'video-scene-1', title: 'Powerful Suction', videoUrl: 'https://cdn.example.com/videos/suction.mp4', imageUrl: { url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=824&h=640&q=80', alt: 'Suction demonstration', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=296&h=360&q=80', alt: 'Suction mobile', }, }, { id: 'video-scene-2', title: 'Smart Navigation', videoUrl: null, // Image only imageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=824&h=640&q=80', alt: 'Smart navigation', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=296&h=360&q=80', alt: 'Navigation mobile', }, }, ], }} /> ) }

示例 3: 亮色主题(右侧布局)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function LightThemeExample() { return ( <MediaSceneSwitcher data={{ title: 'Why Choose Us', subtitle: 'Top Features That Matter', theme: 'light', // 亮色主题 layout: 'right', // 预览区域在右侧 items: [ { id: 'feature-1', title: 'Long Battery Life', tag: 'NEW', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=824&h=640&q=80', alt: 'Battery life', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=296&h=360&q=80', alt: 'Battery mobile', }, }, { id: 'feature-2', title: 'Quiet Operation', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1595246140625-573b715d11dc?w=824&h=640&q=80', alt: 'Quiet operation', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1595246140625-573b715d11dc?w=296&h=360&q=80', alt: 'Quiet mobile', }, }, ], }} /> ) }

示例 4: 暗色主题(左侧布局)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function DarkThemeExample() { return ( <MediaSceneSwitcher data={{ title: 'Premium Experience', subtitle: 'Designed for Excellence', theme: 'dark', // 暗色主题 layout: 'left', // 预览区域在左侧(默认) items: [ { id: 'premium-1', title: 'Premium Build Quality', tag: 'BEST SELLER', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=824&h=640&q=80', alt: 'Premium quality', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=296&h=360&q=80', alt: 'Quality mobile', }, }, { id: 'premium-2', title: 'Advanced AI Features', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=824&h=640&q=80', alt: 'AI features', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=296&h=360&q=80', alt: 'AI mobile', }, }, ], }} /> ) }

示例 5: 多场景展示(5个场景)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function MultiSceneExample() { return ( <MediaSceneSwitcher data={{ title: 'Complete Cleaning Solution', subtitle: '5 Powerful Features in One Device', theme: 'dark', layout: 'left', items: [ { id: 'clean-1', title: 'Deep Carpet Cleaning', tag: '30% OFF', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=824&h=640&q=80', alt: 'Carpet cleaning', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=296&h=360&q=80', alt: 'Carpet mobile', }, }, { id: 'clean-2', title: 'Hard Floor Mopping', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1629116958133-28b8e5334f3d?w=824&h=640&q=80', alt: 'Floor mopping', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1629116958133-28b8e5334f3d?w=296&h=360&q=80', alt: 'Mopping mobile', }, }, { id: 'clean-3', title: 'Edge & Corner Cleaning', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1628177142898-93e36e4e3a50?w=824&h=640&q=80', alt: 'Edge cleaning', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1628177142898-93e36e4e3a50?w=296&h=360&q=80', alt: 'Edge mobile', }, }, { id: 'clean-4', title: 'Self-Emptying Station', tag: 'NEW', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=824&h=640&q=80', alt: 'Self-emptying', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=296&h=360&q=80', alt: 'Emptying mobile', }, }, { id: 'clean-5', title: 'Automatic Drying', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=824&h=640&q=80', alt: 'Automatic drying', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1563453392212-326f5e854473?w=296&h=360&q=80', alt: 'Drying mobile', }, }, ], }} /> ) }

示例 6: 带促销标签

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function PromotionExample() { return ( <MediaSceneSwitcher data={{ title: 'Holiday Sale', subtitle: 'Limited Time Offers', theme: 'light', layout: 'right', items: [ { id: 'promo-1', title: 'Premium Model', tag: '50% OFF', // 促销标签 videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=824&h=640&q=80', alt: 'Premium model', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=296&h=360&q=80', alt: 'Premium mobile', }, }, { id: 'promo-2', title: 'Standard Model', tag: 'SAVE $100', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1595246140625-573b715d11dc?w=824&h=640&q=80', alt: 'Standard model', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1595246140625-573b715d11dc?w=296&h=360&q=80', alt: 'Standard mobile', }, }, { id: 'promo-3', title: 'Compact Model', // 无 tag 字段,不显示标签 videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=824&h=640&q=80', alt: 'Compact model', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=296&h=360&q=80', alt: 'Compact mobile', }, }, ], }} /> ) }

示例 7: 响应式资源(桌面端视频+移动端图片)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function ResponsiveMediaExample() { return ( <MediaSceneSwitcher data={{ title: 'Smart Cleaning', subtitle: 'See the Difference', theme: 'dark', layout: 'left', items: [ { id: 'responsive-1', title: 'Obstacle Detection', videoUrl: 'https://cdn.example.com/videos/obstacle.mp4', // 桌面端播放视频 imageUrl: { url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=824&h=640&q=80', alt: 'Obstacle detection video cover', }, mobImageUrl: { // 移动端显示图片(性能优化) url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=296&h=360&q=80', alt: 'Obstacle detection mobile', }, }, { id: 'responsive-2', title: 'Auto-Mapping', videoUrl: 'https://cdn.example.com/videos/mapping.mp4', imageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=824&h=640&q=80', alt: 'Mapping video cover', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=296&h=360&q=80', alt: 'Mapping mobile', }, }, ], }} /> ) }

示例 8: 自定义容器样式

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function CustomStyleExample() { return ( <div className="my-custom-container"> <MediaSceneSwitcher className="shadow-2xl rounded-xl" // 自定义样式 data={{ title: 'Stylish Display', subtitle: 'With Custom Styling', theme: 'light', layout: 'left', items: [ { id: 'style-1', title: 'Feature One', videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=824&h=640&q=80', alt: 'Feature one', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1625772452888-7e9b8f8f583e?w=296&h=360&q=80', alt: 'Feature one mobile', }, }, ], }} /> </div> ) }

示例 9: 单场景展示

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function SingleSceneExample() { return ( <MediaSceneSwitcher data={{ title: 'Flagship Feature', subtitle: 'Our Most Advanced Technology', theme: 'dark', layout: 'left', items: [ { id: 'single-scene', title: 'Ultra-Clean System', tag: 'FLAGSHIP', videoUrl: 'https://cdn.example.com/videos/flagship.mp4', imageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=824&h=640&q=80', alt: 'Ultra-clean system', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1556742031-c6961e8560b0?w=296&h=360&q=80', alt: 'Ultra-clean mobile', }, }, ], }} /> ) }

示例 10: 完整配置(所有可选参数)

import { MediaSceneSwitcher } from '@anker-in/headless-ui/biz' export default function FullConfigExample() { return ( <MediaSceneSwitcher className="max-w-7xl mx-auto" // 自定义容器样式 data={{ title: 'Complete Feature Set', // 必需 subtitle: 'Everything You Need in One Place\nSecond Line Supported', // 可选,支持换行 theme: 'dark', // 可选,默认 'light' layout: 'right', // 可选,默认 'left' items: [ // 必需 { id: 'full-1', // 必需 title: 'Advanced Cleaning', // 必需 tag: 'EXCLUSIVE', // 可选 videoUrl: 'https://cdn.example.com/videos/advanced.mp4', // 可选 imageUrl: { // 必需(视频封面或主图) url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=824&h=640&q=80', alt: 'Advanced cleaning feature', }, mobImageUrl: { // 必需 url: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=296&h=360&q=80', alt: 'Advanced cleaning mobile', }, }, { id: 'full-2', title: 'Smart Navigation', tag: '4.0 AI', videoUrl: null, // null 表示仅使用图片 imageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=824&h=640&q=80', alt: 'Smart navigation', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=296&h=360&q=80', alt: 'Smart navigation mobile', }, }, { id: 'full-3', title: 'Long Battery Life', // 无 tag 字段 videoUrl: null, imageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=824&h=640&q=80', alt: 'Battery life', }, mobImageUrl: { url: 'https://images.unsplash.com/photo-1609091839311-d98929fc8f1e?w=296&h=360&q=80', alt: 'Battery mobile', }, }, ], }} /> ) }

核心算法解析

1. 桌面端自动轮播算法

功能说明: 桌面端每3秒自动切换到下一个场景,循环播放。

核心代码:

const INTERVAL_TIME = 3000 // 3秒间隔 useEffect(() => { // 移动端或无场景时不启动 if (isMobile || items.length === 0) return // 启动定时器 intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) // 循环索引 }, INTERVAL_TIME) // 清理定时器 return () => { if (intervalRef.current) { window.clearInterval(intervalRef.current) } } }, [isMobile, items.length])

算法流程:

1. 检查环境条件 ├─ 如果是移动端 → 不启动自动播放 ├─ 如果场景数为0 → 不启动自动播放 └─ 否则 → 继续 2. 创建定时器 └─ 每3秒执行一次索引更新 3. 更新当前索引 ├─ 当前索引 + 1 ├─ 对场景总数取模(实现循环) └─ 更新状态触发重渲染 4. 组件卸载时清理定时器

循环逻辑示例:

// 假设有3个场景 (indices: 0, 1, 2) items.length = 3 // 第1次: (0 + 1) % 3 = 1 // 第2次: (1 + 1) % 3 = 2 // 第3次: (2 + 1) % 3 = 0 ← 回到起点 // 第4次: (0 + 1) % 3 = 1 ← 继续循环

2. 点击切换与定时器重置算法

功能说明: 用户点击场景项时,立即切换到该场景并重置自动播放定时器。

核心代码:

const handleItemClick = (index: number) => { // 1. 立即切换到点击的场景 setCurrentIndex(index) // 2. 清除现有定时器 if (intervalRef.current) { window.clearInterval(intervalRef.current) } // 3. 重新启动定时器(从当前场景开始) intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, INTERVAL_TIME) }

算法流程:

用户点击场景2 1. 状态更新: currentIndex = 2 2. 清除旧定时器(停止原有的自动播放计划) 3. 创建新定时器 ├─ 从场景2开始 ├─ 3秒后 → 场景3 ├─ 再3秒 → 场景0 └─ 继续循环...

用户体验优化:

  • 即时响应: 点击后立即显示目标场景,无延迟
  • 时间重置: 用户有完整的3秒时间观看点击的场景
  • 防止冲突: 清除旧定时器避免重复切换

3. CSS 进度条动画算法

功能说明: 视觉化3秒自动播放进度,使用 CSS 线性渐变动画。

核心代码:

// 进度条容器 <div className="relative h-1 w-full overflow-hidden rounded-full bg-gray-200/30"> {/* 进度条(线性渐变 #3ad1ff → #008cd6) */} <div key={currentIndex} // 场景切换时重新触发动画 className="h-full rounded-full bg-gradient-to-r from-[#3ad1ff] to-[#008cd6]" style={{ width: '0%', // 初始宽度0 animation: 'progress-grow 3s linear forwards', // 3秒线性增长 }} /> </div> // CSS 关键帧动画 @keyframes progress-grow { from { width: 0%; } to { width: 100%; } }

动画时序:

t=0s: ━━━━━━━━━━ (0%) ↓ linear 线性增长 t=1s: ███━━━━━━━ (33%) t=2s: ██████━━━━ (67%) t=3s: ██████████ (100%) → 场景切换,进度条重置

关键技术点:

  1. key={currentIndex}: 每次场景切换时,React 重新挂载进度条组件,自动重启动画
  2. animation: ... forwards: 动画结束后保持100%状态,不回弹
  3. linear 缓动函数: 匀速增长,准确反映时间进度
  4. 渐变色 #3ad1ff → #008cd6: 视觉上更吸引人,品牌色应用

4. 移动端 Swiper 配置算法

功能说明: 移动端使用 Swiper 库实现手势滑动,配置自动高度、循环、分页等功能。

核心代码:

import { Swiper, SwiperSlide } from 'swiper/react' import { Autoplay, Pagination } from 'swiper/modules' <Swiper modules={[Autoplay, Pagination]} spaceBetween={16} // 场景间距16px slidesPerView={1} // 每屏显示1个场景 autoHeight={true} // 自适应内容高度 loop={items.length > 1} // 2+场景时启用循环 pagination={{ clickable: true, // 分页器可点击 bulletClass: 'swiper-pagination-bullet custom-bullet', bulletActiveClass: 'swiper-pagination-bullet-active custom-bullet-active', }} onSlideChange={(swiper) => { setCurrentIndex(swiper.realIndex) // 同步索引状态 }} > {items.map((item, index) => ( <SwiperSlide key={item.id}> {/* 场景内容 */} </SwiperSlide> ))} </Swiper>

配置说明:

配置项说明
modules[Autoplay, Pagination]启用自动播放和分页模块
spaceBetween16场景间距16px
slidesPerView1每屏1个场景(全屏)
autoHeighttrue自动调整高度匹配内容
loopitems.length > 12+场景时启用循环
pagination.clickabletrue分页器可点击切换
onSlideChange(swiper) => {...}同步状态到 React

循环逻辑:

场景数 = 1: loop = false (无需循环) 场景数 ≥ 2: loop = true ├─ 左滑到第1个场景时,继续左滑 → 跳转到最后场景 └─ 右滑到最后场景时,继续右滑 → 跳转到第1个场景

性能优化:

  • 使用 item.id 作为 key,避免不必要的重渲染
  • autoHeight: true 避免固定高度导致的内容裁剪或空白
  • 分页器采用自定义样式类,与设计系统统一

5. 响应式资源选择算法

功能说明: 根据设备类型(桌面/移动)和媒体类型(视频/图片),自动选择最优资源。

核心代码:

const getMediaContent = (item: MediaSceneSwitcherItem, isMobile: boolean) => { // 移动端: 始终使用图片(性能优化) if (isMobile) { return ( <Picture src={item.mobImageUrl.url} alt={item.mobImageUrl.alt} className="h-full w-full object-cover" /> ) } // 桌面端: 根据 videoUrl 判断 if (item.videoUrl) { return ( <video src={item.videoUrl} autoPlay loop muted playsInline className="h-full w-full object-cover" /> ) } else { return ( <Picture src={item.imageUrl.url} alt={item.imageUrl.alt} className="h-full w-full object-cover" /> ) } }

决策树:

输入: item, isMobile 是否移动端? ├─ 是 → 返回 item.mobImageUrl (图片) └─ 否 → 是桌面端 item.videoUrl 是否存在? ├─ 是 → 返回 <video> (桌面视频) └─ 否 → 返回 item.imageUrl (桌面图片)

资源优先级矩阵:

设备类型videoUrl 存在videoUrl 不存在
桌面端播放视频显示 imageUrl
移动端显示 mobImageUrl显示 mobImageUrl

为什么移动端不播放视频?

  1. 流量节省: 视频文件通常较大(2-10MB),移动网络成本高
  2. 性能优化: 视频解码消耗CPU/GPU,影响电池续航
  3. 自动播放限制: 移动浏览器通常禁止自动播放视频(用户体验)
  4. 加载速度: 图片加载更快,首屏渲染时间更短

6. 场景切换状态管理算法

功能说明: 统一管理桌面端和移动端的场景索引状态,确保两端行为一致。

核心代码:

const [currentIndex, setCurrentIndex] = useState(0) // 当前场景索引 // 桌面端: 自动轮播更新索引 useEffect(() => { if (isMobile || items.length === 0) return intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, INTERVAL_TIME) return () => { if (intervalRef.current) { window.clearInterval(intervalRef.current) } } }, [isMobile, items.length]) // 桌面端: 点击更新索引 const handleItemClick = (index: number) => { setCurrentIndex(index) // ... 重置定时器 } // 移动端: Swiper 滑动更新索引 <Swiper onSlideChange={(swiper) => { setCurrentIndex(swiper.realIndex) }} > {/* ... */} </Swiper> // 根据索引渲染对应场景 const currentItem = items[currentIndex]

状态同步流程:

桌面端自动播放 setCurrentIndex(1) currentIndex = 1 → 触发重渲染 显示 items[1] 的内容 进度条重置(key={currentIndex})
用户点击场景3 handleItemClick(3) setCurrentIndex(3) currentIndex = 3 → 触发重渲染 显示 items[3] 的内容 定时器重置,3秒后自动切换到场景4
移动端用户滑动 Swiper onSlideChange 回调触发 setCurrentIndex(swiper.realIndex) currentIndex 更新 → 触发重渲染 分页器高亮同步

关键技术点:

  1. 单一状态源: currentIndex 是唯一的真相源,所有UI基于此渲染
  2. 双向同步:
    • 桌面端定时器 → 更新 currentIndex
    • 用户交互(点击/滑动) → 更新 currentIndex
    • currentIndex → 驱动UI渲染
  3. swiper.realIndex vs swiper.activeIndex:
    • activeIndex: Swiper内部索引,启用loop时会有克隆slide
    • realIndex: 实际场景索引,始终在 [0, items.length-1] 范围内
  4. 边界处理: 使用模运算 % items.length 确保索引不越界

7. 布局方向切换算法

功能说明: 根据 layout 参数(‘left’ 或 ‘right’),动态调整桌面端预览区域位置。

核心代码:

const { layout = 'left' } = data // 桌面端布局容器 <div className={cn( 'hidden laptop:grid laptop:grid-cols-2 laptop:gap-8', layout === 'right' && 'laptop:grid-flow-dense' // 反转布局流 )} > {/* 场景列表 */} <div className={cn( 'space-y-4', layout === 'right' && 'laptop:col-start-2' // 右侧布局时,场景列表在第2列 )} > {items.map((item, index) => ( <SceneItem key={item.id} item={item} index={index} /> ))} </div> {/* 预览区域 */} <div className={cn( 'sticky top-24 h-fit', layout === 'right' && 'laptop:col-start-1 laptop:row-start-1' // 右侧布局时,预览在第1列 )} > <MediaPreview item={currentItem} /> </div> </div>

布局矩阵:

layout 值场景列表位置预览区域位置Grid Flow
'left' (默认)右侧(col 2)左侧(col 1)默认
'right'左侧(col 2)右侧(col 1)dense(反转)

CSS Grid 技术解析:

/* layout='left' (默认) */ .laptop:grid-cols-2 { /* 预览区域自动在col 1, 场景列表自动在col 2 */ } /* layout='right' */ .laptop:grid-flow-dense { /* 启用密集布局,允许元素填充空白 */ } .laptop:col-start-2 { /* 场景列表明确在col 2 */ } .laptop:col-start-1 { /* 预览区域明确在col 1 */ } .laptop:row-start-1 { /* 预览区域在第1行(保持顶部对齐) */ }

视觉效果:

layout='left' ┌──────────┬──────────┐ │ 预览区 │ 场景1 │ │ (固定) ├──────────┤ │ │ 场景2 │ │ ├──────────┤ │ │ 场景3 │ └──────────┴──────────┘ layout='right' ┌──────────┬──────────┐ │ 场景1 │ 预览区 │ ├──────────┤ (固定) │ │ 场景2 │ │ ├──────────┤ │ │ 场景3 │ │ └──────────┴──────────┘

Sticky 定位:

.sticky { position: sticky; top: 24px; /* 距离顶部24px时开始固定 */ }
  • 用户向下滚动时,预览区域保持在视口内(不随滚动消失)
  • 用户可以同时看到预览和完整的场景列表

响应式行为

断点定义

断点最小宽度描述主要布局变化
Mobile< 768px手机端Swiper 单列,垂直堆叠
Tablet≥ 768px平板保持 Swiper(过渡阶段)
Laptop≥ 1025px小桌面切换到2列网格,启用自动播放
Desktop≥ 1440px大桌面增大间距和字号
LG Desktop≥ 1920px超大屏最大宽度限制,居中对齐

响应式特性表

特性Mobile/TabletLaptop+说明
交互模式Swiper 滑动点击切换移动端手势,桌面端点击
自动播放❌ 禁用✅ 启用(3秒)移动端节省性能和流量
媒体资源mobImageUrlimageUrl / videoUrl独立资源配置
布局垂直单列水平2列网格桌面端分栏展示
预览区域内联显示Sticky 固定桌面端悬浮预览
进度条❌ 隐藏✅ 显示仅桌面端显示播放进度
分页器✅ 显示❌ 隐藏移动端显示圆点导航

断点行为示例

// Tailwind CSS 响应式类使用示例 <div className=" flex flex-col gap-4 // Mobile: 垂直堆叠,间距4 tablet:gap-6 // Tablet: 增大间距到6 laptop:grid laptop:grid-cols-2 laptop:gap-8 // Laptop+: 2列网格,间距8 desktop:gap-12 // Desktop: 进一步增大间距 "> {/* 内容 */} </div> // 桌面端场景标题 <h3 className=" text-lg font-semibold // Mobile: 18px laptop:text-xl // Laptop: 20px desktop:text-2xl // Desktop: 24px "> {item.title} </h3> // 预览区域 <div className=" w-full // Mobile: 全宽 laptop:sticky laptop:top-24 // Laptop+: 固定定位 "> {/* 媒体内容 */} </div>

设计规范

使用场景

  • 产品功能展示 - 多个关键功能点的场景化展示
  • 使用步骤说明 - 分步骤的操作流程演示
  • 技术特性说明 - 突出多个技术卖点
  • 对比场景展示 - 使用前后对比,多种模式对比
  • 单一功能说明 - 仅1个场景时,推荐使用其他组件
  • 文本为主内容 - 媒体内容较少时,不适合使用

场景数量建议

场景数推荐度说明
1个⭐ 不推荐无法体现”切换”特性,建议使用其他组件
2-3个⭐⭐⭐ 推荐简洁明了,用户不会产生疲劳感
4-5个⭐⭐⭐⭐ 最佳内容丰富但不冗长,自动播放周期适中(12-15秒)
6-8个⭐⭐ 可接受内容较多,注意场景标题简洁,避免信息过载
9+个⭐ 不推荐自动播放周期过长(27秒+),用户注意力难以维持

媒体资源优化

视频规格建议:

平台分辨率码率格式文件大小
桌面端1280x7202-4 MbpsMP4(H.264)< 5MB
移动端不使用视频---

图片规格建议:

资源类型尺寸格式文件大小质量
imageUrl (桌面)824x640pxWebP/JPEG< 200KB80%
mobImageUrl (移动)296x360pxWebP/JPEG< 100KB80%

Unsplash 查询参数:

// 桌面端图片 `https://images.unsplash.com/photo-xxx?w=824&h=640&q=80&fit=crop` // 移动端图片 `https://images.unsplash.com/photo-xxx?w=296&h=360&q=80&fit=crop` // 参数说明: // w=824 - 宽度824px // h=640 - 高度640px // q=80 - 质量80% // fit=crop - 裁剪模式(填满尺寸)

标签文案建议

促销标签(tag)应简短有力:

类型推荐文案长度
折扣”20% OFF”, “50% OFF”, “-30%“6-8字符
价格”SAVE $100”, “¥200 OFF”8-10字符
状态”NEW”, “HOT”, “EXCLUSIVE”3-9字符
标识”BEST SELLER”, “4.0 AI”10-12字符

注意事项:

  • ❌ 避免过长文案: “Limited Time Only Special Discount”
  • ✅ 简短有力: “LIMITED”
  • ❌ 避免复杂符号: ”★★★★★ 5-Star”
  • ✅ 简单直接: “5-STAR”

主题选择建议

页面背景色推荐主题原因
浅色背景(白色/灰色)theme='light'保持视觉一致性,避免对比过强
深色背景(黑色/深灰)theme='dark'暗色模式下文字更清晰
彩色背景根据明度选择亮度高选 light,亮度低选 dark

视觉效果:

  • Light Theme: 白色背景 + 黑色文字 + 浅灰色边框
  • Dark Theme: 深色背景 + 白色文字 + 亮色边框

无障碍性

键盘导航

按键桌面端行为移动端行为
Tab聚焦到下一个场景项聚焦到 Swiper 容器
Shift + Tab聚焦到上一个场景项聚焦到上一个可聚焦元素
Enter / Space切换到当前聚焦的场景无效(使用触摸手势)
Arrow Left切换到上一个场景Swiper 左滑
Arrow Right切换到下一个场景Swiper 右滑
Home切换到第一个场景Swiper 跳转到第一页
End切换到最后一个场景Swiper 跳转到最后一页

ARIA 属性

场景列表项:

<button role="tab" aria-selected={currentIndex === index} aria-controls={`scene-panel-${item.id}`} id={`scene-tab-${item.id}`} onClick={() => handleItemClick(index)} onKeyDown={(e) => handleKeyDown(e, index)} > <h3>{item.title}</h3> {item.tag && <span className="sr-only">促销标签: {item.tag}</span>} </button>

预览区域:

<div role="tabpanel" id={`scene-panel-${currentItem.id}`} aria-labelledby={`scene-tab-${currentItem.id}`} tabIndex={0} > <Picture src={currentItem.imageUrl.url} alt={currentItem.imageUrl.alt} // 必需的替代文本 aria-describedby={`scene-desc-${currentItem.id}`} /> <span id={`scene-desc-${currentItem.id}`} className="sr-only"> 当前显示: {currentItem.title} </span> </div>

Swiper 移动端:

<Swiper role="region" aria-label="场景切换器" aria-live="polite" // 场景切换时通知屏幕阅读器 onSlideChange={(swiper) => { announceSlideChange(swiper.realIndex) // 语音播报 }} > {items.map((item) => ( <SwiperSlide key={item.id} role="group" aria-label={`场景 ${item.title}`} > {/* 内容 */} </SwiperSlide> ))} </Swiper>

屏幕阅读器支持

语音播报示例:

const announceSlideChange = (index: number) => { const item = items[index] const announcement = `切换到场景 ${index + 1},共 ${items.length} 个。${item.title}${item.tag ? `,${item.tag}` : ''}` // 创建 ARIA live region const liveRegion = document.getElementById('scene-live-region') if (liveRegion) { liveRegion.textContent = announcement } } // HTML <div id="scene-live-region" role="status" aria-live="polite" aria-atomic="true" className="sr-only" />

播报内容:

  • “切换到场景 2,共 5 个。Cleans the Mop with Water and Detergent,20% OFF”
  • “自动播放已暂停” (用户点击后)
  • “自动播放已恢复” (定时器重置后)

视频无障碍

字幕和描述:

<video src={item.videoUrl} autoPlay loop muted playsInline aria-label={item.imageUrl.alt} // 视频描述 > <track kind="captions" src={`${item.videoUrl}.vtt`} srcLang="zh-CN" label="简体中文" default /> <track kind="descriptions" src={`${item.videoUrl}-desc.vtt`} srcLang="zh-CN" label="视频描述" /> 您的浏览器不支持视频播放。 </video>

自动播放控制:

  • 提供暂停按钮(可选功能扩展)
  • 尊重用户的 prefers-reduced-motion 设置
@media (prefers-reduced-motion: reduce) { .progress-bar { animation: none !important; /* 禁用进度条动画 */ } }

性能优化

React 优化

1. 组件 Memo 化:

import React, { memo } from 'react' // 场景项组件(纯展示) const SceneItem = memo<SceneItemProps>(({ item, index, isActive, onClick }) => { return ( <button onClick={() => onClick(index)}> <h3>{item.title}</h3> {item.tag && <span>{item.tag}</span>} <ProgressBar isActive={isActive} /> </button> ) }, (prevProps, nextProps) => { // 自定义比较函数 return ( prevProps.isActive === nextProps.isActive && prevProps.item.id === nextProps.item.id ) }) // 预览区域组件 const MediaPreview = memo<MediaPreviewProps>(({ item }) => { return ( <div> {item.videoUrl ? ( <video src={item.videoUrl} autoPlay loop muted playsInline /> ) : ( <Picture src={item.imageUrl.url} alt={item.imageUrl.alt} /> )} </div> ) }, (prevProps, nextProps) => { return prevProps.item.id === nextProps.item.id })

2. useCallback 缓存回调:

const handleItemClick = useCallback((index: number) => { setCurrentIndex(index) if (intervalRef.current) { window.clearInterval(intervalRef.current) } intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, INTERVAL_TIME) }, [items.length]) // 仅依赖 items.length const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => { switch (e.key) { case 'ArrowLeft': e.preventDefault() handleItemClick(Math.max(0, index - 1)) break case 'ArrowRight': e.preventDefault() handleItemClick(Math.min(items.length - 1, index + 1)) break case 'Home': e.preventDefault() handleItemClick(0) break case 'End': e.preventDefault() handleItemClick(items.length - 1) break } }, [items.length, handleItemClick])

3. useMemo 缓存计算结果:

const currentItem = useMemo(() => { return items[currentIndex] || items[0] }, [items, currentIndex]) const desktopScenes = useMemo(() => { return items.map((item, index) => ({ ...item, isActive: index === currentIndex, })) }, [items, currentIndex])

视频优化

1. 懒加载视频:

import { useIntersectionObserver } from '@/hooks/useIntersectionObserver' const LazyVideo = ({ src, ...props }) => { const [isVisible, setIsVisible] = useState(false) const videoRef = useRef<HTMLVideoElement>(null) useIntersectionObserver(videoRef, ([entry]) => { if (entry.isIntersecting) { setIsVisible(true) } }) return ( <video ref={videoRef} src={isVisible ? src : undefined} // 可见时才加载 {...props} /> ) }

2. 预加载策略:

// 仅预加载下一个场景的视频 useEffect(() => { if (!isMobile && items[currentIndex + 1]?.videoUrl) { const nextVideo = document.createElement('video') nextVideo.preload = 'metadata' nextVideo.src = items[currentIndex + 1].videoUrl } }, [currentIndex, items, isMobile])

3. 内存管理:

useEffect(() => { return () => { // 组件卸载时清理视频资源 const videos = document.querySelectorAll('video') videos.forEach((video) => { video.pause() video.src = '' video.load() }) } }, [])

Swiper 优化

1. 虚拟滚动(场景数多时):

import { Virtual } from 'swiper/modules' <Swiper modules={[Virtual]} virtual={{ enabled: items.length > 10, // 10+场景时启用 addSlidesBefore: 2, addSlidesAfter: 2, }} > {items.map((item, index) => ( <SwiperSlide key={item.id} virtualIndex={index}> <SceneContent item={item} /> </SwiperSlide> ))} </Swiper>

2. 懒加载图片:

<Swiper lazy={{ loadPrevNext: true, loadPrevNextAmount: 2, // 预加载前后各2张 }} > <SwiperSlide> <Picture src={item.mobImageUrl.url} alt={item.mobImageUrl.alt} loading="lazy" className="swiper-lazy" /> <div className="swiper-lazy-preloader" /> </SwiperSlide> </Swiper>

3. 销毁实例:

useEffect(() => { return () => { // 组件卸载时销毁 Swiper 实例 if (swiperRef.current && swiperRef.current.destroy) { swiperRef.current.destroy(true, true) } } }, [])

图片优化

1. 响应式图片:

<picture> <source srcSet={`${item.imageUrl.url}&w=412 412w, ${item.imageUrl.url}&w=824 824w`} sizes="(max-width: 768px) 412px, 824px" type="image/webp" /> <img src={item.imageUrl.url} alt={item.imageUrl.alt} loading="lazy" decoding="async" /> </picture>

2. WebP 格式:

const getOptimizedImageUrl = (url: string, width: number) => { // Unsplash 支持 fm 参数指定格式 return `${url}&w=${width}&fm=webp` }

常见问题 FAQ

1. 为什么桌面端自动播放,移动端需要手动滑动?

原因:

  • 性能考虑: 移动端自动播放视频/切换场景会消耗更多电量和流量
  • 用户体验: 移动端用户更习惯主动滑动浏览内容,而非被动观看
  • 浏览器限制: 移动浏览器对自动播放有严格限制(需要 muted + playsInline)

技术实现:

useEffect(() => { if (isMobile || items.length === 0) return // 移动端直接返回,不启动定时器 intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, INTERVAL_TIME) return () => { if (intervalRef.current) { window.clearInterval(intervalRef.current) } } }, [isMobile, items.length])

2. 如何自定义自动播放间隔时间?

当前限制: 组件内部硬编码为3秒(INTERVAL_TIME = 3000)。

未来扩展建议:

interface MediaSceneSwitcherData { // ... 现有字段 autoPlayInterval?: number // 新增可选字段,默认3000ms } // 组件内使用 const interval = data.autoPlayInterval ?? 3000 useEffect(() => { if (isMobile || items.length === 0) return intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, interval) // 使用动态间隔 // ... }, [isMobile, items.length, interval])

使用示例:

<MediaSceneSwitcher data={{ autoPlayInterval: 5000, // 5秒间隔 // ... }} />

3. 为什么点击场景后会重置定时器?

设计理由:

  • 用户意图: 用户点击表示对该场景感兴趣,应给予完整观看时间
  • 避免冲突: 若不重置,用户刚点击场景1秒后可能就自动切换到下一个
  • 提升体验: 用户获得3秒完整时间而非剩余时间

技术实现:

const handleItemClick = (index: number) => { setCurrentIndex(index) // 1. 清除旧定时器(停止原计划) if (intervalRef.current) { window.clearInterval(intervalRef.current) } // 2. 创建新定时器(从当前场景开始,给足3秒) intervalRef.current = window.setInterval(() => { setCurrentIndex((prev) => (prev + 1) % items.length) }, INTERVAL_TIME) }

时间线示例:

自动播放: t=0s: 场景1 (显示) t=3s: 场景2 (自动切换) t=6s: 场景3 (自动切换) 用户在 t=1s 点击场景3: t=1s: 场景3 (点击切换,定时器重置) t=4s: 场景1 (3秒后自动切换) ← 用户获得完整3秒观看时间

4. 如何禁用自动播放?

当前限制: 组件默认启用桌面端自动播放,无配置项禁用。

临时解决方案 (修改源码):

// 在 MediaSceneSwitcher 组件内 const AUTO_PLAY_ENABLED = false // 修改为 false useEffect(() => { if (isMobile || items.length === 0 || !AUTO_PLAY_ENABLED) return // ... }, [isMobile, items.length])

未来扩展建议:

interface MediaSceneSwitcherData { autoPlay?: boolean // 新增字段,默认 true // ... } // 使用示例 <MediaSceneSwitcher data={{ autoPlay: false, // 禁用自动播放 // ... }} />

5. 场景项标题过长时如何处理?

组件内置处理:

<h3 className=" text-lg font-semibold line-clamp-2 // 最多2行 overflow-hidden text-ellipsis "> {item.title} </h3>

建议:

  • 最佳长度: 10-20字符(中文),20-40字符(英文)
  • 最大长度: 40字符(中文),80字符(英文)
  • 超长处理: 使用 line-clamp-2 截断为2行,末尾显示省略号

示例:

✅ 好的标题: "Scrapes Off Dirt" "Cleans the Mop" ⚠️ 可接受: "Cleans the Mop with Water and Detergent" (会截断为2行) ❌ 避免: "Advanced Multi-Surface Deep Cleaning Technology with AI-Powered Obstacle Detection and Navigation" (过长,影响阅读)

6. 如何在移动端也显示进度条?

当前设计: 进度条仅在桌面端显示,移动端使用 Swiper 分页器。

原因:

  • 移动端无自动播放,进度条无意义
  • Swiper 分页器更符合移动端交互习惯

强制显示方法 (修改源码):

// 移除 laptop: 前缀,在所有断点显示 <div className="relative h-1 w-full overflow-hidden rounded-full bg-gray-200/30"> <div key={currentIndex} className="h-full rounded-full bg-gradient-to-r from-[#3ad1ff] to-[#008cd6]" style={{ width: '0%', animation: 'progress-grow 3s linear forwards', }} /> </div>

7. 支持哪些视频格式?

推荐格式: MP4 (H.264 编码)

兼容性:

格式ChromeSafariFirefoxEdge移动端
MP4 (H.264)
WebM (VP9)部分
Ogg (Theora)

示例:

<video src="https://cdn.example.com/video.mp4" // ✅ 推荐 // src="https://cdn.example.com/video.webm" // ⚠️ Safari 不支持 autoPlay loop muted playsInline />

多格式兼容:

<video autoPlay loop muted playsInline> <source src="video.webm" type="video/webm" /> <source src="video.mp4" type="video/mp4" /> 您的浏览器不支持视频播放。 </video>

8. 如何获取当前显示的场景索引?

当前限制: 组件不对外暴露 currentIndex 状态。

未来扩展建议:

interface MediaSceneSwitcherProps { data: MediaSceneSwitcherData onSceneChange?: (index: number) => void // 新增回调 } // 组件内 useEffect(() => { onSceneChange?.(currentIndex) }, [currentIndex, onSceneChange]) // 使用示例 <MediaSceneSwitcher data={data} onSceneChange={(index) => { console.log('Current scene:', index) // 可用于 GA 追踪 }} />

9. 如何自定义进度条颜色?

当前限制: 进度条颜色硬编码为 #3ad1ff → #008cd6 渐变。

临时解决方案 (CSS 覆盖):

/* 全局样式或组件内 <style> */ .media-scene-switcher .progress-bar { background: linear-gradient(to right, #ff6b6b, #ee5a6f) !important; }

未来扩展建议:

interface MediaSceneSwitcherData { progressBarColor?: { from: string to: string } // 默认: { from: '#3ad1ff', to: '#008cd6' } } // 使用示例 <MediaSceneSwitcher data={{ progressBarColor: { from: '#ff6b6b', to: '#ee5a6f', }, // ... }} />

10. 为什么移动端不使用视频?

核心原因:

  1. 流量消耗: 视频文件通常2-10MB,4G/5G流量成本高
  2. 加载时间: 视频加载比图片慢3-10倍,影响首屏体验
  3. 性能影响: 视频解码消耗CPU/GPU,发热+耗电
  4. 自动播放限制: 移动浏览器严格限制自动播放(需用户交互)
  5. 体验一致性: Swiper 手势滑动与视频播放冲突

数据对比:

资源类型文件大小加载时间(4G)CPU占用
图片(WebP)50-100KB0.5-1s
视频(MP4)2-5MB5-15s

最佳实践:

// 移动端始终使用 mobImageUrl if (isMobile) { return ( <Picture src={item.mobImageUrl.url} alt={item.mobImageUrl.alt} loading="lazy" /> ) } // 桌面端根据需要使用视频 if (item.videoUrl) { return <video src={item.videoUrl} autoPlay loop muted playsInline /> } else { return <Picture src={item.imageUrl.url} alt={item.imageUrl.alt} /> }

相关资源

Storybook 示例

查看组件的交互式文档和所有变体:

npm run storybook

访问 http://localhost:6006/?path=/story/components-mediasceneswitcher--default

源代码

  • 组件源码: packages/ui/src/biz-components/MediaSceneSwitcher/index.tsx
  • 类型定义: packages/ui/src/biz-components/MediaSceneSwitcher/types.ts
  • 样式文件: packages/ui/src/biz-components/MediaSceneSwitcher/styles.css
  • 单元测试: packages/ui/tests/MediaSceneSwitcher.test.tsx

依赖库

  • Swiper: https://swiperjs.com/ 
    • 版本要求: ^11.0.0
    • 使用模块: Pagination, Autoplay, Virtual
  • @anker-in/headless-ui/base: 项目内部原子组件库
    • Picture: 响应式图片组件
    • Heading: 标题组件
    • Text: 文本组件

相关组件

  • ImageTextFeature: 单图文特性展示
  • ImageWithText: 图文组合展示
  • MediaPlayerBase: 单视频播放器
  • MediaPlayerMulti: 多媒体组合播放器
  • MediaPlayerSticky: 固定定位媒体播放器
  • TabsWithMedia: 带媒体内容的标签页

设计资源

  • Figma 设计稿: 查看设计 
  • 设计系统文档: 请查阅项目 Design System 文档
  • Tailwind 配置: packages/ui/tailwind.config.js

性能测试

使用 Lighthouse 测试页面性能:

npm run build npm run preview # 在 Chrome DevTools 中运行 Lighthouse

性能目标:

  • Performance: > 90
  • Accessibility: 100
  • Best Practices: > 95
  • SEO: > 90

技术文章

Last updated on