Files
jiaowu-test/可试看功能实现文档.md
KQL 63f8cf2e7d chore: 迁移项目到新仓库并整理代码
- 更新多个组件的功能优化
- 整理简历映射数据
- 优化视频播放和面试模拟相关组件
- 更新就业策略和公司职位页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 18:42:25 +08:00

28 KiB
Raw Permalink Blame History

可试看功能实现文档

📋 目录

  1. 功能概述
  2. 技术架构
  3. 实现步骤
  4. 代码示例
  5. 样式规范
  6. 常见问题

功能概述

功能描述

"可试看"功能允许特定课程在未购买或未解锁的情况下通过iframe方式预览课程内容。该功能分为两个场景

  1. 课程直播间场景:在课程列表中显示可试看标签,点击后在视频播放器区域内嵌显示课程内容
  2. 课后作业场景:在作业卡片上显示可试看标签,点击后全屏显示课程内容

核心特性

  • 视觉标签:醒目的"可试看"标签提示
  • iframe内嵌无缝内嵌外部网页内容
  • 全屏支持:支持全屏观看模式
  • 缩放适配:自动缩放适配显示区域
  • 权限控制:精准控制哪些课程可试看

技术架构

架构流程图

┌─────────────────────────────────────────┐
│ 数据层 (Data Layer)                      │
│ ├─ canPreview: boolean                  │
│ ├─ previewUrl: string                   │
│ └─ isShowCase: boolean                  │
└─────────────────────────────────────────┘
                  ↓
┌─────────────────────────────────────────┐
│ 业务逻辑层 (Business Logic)              │
│ ├─ 判断是否可试看                        │
│ ├─ 控制UI展示状态                        │
│ └─ 处理点击事件                          │
└─────────────────────────────────────────┘
                  ↓
┌─────────────────────────────────────────┐
│ 展示层 (Presentation Layer)              │
│ ├─ 可试看标签组件                        │
│ ├─ iframe内嵌组件                        │
│ └─ 交互控制组件                          │
└─────────────────────────────────────────┘

技术栈

  • 前端框架: React 18+
  • UI组件库: Arco Design
  • 样式方案: CSS Modules / Less
  • 状态管理: React Hooks (useState, useRef)

实现步骤

步骤 1: 数据结构设计

1.1 课程直播数据结构

// 课程对象结构
const courseObj = {
  courseId: "course_001",
  courseName: "展会主题与品牌定位",
  teacherName: "张老师",
  date: "2025-09-15",
  unitName: "消费电子展品牌策划与执行",

  // 可试看相关字段
  canPreview: true,  // 是否可试看
  previewUrl: "https://example.com/preview"  // 试看页面URL
};

1.2 课后作业数据结构

// 作业对象结构
const homeworkItem = {
  id: 142,
  name: "展会主题与品牌定位",
  level: "completed",
  imageUrl: "https://example.com/poster.jpg",

  // 可试看相关字段
  isShowCase: true  // 是否可试看(作业场景)
};

步骤 2: 数据标记逻辑

2.1 在数据处理函数中添加标记

// 示例:处理课程数据时添加可试看标记
const processCourseData = (rawData) => {
  const courses = rawData.map(item => {
    const courseObj = {
      courseId: item.id,
      courseName: item.title,
      // ... 其他字段
    };

    // 为特定课程添加可试看标记
    if (shouldEnablePreview(item)) {
      courseObj.canPreview = true;
      courseObj.previewUrl = getPreviewUrl(item);
    }

    return courseObj;
  });

  return courses;
};

// 判断是否应该开启试看
const shouldEnablePreview = (item) => {
  // 示例:根据课程名称和单元名称判断
  return item.title === "展会主题与品牌定位" &&
         item.unitName === "消费电子展品牌策划与执行";
};

// 获取试看URL
const getPreviewUrl = (item) => {
  // 可以从配置文件、数据库或API获取
  const previewUrls = {
    "展会主题与品牌定位": "https://du9uay.github.io/zhanhui/"
  };
  return previewUrls[item.title] || "";
};

步骤 3: UI组件实现

3.1 可试看标签组件

// PreviewBadge.jsx
import React from 'react';
import './PreviewBadge.css';

const PreviewBadge = ({
  type = 'course',  // 'course' | 'homework' | 'unit'
  className = ''
}) => {
  const classNames = {
    course: 'preview-badge-course',
    homework: 'preview-badge-homework',
    unit: 'preview-badge-unit'
  };

  return (
    <span className={`preview-badge ${classNames[type]} ${className}`}>
      可试看
    </span>
  );
};

export default PreviewBadge;

PreviewBadge.css

.preview-badge {
  display: inline-block;
  font-weight: 600;
  white-space: nowrap;
  border-radius: 12px;
}

/* 课程列表中的标签 */
.preview-badge-course {
  position: absolute;
  left: 50%;
  top: 60%;
  transform: translate(-50%, -50%);
  padding: 4px 12px;
  background: #4080ff;
  color: #fff;
  font-size: 12px;
  z-index: 10;
  box-shadow: 0 3px 8px rgba(64, 128, 255, 0.3);
}

/* 作业卡片上的标签 */
.preview-badge-homework {
  padding: 2px 8px;
  font-size: 11px;
  color: #ff8c00;
  background: rgba(255, 140, 0, 0.1);
  border: 1px solid rgba(255, 140, 0, 0.3);
  margin-top: 2px;
}

/* 单元标题上的标签 */
.preview-badge-unit {
  margin-left: 8px;
  padding: 2px 8px;
  background: #4080ff;
  color: #fff;
  font-size: 11px;
}

3.2 iframe播放器组件

// IframePlayer.jsx
import React, { useState, useRef, useEffect } from 'react';
import './IframePlayer.css';

const IframePlayer = ({
  url,
  title = '课程预览',
  zoom = 0.5,
  onClose
}) => {
  const [isFullscreen, setIsFullscreen] = useState(false);
  const containerRef = useRef(null);

  // 全屏切换
  const handleFullscreen = () => {
    const container = containerRef.current;
    if (!container) return;

    if (!isFullscreen) {
      // 进入全屏
      if (container.requestFullscreen) {
        container.requestFullscreen();
      } else if (container.webkitRequestFullscreen) {
        container.webkitRequestFullscreen();
      } else if (container.mozRequestFullScreen) {
        container.mozRequestFullScreen();
      }
    } else {
      // 退出全屏
      if (document.exitFullscreen) {
        document.exitFullscreen();
      } else if (document.webkitExitFullscreen) {
        document.webkitExitFullscreen();
      } else if (document.mozCancelFullScreen) {
        document.mozCancelFullScreen();
      }
    }
  };

  // 监听全屏状态变化
  useEffect(() => {
    const handleFullscreenChange = () => {
      setIsFullscreen(
        document.fullscreenElement === containerRef.current ||
        document.webkitFullscreenElement === containerRef.current ||
        document.mozFullScreenElement === containerRef.current
      );
    };

    document.addEventListener('fullscreenchange', handleFullscreenChange);
    document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
    document.addEventListener('mozfullscreenchange', handleFullscreenChange);

    return () => {
      document.removeEventListener('fullscreenchange', handleFullscreenChange);
      document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
      document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
    };
  }, []);

  return (
    <div
      ref={containerRef}
      className="iframe-player-container"
    >
      <iframe
        src={url}
        title={title}
        className="iframe-player-content"
        style={{
          zoom: isFullscreen ? 1 : zoom
        }}
        allowFullScreen
      />

      {/* 全屏按钮 */}
      <button
        className="iframe-player-fullscreen-btn"
        onClick={handleFullscreen}
        title={isFullscreen ? "退出全屏" : "全屏"}
      >
        {isFullscreen ? (
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
          </svg>
        ) : (
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
          </svg>
        )}
      </button>
    </div>
  );
};

export default IframePlayer;

IframePlayer.css

.iframe-player-container {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #000;
  border-radius: 8px;
  overflow: hidden;
}

.iframe-player-content {
  width: 100%;
  height: 100%;
  border: none;
}

.iframe-player-fullscreen-btn {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 40px;
  height: 40px;
  border-radius: 8px;
  border: none;
  background-color: rgba(0, 0, 0, 0.6);
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  transition: all 0.3s;
  z-index: 10;
}

.iframe-player-fullscreen-btn:hover {
  background-color: rgba(0, 0, 0, 0.8);
  transform: scale(1.05);
}

/* 全屏模式下的样式 */
.iframe-player-container:fullscreen {
  border-radius: 0;
}

3.3 全屏iframe页面组件

// FullscreenIframePage.jsx
import React from 'react';
import { IconArrowLeft } from '@arco-design/web-react/icon';
import './FullscreenIframePage.css';

const FullscreenIframePage = ({
  url,
  title = '课程预览',
  onBack,
  zoom = 0.8
}) => {
  return (
    <div className="fullscreen-iframe-wrapper">
      <div className="fullscreen-iframe-header">
        <button
          className="fullscreen-iframe-back-btn"
          onClick={onBack}
        >
          <IconArrowLeft style={{ marginRight: '8px' }} />
          返回
        </button>
        <span className="fullscreen-iframe-title">{title}</span>
      </div>
      <iframe
        src={url}
        className="fullscreen-iframe-content"
        title={title}
        style={{ zoom }}
      />
    </div>
  );
};

export default FullscreenIframePage;

FullscreenIframePage.css

.fullscreen-iframe-wrapper {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #fff;
}

.fullscreen-iframe-header {
  height: 60px;
  padding: 0 20px;
  display: flex;
  align-items: center;
  border-bottom: 1px solid #e5e6eb;
  background-color: #fff;
  position: relative;
}

.fullscreen-iframe-back-btn {
  display: flex;
  align-items: center;
  padding: 8px 16px;
  background: linear-gradient(135deg, #2c7aff 0%, #4096ff 100%);
  color: #fff;
  border: none;
  border-radius: 4px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s ease;
}

.fullscreen-iframe-back-btn:hover {
  background: linear-gradient(135deg, #4096ff 0%, #69b1ff 100%);
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(44, 122, 255, 0.3);
}

.fullscreen-iframe-title {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  font-size: 18px;
  font-weight: 600;
  color: #1d2129;
}

.fullscreen-iframe-content {
  flex: 1;
  width: 100%;
  border: none;
}

步骤 4: 场景集成

4.1 课程直播场景集成

// CourseLiveExample.jsx
import React, { useState } from 'react';
import PreviewBadge from './PreviewBadge';
import IframePlayer from './IframePlayer';

const CourseLiveExample = () => {
  const [selectedCourse, setSelectedCourse] = useState(null);

  // 示例课程数据
  const courses = [
    {
      id: 1,
      name: "展会主题与品牌定位",
      canPreview: true,
      previewUrl: "https://du9uay.github.io/zhanhui/"
    },
    {
      id: 2,
      name: "展区规划与动线设计",
      canPreview: false
    }
  ];

  return (
    <div className="course-live-container">
      {/* 课程列表 */}
      <div className="course-list">
        {courses.map(course => (
          <div
            key={course.id}
            className="course-item"
            onClick={() => setSelectedCourse(course)}
          >
            <span>{course.name}</span>
            {course.canPreview && <PreviewBadge type="course" />}
          </div>
        ))}
      </div>

      {/* 视频播放区 */}
      <div className="video-player">
        {selectedCourse ? (
          selectedCourse.canPreview && selectedCourse.previewUrl ? (
            <IframePlayer
              url={selectedCourse.previewUrl}
              title={selectedCourse.name}
              zoom={0.5}
            />
          ) : (
            <div className="locked-view">
              <img src="/lock-icon.png" alt="锁定" />
              <span>非学员无查看权限</span>
            </div>
          )
        ) : (
          <div className="empty-view">请选择课程</div>
        )}
      </div>
    </div>
  );
};

export default CourseLiveExample;

4.2 课后作业场景集成

// HomeworkExample.jsx
import React, { useState } from 'react';
import PreviewBadge from './PreviewBadge';
import FullscreenIframePage from './FullscreenIframePage';

const HomeworkExample = () => {
  const [showIframe, setShowIframe] = useState(false);
  const [selectedHomework, setSelectedHomework] = useState(null);

  // 示例作业数据
  const homeworks = [
    {
      id: 1,
      name: "展会主题与品牌定位",
      imageUrl: "/homework-poster.jpg",
      isShowCase: true,
      previewUrl: "https://du9uay.github.io/zhanhui/#/course-test"
    },
    {
      id: 2,
      name: "展区规划与动线设计",
      imageUrl: "/homework-poster2.jpg",
      isShowCase: false
    }
  ];

  const handleClick = (homework) => {
    if (homework.isShowCase) {
      setSelectedHomework(homework);
      setShowIframe(true);
    }
  };

  // 显示全屏iframe
  if (showIframe && selectedHomework) {
    return (
      <FullscreenIframePage
        url={selectedHomework.previewUrl}
        title={selectedHomework.name}
        onBack={() => setShowIframe(false)}
        zoom={0.8}
      />
    );
  }

  // 显示作业列表
  return (
    <div className="homework-container">
      {homeworks.map(homework => (
        <div key={homework.id} className="homework-card">
          <img src={homework.imageUrl} alt={homework.name} />
          <p>{homework.name}</p>
          <div className="homework-action">
            <button
              className={homework.isShowCase ? 'completed' : 'disabled'}
              onClick={() => handleClick(homework)}
              disabled={!homework.isShowCase}
            >
              已完成
            </button>
            {homework.isShowCase && <PreviewBadge type="homework" />}
          </div>
        </div>
      ))}
    </div>
  );
};

export default HomeworkExample;

代码示例

完整示例:课程列表组件

// CourseList.jsx - 完整实现
import React, { useState } from 'react';
import { Collapse, Timeline } from '@arco-design/web-react';
import './CourseList.css';

const CourseList = ({ courses, onCourseClick }) => {
  const [activeKeys, setActiveKeys] = useState([]);

  return (
    <div className="course-list-wrapper">
      <Collapse
        activeKey={activeKeys}
        onChange={setActiveKeys}
      >
        {courses.map((unit, index) => {
          // 检查单元是否包含可试看课程
          const hasPreviewCourse = unit.courses.some(c => c.canPreview);

          return (
            <Collapse.Item
              key={unit.id}
              name={String(index + 1)}
              header={
                <div className="unit-header">
                  {unit.name}
                  {hasPreviewCourse && (
                    <span className="preview-badge-unit">可试看</span>
                  )}
                </div>
              }
              className={hasPreviewCourse ? 'has-preview-unit' : ''}
            >
              <Timeline>
                {unit.courses.map(course => (
                  <Timeline.Item key={course.id}>
                    <div
                      className={`course-item ${course.canPreview ? 'has-preview' : ''}`}
                      onClick={() => onCourseClick(course)}
                    >
                      {course.canPreview && (
                        <span className="preview-badge-course">可试看</span>
                      )}
                      <p>{course.name}</p>
                      <div className="course-info">
                        <span>{course.teacher}</span>
                        <span>{course.date}</span>
                      </div>
                    </div>
                  </Timeline.Item>
                ))}
              </Timeline>
            </Collapse.Item>
          );
        })}
      </Collapse>
    </div>
  );
};

export default CourseList;

CourseList.css

.course-list-wrapper {
  width: 100%;
  background: #fff;
  border-radius: 8px;
  padding: 20px;
}

/* 单元标题样式 */
.unit-header {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* 包含可试看课程的单元样式 */
.has-preview-unit .arco-collapse-item-header {
  background: linear-gradient(135deg, #f0f7ff 0%, #e8f3ff 100%);
  border: 1px solid #b3d4ff;
  box-shadow: 0 2px 8px rgba(64, 128, 255, 0.1);
}

/* 课程项样式 */
.course-item {
  position: relative;
  padding: 10px;
  background: #f2f3f5;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.course-item:hover {
  background: #e8f3ff;
  transform: translateX(4px);
}

.course-item.has-preview {
  border: 1px dashed #4080ff;
}

.course-item p {
  font-size: 14px;
  font-weight: 600;
  color: #1d2129;
  margin: 0 0 10px 0;
}

.course-info {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #86909c;
}

样式规范

颜色规范

/* 可试看标签颜色 */
--preview-badge-bg: #4080ff;           /* 蓝色背景 */
--preview-badge-color: #ffffff;        /* 白色文字 */
--preview-badge-homework: #ff8c00;     /* 橙色(作业场景) */

/* 可试看单元背景 */
--preview-unit-bg-start: #f0f7ff;
--preview-unit-bg-end: #e8f3ff;
--preview-unit-border: #b3d4ff;

/* 按钮状态 */
--btn-completed-bg: linear-gradient(135deg, #2c7aff 0%, #4096ff 100%);
--btn-disabled-bg: #f5f5f5;
--btn-disabled-color: #c9cdd4;

尺寸规范

/* 标签尺寸 */
--badge-course-padding: 4px 12px;
--badge-homework-padding: 2px 8px;
--badge-unit-padding: 2px 8px;

--badge-font-size-normal: 12px;
--badge-font-size-small: 11px;
--badge-border-radius: 12px;

/* iframe容器 */
--iframe-header-height: 60px;
--iframe-fullscreen-btn-size: 40px;

动画效果

/* 标签脉冲动画 */
@keyframes badge-pulse {
  0%, 100% {
    transform: scale(1);
    box-shadow: 0 3px 8px rgba(64, 128, 255, 0.3);
  }
  50% {
    transform: scale(1.05);
    box-shadow: 0 4px 12px rgba(64, 128, 255, 0.5);
  }
}

.preview-badge-course {
  animation: badge-pulse 2s ease-in-out infinite;
}

/* 按钮悬停效果 */
.completed-btn {
  transition: all 0.3s ease;
}

.completed-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(44, 122, 255, 0.3);
}

可试看配置表(数据库设计)

CREATE TABLE preview_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  resource_type VARCHAR(20) NOT NULL COMMENT '资源类型course/homework',
  resource_id VARCHAR(50) NOT NULL COMMENT '资源ID',
  is_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用试看',
  preview_url VARCHAR(500) COMMENT '试看链接',
  zoom_level DECIMAL(3,2) DEFAULT 0.5 COMMENT '缩放比例',
  allow_fullscreen BOOLEAN DEFAULT TRUE COMMENT '是否允许全屏',
  expires_at DATETIME COMMENT '试看过期时间',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_resource (resource_type, resource_id)
);

配置示例数据

INSERT INTO preview_config (resource_type, resource_id, is_enabled, preview_url, zoom_level) VALUES
('course', 'course_001', TRUE, 'https://du9uay.github.io/zhanhui/', 0.5),
('homework', 'homework_142', TRUE, 'https://du9uay.github.io/zhanhui/#/course-test', 0.8);

常见问题

Q1: iframe 内容不显示或加载失败?

可能原因:

  • 目标网站设置了 X-Frame-Options 禁止被嵌套
  • HTTPS 网站嵌套 HTTP 内容被浏览器阻止
  • 网络问题或 URL 错误

解决方案:

// 1. 检查目标网站是否允许iframe嵌套
// 2. 确保URL协议一致都使用HTTPS
// 3. 添加错误处理和加载状态

const [loadError, setLoadError] = useState(false);
const [loading, setLoading] = useState(true);

<iframe
  src={url}
  onLoad={() => setLoading(false)}
  onError={() => {
    setLoadError(true);
    setLoading(false);
  }}
/>

{loadError && <div className="error-message">内容加载失败</div>}
{loading && <div className="loading">加载中...</div>}

Q2: 如何控制不同用户的试看权限?

解决方案:

// 在数据请求时根据用户权限返回不同的数据
const fetchCourses = async (userId) => {
  const response = await api.get('/courses/list', {
    params: { userId }
  });

  // 后端根据用户VIP等级、购买记录等判断是否可试看
  return response.data;
};

// 前端也可以做二次验证
const canUserPreview = (course, userInfo) => {
  // VIP用户可以试看所有课程
  if (userInfo.isVIP) return true;

  // 已购买用户可以观看完整内容
  if (course.isPurchased) return true;

  // 其他用户只能试看标记为canPreview的课程
  return course.canPreview;
};

Q3: 如何限制试看时长?

解决方案:

// 添加试看时长限制
const IframePlayerWithTimeLimit = ({ url, maxDuration = 300 }) => {
  const [remainingTime, setRemainingTime] = useState(maxDuration);
  const [expired, setExpired] = useState(false);

  useEffect(() => {
    if (remainingTime <= 0) {
      setExpired(true);
      return;
    }

    const timer = setInterval(() => {
      setRemainingTime(prev => prev - 1);
    }, 1000);

    return () => clearInterval(timer);
  }, [remainingTime]);

  if (expired) {
    return (
      <div className="trial-expired">
        <p>试看时间已结束</p>
        <button onClick={() => handlePurchase()}>立即购买完整课程</button>
      </div>
    );
  }

  return (
    <div>
      <div className="time-remaining">
        剩余试看时间: {Math.floor(remainingTime / 60)}:{remainingTime % 60}
      </div>
      <iframe src={url} />
    </div>
  );
};

Q4: 如何优化iframe加载性能

解决方案:

// 1. 懒加载iframe
const [shouldLoadIframe, setShouldLoadIframe] = useState(false);

<div onClick={() => setShouldLoadIframe(true)}>
  {shouldLoadIframe ? (
    <iframe src={url} />
  ) : (
    <div className="preview-placeholder">
      <img src={posterUrl} />
      <button>点击播放</button>
    </div>
  )}
</div>

// 2. 预加载关键资源
<link rel="preconnect" href="https://du9uay.github.io" />

// 3. 使用 loading="lazy" 属性(现代浏览器支持)
<iframe src={url} loading="lazy" />

// 4. 添加缓存策略
const getCachedPreviewUrl = (courseId) => {
  const cached = sessionStorage.getItem(`preview_${courseId}`);
  if (cached) return cached;

  const url = fetchPreviewUrl(courseId);
  sessionStorage.setItem(`preview_${courseId}`, url);
  return url;
};

Q5: 如何追踪用户的试看行为?

解决方案:

// 添加试看行为追踪
const trackPreviewBehavior = (eventType, data) => {
  api.post('/api/analytics/preview', {
    eventType,  // 'start', 'end', 'fullscreen', 'exit'
    courseId: data.courseId,
    userId: data.userId,
    duration: data.duration,
    timestamp: new Date().toISOString()
  });
};

// 在组件中使用
useEffect(() => {
  const startTime = Date.now();

  // 记录开始试看
  trackPreviewBehavior('start', { courseId, userId });

  return () => {
    // 记录结束试看
    const duration = Math.floor((Date.now() - startTime) / 1000);
    trackPreviewBehavior('end', { courseId, userId, duration });
  };
}, [courseId, userId]);

// 监听全屏事件
const handleFullscreenChange = () => {
  if (document.fullscreenElement) {
    trackPreviewBehavior('fullscreen', { courseId, userId });
  }
};

Q6: 如何实现试看内容的水印?

解决方案:

// 添加水印层覆盖在iframe上
const IframeWithWatermark = ({ url, userInfo }) => {
  return (
    <div className="iframe-container">
      <iframe src={url} />
      <div className="watermark-layer">
        <span className="watermark-text">
          试看用户{userInfo.name} | {userInfo.id}
        </span>
      </div>
    </div>
  );
};

CSS

.iframe-container {
  position: relative;
}

.watermark-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 1000;
}

.watermark-text {
  position: absolute;
  bottom: 20px;
  right: 20px;
  padding: 4px 12px;
  background: rgba(0, 0, 0, 0.5);
  color: rgba(255, 255, 255, 0.6);
  font-size: 12px;
  border-radius: 4px;
}

最佳实践

1. 安全性考虑

// 验证预览URL的合法性
const isValidPreviewUrl = (url) => {
  const allowedDomains = [
    'du9uay.github.io',
    'example.com'
  ];

  try {
    const urlObj = new URL(url);
    return allowedDomains.some(domain => urlObj.hostname.includes(domain));
  } catch {
    return false;
  }
};

// 使用时验证
if (course.canPreview && isValidPreviewUrl(course.previewUrl)) {
  // 显示iframe
}

2. 用户体验优化

// 添加loading状态
const [iframeLoading, setIframeLoading] = useState(true);

<div className="iframe-wrapper">
  {iframeLoading && (
    <div className="iframe-loading">
      <Spin tip="课程加载中..." />
    </div>
  )}
  <iframe
    src={url}
    onLoad={() => setIframeLoading(false)}
    style={{ opacity: iframeLoading ? 0 : 1 }}
  />
</div>

3. 响应式设计

/* 移动端适配 */
@media (max-width: 768px) {
  .iframe-player-container {
    border-radius: 0;
  }

  .preview-badge-course {
    font-size: 10px;
    padding: 2px 8px;
  }

  .fullscreen-iframe-header {
    height: 50px;
    padding: 0 12px;
  }
}

4. 错误边界处理

// 错误边界组件
class PreviewErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Preview error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="preview-error">
          <p>内容加载出错</p>
          <button onClick={() => window.location.reload()}>
            刷新重试
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 使用
<PreviewErrorBoundary>
  <IframePlayer url={previewUrl} />
</PreviewErrorBoundary>

总结

通过以上文档,你可以快速在其他系统中复用可试看功能。核心要点:

  1. 数据层:添加 canPreviewpreviewUrl 等字段标记
  2. 组件层:封装可复用的标签组件和播放器组件
  3. 样式层:遵循统一的设计规范和颜色体系
  4. 安全性验证URL合法性、控制权限、追踪行为
  5. 用户体验loading状态、错误处理、响应式设计

如有问题,请参考常见问题章节或联系开发团队。


文档版本: v1.0 最后更新: 2025-01-15 维护者: 教务系统前端团队