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或轮询实现多设备进度同步
  • 观看历史管理:支持查看、删除、清空历史记录
  • 进度可视化:缩略图上的进度条直观显示观看进度

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

相关推荐
i220818 Faiz Ul13 分钟前
个人健康系统|健康管理|基于java+Android+微信小程序的个人健康系统设计与实现(源码+数据库+文档)
android·java·vue.js·spring boot·微信小程序·毕设·个人健康系统
组合缺一17 分钟前
agentscope-harness vs solon-ai-harness:Java 智能体「马具引擎」的双雄对决
java·人工智能·ai·llm·agent·solon·agentscope
zhangfeng11332 小时前
openclaw skills 小龙虾技能 通讯仿真 matlab skill Simulink Agentic Toolkit,通过kimi找到,mcp通讯
开发语言·matlab·openclaw·通讯仿真
Javatutouhouduan8 小时前
2026Java面试的正确打开方式!
java·高并发·java面试·java面试题·后端开发·java编程·java八股文
chao1898449 小时前
基于 SPEA2 的多目标优化算法 MATLAB 实现
开发语言·算法·matlab
JAVA面经实录9179 小时前
Java初级最终完整版学习路线图
java·spring·eclipse·maven
赏金术士9 小时前
Kotlin 习题集 · 高级篇
android·开发语言·kotlin
Cat_Rocky10 小时前
k8s-持久化存储,粗浅学习
java·学习·kubernetes
楼兰公子10 小时前
buildroot 在编译rust时裁剪平台类型数量的方法
开发语言·后端·rust
潜创微科技10 小时前
IT9201+IT66021:便携 KVM 一站式方案,音视控三合一免驱即插即用
嵌入式硬件·音视频