Java 视频在线播放功能案例

Java 视频在线播放功能教程,包含播放历史记录功能。使用 Spring Boot + Vue.js 技术栈,实现一个简单的视频播放系统。

功能点

  • 断点续播 - 自动记录上次观看位置,支持继续观看提示
  • 多设备同步 - 通过 WebSocket 实现播放进度实时同步
  • 观看历史 - 完整的观看记录管理,支持进度可视化
  • 智能完成检测 - 观看 95% 自动标记为已完成

技术架构

复制代码
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   前端 (Vue3)    │ ←→ │  Spring Boot   │ ←→ │   MySQL/Redis    │
│  Video.js播放器  │     │   REST API     │     │  视频元数据/进度  │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                              ↓
                        ┌─────────────────┐
                        │  视频存储 (OSS)  │
                        │ 或本地文件系统    │
                        └─────────────────┘
  • 高性能 - Redis 缓存 + 异步批量写入 MySQL,支持高并发
  • 防抖保存 - 前端 500ms 防抖,避免频繁请求
  • 乐观锁 - 数据库版本号控制,防止并发更新冲突
  • 断点续传 - HTTP 206 支持视频拖动跳转

环境准备

  • 后端
    • Spring Boot :3.2.0、MyBatis-Plus :3.5.5、MySQL Connector :8.2.0、JDK :17、Redis Server:7.2.x 或 7.4.x
  • 前端
    • Vue :3.3.8、Vue Router :4.2.5、Vite :5.0.0、Axios :1.6.2、Node.js:18.x+

1. 后端实现 (Spring Boot)

完整项目结构

复制代码
video-player-mp/
├── src/main/java/com/example/videoplayer/
│   ├── VideoPlayerApplication.java
│   ├── config/
│   │   ├── MyBatisPlusConfig.java
│   │   ├── WebConfig.java
│   │   └── WebSocketConfig.java
│   ├── controller/
│   │   ├── VideoController.java
│   │   └── VideoProgressController.java
│   ├── dto/
│   │   ├── ProgressUpdateRequest.java
│   │   ├── VideoHistoryDTO.java
│   │   └── ApiResponse.java
│   ├── entity/
│   │   ├── Video.java
│   │   └── VideoProgress.java
│   ├── handler/
│   │   └── MyMetaObjectHandler.java
│   ├── interceptor/
│   │   └── AuthInterceptor.java
│   ├── mapper/
│   │   ├── VideoMapper.java
│   │   └── VideoProgressMapper.java
│   ├── service/
│   │   ├── VideoService.java
│   │   └── VideoProgressService.java
│   └── websocket/
│       └── VideoProgressWebSocketHandler.java
├── src/main/resources/
│   ├── application.yml
│   ├── mapper/
│   │   └── VideoProgressMapper.xml
│   └── static/ (前端文件)
└── pom.xml

项目依赖 (pom.xml)

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>video-player-mp</artifactId>
    <version>1.0.0</version>
    
    <properties>
        <java.version>17</java.version>
        <mybatis-plus.version>3.5.5</mybatis-plus.version>
    </properties>
    
    <dependencies>
        <!-- Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- MyBatis-Plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        
        <!-- MyBatis-Plus 分页插件(必须) -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-jsqlparser</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        
        <!-- MySQL -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- 连接池(推荐HikariCP,SpringBoot默认) -->
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
        </dependency>
        
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        
        <!-- WebSocket -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        
        <!-- Validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

yaml配置

复制代码
# resources/application.yml
server:
  port: 8080

spring:
  application:
    name: video-player-mp
  
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/video_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: your_password
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
  
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 5000
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
  
  servlet:
    multipart:
      max-file-size: 500MB
      max-request-size: 500MB

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    map-underscore-to-camel-case: true
    cache-enabled: true
  global-config:
    db-config:
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0
      id-type: auto
  mapper-locations: classpath*:/mapper/**/*.xml
  type-aliases-package: com.example.videoplayer.entity

video:
  storage:
    path: /var/www/videos

logging:
  level:
    com.example.videoplayer: DEBUG

1.1 启动类与配置

复制代码
// VideoPlayerApplication.java
package com.example.videoplayer;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@MapperScan("com.example.videoplayer.mapper")
@EnableCaching
@EnableScheduling
public class VideoPlayerApplication {
    public static void main(String[] args) {
        SpringApplication.run(VideoPlayerApplication.class, args);
    }
}

// config/MyBatisPlusConfig.java
package com.example.videoplayer.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
@MapperScan("com.example.videoplayer.mapper")
public class MyBatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 分页插件(必须放在最前面)
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        
        // 乐观锁插件(并发更新播放进度时用)
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        
        return interceptor;
    }
}

// config/WebConfig.java
package com.example.videoplayer.config;

import com.example.videoplayer.interceptor.AuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .maxAge(3600);
    }
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor())
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/videos/stream/**", "/api/auth/**");
    }
    
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/uploads/**")
                .addResourceLocations("file:${video.storage.path}/");
    }
}

// config/WebSocketConfig.java
package com.example.videoplayer.config;

import com.example.videoplayer.websocket.VideoProgressWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new VideoProgressWebSocketHandler(), "/ws/video-progress")
                .setAllowedOrigins("*");
    }
}

1.2 实体类

复制代码
// entity/Video.java
package com.example.videoplayer.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@TableName("videos")
public class Video {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("title")
    private String title;
    
    @TableField("description")
    private String description;
    
    @TableField("file_path")
    private String filePath;
    
    @TableField("thumbnail_url")
    private String thumbnailUrl;
    
    @TableField("duration")
    private Long duration;
    
    @TableField("resolution")
    private String resolution;
    
    @TableField("status")
    private VideoStatus status;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(value = "created_at", fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(value = "updated_at", fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;
    
    @TableLogic
    @TableField("deleted")
    private Integer deleted;
    
    public enum VideoStatus {
        ACTIVE, INACTIVE, DELETED
    }
}

// entity/VideoProgress.java
package com.example.videoplayer.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDateTime;
import java.util.Map;

@Data
@TableName("video_progress")
public class VideoProgress {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    @TableField("user_id")
    private Long userId;
    
    @TableField("video_id")
    private Long videoId;
    
    @TableField("current_time")
    private Long currentTime;
    
    @TableField("total_duration")
    private Long totalDuration;
    
    @TableField("completed")
    private Boolean completed;
    
    @TableField("watch_count")
    private Integer watchCount;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField("last_watched_at")
    private LocalDateTime lastWatchedAt;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(value = "created_at", fill = FieldFill.INSERT)
    private LocalDateTime createdAt;
    
    // 乐观锁版本号(防止并发更新冲突)
    @Version
    @TableField("version")
    private Integer version;
    
    // 扩展参数(关联查询时存放额外字段)
    @TableField(exist = false)
    private Map<String, Object> param;
    
    // 计算观看百分比
    public Integer getProgressPercentage() {
        if (totalDuration == null || totalDuration == 0) return 0;
        return (int) ((currentTime * 100) / totalDuration);
    }
}

1.3 DTO 类

复制代码
// dto/ProgressUpdateRequest.java
package com.example.videoplayer.dto;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import lombok.Data;

@Data
public class ProgressUpdateRequest {
    
    @NotNull(message = "视频ID不能为空")
    private Long videoId;
    
    @NotNull(message = "当前播放时间不能为空")
    @Min(value = 0, message = "播放时间不能为负数")
    private Long currentTime;
    
    @Min(value = 0, message = "总时长不能为负数")
    private Long duration;
    
    private Boolean completed = false;
}

// dto/VideoHistoryDTO.java
package com.example.videoplayer.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class VideoHistoryDTO {
    private Long videoId;
    private String title;
    private String thumbnailUrl;
    private Long currentTime;
    private Long duration;
    private Integer progressPercentage;
    private Boolean completed;
    
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime lastWatchedAt;
    
    private String formattedTime;
    private String lastWatchedText;
    private String filePath;
}

// dto/ApiResponse.java
package com.example.videoplayer.dto;

import lombok.Data;

@Data
public class ApiResponse<T> {
    private Integer code;
    private String message;
    private T data;
    private Long timestamp;
    
    public ApiResponse() {
        this.timestamp = System.currentTimeMillis();
    }
    
    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.setCode(200);
        response.setMessage("success");
        response.setData(data);
        return response;
    }
    
    public static <T> ApiResponse<T> error(String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.setCode(500);
        response.setMessage(message);
        return response;
    }
    
    public static <T> ApiResponse<T> error(Integer code, String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.setCode(code);
        response.setMessage(message);
        return response;
    }
}

1.4 自动填充处理器

复制代码
// handler/MyMetaObjectHandler.java
package com.example.videoplayer.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    
    @Override
    public void insertFill(MetaObject metaObject) {
        log.debug("开始插入填充...");
        
        LocalDateTime now = LocalDateTime.now();
        
        // 创建时间
        this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
        // 更新时间
        this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
        // 最后观看时间
        this.strictInsertFill(metaObject, "lastWatchedAt", LocalDateTime.class, now);
        // 观看次数
        this.strictInsertFill(metaObject, "watchCount", Integer.class, 1);
        // 是否完成
        this.strictInsertFill(metaObject, "completed", Boolean.class, false);
        // 版本号(乐观锁)
        this.strictInsertFill(metaObject, "version", Integer.class, 1);
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        log.debug("开始更新填充...");
        
        LocalDateTime now = LocalDateTime.now();
        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, now);
        this.strictUpdateFill(metaObject, "lastWatchedAt", LocalDateTime.class, now);
    }
}

1.5 Mapper 接口

复制代码
// mapper/VideoMapper.java
package com.example.videoplayer.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.videoplayer.entity.Video;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import java.util.List;

@Mapper
public interface VideoMapper extends BaseMapper<Video> {
    
    /**
     * 自定义搜索分页(带视频统计信息)
     */
    @Select("SELECT v.*, " +
            "(SELECT COUNT(*) FROM video_progress WHERE video_id = v.id) as play_count " +
            "FROM videos v " +
            "WHERE v.deleted = 0 AND v.status = 'ACTIVE' AND " +
            "(v.title LIKE CONCAT('%', #{keyword}, '%') OR v.description LIKE CONCAT('%', #{keyword}, '%')) " +
            "ORDER BY v.created_at DESC")
    IPage<Video> searchVideos(Page<Video> page, @Param("keyword") String keyword);
    
    /**
     * 查询热门视频(按观看次数排序)
     */
    @Select("SELECT v.*, COUNT(vp.id) as view_count " +
            "FROM videos v " +
            "LEFT JOIN video_progress vp ON v.id = vp.video_id " +
            "WHERE v.deleted = 0 AND v.status = 'ACTIVE' " +
            "GROUP BY v.id " +
            "ORDER BY view_count DESC " +
            "LIMIT #{limit}")
    List<Video> selectHotVideos(@Param("limit") int limit);
    
    /**
     * 增加播放次数(原子更新)
     */
    @Update("UPDATE videos SET play_count = play_count + 1 WHERE id = #{videoId}")
    int incrementPlayCount(@Param("videoId") Long videoId);
    
    /**
     * 根据ID列表批量查询
     */
    List<Video> selectByIds(@Param("ids") List<Long> ids);
}

// mapper/VideoProgressMapper.java
package com.example.videoplayer.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.videoplayer.entity.VideoProgress;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

import java.time.LocalDateTime;
import java.util.List;

@Mapper
public interface VideoProgressMapper extends BaseMapper<VideoProgress> {
    
    /**
     * 查询用户的特定视频进度(带视频信息)
     */
    @Select("SELECT vp.*, v.title, v.thumbnail_url, v.duration as video_duration, v.file_path " +
            "FROM video_progress vp " +
            "LEFT JOIN videos v ON vp.video_id = v.id " +
            "WHERE vp.user_id = #{userId} AND vp.video_id = #{videoId}")
    VideoProgress selectProgressWithVideo(@Param("userId") Long userId, 
                                          @Param("videoId") Long videoId);
    
    /**
     * 获取用户观看历史(分页,带视频信息)
     */
    @Select("SELECT vp.*, v.title, v.thumbnail_url, v.file_path " +
            "FROM video_progress vp " +
            "LEFT JOIN videos v ON vp.video_id = v.id " +
            "WHERE vp.user_id = #{userId} " +
            "ORDER BY vp.last_watched_at DESC")
    IPage<VideoProgress> selectHistoryPage(Page<VideoProgress> page, 
                                           @Param("userId") Long userId);
    
    /**
     * 获取最近观看的视频ID列表
     */
    @Select("SELECT video_id FROM video_progress " +
            "WHERE user_id = #{userId} " +
            "ORDER BY last_watched_at DESC " +
            "LIMIT #{limit}")
    List<Long> selectRecentVideoIds(@Param("userId") Long userId, 
                                    @Param("limit") int limit);
    
    /**
     * 原子性更新进度(使用乐观锁)
     */
    @Update("UPDATE video_progress " +
            "SET current_time = #{currentTime}, " +
            "    last_watched_at = #{lastWatchedAt}, " +
            "    watch_count = watch_count + 1, " +
            "    completed = #{completed}, " +
            "    version = version + 1 " +
            "WHERE user_id = #{userId} AND video_id = #{videoId}")
    int updateProgressAtomic(@Param("userId") Long userId,
                            @Param("videoId") Long videoId,
                            @Param("currentTime") Long currentTime,
                            @Param("lastWatchedAt") LocalDateTime lastWatchedAt,
                            @Param("completed") Boolean completed);
    
    /**
     * 获取未完成的最近观看记录(继续观看功能)
     */
    @Select("SELECT vp.*, v.title, v.thumbnail_url, v.file_path " +
            "FROM video_progress vp " +
            "LEFT JOIN videos v ON vp.video_id = v.id " +
            "WHERE vp.user_id = #{userId} " +
            "AND vp.completed = false " +
            "AND vp.current_time > 10 " +
            "ORDER BY vp.last_watched_at DESC " +
            "LIMIT 1")
    VideoProgress selectContinueWatching(@Param("userId") Long userId);
    
    /**
     * 批量插入或更新(MySQL ON DUPLICATE KEY UPDATE)
     */
    int upsertBatch(@Param("list") List<VideoProgress> list);
    
    /**
     * 获取用户观看统计
     */
    @Select("SELECT " +
            "COUNT(*) as total_count, " +
            "SUM(CASE WHEN completed = true THEN 1 ELSE 0 END) as completed_count, " +
            "SUM(current_time) as total_watch_time " +
            "FROM video_progress " +
            "WHERE user_id = #{userId}")
    Map<String, Object> selectUserStats(@Param("userId") Long userId);
}

1.6 XML Mapper

复制代码
<!-- resources/mapper/VideoMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.videoplayer.mapper.VideoMapper">
    
    <select id="selectByIds" resultType="com.example.videoplayer.entity.Video">
        SELECT * FROM videos 
        WHERE id IN 
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
        AND deleted = 0
    </select>
</mapper>

<!-- resources/mapper/VideoProgressMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.videoplayer.mapper.VideoProgressMapper">
    
    <!-- 批量插入或更新(MySQL语法) -->
    <insert id="upsertBatch">
        INSERT INTO video_progress 
        (user_id, video_id, current_time, total_duration, completed, watch_count, last_watched_at, created_at, version)
        VALUES
        <foreach collection="list" item="item" separator=",">
            (#{item.userId}, #{item.videoId}, #{item.currentTime}, #{item.totalDuration}, 
             #{item.completed}, #{item.watchCount}, #{item.lastWatchedAt}, #{item.createdAt}, #{item.version})
        </foreach>
        ON DUPLICATE KEY UPDATE
        current_time = VALUES(current_time),
        total_duration = VALUES(total_duration),
        completed = VALUES(completed),
        watch_count = video_progress.watch_count + 1,
        last_watched_at = VALUES(last_watched_at),
        version = video_progress.version + 1
    </insert>
    
    <!-- 复杂条件查询示例 -->
    <select id="selectByComplexCondition" resultType="com.example.videoplayer.entity.VideoProgress">
        SELECT * FROM video_progress
        <where>
            <if test="userId != null">
                AND user_id = #{userId}
            </if>
            <if test="completed != null">
                AND completed = #{completed}
            </if>
            <if test="startTime != null">
                AND last_watched_at >= #{startTime}
            </if>
            <if test="endTime != null">
                AND last_watched_at &lt;= #{endTime}
            </if>
        </where>
        ORDER BY last_watched_at DESC
    </select>
</mapper>

1.7 Service 层

复制代码
// service/VideoService.java
package com.example.videoplayer.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.mapper.VideoMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class VideoService extends ServiceImpl<VideoMapper, Video> {
    
    @Value("${video.storage.path:/tmp/videos}")
    private String videoStoragePath;
    
    private final VideoMapper videoMapper;
    
    /**
     * 获取视频列表(MP分页)
     */
    public IPage<Video> getVideoList(long current, long size) {
        Page<Video> page = new Page<>(current, size);
        
        LambdaQueryWrapper<Video> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Video::getStatus, Video.VideoStatus.ACTIVE)
               .eq(Video::getDeleted, 0)
               .orderByDesc(Video::getCreatedAt);
        
        return videoMapper.selectPage(page, wrapper);
    }
    
    /**
     * 搜索视频(自定义SQL分页)
     */
    public IPage<Video> searchVideos(String keyword, long current, long size) {
        Page<Video> page = new Page<>(current, size);
        return videoMapper.searchVideos(page, keyword);
    }
    
    /**
     * 获取视频详情
     */
    public Video getVideoById(Long id) {
        Video video = videoMapper.selectById(id);
        if (video == null || video.getDeleted() == 1) {
            throw new RuntimeException("视频不存在或已被删除");
        }
        return video;
    }
    
    /**
     * 获取热门视频
     */
    public List<Video> getHotVideos(int limit) {
        return videoMapper.selectHotVideos(limit);
    }
    
    /**
     * 上传视频
     */
    public Video uploadVideo(MultipartFile file, String title, String description) {
        try {
            // 确保目录存在
            Path storagePath = Paths.get(videoStoragePath);
            if (!Files.exists(storagePath)) {
                Files.createDirectories(storagePath);
            }
            
            // 生成唯一文件名
            String originalFilename = file.getOriginalFilename();
            String extension = originalFilename != null ? 
                originalFilename.substring(originalFilename.lastIndexOf(".")) : ".mp4";
            String filename = UUID.randomUUID().toString() + extension;
            
            // 保存文件
            Path targetPath = storagePath.resolve(filename);
            Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
            
            // 创建视频记录
            Video video = new Video();
            video.setTitle(title);
            video.setDescription(description);
            video.setFilePath(filename);
            video.setStatus(Video.VideoStatus.ACTIVE);
            
            // TODO: 使用FFmpeg获取视频时长和生成缩略图
            
            videoMapper.insert(video);
            return video;
            
        } catch (IOException e) {
            log.error("视频上传失败", e);
            throw new RuntimeException("视频上传失败: " + e.getMessage());
        }
    }
    
    /**
     * 获取视频资源(支持断点续传)
     */
    public Resource loadVideoAsResource(String filename) {
        try {
            Path filePath = Paths.get(videoStoragePath).resolve(filename).normalize();
            Resource resource = new UrlResource(filePath.toUri());
            
            if (resource.exists() && resource.isReadable()) {
                return resource;
            } else {
                throw new RuntimeException("视频文件不存在或无法读取: " + filename);
            }
        } catch (MalformedURLException e) {
            throw new RuntimeException("视频文件路径错误: " + filename, e);
        }
    }
    
    /**
     * 更新视频信息
     */
    public boolean updateVideo(Video video) {
        return videoMapper.updateById(video) > 0;
    }
    
    /**
     * 删除视频(逻辑删除,MP自动处理@TableLogic)
     */
    public boolean deleteVideo(Long id) {
        return videoMapper.deleteById(id) > 0;
    }
    
    /**
     * 批量删除
     */
    public boolean deleteBatch(List<Long> ids) {
        return videoMapper.deleteBatchIds(ids) > 0;
    }
}

// service/VideoProgressService.java
package com.example.videoplayer.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.videoplayer.dto.ProgressUpdateRequest;
import com.example.videoplayer.dto.VideoHistoryDTO;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.entity.VideoProgress;
import com.example.videoplayer.mapper.VideoMapper;
import com.example.videoplayer.mapper.VideoProgressMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class VideoProgressService extends ServiceImpl<VideoProgressMapper, VideoProgress> {
    
    private final VideoProgressMapper progressMapper;
    private final VideoMapper videoMapper;
    private final RedisTemplate<String, Object> redisTemplate;
    
    private static final String PROGRESS_KEY_PREFIX = "video:progress:";
    private static final String PROGRESS_SYNC_KEY = "video:progress:sync:queue";
    private static final long CACHE_TTL = 7;
    
    /**
     * 获取播放进度(优先Redis)
     */
    public VideoProgress getProgress(Long userId, Long videoId) {
        String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
        
        // 1. 查Redis
        @SuppressWarnings("unchecked")
        VideoProgress cached = (VideoProgress) redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            log.debug("从缓存获取进度: user={}, video={}", userId, videoId);
            return cached;
        }
        
        // 2. 查数据库(带视频信息)
        VideoProgress progress = progressMapper.selectProgressWithVideo(userId, videoId);
        
        if (progress != null) {
            redisTemplate.opsForValue().set(cacheKey, progress, CACHE_TTL, TimeUnit.DAYS);
        }
        
        return progress;
    }
    
    /**
     * 更新播放进度(双写策略)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateProgress(Long userId, ProgressUpdateRequest request) {
        Long videoId = request.getVideoId();
        Long currentTime = request.getCurrentTime();
        Long duration = request.getDuration();
        
        // 判断是否看完(95%进度或收到completed标记)
        boolean isCompleted = request.getCompleted() || 
                (duration != null && currentTime >= duration * 0.95);
        
        String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
        
        // 1. 更新Redis(高频操作,保证性能)
        VideoProgress cacheProgress = new VideoProgress();
        cacheProgress.setUserId(userId);
        cacheProgress.setVideoId(videoId);
        cacheProgress.setCurrentTime(currentTime);
        cacheProgress.setTotalDuration(duration);
        cacheProgress.setCompleted(isCompleted);
        cacheProgress.setLastWatchedAt(LocalDateTime.now());
        cacheProgress.setVersion(1);
        
        redisTemplate.opsForValue().set(cacheKey, cacheProgress, CACHE_TTL, TimeUnit.DAYS);
        
        // 2. 加入同步队列(延迟写入MySQL)
        redisTemplate.opsForSet().add(PROGRESS_SYNC_KEY, cacheKey);
        
        // 3. 立即同步到数据库(关键节点:暂停、完成、每30秒)
        if (isCompleted || request.getCompleted() != null) {
            syncToDatabase(userId, videoId, currentTime, duration, isCompleted);
        }
        
        log.info("更新播放进度: user={}, video={}, time={}, completed={}", 
                userId, videoId, currentTime, isCompleted);
    }
    
    /**
     * 同步到数据库
     */
    @Transactional
    protected void syncToDatabase(Long userId, Long videoId, Long currentTime, 
                                Long duration, boolean isCompleted) {
        // 尝试原子更新
        int updated = progressMapper.updateProgressAtomic(
            userId, videoId, currentTime, LocalDateTime.now(), isCompleted
        );
        
        // 如果没有记录,插入新记录
        if (updated == 0) {
            VideoProgress newProgress = new VideoProgress();
            newProgress.setUserId(userId);
            newProgress.setVideoId(videoId);
            newProgress.setCurrentTime(currentTime);
            newProgress.setTotalDuration(duration);
            newProgress.setCompleted(isCompleted);
            // created_at, last_watched_at, watch_count由自动填充处理
            
            try {
                progressMapper.insert(newProgress);
            } catch (Exception e) {
                // 如果插入失败(并发情况下可能已存在),再尝试更新
                progressMapper.updateProgressAtomic(
                    userId, videoId, currentTime, LocalDateTime.now(), isCompleted
                );
            }
        }
    }
    
    /**
     * 获取观看历史(MP分页)
     */
    public IPage<VideoHistoryDTO> getUserHistory(Long userId, long current, long size) {
        // 先同步该用户的Redis数据到数据库(保证数据一致性)
        syncUserProgressToDb(userId);
        
        Page<VideoProgress> page = new Page<>(current, size);
        IPage<VideoProgress> progressPage = progressMapper.selectHistoryPage(page, userId);
        
        // 转换为DTO
        List<VideoHistoryDTO> records = progressPage.getRecords().stream()
            .map(this::convertToDTO)
            .collect(Collectors.toList());
        
        // 构建新的分页对象
        Page<VideoHistoryDTO> resultPage = new Page<>();
        resultPage.setCurrent(progressPage.getCurrent());
        resultPage.setSize(progressPage.getSize());
        resultPage.setTotal(progressPage.getTotal());
        resultPage.setPages(progressPage.getPages());
        resultPage.setRecords(records);
        
        return resultPage;
    }
    
    /**
     * 继续观看
     */
    public VideoHistoryDTO getContinueWatching(Long userId) {
        // 先同步
        syncUserProgressToDb(userId);
        
        VideoProgress progress = progressMapper.selectContinueWatching(userId);
        return progress != null ? convertToDTO(progress) : null;
    }
    
    /**
     * 删除单条记录
     */
    @Transactional
    public boolean deleteProgress(Long userId, Long videoId) {
        // 1. 删Redis
        String cacheKey = PROGRESS_KEY_PREFIX + userId + ":" + videoId;
        redisTemplate.delete(cacheKey);
        
        // 2. 删数据库
        LambdaQueryWrapper<VideoProgress> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(VideoProgress::getUserId, userId)
               .eq(VideoProgress::getVideoId, videoId);
        
        return progressMapper.delete(wrapper) > 0;
    }
    
    /**
     * 清空用户历史
     */
    @Transactional
    public boolean clearUserHistory(Long userId) {
        // 1. 清理Redis
        String pattern = PROGRESS_KEY_PREFIX + userId + ":*";
        // 使用keys扫描(生产环境建议用scan)
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
        
        // 2. 删数据库
        LambdaQueryWrapper<VideoProgress> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(VideoProgress::getUserId, userId);
        
        return progressMapper.delete(wrapper) > 0;
    }
    
    /**
     * 获取用户观看统计
     */
    public UserWatchStats getUserStats(Long userId) {
        // 先同步
        syncUserProgressToDb(userId);
        
        Map<String, Object> stats = progressMapper.selectUserStats(userId);
        
        UserWatchStats result = new UserWatchStats();
        if (stats != null) {
            result.setTotalWatched(((Number) stats.get("total_count")).longValue());
            result.setCompletedCount(((Number) stats.get("completed_count")).longValue());
            result.setTotalWatchTime(((Number) stats.get("total_watch_time")).longValue());
            long total = result.getTotalWatched();
            result.setCompletionRate(total > 0 ? (int)(result.getCompletedCount() * 100 / total) : 0);
        }
        
        return result;
    }
    
    /**
     * 定时任务:批量同步Redis数据到MySQL(每5分钟)
     */
    @Scheduled(fixedDelay = 300000) // 5分钟
    @Transactional
    public void batchSyncToDatabase() {
        log.info("开始批量同步播放进度到数据库...");
        
        Set<String> keys = redisTemplate.opsForSet().members(PROGRESS_SYNC_KEY);
        if (keys == null || keys.isEmpty()) {
            return;
        }
        
        List<VideoProgress> list = new ArrayList<>();
        for (String key : keys) {
            VideoProgress progress = (VideoProgress) redisTemplate.opsForValue().get(key);
            if (progress != null) {
                list.add(progress);
            }
        }
        
        if (!list.isEmpty()) {
            // 使用批量upsert
            progressMapper.upsertBatch(list);
            log.info("批量同步完成,共{}条记录", list.size());
        }
        
        // 清空同步队列
        redisTemplate.delete(PROGRESS_SYNC_KEY);
    }
    
    /**
     * 同步指定用户的进度到数据库
     */
    private void syncUserProgressToDb(Long userId) {
        String pattern = PROGRESS_KEY_PREFIX + userId + ":*";
        Set<String> keys = redisTemplate.keys(pattern);
        
        if (keys == null || keys.isEmpty()) return;
        
        for (String key : keys) {
            VideoProgress progress = (VideoProgress) redisTemplate.opsForValue().get(key);
            if (progress != null) {
                syncToDatabase(
                    progress.getUserId(),
                    progress.getVideoId(),
                    progress.getCurrentTime(),
                    progress.getTotalDuration(),
                    progress.getCompleted()
                );
            }
        }
    }
    
    /**
     * 转换为DTO
     */
    private VideoHistoryDTO convertToDTO(VideoProgress progress) {
        VideoHistoryDTO dto = new VideoHistoryDTO();
        dto.setVideoId(progress.getVideoId());
        dto.setCurrentTime(progress.getCurrentTime());
        dto.setDuration(progress.getTotalDuration());
        dto.setProgressPercentage(progress.getProgressPercentage());
        dto.setCompleted(progress.getCompleted());
        dto.setLastWatchedAt(progress.getLastWatchedAt());
        dto.setFormattedTime(formatDuration(progress.getCurrentTime()));
        dto.setLastWatchedText(formatRelativeTime(progress.getLastWatchedAt()));
        
        // 从param中获取关联的视频信息(如果使用了selectProgressWithVideo)
        if (progress.getParam() != null) {
            dto.setTitle((String) progress.getParam().get("title"));
            dto.setThumbnailUrl((String) progress.getParam().get("thumbnail_url"));
            dto.setFilePath((String) progress.getParam().get("file_path"));
        }
        
        return dto;
    }
    
    private String formatDuration(Long seconds) {
        if (seconds == null) return "00:00";
        long hours = seconds / 3600;
        long minutes = (seconds % 3600) / 60;
        long secs = seconds % 60;
        
        if (hours > 0) {
            return String.format("%02d:%02d:%02d", hours, minutes, secs);
        }
        return String.format("%02d:%02d", minutes, secs);
    }
    
    private String formatRelativeTime(LocalDateTime time) {
        if (time == null) return "未知";
        
        long minutes = ChronoUnit.MINUTES.between(time, LocalDateTime.now());
        if (minutes < 1) return "刚刚";
        if (minutes < 60) return minutes + "分钟前";
        
        long hours = ChronoUnit.HOURS.between(time, LocalDateTime.now());
        if (hours < 24) return hours + "小时前";
        
        long days = ChronoUnit.DAYS.between(time, LocalDateTime.now());
        if (days < 30) return days + "天前";
        
        return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }
    
    @Data
    public static class UserWatchStats {
        private long totalWatched;
        private long completedCount;
        private long totalWatchTime;
        private int completionRate;
    }
}

1.8 Controller 层

复制代码
// controller/VideoController.java
package com.example.videoplayer.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.videoplayer.dto.ApiResponse;
import com.example.videoplayer.entity.Video;
import com.example.videoplayer.service.VideoService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoController {
    
    private final VideoService videoService;
    
    /**
     * 获取视频列表(MP分页,current从1开始)
     */
    @GetMapping
    public ApiResponse<IPage<Video>> listVideos(
            @RequestParam(defaultValue = "1") long current,
            @RequestParam(defaultValue = "12") long size) {
        
        IPage<Video> page = videoService.getVideoList(current, size);
        return ApiResponse.success(page);
    }
    
    /**
     * 获取视频详情
     */
    @GetMapping("/{id}")
    public ApiResponse<Video> getVideo(@PathVariable Long id) {
        Video video = videoService.getVideoById(id);
        return ApiResponse.success(video);
    }
    
    /**
     * 搜索视频(MP分页)
     */
    @GetMapping("/search")
    public ApiResponse<IPage<Video>> searchVideos(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "1") long current,
            @RequestParam(defaultValue = "12") long size) {
        
        IPage<Video> page = videoService.searchVideos(keyword, current, size);
        return ApiResponse.success(page);
    }
    
    /**
     * 获取热门视频
     */
    @GetMapping("/hot")
    public ApiResponse<List<Video>> getHotVideos(
            @RequestParam(defaultValue = "10") int limit) {
        
        List<Video> videos = videoService.getHotVideos(limit);
        return ApiResponse.success(videos);
    }
    
    /**
     * 流式播放视频(支持断点续传)
     */
    @GetMapping("/stream/{filename}")
    public ResponseEntity<Resource> streamVideo(
            @PathVariable String filename,
            HttpServletRequest request,
            HttpServletResponse response) throws IOException {
        
        Resource videoResource = videoService.loadVideoAsResource(filename);
        
        // 处理Range请求(断点续传)
        String rangeHeader = request.getHeader(HttpHeaders.RANGE);
        
        if (rangeHeader != null) {
            return handlePartialContent(videoResource, rangeHeader, request, response);
        }
        
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_TYPE, "video/mp4")
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .body(videoResource);
    }
    
    /**
     * 上传视频
     */
    @PostMapping("/upload")
    public ApiResponse<Video> uploadVideo(
            @RequestParam("file") MultipartFile file,
            @RequestParam("title") String title,
            @RequestParam(value = "description", required = false) String description) {
        
        Video video = videoService.uploadVideo(file, title, description);
        return ApiResponse.success(video);
    }
    
    /**
     * 更新视频信息
     */
    @PutMapping("/{id}")
    public ApiResponse<Boolean> updateVideo(@PathVariable Long id, @RequestBody Video video) {
        video.setId(id);
        boolean success = videoService.updateVideo(video);
        return ApiResponse.success(success);
    }
    
    /**
     * 删除视频(逻辑删除)
     */
    @DeleteMapping("/{id}")
    public ApiResponse<Boolean> deleteVideo(@PathVariable Long id) {
        boolean success = videoService.deleteVideo(id);
        return ApiResponse.success(success);
    }
    
    /**
     * 处理断点续传(HTTP 206)
     */
    private ResponseEntity<Resource> handlePartialContent(
            Resource resource, String rangeHeader, 
            HttpServletRequest request, HttpServletResponse response) throws IOException {
        
        // 简化实现,实际生产环境需要完整实现Range解析
        long fileSize = resource.contentLength();
        String[] ranges = rangeHeader.replace("bytes=", "").split("-");
        long start = Long.parseLong(ranges[0]);
        long end = ranges.length > 1 && !ranges[1].isEmpty() ? 
                   Long.parseLong(ranges[1]) : fileSize - 1;
        
        long contentLength = end - start + 1;
        
        response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
        response.setHeader(HttpHeaders.CONTENT_TYPE, "video/mp4");
        response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
        response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength));
        response.setHeader(HttpHeaders.CONTENT_RANGE, 
                String.format("bytes %d-%d/%d", start, end, fileSize));
        
        // 实际实现需要使用RandomAccessFile或InputStream.skip()
        return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                .body(resource);
    }
}

// controller/VideoProgressController.java
package com.example.videoplayer.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.videoplayer.dto.ApiResponse;
import com.example.videoplayer.dto.ProgressUpdateRequest;
import com.example.videoplayer.dto.VideoHistoryDTO;
import com.example.videoplayer.entity.VideoProgress;
import com.example.videoplayer.service.VideoProgressService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/progress")
@RequiredArgsConstructor
public class VideoProgressController {
    
    private final VideoProgressService progressService;
    
    /**
     * 获取当前视频的播放进度
     */
    @GetMapping("/{videoId}")
    public ApiResponse<Map<String, Object>> getProgress(
            @RequestAttribute("userId") Long userId,
            @PathVariable Long videoId) {
        
        VideoProgress progress = progressService.getProgress(userId, videoId);
        
        Map<String, Object> response = new HashMap<>();
        if (progress != null) {
            response.put("currentTime", progress.getCurrentTime());
            response.put("duration", progress.getTotalDuration());
            response.put("percentage", progress.getProgressPercentage());
            response.put("completed", progress.getCompleted());
            response.put("lastWatchedAt", progress.getLastWatchedAt());
            response.put("watchCount", progress.getWatchCount());
        } else {
            response.put("currentTime", 0);
            response.put("percentage", 0);
            response.put("completed", false);
        }
        
        return ApiResponse.success(response);
    }
    
    /**
     * 更新播放进度(心跳包,每5-10秒调用一次)
     */
    @PostMapping("/update")
    public ApiResponse<Void> updateProgress(
            @RequestAttribute("userId") Long userId,
            @Valid @RequestBody ProgressUpdateRequest request) {
        
        progressService.updateProgress(userId, request);
        return ApiResponse.success(null);
    }
    
    /**
     * 获取观看历史(MP分页,current从1开始)
     */
    @GetMapping("/history")
    public ApiResponse<IPage<VideoHistoryDTO>> getHistory(
            @RequestAttribute("userId") Long userId,
            @RequestParam(defaultValue = "1") long current,
            @RequestParam(defaultValue = "20") long size) {
        
        IPage<VideoHistoryDTO> page = progressService.getUserHistory(userId, current, size);
        return ApiResponse.success(page);
    }
    
    /**
     * 获取"继续观看"(上次未看完的视频)
     */
    @GetMapping("/continue")
    public ApiResponse<VideoHistoryDTO> getContinueWatching(
            @RequestAttribute("userId") Long userId) {
        
        VideoHistoryDTO dto = progressService.getContinueWatching(userId);
        return ApiResponse.success(dto);
    }
    
    /**
     * 标记为已完成
     */
    @PostMapping("/complete/{videoId}")
    public ApiResponse<Void> markCompleted(
            @RequestAttribute("userId") Long userId,
            @PathVariable Long videoId) {
        
        ProgressUpdateRequest request = new ProgressUpdateRequest();
        request.setVideoId(videoId);
        request.setCurrentTime(999999L); // 大数字确保被视为完成
        request.setCompleted(true);
        
        progressService.updateProgress(userId, request);
        return ApiResponse.success(null);
    }
    
    /**
     * 删除单条历史记录
     */
    @DeleteMapping("/{videoId}")
    public ApiResponse<Boolean> deleteProgress(
            @RequestAttribute("userId") Long userId,
            @PathVariable Long videoId) {
        
        boolean success = progressService.deleteProgress(userId, videoId);
        return ApiResponse.success(success);
    }
    
    /**
     * 清空所有历史记录
     */
    @DeleteMapping("/history")
    public ApiResponse<Boolean> clearHistory(
            @RequestAttribute("userId") Long userId) {
        
        boolean success = progressService.clearUserHistory(userId);
        return ApiResponse.success(success);
    }
    
    /**
     * 获取用户观看统计
     */
    @GetMapping("/stats")
    public ApiResponse<VideoProgressService.UserWatchStats> getStats(
            @RequestAttribute("userId") Long userId) {
        
        VideoProgressService.UserWatchStats stats = progressService.getUserStats(userId);
        return ApiResponse.success(stats);
    }
}

1.9 拦截器与 WebSocket

复制代码
// interceptor/AuthInterceptor.java
package com.example.videoplayer.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
@Component
public class AuthInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 简化版:从Header获取userId,实际应使用JWT解析
        String userIdStr = request.getHeader("X-User-Id");
        if (userIdStr == null) {
            // 开发测试用默认值
            userIdStr = "1";
        }
        
        try {
            Long userId = Long.parseLong(userIdStr);
            request.setAttribute("userId", userId);
            return true;
        } catch (NumberFormatException e) {
            response.setStatus(401);
            return false;
        }
    }
}

// websocket/VideoProgressWebSocketHandler.java
package com.example.videoplayer.websocket;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
public class VideoProgressWebSocketHandler extends TextWebSocketHandler {
    
    // 存储用户会话:userId -> session
    private static final Map<Long, WebSocketSession> sessions = new ConcurrentHashMap<>();
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        Long userId = getUserIdFromSession(session);
        if (userId != null) {
            sessions.put(userId, session);
            log.info("WebSocket连接建立: userId={}", userId);
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 接收实时进度更新,广播到其他设备
        Long userId = getUserIdFromSession(session);
        if (userId == null) return;
        
        String payload = message.getPayload();
        log.debug("收到进度更新: userId={}, data={}", userId, payload);
        
        // 解析消息
        Map<String, Object> data = objectMapper.readValue(payload, Map.class);
        
        // 广播给该用户的其他设备(实现多设备同步)
        broadcastToUserDevices(userId, payload, session);
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        Long userId = getUserIdFromSession(session);
        if (userId != null) {
            sessions.remove(userId);
            log.info("WebSocket连接关闭: userId={}", userId);
        }
    }
    
    /**
     * 广播给用户的所有设备(除了发送者)
     */
    private void broadcastToUserDevices(Long userId, String message, WebSocketSession excludeSession) {
        // 实际实现中,一个用户可能有多个设备,需要更复杂的session管理
        // 这里简化处理
        WebSocketSession targetSession = sessions.get(userId);
        if (targetSession != null && targetSession.isOpen() && targetSession != excludeSession) {
            try {
                targetSession.sendMessage(new TextMessage(message));
            } catch (IOException e) {
                log.error("WebSocket广播失败", e);
            }
        }
    }
    
    private Long getUserIdFromSession(WebSocketSession session) {
        // 从URL参数或header获取userId
        String query = session.getUri().getQuery();
        if (query != null && query.contains("userId=")) {
            String userIdStr = query.split("userId=")[1].split("&")[0];
            return Long.parseLong(userIdStr);
        }
        return null;
    }
}

2. 前端实现(Vue 3 + Vite)

项目结构

复制代码
video-player-frontend/
├── src/
│   ├── api/
│   │   ├── request.js
│   │   ├── video.js
│   │   └── progress.js
│   ├── components/
│   │   ├── VideoPlayer.vue          # 视频播放器组件
│   │   ├── VideoCard.vue            # 视频卡片组件
│   │   ├── HistoryList.vue          # 历史记录列表
│   │   └── ContinueWatchingCard.vue # 继续观看卡片
│   ├── composables/
│   │   └── useVideoProgress.js      # 播放进度组合式函数
│   ├── views/
│   │   ├── Home.vue                 # 首页
│   │   ├── VideoDetail.vue          # 视频详情页
│   │   ├── History.vue              # 观看历史页
│   │   └── Search.vue               # 搜索结果页
│   ├── router/
│   │   └── index.js
│   ├── utils/
│   │   └── format.js                # 格式化工具
│   ├── App.vue
│   └── main.js
├── public/
├── package.json
└── vite.config.js

2.1 基础配置

复制代码
// src/api/request.js
import axios from 'axios'

const request = axios.create({
  baseURL: 'http://localhost:8080/api',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器 - 添加用户ID(实际应从登录状态/JWT获取)
request.interceptors.request.use(
  config => {
    // 从localStorage获取用户ID,默认1
    const userId = localStorage.getItem('userId') || '1'
    config.headers['X-User-Id'] = userId
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器 - 统一处理MP分页格式
request.interceptors.response.use(
  response => {
    const res = response.data
    
    // 处理后端统一响应格式 { code, message, data, timestamp }
    if (res.code !== 200) {
      console.error('请求错误:', res.message)
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    
    // 返回data部分
    return res.data
  },
  error => {
    console.error('网络错误:', error.message)
    return Promise.reject(error)
  }
)

export default request

JavaScript

复制

复制代码
// src/api/video.js
import request from './request'

export const videoApi = {
  // 获取视频列表(MP分页参数:current从1开始)
  getList(current = 1, size = 12) {
    return request.get('/videos', {
      params: { current, size }
    })
  },
  
  // 获取视频详情
  getById(id) {
    return request.get(`/videos/${id}`)
  },
  
  // 搜索视频(MP分页)
  search(keyword, current = 1, size = 12) {
    return request.get('/videos/search', {
      params: { keyword, current, size }
    })
  },
  
  // 获取热门视频
  getHot(limit = 10) {
    return request.get('/videos/hot', { params: { limit } })
  },
  
  // 获取视频流URL(用于video标签src)
  getStreamUrl(filename) {
    return `http://localhost:8080/api/videos/stream/${filename}`
  }
}

JavaScript

复制

复制代码
// src/api/progress.js
import request from './request'

export const progressApi = {
  // 获取播放进度
  getProgress(videoId) {
    return request.get(`/progress/${videoId}`)
  },
  
  // 更新播放进度
  updateProgress(data) {
    return request.post('/progress/update', {
      videoId: data.videoId,
      currentTime: Math.floor(data.currentTime),
      duration: Math.floor(data.duration),
      completed: data.completed || false
    })
  },
  
  // 获取观看历史(MP分页:current从1开始)
  getHistory(current = 1, size = 20) {
    return request.get('/progress/history', {
      params: { current, size }
    })
  },
  
  // 获取继续观看
  getContinueWatching() {
    return request.get('/progress/continue')
  },
  
  // 标记完成
  markComplete(videoId) {
    return request.post(`/progress/complete/${videoId}`)
  },
  
  // 删除单条历史
  deleteProgress(videoId) {
    return request.delete(`/progress/${videoId}`)
  },
  
  // 清空历史
  clearHistory() {
    return request.delete('/progress/history')
  },
  
  // 获取统计
  getStats() {
    return request.get('/progress/stats')
  }
}

2.2 工具函数

复制代码
// src/utils/format.js

// 格式化时长(秒 -> HH:MM:SS或MM:SS)
export function formatDuration(seconds) {
  if (!seconds || seconds < 0) return '00:00'
  
  const hours = Math.floor(seconds / 3600)
  const mins = Math.floor((seconds % 3600) / 60)
  const secs = Math.floor(seconds % 60)
  
  if (hours > 0) {
    return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
  }
  return `${mins}:${secs.toString().padStart(2, '0')}`
}

// 格式化相对时间
export function formatRelativeTime(timeStr) {
  if (!timeStr) return '从未'
  
  const date = new Date(timeStr)
  const now = new Date()
  const diffMs = now - date
  
  const diffSecs = Math.floor(diffMs / 1000)
  const diffMins = Math.floor(diffMs / 60000)
  const diffHours = Math.floor(diffMs / 3600000)
  const diffDays = Math.floor(diffMs / 86400000)
  const diffMonths = Math.floor(diffDays / 30)
  const diffYears = Math.floor(diffDays / 365)
  
  if (diffSecs < 60) return '刚刚'
  if (diffMins < 60) return `${diffMins}分钟前`
  if (diffHours < 24) return `${diffHours}小时前`
  if (diffDays < 30) return `${diffDays}天前`
  if (diffMonths < 12) return `${diffMonths}个月前`
  return `${diffYears}年前`
}

// 格式化数字(千分位)
export function formatNumber(num) {
  if (num >= 10000) {
    return (num / 10000).toFixed(1) + '万'
  }
  return num.toString()
}

// 格式化百分比
export function formatPercent(value, total) {
  if (!total) return 0
  return Math.min(100, Math.round((value / total) * 100))
}

2.3 组合式函数

复制代码
// src/composables/useVideoProgress.js
import { ref, computed } from 'vue'
import { progressApi } from '@/api/progress'
import { formatDuration, formatRelativeTime } from '@/utils/format'

export function useVideoProgress(userId, videoId) {
  const progress = ref({
    currentTime: 0,
    duration: 0,
    percentage: 0,
    completed: false,
    lastWatchedAt: null,
    watchCount: 0
  })
  
  const isLoading = ref(false)
  const saveError = ref(null)
  
  // 是否有观看历史(观看超过10秒)
  const hasHistory = computed(() => progress.value.currentTime > 10)
  
  // 格式化后的当前时间
  const formattedCurrentTime = computed(() => formatDuration(progress.value.currentTime))
  
  // 格式化后的总时长
  const formattedDuration = computed(() => formatDuration(progress.value.duration))
  
  // 格式化后的上次观看时间
  const lastWatchedText = computed(() => formatRelativeTime(progress.value.lastWatchedAt))
  
  // 加载进度
  const loadProgress = async () => {
    try {
      isLoading.value = true
      saveError.value = null
      
      const data = await progressApi.getProgress(videoId)
      
      progress.value = {
        currentTime: data.currentTime || 0,
        duration: data.duration || 0,
        percentage: data.percentage || 0,
        completed: data.completed || false,
        lastWatchedAt: data.lastWatchedAt,
        watchCount: data.watchCount || 0
      }
      
      return progress.value
    } catch (error) {
      console.error('加载进度失败:', error)
      saveError.value = error.message
      // 尝试从localStorage恢复
      const localData = localStorage.getItem(`video_progress_${videoId}`)
      if (localData) {
        const parsed = JSON.parse(localData)
        progress.value.currentTime = parsed.currentTime || 0
        progress.value.duration = parsed.duration || 0
      }
      return null
    } finally {
      isLoading.value = false
    }
  }
  
  // 保存进度(防抖处理)
  let saveTimeout = null
  const pendingSave = ref(null)
  
  const saveProgress = async (currentTime, duration, completed = false, immediate = false) => {
    // 更新本地状态
    progress.value.currentTime = currentTime
    progress.value.duration = duration
    progress.value.percentage = duration ? Math.round((currentTime / duration) * 100) : 0
    if (completed) progress.value.completed = true
    
    // 准备保存的数据
    const saveData = {
      videoId,
      currentTime: Math.floor(currentTime),
      duration: Math.floor(duration),
      completed
    }
    
    // 备份到localStorage
    localStorage.setItem(`video_progress_${videoId}`, JSON.stringify({
      ...saveData,
      timestamp: Date.now()
    }))
    
    // 清除之前的定时器
    if (saveTimeout) {
      clearTimeout(saveTimeout)
    }
    
    // 立即保存或延迟保存
    const doSave = async () => {
      try {
        saveError.value = null
        pendingSave.value = saveData
        
        await progressApi.updateProgress(saveData)
        
        // 清除localStorage备份
        localStorage.removeItem(`video_progress_${videoId}`)
        pendingSave.value = null
        
      } catch (error) {
        console.error('保存进度失败:', error)
        saveError.value = error.message
      }
    }
    
    if (immediate) {
      await doSave()
    } else {
      saveTimeout = setTimeout(doSave, 500) // 500ms防抖
    }
  }
  
  // 标记完成
  const markComplete = async () => {
    try {
      await progressApi.markComplete(videoId)
      progress.value.completed = true
      progress.value.percentage = 100
    } catch (error) {
      console.error('标记完成失败:', error)
    }
  }
  
  return {
    progress,
    isLoading,
    saveError,
    hasHistory,
    formattedCurrentTime,
    formattedDuration,
    lastWatchedText,
    loadProgress,
    saveProgress,
    markComplete
  }
}

2.4 组件

复制代码
<!-- src/components/VideoPlayer.vue -->
<template>
  <div class="video-player-container">
    <!-- 继续观看提示 -->
    <div v-if="showResumePrompt" class="resume-prompt">
      <div class="prompt-content">
        <div class="prompt-icon">▶</div>
        <div class="prompt-text">
          <div class="prompt-title">上次观看到 {{ formattedResumeTime }}</div>
          <div class="prompt-subtitle">进度 {{ progress.percentage }}%</div>
        </div>
        <div class="prompt-actions">
          <button @click="resumeFromLast" class="btn-primary">继续观看</button>
          <button @click="startFromBeginning" class="btn-secondary">从头开始</button>
        </div>
        <button @click="closePrompt" class="btn-close">×</button>
      </div>
    </div>
    
    <!-- 视频容器 -->
    <div class="video-wrapper" ref="containerRef">
      <video
        ref="videoRef"
        class="video-element"
        :poster="video.thumbnailUrl"
        preload="metadata"
        playsinline
        webkit-playsinline
        x5-playsinline
        @play="onPlay"
        @pause="onPause"
        @ended="onEnded"
        @timeupdate="onTimeUpdate"
        @loadedmetadata="onLoadedMetadata"
        @waiting="onWaiting"
        @playing="onPlaying"
        @click="togglePlay"
      >
        <source :src="videoStreamUrl" type="video/mp4" />
        您的浏览器不支持视频播放
      </video>
      
      <!-- 加载动画 -->
      <div class="video-loading" v-show="loading">
        <div class="spinner"></div>
        <span>加载中...</span>
      </div>
      
      <!-- 大播放按钮(暂停时显示) -->
      <div class="big-play-btn" v-show="!isPlaying && !loading && !showResumePrompt" @click="play">
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M8 5v14l11-7z"/>
        </svg>
      </div>
      
      <!-- 自定义控制栏 -->
      <div class="custom-controls" v-show="showControls" @click.stop>
        <!-- 进度条 -->
        <div class="progress-container" @click="seek">
          <div class="progress-bar">
            <div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
            <div class="progress-handle" :style="{ left: progressPercent + '%' }"></div>
          </div>
        </div>
        
        <div class="controls-row">
          <button @click="togglePlay" class="control-btn play-btn">
            <svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor">
              <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
            </svg>
            <svg v-else viewBox="0 0 24 24" fill="currentColor">
              <path d="M8 5v14l11-7z"/>
            </svg>
          </button>
          
          <div class="time-display">
            {{ currentTimeDisplay }} / {{ durationDisplay }}
          </div>
          
          <div class="spacer"></div>
          
          <!-- 倍速播放 -->
          <select v-model="playbackRate" @change="setPlaybackRate" class="speed-select">
            <option value="0.5">0.5x</option>
            <option value="1">1.0x</option>
            <option value="1.25">1.25x</option>
            <option value="1.5">1.5x</option>
            <option value="2">2.0x</option>
          </select>
          
          <button @click="toggleMute" class="control-btn">
            <svg v-if="isMuted" viewBox="0 0 24 24" fill="currentColor">
              <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
            </svg>
            <svg v-else viewBox="0 0 24 24" fill="currentColor">
              <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
            </svg>
          </button>
          
          <button @click="toggleFullscreen" class="control-btn">
            <svg viewBox="0 0 24 24" fill="currentColor">
              <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
            </svg>
          </button>
        </div>
      </div>
    </div>
    
    <!-- 视频信息 -->
    <div class="video-info">
      <h1>{{ video.title }}</h1>
      <p class="description">{{ video.description }}</p>
      
      <div class="meta-bar">
        <span class="meta-item" v-if="progress.lastWatchedAt">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
          上次观看: {{ lastWatchedText }}
        </span>
        <span class="meta-item" v-if="progress.watchCount > 0">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
          {{ progress.watchCount }} 次观看
        </span>
        <span class="meta-item" :class="{ 'completed': progress.completed }">
          <svg v-if="progress.completed" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
          <svg v-else viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
          {{ progress.completed ? '已完成' : `观看进度: ${progress.percentage}%` }}
        </span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { videoApi } from '@/api/video'
import { useVideoProgress } from '@/composables/useVideoProgress'
import { formatDuration, formatRelativeTime } from '@/utils/format'

const props = defineProps({
  video: {
    type: Object,
    required: true
  },
  userId: {
    type: Number,
    default: 1
  }
})

const emit = defineEmits(['progress-updated', 'video-completed'])

const videoRef = ref(null)
const containerRef = ref(null)
const isPlaying = ref(false)
const isMuted = ref(false)
const isFullscreen = ref(false)
const showControls = ref(true)
const loading = ref(false)
const showResumePrompt = ref(false)
const playbackRate = ref(1)
let controlsTimeout = null
let progressInterval = null

// 播放进度管理
const { 
  progress, 
  hasHistory,
  lastWatchedText,
  loadProgress, 
  saveProgress 
} = useVideoProgress(props.userId, props.video.id)

// 计算属性
const videoStreamUrl = computed(() => {
  return videoApi.getStreamUrl(props.video.filePath)
})

const resumeTime = computed(() => progress.value.currentTime)

const formattedResumeTime = computed(() => {
  return formatDuration(resumeTime.value)
})

const progressPercent = computed(() => {
  if (!videoRef.value || !videoRef.value.duration) return progress.value.percentage || 0
  return (videoRef.value.currentTime / videoRef.value.duration) * 100
})

const currentTimeDisplay = computed(() => formatDuration(videoRef.value?.currentTime || 0))
const durationDisplay = computed(() => formatDuration(videoRef.value?.duration || 0))

// 初始化
onMounted(async () => {
  // 加载历史进度
  await loadProgress()
  
  // 检查是否需要显示继续观看提示
  if (hasHistory.value && !progress.value.completed && progress.value.currentTime > 10) {
    showResumePrompt.value = true
  } else if (progress.value.currentTime > 0) {
    // 直接恢复位置
    videoRef.value.currentTime = progress.value.currentTime
  }
  
  // 键盘快捷键
  document.addEventListener('keydown', handleKeydown)
})

// 视频事件处理
const onLoadedMetadata = () => {
  console.log('视频时长:', videoRef.value.duration)
  // 如果视频有记录的总时长,但播放器获取不到,使用记录的
  if (!videoRef.value.duration && progress.value.duration) {
    // 部分浏览器可能无法获取时长,这里可以做一些兼容性处理
  }
}

const onWaiting = () => {
  loading.value = true
}

const onPlaying = () => {
  loading.value = false
}

const onPlay = () => {
  isPlaying.value = true
  hideControlsDelay()
  startProgressTracking()
}

const onPause = () => {
  isPlaying.value = false
  showControls.value = true
  stopProgressTracking()
  // 立即保存进度
  saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
  emit('progress-updated', progress.value)
}

const onEnded = () => {
  isPlaying.value = false
  stopProgressTracking()
  showControls.value = true
  
  // 标记完成
  saveProgress(videoRef.value.currentTime, videoRef.value.duration, true, true)
  emit('video-completed', props.video.id)
}

const onTimeUpdate = () => {
  // 可以在这里做更精细的进度跟踪
}

// 控制方法
const togglePlay = () => {
  if (videoRef.value.paused) {
    videoRef.value.play()
  } else {
    videoRef.value.pause()
  }
}

const play = () => {
  videoRef.value.play()
}

const seek = (e) => {
  const rect = e.currentTarget.getBoundingClientRect()
  const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
  const newTime = percent * videoRef.value.duration
  videoRef.value.currentTime = newTime
  // 拖动后立即保存
  saveProgress(newTime, videoRef.value.duration)
}

const toggleMute = () => {
  videoRef.value.muted = !videoRef.value.muted
  isMuted.value = videoRef.value.muted
}

const setPlaybackRate = () => {
  videoRef.value.playbackRate = parseFloat(playbackRate.value)
}

const toggleFullscreen = () => {
  if (!document.fullscreenElement) {
    containerRef.value.requestFullscreen()
    isFullscreen.value = true
  } else {
    document.exitFullscreen()
    isFullscreen.value = false
  }
}

const hideControlsDelay = () => {
  clearTimeout(controlsTimeout)
  controlsTimeout = setTimeout(() => {
    if (isPlaying.value) {
      showControls.value = false
    }
  }, 3000)
}

// 进度跟踪
const startProgressTracking = () => {
  // 每5秒保存一次进度
  progressInterval = setInterval(() => {
    if (videoRef.value && !videoRef.value.paused) {
      saveProgress(videoRef.value.currentTime, videoRef.value.duration)
    }
  }, 5000)
}

const stopProgressTracking = () => {
  if (progressInterval) {
    clearInterval(progressInterval)
    progressInterval = null
  }
}

// 继续观看逻辑
const resumeFromLast = () => {
  videoRef.value.currentTime = resumeTime.value
  showResumePrompt.value = false
  play()
}

const startFromBeginning = () => {
  videoRef.value.currentTime = 0
  showResumePrompt.value = false
  play()
}

const closePrompt = () => {
  showResumePrompt.value = false
}

// 键盘快捷键
const handleKeydown = (e) => {
  // 如果在输入框中,不处理
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
  
  switch(e.key) {
    case ' ':
    case 'k':
      e.preventDefault()
      togglePlay()
      break
    case 'ArrowLeft':
      e.preventDefault()
      videoRef.value.currentTime -= 5
      break
    case 'ArrowRight':
      e.preventDefault()
      videoRef.value.currentTime += 5
      break
    case 'ArrowUp':
      e.preventDefault()
      videoRef.value.volume = Math.min(1, videoRef.value.volume + 0.1)
      break
    case 'ArrowDown':
      e.preventDefault()
      videoRef.value.volume = Math.max(0, videoRef.value.volume - 0.1)
      break
    case 'f':
      toggleFullscreen()
      break
    case 'm':
      toggleMute()
      break
    case 'j':
      videoRef.value.currentTime -= 10
      break
    case 'l':
      videoRef.value.currentTime += 10
      break
  }
}

// 页面关闭前保存
const beforeUnload = () => {
  if (videoRef.value) {
    saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
  }
}

window.addEventListener('beforeunload', beforeUnload)

onUnmounted(() => {
  stopProgressTracking()
  clearTimeout(controlsTimeout)
  window.removeEventListener('beforeunload', beforeUnload)
  document.removeEventListener('keydown', handleKeydown)
  
  // 保存最终进度
  if (videoRef.value) {
    saveProgress(videoRef.value.currentTime, videoRef.value.duration, false, true)
  }
})
</script>

<style scoped>
.video-player-container {
  width: 100%;
  background: #0f0f0f;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}

/* 继续观看提示 */
.resume-prompt {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  padding: 16px 24px;
  animation: slideDown 0.3s ease;
}

@keyframes slideDown {
  from { opacity: 0; transform: translateY(-100%); }
  to { opacity: 1; transform: translateY(0); }
}

.prompt-content {
  display: flex;
  align-items: center;
  gap: 16px;
  color: white;
  max-width: 1200px;
  margin: 0 auto;
}

.prompt-icon {
  width: 48px;
  height: 48px;
  background: rgba(255,255,255,0.2);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
}

.prompt-text {
  flex: 1;
}

.prompt-title {
  font-weight: 600;
  font-size: 16px;
}

.prompt-subtitle {
  font-size: 13px;
  opacity: 0.9;
  margin-top: 4px;
}

.prompt-actions {
  display: flex;
  gap: 12px;
}

.btn-primary, .btn-secondary {
  padding: 10px 20px;
  border-radius: 8px;
  font-weight: 600;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;
  border: none;
}

.btn-primary {
  background: white;
  color: #667eea;
}

.btn-primary:hover {
  transform: scale(1.05);
  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}

.btn-secondary {
  background: transparent;
  color: white;
  border: 1px solid rgba(255,255,255,0.5);
}

.btn-secondary:hover {
  background: rgba(255,255,255,0.1);
}

.btn-close {
  background: none;
  border: none;
  color: white;
  font-size: 24px;
  cursor: pointer;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  transition: background 0.2s;
}

.btn-close:hover {
  background: rgba(255,255,255,0.2);
}

/* 视频区域 */
.video-wrapper {
  position: relative;
  aspect-ratio: 16/9;
  background: #000;
}

.video-element {
  width: 100%;
  height: 100%;
  object-fit: contain;
  cursor: pointer;
}

/* 加载动画 */
.video-loading {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: rgba(0,0,0,0.5);
  color: white;
  gap: 12px;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 3px solid rgba(255,255,255,0.3);
  border-top-color: #ff6b6b;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* 大播放按钮 */
.big-play-btn {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80px;
  height: 80px;
  background: rgba(255,107,107,0.9);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 0.2s, background 0.2s;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}

.big-play-btn:hover {
  transform: translate(-50%, -50%) scale(1.1);
  background: #ff5252;
}

.big-play-btn svg {
  width: 40px;
  height: 40px;
  color: white;
  margin-left: 4px;
}

/* 控制栏 */
.custom-controls {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: linear-gradient(transparent, rgba(0,0,0,0.8));
  padding: 20px;
  opacity: 0;
  transition: opacity 0.3s;
}

.video-wrapper:hover .custom-controls {
  opacity: 1;
}

.video-wrapper.paused .custom-controls {
  opacity: 1;
}

/* 进度条 */
.progress-container {
  width: 100%;
  height: 24px;
  display: flex;
  align-items: center;
  cursor: pointer;
  margin-bottom: 8px;
  padding: 10px 0;
}

.progress-bar {
  width: 100%;
  height: 4px;
  background: rgba(255,255,255,0.3);
  border-radius: 2px;
  position: relative;
  transition: height 0.2s;
}

.progress-container:hover .progress-bar {
  height: 6px;
}

.progress-fill {
  height: 100%;
  background: #ff6b6b;
  border-radius: 2px;
  transition: width 0.1s linear;
  position: relative;
}

.progress-fill::after {
  content: '';
  position: absolute;
  right: 0;
  top: 50%;
  transform: translate(50%, -50%);
  width: 12px;
  height: 12px;
  background: #ff6b6b;
  border-radius: 50%;
  opacity: 0;
  transition: opacity 0.2s;
}

.progress-container:hover .progress-fill::after {
  opacity: 1;
}

.progress-handle {
  position: absolute;
  top: 50%;
  width: 16px;
  height: 16px;
  background: white;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  margin-top: -1px;
  opacity: 0;
  transition: opacity 0.2s, transform 0.2s;
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}

.progress-container:hover .progress-handle {
  opacity: 1;
}

/* 控制按钮行 */
.controls-row {
  display: flex;
  align-items: center;
  gap: 12px;
  color: white;
}

.control-btn {
  background: none;
  border: none;
  color: white;
  cursor: pointer;
  padding: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.2s;
  border-radius: 4px;
}

.control-btn:hover {
  transform: scale(1.1);
  background: rgba(255,255,255,0.1);
}

.control-btn svg {
  width: 24px;
  height: 24px;
}

.play-btn svg {
  width: 28px;
  height: 28px;
}

.time-display {
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  min-width: 100px;
  color: rgba(255,255,255,0.9);
}

.spacer {
  flex: 1;
}

.speed-select {
  background: rgba(255,255,255,0.1);
  color: white;
  border: none;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 13px;
  cursor: pointer;
  outline: none;
}

.speed-select option {
  background: #333;
  color: white;
}

/* 视频信息 */
.video-info {
  padding: 24px;
  background: #f8f9fa;
}

.video-info h1 {
  margin: 0 0 12px 0;
  font-size: 24px;
  color: #1a1a1a;
  font-weight: 600;
}

.description {
  color: #666;
  line-height: 1.6;
  margin: 0 0 16px 0;
  font-size: 14px;
}

.meta-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.meta-item {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #888;
}

.meta-item svg {
  width: 16px;
  height: 16px;
  fill: currentColor;
}

.meta-item.completed {
  color: #52c41a;
  font-weight: 500;
}
</style>

vue

复制

复制代码
<!-- src/components/VideoCard.vue -->
<template>
  <div class="video-card" @click="goToVideo">
    <div class="thumbnail-wrapper">
      <img 
        :src="video.thumbnailUrl || '/default-thumb.jpg'" 
        :alt="video.title"
        class="thumbnail"
        loading="lazy"
      />
      
      <!-- 时长标签 -->
      <span class="duration-badge" v-if="video.duration">
        {{ formatDuration(video.duration) }}
      </span>
      
      <!-- 进度条(如果观看过) -->
      <div class="progress-overlay" v-if="progress > 0 && progress < 100">
        <div class="progress-bar" :style="{ width: progress + '%' }"></div>
      </div>
      
      <!-- 已完成标记 -->
      <div class="completed-badge" v-if="progress === 100">
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
        </svg>
        已看完
      </div>
    </div>
    
    <div class="card-info">
      <h3 class="title">{{ video.title }}</h3>
      <p class="description" v-if="video.description">{{ truncateDesc(video.description) }}</p>
      <div class="meta">
        <span class="time" v-if="video.createdAt">{{ formatRelativeTime(video.createdAt) }}</span>
        <span class="dot" v-if="video.createdAt && video.resolution">·</span>
        <span class="resolution" v-if="video.resolution">{{ video.resolution }}</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDuration, formatRelativeTime } from '@/utils/format'

const props = defineProps({
  video: {
    type: Object,
    required: true
  },
  watchProgress: {
    type: Number,
    default: 0 // 0-100
  }
})

const router = useRouter()

const progress = computed(() => props.watchProgress)

const goToVideo = () => {
  router.push(`/video/${props.video.id}`)
}

const truncateDesc = (desc) => {
  if (!desc) return ''
  return desc.length > 60 ? desc.substring(0, 60) + '...' : desc
}
</script>

<style scoped>
.video-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

.video-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 25px rgba(0,0,0,0.12);
}

.thumbnail-wrapper {
  position: relative;
  aspect-ratio: 16/9;
  background: #f0f0f0;
  overflow: hidden;
}

.thumbnail {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.3s;
}

.video-card:hover .thumbnail {
  transform: scale(1.05);
}

.duration-badge {
  position: absolute;
  bottom: 8px;
  right: 8px;
  background: rgba(0,0,0,0.8);
  color: white;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 500;
}

.progress-overlay {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: rgba(0,0,0,0.2);
}

.progress-bar {
  height: 100%;
  background: #ff6b6b;
  transition: width 0.3s;
}

.completed-badge {
  position: absolute;
  top: 8px;
  right: 8px;
  background: #52c41a;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 4px;
}

.completed-badge svg {
  width: 14px;
  height: 14px;
}

.card-info {
  padding: 16px;
}

.title {
  margin: 0 0 8px 0;
  font-size: 15px;
  font-weight: 600;
  color: #1a1a1a;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.description {
  margin: 0 0 12px 0;
  font-size: 13px;
  color: #666;
  line-height: 1.5;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.meta {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: #999;
}

.dot {
  opacity: 0.5;
}

.resolution {
  background: #f0f0f0;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 11px;
}
</style>

vue

复制

复制代码
<!-- src/components/ContinueWatchingCard.vue -->
<template>
  <div v-if="item" class="continue-card" @click="goToVideo">
    <div class="thumbnail">
      <img :src="item.thumbnailUrl || '/default-thumb.jpg'" :alt="item.title" />
      <div class="progress-bar">
        <div class="progress-fill" :style="{ width: item.progressPercentage + '%' }"></div>
      </div>
      <div class="play-overlay">
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M8 5v14l11-7z"/>
        </svg>
      </div>
    </div>
    <div class="info">
      <h3>继续观看</h3>
      <h4>{{ item.title }}</h4>
      <p>观看到 {{ item.formattedTime }} · 剩余 {{ remainingTime }}</p>
    </div>
    <button class="btn-dismiss" @click.stop="dismiss">×</button>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDuration } from '@/utils/format'

const props = defineProps({
  item: {
    type: Object,
    default: null
  }
})

const emit = defineEmits(['dismiss'])
const router = useRouter()

const remainingTime = computed(() => {
  if (!props.item) return ''
  const remaining = props.item.duration - props.item.currentTime
  return formatDuration(remaining) + ''
})

const goToVideo = () => {
  if (props.item) {
    router.push(`/video/${props.item.videoId}`)
  }
}

const dismiss = () => {
  emit('dismiss')
}
</script>

<style scoped>
.continue-card {
  display: flex;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 16px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
  margin-bottom: 24px;
  position: relative;
}

.continue-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 30px rgba(102, 126, 234, 0.3);
}

.thumbnail {
  position: relative;
  width: 280px;
  flex-shrink: 0;
  aspect-ratio: 16/9;
  overflow: hidden;
}

.thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.progress-bar {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: rgba(255,255,255,0.3);
}

.progress-fill {
  height: 100%;
  background: #ff6b6b;
  transition: width 0.3s;
}

.play-overlay {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 60px;
  height: 60px;
  background: rgba(255,255,255,0.9);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.2s;
}

.continue-card:hover .play-overlay {
  opacity: 1;
}

.play-overlay svg {
  width: 30px;
  height: 30px;
  color: #667eea;
  margin-left: 4px;
}

.info {
  padding: 24px;
  color: white;
  display: flex;
  flex-direction: column;
  justify-content: center;
  flex: 1;
}

.info h3 {
  margin: 0 0 8px 0;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 1px;
  opacity: 0.9;
}

.info h4 {
  margin: 0 0 8px 0;
  font-size: 20px;
  font-weight: 600;
  line-height: 1.3;
}

.info p {
  margin: 0;
  font-size: 14px;
  opacity: 0.9;
}

.btn-dismiss {
  position: absolute;
  top: 12px;
  right: 12px;
  background: rgba(0,0,0,0.3);
  border: none;
  color: white;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  cursor: pointer;
  font-size: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.2s;
  opacity: 0;
}

.continue-card:hover .btn-dismiss {
  opacity: 1;
}

.btn-dismiss:hover {
  background: rgba(0,0,0,0.5);
}

@media (max-width: 768px) {
  .continue-card {
    flex-direction: column;
  }
  
  .thumbnail {
    width: 100%;
  }
}
</style>

vue

复制

复制代码
<!-- src/components/HistoryList.vue -->
<template>
  <div class="history-list">
    <div v-if="loading" class="loading-state">
      <div class="spinner"></div>
      <p>加载中...</p>
    </div>
    
    <div v-else-if="history.length === 0" class="empty-state">
      <svg viewBox="0 0 24 24" fill="currentColor">
        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
      </svg>
      <p>暂无观看记录</p>
      <router-link to="/" class="btn-browse">去浏览视频</router-link>
    </div>
    
    <div v-else class="history-grid">
      <div 
        v-for="item in history" 
        :key="item.videoId"
        class="history-item"
      >
        <div class="thumbnail" @click="playVideo(item.videoId)">
          <img :src="item.thumbnailUrl" :alt="item.title" />
          <div class="progress-overlay" v-if="!item.completed && item.progressPercentage > 0">
            <div class="progress-bar" :style="{ width: item.progressPercentage + '%' }"></div>
          </div>
          <div class="completed-badge" v-if="item.completed">
            <svg viewBox="0 0 24 24" fill="currentColor">
              <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
            </svg>
          </div>
          <div class="play-icon">
            <svg viewBox="0 0 24 24" fill="currentColor">
              <path d="M8 5v14l11-7z"/>
            </svg>
          </div>
        </div>
        
        <div class="item-info">
          <h4 @click="playVideo(item.videoId)">{{ item.title }}</h4>
          <div class="meta">
            <span class="progress-text" v-if="!item.completed">
              看到 {{ item.formattedTime }}
            </span>
            <span class="completed-text" v-else>已完成观看</span>
            <span class="dot">·</span>
            <span class="time">{{ item.lastWatchedText }}</span>
          </div>
        </div>
        
        <button class="btn-delete" @click="deleteItem(item.videoId)" title="删除记录">
          <svg viewBox="0 0 24 24" fill="currentColor">
            <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
          </svg>
        </button>
      </div>
    </div>
    
    <!-- 分页加载更多 -->
    <div v-if="hasMore" class="load-more">
      <button @click="loadMore" :disabled="loadingMore">
        {{ loadingMore ? '加载中...' : '加载更多' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { progressApi } from '@/api/progress'

const router = useRouter()

const history = ref([])
const loading = ref(true)
const loadingMore = ref(false)
const current = ref(1)
const size = ref(20)
const hasMore = ref(true)
const total = ref(0)

const loadHistory = async () => {
  try {
    loading.value = true
    const res = await progressApi.getHistory(1, size.value)
    
    history.value = res.records || []
    total.value = res.total || 0
    hasMore.value = history.value.length < total.value
    current.value = 1
  } catch (error) {
    console.error('加载历史失败:', error)
  } finally {
    loading.value = false
  }
}

const loadMore = async () => {
  if (loadingMore.value || !hasMore.value) return
  
  try {
    loadingMore.value = true
    current.value++
    
    const res = await progressApi.getHistory(current.value, size.value)
    const newRecords = res.records || []
    
    history.value.push(...newRecords)
    hasMore.value = history.value.length < (res.total || 0)
  } catch (error) {
    console.error('加载更多失败:', error)
  } finally {
    loadingMore.value = false
  }
}

const playVideo = (videoId) => {
  router.push(`/video/${videoId}`)
}

const deleteItem = async (videoId) => {
  if (!confirm('确定要删除这条观看记录吗?')) return
  
  try {
    await progressApi.deleteProgress(videoId)
    history.value = history.value.filter(item => item.videoId !== videoId)
  } catch (error) {
    console.error('删除失败:', error)
    alert('删除失败,请重试')
  }
}

onMounted(loadHistory)

defineExpose({
  refresh: loadHistory
})
</script>

<style scoped>
.history-list {
  width: 100%;
}

.loading-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 60px;
  color: #999;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top-color: #667eea;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 80px 20px;
  color: #999;
}

.empty-state svg {
  width: 64px;
  height: 64px;
  fill: #ddd;
  margin-bottom: 16px;
}

.empty-state p {
  margin: 0 0 20px 0;
  font-size: 16px;
}

.btn-browse {
  padding: 10px 24px;
  background: #667eea;
  color: white;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 500;
  transition: background 0.2s;
}

.btn-browse:hover {
  background: #5a6fd6;
}

.history-grid {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.history-item {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px;
  background: white;
  border-radius: 12px;
  transition: background 0.2s;
}

.history-item:hover {
  background: #f5f5f5;
}

.thumbnail {
  position: relative;
  width: 160px;
  aspect-ratio: 16/9;
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  flex-shrink: 0;
}

.thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.3s;
}

.thumbnail:hover img {
  transform: scale(1.05);
}

.progress-overlay {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 4px;
  background: rgba(0,0,0,0.2);
}

.progress-bar {
  height: 100%;
  background: #ff6b6b;
}

.completed-badge {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  background: #52c41a;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.completed-badge svg {
  width: 16px;
  height: 16px;
  fill: white;
}

.play-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  background: rgba(0,0,0,0.7);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.2s;
}

.thumbnail:hover .play-icon {
  opacity: 1;
}

.play-icon svg {
  width: 20px;
  height: 20px;
  fill: white;
  margin-left: 2px;
}

.item-info {
  flex: 1;
  min-width: 0;
}

.item-info h4 {
  margin: 0 0 8px 0;
  font-size: 15px;
  font-weight: 600;
  color: #1a1a1a;
  cursor: pointer;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.item-info h4:hover {
  color: #667eea;
}

.meta {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: #888;
}

.progress-text {
  color: #ff6b6b;
}

.completed-text {
  color: #52c41a;
  font-weight: 500;
}

.dot {
  opacity: 0.5;
}

.btn-delete {
  width: 36px;
  height: 36px;
  border: none;
  background: transparent;
  color: #999;
  cursor: pointer;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
  flex-shrink: 0;
}

.btn-delete:hover {
  background: #ff4d4f;
  color: white;
}

.btn-delete svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
}

.load-more {
  text-align: center;
  padding: 40px;
}

.load-more button {
  padding: 12px 32px;
  background: #f0f0f0;
  border: none;
  border-radius: 8px;
  color: #666;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;
}

.load-more button:hover:not(:disabled) {
  background: #e0e0e0;
  color: #333;
}

.load-more button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

@media (max-width: 768px) {
  .history-item {
    flex-direction: column;
    align-items: stretch;
  }
  
  .thumbnail {
    width: 100%;
  }
  
  .btn-delete {
    align-self: flex-end;
  }
}
</style>

2.5 页面视图

复制代码
<!-- src/views/Home.vue -->
<template>
  <div class="home-page">
    <!-- 继续观看 -->
    <section class="section continue-section" v-if="continueWatching">
      <ContinueWatchingCard 
        :item="continueWatching" 
        @dismiss="continueWatching = null"
      />
    </section>
    
    <!-- 热门视频 -->
    <section class="section">
      <div class="section-header">
        <h2>🔥 热门视频</h2>
        <router-link to="/search" class="view-all">查看全部</router-link>
      </div>
      <div class="video-grid">
        <VideoCard 
          v-for="video in hotVideos" 
          :key="video.id"
          :video="video"
          :watch-progress="getVideoProgress(video.id)"
        />
      </div>
    </section>
    
    <!-- 最新视频 -->
    <section class="section">
      <div class="section-header">
        <h2>🆕 最新上传</h2>
      </div>
      <div class="video-grid">
        <VideoCard 
          v-for="video in latestVideos" 
          :key="video.id"
          :video="video"
          :watch-progress="getVideoProgress(video.id)"
        />
      </div>
      
      <!-- 加载更多 -->
      <div class="load-more" v-if="hasMore">
        <button @click="loadMore" :disabled="loading">
          {{ loading ? '加载中...' : '加载更多' }}
        </button>
      </div>
    </section>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import VideoCard from '@/components/VideoCard.vue'
import ContinueWatchingCard from '@/components/ContinueWatchingCard.vue'
import { videoApi } from '@/api/video'
import { progressApi } from '@/api/progress'

const hotVideos = ref([])
const latestVideos = ref([])
const continueWatching = ref(null)
const videoProgressMap = ref(new Map())

const current = ref(1)
const size = ref(12)
const hasMore = ref(true)
const loading = ref(false)

const getVideoProgress = (videoId) => {
  return videoProgressMap.value.get(videoId) || 0
}

const loadData = async () => {
  try {
    // 并行加载数据
    const [hotRes, latestRes, continueRes] = await Promise.all([
      videoApi.getHot(6),
      videoApi.getList(1, size.value),
      progressApi.getContinueWatching().catch(() => null)
    ])
    
    hotVideos.value = hotRes || []
    latestVideos.value = latestRes.records || []
    hasMore.value = latestRes.records.length < latestRes.total
    continueWatching.value = continueRes
    
    // 加载这些视频的观看进度
    await loadVideoProgress([...hotVideos.value, ...latestVideos.value])
  } catch (error) {
    console.error('加载数据失败:', error)
  }
}

const loadVideoProgress = async (videos) => {
  // 这里可以批量获取进度,或者逐个获取
  // 简化处理:在实际项目中可以设计一个批量查询接口
  for (const video of videos) {
    try {
      const progress = await progressApi.getProgress(video.id)
      if (progress && progress.percentage > 0) {
        videoProgressMap.value.set(video.id, progress.percentage)
      }
    } catch (e) {
      // 忽略错误
    }
  }
}

const loadMore = async () => {
  if (loading.value || !hasMore.value) return
  
  loading.value = true
  current.value++
  
  try {
    const res = await videoApi.getList(current.value, size.value)
    latestVideos.value.push(...res.records)
    hasMore.value = latestVideos.value.length < res.total
    
    await loadVideoProgress(res.records)
  } catch (error) {
    console.error('加载更多失败:', error)
  } finally {
    loading.value = false
  }
}

onMounted(loadData)
</script>

<style scoped>
.home-page {
  max-width: 1400px;
  margin: 0 auto;
  padding: 24px;
}

.section {
  margin-bottom: 40px;
}

.continue-section {
  margin-top: -10px;
}

.section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.section-header h2 {
  margin: 0;
  font-size: 20px;
  font-weight: 600;
  color: #1a1a1a;
}

.view-all {
  color: #667eea;
  text-decoration: none;
  font-size: 14px;
  font-weight: 500;
  transition: color 0.2s;
}

.view-all:hover {
  color: #5a6fd6;
}

.video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
}

.load-more {
  text-align: center;
  padding: 40px;
}

.load-more button {
  padding: 12px 40px;
  background: #f0f0f0;
  border: none;
  border-radius: 8px;
  color: #666;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.2s;
}

.load-more button:hover:not(:disabled) {
  background: #667eea;
  color: white;
}

.load-more button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

@media (max-width: 768px) {
  .home-page {
    padding: 16px;
  }
  
  .video-grid {
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 16px;
  }
}
</style>

<!-- src/views/VideoDetail.vue -->
<template>
  <div class="video-detail-page">
    <div class="content-wrapper">
      <!-- 视频播放器 -->
      <VideoPlayer 
        v-if="video.id"
        :video="video"
        :userId="userId"
        @progress-updated="onProgressUpdated"
        @video-completed="onVideoCompleted"
      />
      
      <!-- 加载状态 -->
      <div v-else-if="loading" class="loading-state">
        <div class="spinner"></div>
        <p>加载视频中...</p>
      </div>
      
      <!-- 错误状态 -->
      <div v-else-if="error" class="error-state">
        <p>{{ error }}</p>
        <button @click="loadVideo">重试</button>
      </div>
    </div>
    
    <!-- 推荐视频 -->
    <aside class="sidebar" v-if="relatedVideos.length > 0">
      <h3>相关推荐</h3>
      <div class="related-list">
        <div 
          v-for="item in relatedVideos" 
          :key="item.id"
          class="related-item"
          @click="goToVideo(item.id)"
        >
          <img :src="item.thumbnailUrl" :alt="item.title" />
          <div class="info">
            <h4>{{ item.title }}</h4>
            <span>{{ formatDuration(item.duration) }}</span>
          </div>
        </div>
      </div>
    </aside>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VideoPlayer from '@/components/VideoPlayer.vue'
import { videoApi } from '@/api/video'
import { formatDuration } from '@/utils/format'

const route = useRoute()
const router = useRouter()

const video = ref({})
const relatedVideos = ref([])
const loading = ref(true)
const error = ref(null)
const userId = ref(parseInt(localStorage.getItem('userId') || '1'))

const loadVideo = async () => {
  const videoId = route.params.id
  if (!videoId) {
    error.value = '视频ID不能为空'
    return
  }
  
  try {
    loading.value = true
    error.value = null
    
    const res = await videoApi.getById(videoId)
    video.value = res
    
    // 加载相关视频(这里简化处理,实际应该根据标签/分类推荐)
    const listRes = await videoApi.getList(1, 6)
    relatedVideos.value = listRes.records.filter(v => v.id !== parseInt(videoId))
  } catch (err) {
    error.value = '视频加载失败: ' + (err.message || '未知错误')
  } finally {
    loading.value = false
  }
}

const goToVideo = (id) => {
  router.push(`/video/${id}`)
  // 重新加载页面数据
  loadVideo()
}

const onProgressUpdated = (progress) => {
  console.log('进度更新:', progress)
}

const onVideoCompleted = (videoId) => {
  console.log('视频完成:', videoId)
  // 可以显示完成提示或推荐下一个视频
}

onMounted(loadVideo)
</script>

<style scoped>
.video-detail-page {
  max-width: 1400px;
  margin: 0 auto;
  padding: 24px;
  display: grid;
  grid-template-columns: 1fr 360px;
  gap: 24px;
}

.content-wrapper {
  min-width: 0;
}

.loading-state, .error-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 100px 20px;
  color: #999;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top-color: #667eea;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.error-state button {
  margin-top: 16px;
  padding: 10px 24px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.sidebar {
  border-left: 1px solid #eee;
  padding-left: 24px;
}

.sidebar h3 {
  margin: 0 0 16px 0;
  font-size: 16px;
  color: #333;
}

.related-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.related-item {
  display: flex;
  gap: 12px;
  cursor: pointer;
  padding: 8px;
  border-radius: 8px;
  transition: background 0.2s;
}

.related-item:hover {
  background: #f5f5f5;
}

.related-item img {
  width: 120px;
  aspect-ratio: 16/9;
  object-fit: cover;
  border-radius: 6px;
}

.related-item .info {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.related-item h4 {
  margin: 0 0 4px 0;
  font-size: 14px;
  color: #1a1a1a;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.related-item span {
  font-size: 12px;
  color: #999;
}

@media (max-width: 1024px) {
  .video-detail-page {
    grid-template-columns: 1fr;
  }
  
  .sidebar {
    border-left: none;
    padding-left: 0;
    border-top: 1px solid #eee;
    padding-top: 24px;
  }
}
</style>

<!-- src/views/History.vue -->
<template>
  <div class="history-page">
    <div class="page-header">
      <h1>📚 观看历史</h1>
      <div class="header-actions">
        <div class="stats-summary" v-if="stats">
          <span>总观看: {{ stats.totalWatched }}部</span>
          <span>已完成: {{ stats.completedCount }}部</span>
          <span>完成率: {{ stats.completionRate }}%</span>
        </div>
        <button 
          class="btn-clear" 
          @click="clearAllHistory"
          :disabled="historyList.length === 0"
        >
          <svg viewBox="0 0 24 24" fill="currentColor">
            <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
          </svg>
          清空历史
        </button>
      </div>
    </div>
    
    <!-- 继续观看 -->
    <section class="continue-section" v-if="continueWatching">
      <h2>继续观看</h2>
      <ContinueWatchingCard 
        :item="continueWatching"
        @dismiss="continueWatching = null"
      />
    </section>
    
    <!-- 历史列表 -->
    <section class="history-section">
      <h2>全部记录</h2>
      <HistoryList ref="historyListRef" />
    </section>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import HistoryList from '@/components/HistoryList.vue'
import ContinueWatchingCard from '@/components/ContinueWatchingCard.vue'
import { progressApi } from '@/api/progress'

const historyListRef = ref(null)
const continueWatching = ref(null)
const stats = ref(null)
const historyList = ref([])

const loadContinueWatching = async () => {
  try {
    const res = await progressApi.getContinueWatching()
    continueWatching.value = res
  } catch (error) {
    console.error('加载继续观看失败:', error)
  }
}

const loadStats = async () => {
  try {
    const res = await progressApi.getStats()
    stats.value = res
  } catch (error) {
    console.error('加载统计失败:', error)
  }
}

const clearAllHistory = async () => {
  if (!confirm('确定要清空所有观看历史吗?此操作不可恢复!')) return
  
  try {
    await progressApi.clearHistory()
    // 刷新列表
    historyListRef.value?.refresh()
    continueWatching.value = null
    stats.value = null
    alert('已清空所有观看历史')
  } catch (error) {
    console.error('清空失败:', error)
    alert('清空失败,请重试')
  }
}

onMounted(() => {
  loadContinueWatching()
  loadStats()
})
</script>

<style scoped>
.history-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 32px;
  flex-wrap: wrap;
  gap: 16px;
}

.page-header h1 {
  margin: 0;
  font-size: 28px;
  color: #1a1a1a;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 20px;
}

.stats-summary {
  display: flex;
  gap: 16px;
  font-size: 14px;
  color: #666;
}

.stats-summary span {
  background: #f0f0f0;
  padding: 6px 12px;
  border-radius: 20px;
}

.btn-clear {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  background: #ff4d4f;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-clear:hover:not(:disabled) {
  background: #ff7875;
}

.btn-clear:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.btn-clear svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
}

.continue-section {
  margin-bottom: 32px;
}

.continue-section h2,
.history-section h2 {
  font-size: 18px;
  color: #333;
  margin: 0 0 16px 0;
}

@media (max-width: 768px) {
  .page-header {
    flex-direction: column;
    align-items: flex-start;
  }
  
  .header-actions {
    width: 100%;
    flex-direction: column;
    align-items: stretch;
  }
  
  .stats-summary {
    justify-content: center;
  }
}
</style>

vue

复制

复制代码
<!-- src/views/Search.vue -->
<template>
  <div class="search-page">
    <div class="search-header">
      <div class="search-box">
        <svg class="search-icon" viewBox="0 0 24 24" fill="currentColor">
          <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
        </svg>
        <input 
          v-model="keyword"
          @keyup.enter="doSearch"
          placeholder="搜索视频标题、描述..."
          type="text"
        />
        <button v-if="keyword" class="btn-clear-input" @click="keyword = ''">×</button>
      </div>
      <button class="btn-search" @click="doSearch" :disabled="!keyword.trim() || searching">
        {{ searching ? '搜索中...' : '搜索' }}
      </button>
    </div>
    
    <!-- 搜索结果 -->
    <div v-if="hasSearched" class="search-results">
      <div class="results-header">
        <p v-if="total > 0">找到 {{ total }} 个相关视频</p>
        <p v-else-if="!searching">未找到相关视频</p>
      </div>
      
      <div v-if="results.length > 0" class="video-grid">
        <VideoCard 
          v-for="video in results" 
          :key="video.id"
          :video="video"
          :watch-progress="getVideoProgress(video.id)"
        />
      </div>
      
      <!-- 分页 -->
      <div v-if="total > size" class="pagination">
        <button 
          :disabled="current === 1 || searching" 
          @click="changePage(current - 1)"
        >
          上一页
        </button>
        <span class="page-info">第 {{ current }} / {{ totalPages }} 页</span>
        <button 
          :disabled="current >= totalPages || searching" 
          @click="changePage(current + 1)"
        >
          下一页
        </button>
      </div>
    </div>
    
    <!-- 推荐搜索/热门标签 -->
    <div v-else class="search-suggestions">
      <h3>热门搜索</h3>
      <div class="hot-tags">
        <span 
          v-for="tag in hotTags" 
          :key="tag"
          class="tag"
          @click="quickSearch(tag)"
        >
          {{ tag }}
        </span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VideoCard from '@/components/VideoCard.vue'
import { videoApi } from '@/api/video'
import { progressApi } from '@/api/progress'

const route = useRoute()
const router = useRouter()

const keyword = ref('')
const results = ref([])
const current = ref(1)
const size = ref(12)
const total = ref(0)
const searching = ref(false)
const hasSearched = ref(false)
const videoProgressMap = ref(new Map())

const hotTags = ['Java', 'Spring Boot', 'Vue', 'MyBatis', 'Redis', '微服务', '面试', '项目实战']

const totalPages = computed(() => Math.ceil(total.value / size.value))

const getVideoProgress = (videoId) => {
  return videoProgressMap.value.get(videoId) || 0
}

const doSearch = async () => {
  if (!keyword.value.trim()) return
  
  searching.value = true
  hasSearched.value = true
  current.value = 1
  
  try {
    const res = await videoApi.search(keyword.value.trim(), current.value, size.value)
    results.value = res.records || []
    total.value = res.total || 0
    
    // 更新URL
    router.replace({ query: { q: keyword.value } })
    
    // 加载观看进度
    await loadVideoProgress(results.value)
  } catch (error) {
    console.error('搜索失败:', error)
  } finally {
    searching.value = false
  }
}

const changePage = async (page) => {
  current.value = page
  searching.value = true
  
  try {
    const res = await videoApi.search(keyword.value, current.value, size.value)
    results.value = res.records || []
    // 加载进度
    await loadVideoProgress(results.value)
    // 滚动到顶部
    window.scrollTo({ top: 0, behavior: 'smooth' })
  } catch (error) {
    console.error('翻页失败:', error)
  } finally {
    searching.value = false
  }
}

const quickSearch = (tag) => {
  keyword.value = tag
  doSearch()
}

const loadVideoProgress = async (videos) => {
  for (const video of videos) {
    try {
      const progress = await progressApi.getProgress(video.id)
      if (progress && progress.percentage > 0) {
        videoProgressMap.value.set(video.id, progress.percentage)
      }
    } catch (e) {
      // 忽略
    }
  }
}

// 从URL初始化搜索词
watch(() => route.query.q, (newQ) => {
  if (newQ && !hasSearched.value) {
    keyword.value = newQ
    doSearch()
  }
}, { immediate: true })
</script>

<style scoped>
.search-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 40px 24px;
}

.search-header {
  display: flex;
  gap: 12px;
  margin-bottom: 32px;
  max-width: 800px;
  margin-left: auto;
  margin-right: auto;
}

.search-box {
  flex: 1;
  position: relative;
  display: flex;
  align-items: center;
}

.search-icon {
  position: absolute;
  left: 16px;
  width: 20px;
  height: 20px;
  color: #999;
  pointer-events: none;
}

.search-box input {
  width: 100%;
  padding: 14px 40px 14px 48px;
  border: 2px solid #e0e0e0;
  border-radius: 12px;
  font-size: 16px;
  transition: border-color 0.2s;
  outline: none;
}

.search-box input:focus {
  border-color: #667eea;
}

.btn-clear-input {
  position: absolute;
  right: 12px;
  width: 24px;
  height: 24px;
  border: none;
  background: #e0e0e0;
  color: #666;
  border-radius: 50%;
  cursor: pointer;
  font-size: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.btn-search {
  padding: 14px 32px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 12px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
  white-space: nowrap;
}

.btn-search:hover:not(:disabled) {
  background: #5a6fd6;
}

.btn-search:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.results-header {
  margin-bottom: 20px;
  color: #666;
}

.video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin-bottom: 40px;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  padding: 20px;
}

.pagination button {
  padding: 10px 20px;
  background: #f0f0f0;
  border: none;
  border-radius: 8px;
  color: #666;
  cursor: pointer;
  transition: all 0.2s;
}

.pagination button:hover:not(:disabled) {
  background: #667eea;
  color: white;
}

.pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.page-info {
  color: #666;
  font-size: 14px;
}

.search-suggestions {
  text-align: center;
  padding: 60px 20px;
}

.search-suggestions h3 {
  color: #666;
  margin-bottom: 20px;
}

.hot-tags {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.tag {
  padding: 10px 20px;
  background: #f0f0f0;
  border-radius: 20px;
  color: #666;
  cursor: pointer;
  transition: all 0.2s;
}

.tag:hover {
  background: #667eea;
  color: white;
}

@media (max-width: 768px) {
  .search-header {
    flex-direction: column;
  }
  
  .btn-search {
    width: 100%;
  }
  
  .video-grid {
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 16px;
  }
}
</style>

2.6 路由与入口文件

复制代码
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import VideoDetail from '@/views/VideoDetail.vue'
import History from '@/views/History.vue'
import Search from '@/views/Search.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/video/:id',
    name: 'VideoDetail',
    component: VideoDetail
  },
  {
    path: '/history',
    name: 'History',
    component: History
  },
  {
    path: '/search',
    name: 'Search',
    component: Search
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

<!-- src/App.vue -->
<template>
  <div class="app">
    <!-- 导航栏 -->
    <nav class="navbar">
      <div class="nav-brand">
        <router-link to="/">
          <svg viewBox="0 0 24 24" fill="currentColor">
            <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
          </svg>
          <span>VideoPlayer</span>
        </router-link>
      </div>
      
      <div class="nav-links">
        <router-link to="/" active-class="active">首页</router-link>
        <router-link to="/history" active-class="active">观看历史</router-link>
        <router-link to="/search" active-class="active">搜索</router-link>
      </div>
      
      <div class="nav-user">
        <span class="user-id">用户 {{ userId }}</span>
      </div>
    </nav>
    
    <!-- 主内容区 -->
    <main class="main-content">
      <router-view v-slot="{ Component }">
        <keep-alive :include="['Home']">
          <component :is="Component" />
        </keep-alive>
      </router-view>
    </main>
    
    <!-- 页脚 -->
    <footer class="footer">
      <p>© 2026 VideoPlayer - Java视频在线播放系统</p>
    </footer>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const userId = ref(localStorage.getItem('userId') || '1')
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  background: #f5f5f5;
  color: #333;
  line-height: 1.6;
}

.app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* 导航栏 */
.navbar {
  background: white;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  padding: 0 24px;
  height: 64px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  position: sticky;
  top: 0;
  z-index: 100;
}

.nav-brand a {
  display: flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  color: #667eea;
  font-size: 20px;
  font-weight: 700;
}

.nav-brand svg {
  width: 32px;
  height: 32px;
  fill: #667eea;
}

.nav-links {
  display: flex;
  gap: 32px;
}

.nav-links a {
  color: #666;
  text-decoration: none;
  font-size: 15px;
  font-weight: 500;
  padding: 8px 0;
  position: relative;
  transition: color 0.2s;
}

.nav-links a:hover,
.nav-links a.active {
  color: #667eea;
}

.nav-links a.active::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: #667eea;
  border-radius: 2px;
}

.nav-user {
  font-size: 14px;
  color: #999;
}

.user-id {
  background: #f0f0f0;
  padding: 6px 12px;
  border-radius: 20px;
}

/* 主内容 */
.main-content {
  flex: 1;
  min-height: calc(100vh - 64px - 60px);
}

/* 页脚 */
.footer {
  background: white;
  border-top: 1px solid #eee;
  padding: 20px;
  text-align: center;
  color: #999;
  font-size: 14px;
}

@media (max-width: 768px) {
  .navbar {
    padding: 0 16px;
  }
  
  .nav-links {
    gap: 16px;
  }
  
  .nav-links a {
    font-size: 14px;
  }
  
  .nav-user {
    display: none;
  }
}
</style>

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(router)
app.mount('#app')

2.7 项目配置文件

package.json

复制代码
// package.json
{
  "name": "video-player-frontend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.6.2",
    "vue": "^3.3.8",
    "vue-router": "^4.2.5"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.5.0",
    "vite": "^5.0.0"
  }
}

vite.config.js

复制代码
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
})

index.html

复制代码
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>VideoPlayer - Java视频在线播放系统</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

3. 数据库表结构

复制代码
-- 视频表
CREATE TABLE videos (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL COMMENT '视频标题',
    description TEXT COMMENT '视频描述',
    file_path VARCHAR(500) NOT NULL COMMENT '文件路径或URL',
    thumbnail_url VARCHAR(500) COMMENT '缩略图URL',
    duration BIGINT COMMENT '视频时长(秒)',
    resolution VARCHAR(20) COMMENT '分辨率',
    status ENUM('ACTIVE', 'INACTIVE', 'DELETED') DEFAULT 'ACTIVE',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_status (status),
    FULLTEXT INDEX idx_search (title, description)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户视频播放进度表(核心表)
CREATE TABLE video_progress (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    video_id BIGINT NOT NULL COMMENT '视频ID',
    current_time BIGINT NOT NULL DEFAULT 0 COMMENT '当前播放位置(秒)',
    total_duration BIGINT COMMENT '视频总时长',
    completed BOOLEAN DEFAULT FALSE COMMENT '是否已看完',
    watch_count INT DEFAULT 1 COMMENT '观看次数',
    last_watched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后观看时间',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_video (user_id, video_id),
    INDEX idx_user_time (user_id, last_watched_at),
    INDEX idx_completed (user_id, completed),
    FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4. 关键功能说明

4.1 播放进度保存策略

场景 策略 说明
播放中 每5秒上报 使用定时器,减少请求频率
暂停/关闭 立即上报 确保进度不丢失
视频结束 标记完成 更新completed字段
网络异常 LocalStorage备份 失败时本地存储,下次恢复

4.2 性能优化点

  1. Redis缓存:高频读取的播放进度优先从Redis获取
  2. 延迟写入:播放进度先写Redis,异步批量写入MySQL
  3. 断点续传:HTTP 206 Partial Content支持拖动跳转
  4. 防抖处理:前端保存进度使用500ms防抖

4.3 用户体验特性

  • 继续观看提示:进入视频页时自动提示上次观看位置
  • 多设备同步:通过WebSocket或轮询实现多设备进度同步
  • 观看历史管理:支持查看、删除、清空历史记录
  • 进度可视化:缩略图上的进度条直观显示观看进度

这个实现提供了完整的视频播放历史记录功能,包括精确到秒级的进度保存、继续观看提示、观看历史管理等核心功能,同时考虑了高并发场景下的性能优化。

相关推荐
星轨初途1 小时前
【C/C++底层修炼】拆解动态内存管理:四大动态内存函数、六大错误与柔性数组
c语言·开发语言·c++·经验分享·笔记·柔性数组
小江的记录本2 小时前
【泛型】泛型:泛型擦除、通配符、上下界限定
java·windows·spring boot·后端·spring·maven·mybatis
froginwe112 小时前
PHP 过滤器
开发语言
pupudawang2 小时前
springboot下使用druid-spring-boot-starter
java·spring boot·后端
2301_764441332 小时前
Helios:14B实时长视频生成模型
人工智能·音视频
rrrjqy2 小时前
Java基础篇(一)
java·开发语言
我是咸鱼不闲呀2 小时前
力扣Hot100系列23(Java)——[回溯]总结(上)(全排列,子集,电话号码的字母组合,组合总和)
java·算法·leetcode
EasyGBS2 小时前
国密GB35114协议+国标GB28181平台EasyGBS双重保障筑牢安防视频安全防线
安全·https·音视频