diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss new file mode 100644 index 00000000..308c00d5 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.module.scss @@ -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; + } + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx new file mode 100644 index 00000000..a26e824a --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/VideoPlayer.tsx @@ -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 = ({ + videoUrl, + visible, + onClose, + title = "操作视频", +}) => { + const videoRef = useRef(null); + const containerRef = useRef(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) => { + // 如果点击的是遮罩层本身(不是视频容器),则关闭 + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + const handleClose = () => { + if (videoRef.current) { + videoRef.current.pause(); + } + onClose(); + }; + + // 阻止事件冒泡 + const handleContentClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + if (!visible) { + return null; + } + + return ( +
+
+
+ {title} + +
+
+ +
+
+
+ ); +}; + +export default VideoPlayer; diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.module.scss b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss new file mode 100644 index 00000000..0b35e258 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.module.scss @@ -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); + } +} diff --git a/Cunkebao/src/components/FloatingVideoHelp/index.tsx b/Cunkebao/src/components/FloatingVideoHelp/index.tsx new file mode 100644 index 00000000..a85ad480 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/index.tsx @@ -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 = ({ + visible = true, + className, +}) => { + const location = useLocation(); + const [showPlayer, setShowPlayer] = useState(false); + const [currentVideoUrl, setCurrentVideoUrl] = useState(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 ( + <> +
+ +
+ + {showPlayer && currentVideoUrl && ( + + )} + + ); +}; + +export default FloatingVideoHelp; diff --git a/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts new file mode 100644 index 00000000..35798056 --- /dev/null +++ b/Cunkebao/src/components/FloatingVideoHelp/videoConfig.ts @@ -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; diff --git a/Cunkebao/src/router/index.tsx b/Cunkebao/src/router/index.tsx index 117681a7..d52ba041 100644 --- a/Cunkebao/src/router/index.tsx +++ b/Cunkebao/src/router/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { BrowserRouter, useRoutes, RouteObject } from "react-router-dom"; import PermissionRoute from "./permissionRoute"; +import FloatingVideoHelp from "@/components/FloatingVideoHelp"; // 动态导入所有 module 下的 ts/tsx 路由模块 const modules = import.meta.glob("./module/*.{ts,tsx}", { eager: true }); @@ -43,6 +44,7 @@ const AppRouter: React.FC = () => ( }} > + );