Add FloatingVideoHelp component to AppRouter for enhanced user assistance.

This commit is contained in:
2025-11-12 11:40:05 +08:00
parent 888e2b376a
commit b1b68f4397
6 changed files with 465 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
.modalMask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.3s ease;
}
.videoContainer {
width: 100%;
max-width: 90vw;
max-height: 90vh;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.title {
font-size: 16px;
font-weight: 600;
color: #fff;
}
.closeButton {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&:active {
transform: scale(0.95);
}
svg {
font-size: 16px;
}
}
}
.videoWrapper {
width: 100%;
position: relative;
padding-top: 56.25%; // 16:9 比例
background: #000;
}
.video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
outline: none;
}
// 动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// 移动端适配
@media (max-width: 768px) {
.modalMask {
padding: 0;
}
.videoContainer {
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
}
.header {
padding: 10px 12px;
.title {
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,101 @@
import React, { useRef, useEffect } from "react";
import { CloseOutlined } from "@ant-design/icons";
import styles from "./VideoPlayer.module.scss";
interface VideoPlayerProps {
/** 视频URL */
videoUrl: string;
/** 是否显示播放器 */
visible: boolean;
/** 关闭回调 */
onClose: () => void;
/** 视频标题 */
title?: string;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoUrl,
visible,
onClose,
title = "操作视频",
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible && videoRef.current) {
// 播放器打开时播放视频
videoRef.current.play().catch(err => {
console.error("视频播放失败:", err);
});
// 阻止背景滚动
document.body.style.overflow = "hidden";
} else if (videoRef.current) {
// 播放器关闭时暂停视频
videoRef.current.pause();
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [visible]);
// 点击遮罩层关闭
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 如果点击的是遮罩层本身(不是视频容器),则关闭
if (e.target === e.currentTarget) {
handleClose();
}
};
const handleClose = () => {
if (videoRef.current) {
videoRef.current.pause();
}
onClose();
};
// 阻止事件冒泡
const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
if (!visible) {
return null;
}
return (
<div
ref={containerRef}
className={styles.modalMask}
onClick={handleMaskClick}
>
<div className={styles.videoContainer} onClick={handleContentClick}>
<div className={styles.header}>
<span className={styles.title}>{title}</span>
<button className={styles.closeButton} onClick={handleClose}>
<CloseOutlined />
</button>
</div>
<div className={styles.videoWrapper}>
<video
ref={videoRef}
src={videoUrl}
controls
className={styles.video}
playsInline
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
>
</video>
</div>
</div>
</div>
);
};
export default VideoPlayer;

View File

@@ -0,0 +1,56 @@
.floatingButton {
position: fixed;
right: 20px;
bottom: 80px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 9998;
transition: all 0.3s ease;
animation: float 3s ease-in-out infinite;
&:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.5);
}
&:active {
transform: scale(0.95);
}
.icon {
font-size: 28px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
}
// 移动端适配
@media (max-width: 768px) {
right: 16px;
bottom: 70px;
width: 50px;
height: 50px;
.icon {
font-size: 24px;
}
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -0,0 +1,68 @@
import React, { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import { PlayCircleOutlined } from "@ant-design/icons";
import VideoPlayer from "./VideoPlayer";
import { getVideoUrlByRoute } from "./videoConfig";
import styles from "./index.module.scss";
interface FloatingVideoHelpProps {
/** 是否显示悬浮窗,默认为 true */
visible?: boolean;
/** 自定义样式类名 */
className?: string;
}
const FloatingVideoHelp: React.FC<FloatingVideoHelpProps> = ({
visible = true,
className,
}) => {
const location = useLocation();
const [showPlayer, setShowPlayer] = useState(false);
const [currentVideoUrl, setCurrentVideoUrl] = useState<string | null>(null);
// 根据当前路由获取视频URL
useEffect(() => {
const videoUrl = getVideoUrlByRoute(location.pathname);
setCurrentVideoUrl(videoUrl);
}, [location.pathname]);
const handleClick = () => {
if (currentVideoUrl) {
setShowPlayer(true);
} else {
// 如果没有对应的视频,可以显示提示
console.warn("当前路由没有对应的操作视频");
}
};
const handleClose = () => {
setShowPlayer(false);
};
// 如果没有视频URL不显示悬浮窗
if (!visible || !currentVideoUrl) {
return null;
}
return (
<>
<div
className={`${styles.floatingButton} ${className || ""}`}
onClick={handleClick}
title="查看操作视频"
>
<PlayCircleOutlined className={styles.icon} />
</div>
{showPlayer && currentVideoUrl && (
<VideoPlayer
videoUrl={currentVideoUrl}
visible={showPlayer}
onClose={handleClose}
/>
)}
</>
);
};
export default FloatingVideoHelp;

View File

@@ -0,0 +1,110 @@
/**
* 路由到视频URL的映射配置
* key: 路由路径(支持正则表达式)
* value: 视频URL
*/
interface VideoConfig {
[route: string]: string;
}
// 视频URL配置
const videoConfig: VideoConfig = {
// 首页
"/": "/videos/home.mp4",
"/mobile/home": "/videos/home.mp4",
// 工作台
"/workspace": "/videos/workspace.mp4",
"/workspace/auto-like": "/videos/auto-like-list.mp4",
"/workspace/auto-like/new": "/videos/auto-like-new.mp4",
"/workspace/auto-like/record": "/videos/auto-like-record.mp4",
"/workspace/auto-group": "/videos/auto-group-list.mp4",
"/workspace/auto-group/new": "/videos/auto-group-new.mp4",
"/workspace/group-push": "/videos/group-push-list.mp4",
"/workspace/group-push/new": "/videos/group-push-new.mp4",
"/workspace/moments-sync": "/videos/moments-sync-list.mp4",
"/workspace/moments-sync/new": "/videos/moments-sync-new.mp4",
"/workspace/ai-assistant": "/videos/ai-assistant.mp4",
"/workspace/ai-analyzer": "/videos/ai-analyzer.mp4",
"/workspace/traffic-distribution": "/videos/traffic-distribution-list.mp4",
"/workspace/traffic-distribution/new": "/videos/traffic-distribution-new.mp4",
"/workspace/contact-import": "/videos/contact-import-list.mp4",
"/workspace/contact-import/form": "/videos/contact-import-form.mp4",
"/workspace/ai-knowledge": "/videos/ai-knowledge-list.mp4",
"/workspace/ai-knowledge/new": "/videos/ai-knowledge-new.mp4",
// 我的
"/mobile/mine": "/videos/mine.mp4",
"/mobile/mine/devices": "/videos/devices.mp4",
"/mobile/mine/wechat-accounts": "/videos/wechat-accounts.mp4",
"/mobile/mine/content": "/videos/content.mp4",
"/mobile/mine/traffic-pool": "/videos/traffic-pool.mp4",
"/mobile/mine/recharge": "/videos/recharge.mp4",
"/mobile/mine/setting": "/videos/setting.mp4",
// 场景
"/mobile/scenarios": "/videos/scenarios.mp4",
"/mobile/scenarios/plan": "/videos/scenarios-plan.mp4",
};
/**
* 根据路由路径获取对应的视频URL
* @param routePath 当前路由路径
* @returns 视频URL如果没有匹配则返回 null
*/
export function getVideoUrlByRoute(routePath: string): string | null {
// 精确匹配
if (videoConfig[routePath]) {
return videoConfig[routePath];
}
// 模糊匹配(支持动态路由参数)
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like/edit/:id
const routeKeys = Object.keys(videoConfig);
for (const key of routeKeys) {
// 将配置中的 :id 等参数转换为正则表达式
const regexPattern = key.replace(/:\w+/g, "[^/]+");
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(routePath)) {
return videoConfig[key];
}
}
// 前缀匹配(作为兜底方案)
// 例如:/workspace/auto-like/edit/123 会匹配 /workspace/auto-like
const sortedKeys = routeKeys.sort((a, b) => b.length - a.length); // 按长度降序排列
for (const key of sortedKeys) {
if (routePath.startsWith(key)) {
return videoConfig[key];
}
}
return null;
}
/**
* 添加或更新视频配置
* @param route 路由路径
* @param videoUrl 视频URL
*/
export function setVideoConfig(route: string, videoUrl: string): void {
videoConfig[route] = videoUrl;
}
/**
* 批量添加视频配置
* @param config 视频配置对象
*/
export function setVideoConfigs(config: VideoConfig): void {
Object.assign(videoConfig, config);
}
/**
* 获取所有视频配置
* @returns 视频配置对象
*/
export function getAllVideoConfigs(): VideoConfig {
return { ...videoConfig };
}
export default videoConfig;