Add FloatingVideoHelp component to AppRouter for enhanced user assistance.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal file
101
Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx
Normal 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;
|
||||
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal file
56
Cunkebao/src/components/FloatingVideoHelp/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal file
68
Cunkebao/src/components/FloatingVideoHelp/index.tsx
Normal 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;
|
||||
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal file
110
Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts
Normal 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;
|
||||
Reference in New Issue
Block a user