diff --git a/server/handler.go b/server/handler.go index bcf99f9..c6196d6 100644 --- a/server/handler.go +++ b/server/handler.go @@ -10,6 +10,98 @@ import ( "github.com/gin-gonic/gin" ) +// 重命名电影的处理函数 +func PostRename(c *gin.Context) { + response := gin.H{ + "code": http.StatusInternalServerError, + "message": "An unexpected error occurred.", + } + + // 定义请求结构体 + type RenameRequest struct { + OldName string `json:"old_name" binding:"required"` + NewName string `json:"new_name" binding:"required"` + } + + var req RenameRequest + if err := c.ShouldBindJSON(&req); err != nil { + response["code"] = http.StatusBadRequest + response["message"] = "Invalid request body: " + err.Error() + c.JSON(http.StatusBadRequest, response) + return + } + + oldName := req.OldName + newName := req.NewName + + if oldName == "" || newName == "" { + response["code"] = http.StatusBadRequest + response["message"] = "Old name and new name are required." + c.JSON(http.StatusBadRequest, response) + return + } + + if oldName == newName { + response["code"] = http.StatusBadRequest + response["message"] = "Old name and new name cannot be the same." + c.JSON(http.StatusBadRequest, response) + return + } + + MovieDictLock.Lock() + defer MovieDictLock.Unlock() + + // 检查新名称是否已存在 + if _, exists := MovieDict[newName]; exists { + response["code"] = http.StatusConflict + response["message"] = "New name already exists." + c.JSON(http.StatusConflict, response) + return + } + + // 查找旧名称对应的电影 + movie, exists := MovieDict[oldName] + if exists { + // 更新电影信息 + err := movie.RenameVideo(newName) // 重命名视频文件 + if err != nil { + response["code"] = http.StatusInternalServerError + response["message"] = "Failed to rename video file." + c.JSON(http.StatusInternalServerError, response) + return + } + + delete(MovieDict, oldName) // 删除旧名称的条目 + MovieDict[newName] = movie // 添加新名称的条目 + + response["code"] = http.StatusOK + response["message"] = "Movie renamed successfully." + response["data"] = gin.H{"old_name": oldName, "new_name": newName} + c.JSON(http.StatusOK, response) + log.Printf("Movie renamed from %s to %s successfully", oldName, newName) + return + } + + // 如果旧名称不存在,返回404错误 + response["code"] = http.StatusNotFound + response["message"] = "Movie not found." + c.JSON(http.StatusNotFound, response) + log.Printf("Movie rename failed: %s not found", oldName) +} + +func GetCategoryList(c *gin.Context) { + response := gin.H{ + "code": http.StatusInternalServerError, + "message": "An unexpected error occurred.", + "data": []string{}, + } + + response["code"] = http.StatusOK + response["message"] = "Success" + response["data"] = Categories + c.JSON(http.StatusOK, response) +} + // MovieList 电影列表 func MovieList(c *gin.Context) { response := gin.H{ @@ -18,27 +110,27 @@ func MovieList(c *gin.Context) { "data": gin.H{"items": []Movie{}, "total": 0}, } - var moviesToPaginate []Movie + var moviesToPaginate []*Movie categoryName := c.Query("category") searchQuery := c.Query("search") // 获取搜索参数 // === 第一步:基础过滤(类别)=== if categoryName != "" && categoryName != "最新添加" { // 普通类别:直接过滤 - for _, m := range movies { + for _, m := range Movies { if m.TimeCategory == categoryName { moviesToPaginate = append(moviesToPaginate, m) } } } else { // 最新添加/所有电影:使用全局movies - moviesToPaginate = make([]Movie, len(movies)) - copy(moviesToPaginate, movies) + moviesToPaginate = make([]*Movie, len(Movies)) + copy(moviesToPaginate, Movies) } // === 第二步:搜索过滤 === if searchQuery != "" { - var searchResults []Movie + var searchResults []*Movie lowerSearch := strings.ToLower(searchQuery) for _, m := range moviesToPaginate { diff --git a/server/main.go b/server/main.go index 44e459b..2142a47 100644 --- a/server/main.go +++ b/server/main.go @@ -32,7 +32,9 @@ func main() { movie := eg.Group("movie") movie.Use(jwtMiddleware()) - movie.GET("/", MovieList) + movie.GET("/list/category", GetCategoryList) + movie.GET("/list", MovieList) + movie.POST("/rename", PostRename) eg.Run("0.0.0.0:4444") } diff --git a/server/movie.go b/server/movie.go index 4af5d5a..dfaf42e 100644 --- a/server/movie.go +++ b/server/movie.go @@ -12,20 +12,71 @@ import ( "sort" "strconv" "strings" + "sync" ) // Movie 电影信息结构 type Movie struct { - FileName string `json:"filename"` // 原始文件名(如 "video1.mp4") - Image string `json:"image"` // 对应的缩略图文件名(如 "video1.png") - Duration int `json:"duration"` // 视频时长(分钟) - TimeCategory string `json:"time_category"` // 时长分类(如 "15min", "30min") - VideoPath string `json:"-"` // 视频文件完整路径(用于ffmpeg) - CreatedTime int64 `json:"created_time"` // 文件创建时间戳 + FileName string `json:"filename"` // 原始文件名(如 "video1.mp4") + Image string `json:"image"` // 对应的缩略图文件名(如 "video1.png") + Duration int `json:"duration"` // 视频时长(分钟) + TimeCategory string `json:"time_category"` // 时长分类(如 "15min", "30min") + VideoPath string `json:"-"` // 视频文件完整路径(用于ffmpeg) + CreatedTime int64 `json:"created_time"` // 文件创建时间戳 + mu sync.Mutex // 保护视频文件的并发访问 } -var movies []Movie // 存储所有电影信息的全局切片 -var IsRemakePNG = false // 是否重新生成所有PNG缩略图 +func (m *Movie) RenameVideo(newName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if newName == m.FileName { + return nil // 如果新名称与当前名称相同,则不需要重命名 + } + + // 获取文件扩展名 + videoExt := filepath.Ext(m.FileName) + imageExt := filepath.Ext(m.Image) + + // 获取当前文件的目录 + dir := filepath.Dir(m.VideoPath) + + // 构建新的文件名(不带扩展名) + newBaseName := strings.TrimSuffix(newName, videoExt) + + // 重命名视频文件 + oldVideoPath := m.VideoPath + newVideoPath := filepath.Join(dir, newBaseName+videoExt) + if err := os.Rename(oldVideoPath, newVideoPath); err != nil { + return fmt.Errorf("重命名视频文件失败: %w", err) + } + + // 重命名缩略图文件 + oldImagePath := filepath.Join(dir, m.Image) + newImagePath := filepath.Join(dir, newBaseName+imageExt) + if _, err := os.Stat(oldImagePath); err == nil { + if err := os.Rename(oldImagePath, newImagePath); err != nil { + // 如果缩略图重命名失败,尝试回滚视频文件重命名 + _ = os.Rename(newVideoPath, oldVideoPath) + return fmt.Errorf("重命名缩略图失败: %w", err) + } + m.Image = newBaseName + imageExt + } + + // 更新结构体信息 + m.VideoPath = newVideoPath + m.FileName = newBaseName + videoExt + + return nil +} + +var Movies []*Movie // 存储所有电影信息的全局切片 +var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典 +var MovieDictLock sync.Mutex // 保护MovieDict的并发访问 + +var IsRemakePNG = false // 是否重新生成所有PNG缩略图 +var Categories = []string{ // 分类 + "15min", "30min", "60min", "大于60min", "最新添加"} // getVideoDuration 获取视频时长(秒) func getVideoDuration(videoPath string) (float64, error) { @@ -131,7 +182,7 @@ func processVideoFile(path string, movieDict map[string]*Movie) error { timeCat := getTimeCategory(durationMin) // 创建电影记录 - movie := Movie{ + movie := &Movie{ FileName: filename, Image: baseName + ".png", Duration: durationMin, @@ -139,7 +190,8 @@ func processVideoFile(path string, movieDict map[string]*Movie) error { VideoPath: path, CreatedTime: createdTime, } - movies = append(movies, movie) + Movies = append(Movies, movie) + MovieDict[movie.FileName] = movie // 添加到全局字典 // 更新缩略图字典 if entry, exists := movieDict[baseName]; exists { @@ -166,34 +218,6 @@ func getTimeCategory(durationMin int) string { } } -// sortMovies 对电影列表排序 -func sortMovies() { - categoryOrder := map[string]int{ - "15min": 0, - "30min": 1, - "60min": 2, - "大于60min": 3, - "最新添加": 4, // 新增的"最新添加"类别 - } - - sort.Slice(movies, func(i, j int) bool { - catI := categoryOrder[movies[i].TimeCategory] - catJ := categoryOrder[movies[j].TimeCategory] - - if catI != catJ { - return catI < catJ - } - - // 如果是recent类别,按创建时间倒序排序 - if movies[i].TimeCategory == "最新添加" { - return movies[i].CreatedTime > movies[j].CreatedTime - } - - // 其他类别按时长排序 - return movies[i].Duration < movies[j].Duration - }) -} - // generateThumbnail 生成单个视频缩略图 func generateThumbnail(movie *Movie) { baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName)) @@ -244,7 +268,7 @@ func initMovie() { // 创建需要处理缩略图的视频字典 movieDict := createMovieDict(files) - movies = make([]Movie, 0) // 重置全局电影列表 + Movies = make([]*Movie, 0) // 重置全局电影列表 // 遍历处理每个视频文件 err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error { @@ -274,17 +298,19 @@ func initMovie() { } generateThumbnail(movie) } + + log.Printf("成功初始化电影数据,共找到 %d 个视频文件", len(Movies)) } // markRecentMovies 标记最近添加的电影 func markRecentMovies() { // 首先按创建时间排序 - sort.Slice(movies, func(i, j int) bool { - return movies[i].CreatedTime > movies[j].CreatedTime + sort.Slice(Movies, func(i, j int) bool { + return Movies[i].CreatedTime > Movies[j].CreatedTime }) // 标记前20个最新文件为"最新添加"类别 - for i := 0; i < len(movies) && i < 20; i++ { - movies[i].TimeCategory = "最新添加" + for i := 0; i < len(Movies) && i < 20; i++ { + Movies[i].TimeCategory = "最新添加" } } diff --git a/src/Main.jsx b/src/Main.jsx index 7e01879..ddd1e56 100644 --- a/src/Main.jsx +++ b/src/Main.jsx @@ -47,6 +47,9 @@ const Main = () => { const isFirstLoad = useRef(true); const scrollListenerActive = useRef(false); + + + // 初始化状态 - 添加搜索相关状态 const [params, setParams] = usePersistedState('params', { lastCategory: DEFAULT_CATEGORY, @@ -119,7 +122,7 @@ const Main = () => { const isSearch = category === SEARCH_CATEGORY; const isLatest = category === '最新添加'; - let apiUrl = `/movie?page=${page}&limit=${LIMIT}`; + let apiUrl = `/movie/list?page=${page}&limit=${LIMIT}`; if (isSearch) { // 搜索请求 @@ -326,6 +329,32 @@ const Main = () => { /> ); + const handleRename = useCallback(async (oldName, newName) => { + try { + const response = await axios.post('/movie/rename', { + old_name: oldName, + new_name: newName + }); + + if (response.data.code === 200) { + // 重命名成功后重新加载当前页面数据 + const category = params.lastCategory; + const categoryHistory = params.history[category] || { lastPage: 1 }; + + if (category === SEARCH_CATEGORY) { + await fetchMovies(category, categoryHistory.lastPage, params.searchQuery); + } else { + await fetchMovies(category, categoryHistory.lastPage); + } + } else { + throw new Error(response.data.message || '重命名失败'); + } + } catch (error) { + console.error('重命名错误:', error); + throw error; + } + }, [fetchMovies, params.lastCategory, params.history, params.searchQuery]); + return ( {/* 添加搜索框 */} @@ -379,7 +408,7 @@ const Main = () => { style={{ textDecoration: 'none', paddingBottom: 10 }} onClick={handleMovieCardClick} > - + ))} diff --git a/src/components/MovieCard.jsx b/src/components/MovieCard.jsx index 9659948..80e2af0 100644 --- a/src/components/MovieCard.jsx +++ b/src/components/MovieCard.jsx @@ -1,19 +1,78 @@ -import React from 'react'; -import {useEffect} from 'react'; - - -import { Card, CardContent, CardMedia, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { Card, CardContent, CardMedia, Typography, IconButton, TextField, Dialog, DialogActions, DialogContent, DialogTitle, Button } from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; import { styled } from '@mui/system'; -const MovieCard = ({ movie, config }) => { - +const MovieCard = ({ movie, config, onRename }) => { + const [newName, setNewName] = useState(''); + const [openDialog, setOpenDialog] = useState(false); const truncateFilename = (filename, maxLength) => { return filename.length > maxLength ? filename.substring(0, maxLength - 3) + '...' : filename; }; - + + const handleRenameClick = (e) => { + e.stopPropagation(); + e.preventDefault(); // 添加阻止默认行为 + const lastDotIndex = movie.filename.lastIndexOf('.'); + const nameWithoutExt = lastDotIndex === -1 + ? movie.filename + : movie.filename.substring(0, lastDotIndex); + setNewName(nameWithoutExt); + setOpenDialog(true); + }; + + const handleDialogClose = (e) => { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + setOpenDialog(false); + }; + + const handleRenameSubmit = async (e) => { + if (e) { + e.stopPropagation(); + e.preventDefault(); // 添加阻止默认行为 + } + + if (!newName.trim()) return; + + try { + const lastDotIndex = movie.filename.lastIndexOf('.'); + const extension = lastDotIndex === -1 + ? '' + : movie.filename.substring(lastDotIndex); + const fullNewName = newName.trim() + extension; + + if (fullNewName === movie.filename) { + handleDialogClose(); + return; + } + + await onRename(movie.filename, fullNewName); + handleDialogClose(); + } catch (error) { + console.error('重命名失败:', error); + } + }; + + // 处理输入框回车键提交 + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + handleRenameSubmit(e); + } + }; + + // 阻止对话框内容区域的点击事件冒泡 + const handleDialogContentClick = (e) => { + e.stopPropagation(); + }; + const StyledCard = styled(Card)({ '&:hover': { transform: 'scale(1.05)', @@ -23,31 +82,84 @@ const MovieCard = ({ movie, config }) => { }); return ( - - - - - {truncateFilename(movie.filename, 15)} - - 时长: {movie.duration} min - - - - + <> + + + + + + + + {truncateFilename(movie.filename, 15)} + + 时长: {movie.duration} min + + + + + + + 重命名文件 + + setNewName(e.target.value)} + onKeyPress={handleKeyPress} // 支持回车键提交 + helperText="请不要修改文件后缀" + onClick={(e) => e.stopPropagation()} // 防止输入框点击冒泡 + /> + + + + + + + ); };