feat(server): 添加删除视频文件的功能

新增 `/movie/delete` 接口用于删除指定视频文件及其缩略图,并从全局电影列表中移除记录。
同时完善了前端删除交互,包括确认对话框、成功提示及错误处理。

- 后端:
  - 新增 `PostDelete` 处理函数,支持通过文件名删除视频
  - 实现 `Movie.DeleteVideo()` 方法,用于删除视频文件和关联缩略图
 - 更新路由注册,增加 `/delete` 接口
- 前端:
  - 新增删除确认弹窗与交互逻辑
  - 添加删除成功的 Snackbar 提示
  - 调整 MovieCard 按钮布局以容纳删除按钮
  - 传递并使用 onDelete 回调函数处理删除操作

该功能增强了系统的文件管理能力,使用户能够安全地移除不需要的视频内容。
This commit is contained in:
eson 2025-11-12 01:21:20 +08:00
parent b17e9dea28
commit 1a058a31c8
5 changed files with 251 additions and 14 deletions

View File

@ -3,9 +3,9 @@ package main
import (
"log"
"net/http"
"sort"
"strconv"
"strings"
"sort"
"github.com/gin-gonic/gin"
)
@ -196,4 +196,67 @@ func PostRename(c *gin.Context) {
response["message"] = "Rename successful"
c.JSON(http.StatusOK, response)
}
}
// PostDelete 删除文件
func PostDelete(c *gin.Context) {
var requestData struct {
FileName string `json:"file_name"`
}
response := gin.H{
"code": http.StatusOK,
"message": "Success",
"data": nil,
}
if err := c.ShouldBindJSON(&requestData); err != nil {
response["code"] = http.StatusBadRequest
response["message"] = "Invalid request data: " + err.Error()
c.JSON(http.StatusBadRequest, response)
return
}
if requestData.FileName == "" {
response["code"] = http.StatusBadRequest
response["message"] = "file_name is required"
c.JSON(http.StatusBadRequest, response)
return
}
// 查找要删除的电影
movie, exists := MovieDict[requestData.FileName]
if !exists {
response["code"] = http.StatusNotFound
response["message"] = "Movie not found"
c.JSON(http.StatusNotFound, response)
return
}
// 执行删除
if err := movie.DeleteVideo(); err != nil {
response["code"] = http.StatusInternalServerError
response["message"] = "Failed to delete video: " + err.Error()
c.JSON(http.StatusInternalServerError, response)
return
}
// 从全局Movies切片中移除记录
MovieDictLock.Lock()
defer MovieDictLock.Unlock()
// 从MovieDict中删除
delete(MovieDict, requestData.FileName)
// 从Movies切片中移除记录
for i, m := range Movies {
if m.FileName == requestData.FileName {
// 移除切片中的元素
Movies = append(Movies[:i], Movies[i+1:]...)
break
}
}
response["message"] = "Delete successful"
c.JSON(http.StatusOK, response)
}

View File

@ -12,7 +12,7 @@ func main() {
// 设置为发布模式
gin.SetMode(gin.ReleaseMode)
eg := gin.Default()
eg.Use(Cors())
eg.Static("/res", "movie/")
@ -38,6 +38,7 @@ func main() {
movie.GET("/list/category", GetCategoryList)
movie.GET("/list", MovieList)
movie.POST("/rename", PostRename)
movie.POST("/delete", PostDelete)
eg.Run("0.0.0.0:4444")
}
}

View File

@ -71,6 +71,30 @@ func (m *Movie) RenameVideo(newName string) error {
return nil
}
// DeleteVideo 删除视频文件和对应的缩略图
func (m *Movie) DeleteVideo() error {
m.mu.Lock()
defer m.mu.Unlock()
// 删除视频文件
if err := os.Remove(m.VideoPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("删除视频文件失败: %w", err)
}
// 删除缩略图文件
dir := filepath.Dir(m.VideoPath)
imagePath := filepath.Join(dir, m.Image)
if _, err := os.Stat(imagePath); err == nil {
if err := os.Remove(imagePath); err != nil && !os.IsNotExist(err) {
log.Printf("警告:删除缩略图文件失败: %s, 错误: %v", imagePath, err)
// 不返回错误,因为主要文件已删除
}
}
log.Printf("成功删除视频文件: %s", m.FileName)
return nil
}
var Movies []*Movie // 存储所有电影信息的全局切片
var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典
var MovieDictLock sync.Mutex // 保护MovieDict的并发访问
@ -331,4 +355,4 @@ func markRecentMovies() {
for i := 0; i < len(Movies) && i < 20; i++ {
Movies[i].TimeCategory = "最新添加"
}
}
}

View File

@ -11,6 +11,8 @@ import InputAdornment from '@mui/material/InputAdornment';
import IconButton from '@mui/material/IconButton';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import ConfigContext from './Config';
import MovieCard from './components/MovieCard';
@ -55,6 +57,7 @@ const Main = () => {
const [movies, setMovies] = useState([]);
const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 });
const [searchInput, setSearchInput] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [activeCategory, setActiveCategory] = useState(DEFAULT_CATEGORY);
const [currentPage, setCurrentPage] = useState(1);
@ -410,7 +413,7 @@ const Main = () => {
}, [activeCategory, persistedParams.categoryHistory, setPersistedParams, fetchMovies]);
// MODIFIED handleRename
// handleRename
const handleRename = useCallback(async (oldName, newName) => {
const scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position
@ -432,7 +435,26 @@ const Main = () => {
console.error('重命名错误:', error);
throw error; // Re-throw for MovieCard to handle Snackbar
}
}, [fetchMovies, activeCategory, currentPage, activeSearchQuery]); // Added fetchMovies dependency
}, [fetchMovies, activeCategory, currentPage, activeSearchQuery]);
// handleDelete
const handleDelete = useCallback(async (filename) => {
try {
const response = await axios.post('/movie/delete', { file_name: filename });
if (response.data.code === 200) {
//
setSuccessMessage(`已成功删除文件:${filename}`);
//
await fetchMovies(activeCategory, currentPage, activeSearchQuery, { skipScrollRestore: true });
} else {
throw new Error(response.data.message || '删除失败');
}
} catch (error) {
console.error('删除错误:', error);
throw error; // Re-throw for MovieCard to handle Snackbar
}
}, [fetchMovies, activeCategory, currentPage, activeSearchQuery]);
const handleMovieCardClick = useCallback(() => {
if (window.history.state && window.history.state.appState) {
@ -463,6 +485,10 @@ const Main = () => {
if (e.key === 'Enter') handleSearchSubmit();
};
const handleSuccessSnackbarClose = () => {
setSuccessMessage('');
};
const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && (
<Pagination
count={pagination.pages}
@ -523,7 +549,7 @@ const Main = () => {
style={{ textDecoration: 'none' }}
onClick={handleMovieCardClick}
>
<MovieCard movie={item} config={config} onRename={handleRename} />
<MovieCard movie={item} config={config} onRename={handleRename} onDelete={handleDelete} />
</Link>
</Grid>
))}
@ -531,8 +557,20 @@ const Main = () => {
)}
{paginationComponent}
{/* 删除成功提示 */}
<Snackbar
open={!!successMessage}
autoHideDuration={4000}
onClose={handleSuccessSnackbarClose}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert onClose={handleSuccessSnackbarClose} severity="success" sx={{ width: '100%' }}>
{successMessage}
</Alert>
</Snackbar>
</Container>
);
};
export default Main;
export default Main;

View File

@ -15,13 +15,17 @@ import {
Alert
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { styled } from '@mui/system';
const MovieCard = ({ movie, config, onRename }) => {
const MovieCard = ({ movie, config, onRename, onDelete }) => {
const [newName, setNewName] = useState('');
const [openDialog, setOpenDialog] = useState(false);
const [deleteDialog, setDeleteDialog] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [error, setError] = useState(null);
const [openSnackbar, setOpenSnackbar] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const truncateFilename = (filename, maxLength) => {
return filename.length > maxLength
@ -40,12 +44,21 @@ const MovieCard = ({ movie, config, onRename }) => {
setOpenDialog(true);
};
const handleDeleteClick = (e) => {
e.stopPropagation();
e.preventDefault();
setDeleteDialog(true);
setDeleteConfirmText('');
};
const handleDialogClose = (e) => {
if (e) {
e.stopPropagation();
e.preventDefault();
}
setOpenDialog(false);
setDeleteDialog(false);
setDeleteConfirmText('');
};
const handleRenameSubmit = async (e) => {
@ -71,7 +84,6 @@ const MovieCard = ({ movie, config, onRename }) => {
await onRename(movie.filename, fullNewName);
handleDialogClose();
} catch (error) {
// message error.message
setError(
error.response?.data?.message ||
@ -82,6 +94,28 @@ const MovieCard = ({ movie, config, onRename }) => {
}
};
const handleDeleteConfirm = async () => {
if (deleteConfirmText !== '确认删除') {
setError('请输入"确认删除"来确认操作');
setOpenSnackbar(true);
return;
}
setIsDeleting(true);
try {
await onDelete(movie.filename);
handleDialogClose();
} catch (error) {
setError(
error.response?.data?.message ||
error.message ||
'删除失败,请稍后重试'
);
setOpenSnackbar(true);
setIsDeleting(false);
}
};
const handleCloseSnackbar = () => {
setOpenSnackbar(false);
};
@ -94,6 +128,14 @@ const MovieCard = ({ movie, config, onRename }) => {
}
};
const handleDeleteKeyPress = (e) => {
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
handleDeleteConfirm();
}
};
const handleDialogContentClick = (e) => {
e.stopPropagation();
};
@ -124,13 +166,29 @@ const MovieCard = ({ movie, config, onRename }) => {
style={{
position: 'absolute',
top: 5,
right: 5,
right: 35, //
backgroundColor: 'rgba(255, 255, 255, 0.7)',
zIndex: 2,
}}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
onClick={handleDeleteClick}
size="small"
style={{
position: 'absolute',
top: 5,
right: 5,
backgroundColor: 'rgba(255, 255, 255, 0.7)',
zIndex: 2,
color: '#f44336', //
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
<CardMedia
component="img"
height="120"
@ -145,6 +203,7 @@ const MovieCard = ({ movie, config, onRename }) => {
</Card>
</StyledCard>
{/* 重命名对话框 */}
<Dialog
open={openDialog}
onClose={handleDialogClose}
@ -185,6 +244,60 @@ const MovieCard = ({ movie, config, onRename }) => {
</DialogActions>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteDialog}
onClose={handleDialogClose}
onClick={handleDialogContentClick}
maxWidth="sm"
fullWidth
>
<DialogTitle>确认删除</DialogTitle>
<DialogContent onClick={handleDialogContentClick}>
<Typography variant="body1" sx={{ mb: 2, color: '#f44336' }}>
此操作不可逆转
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
您即将删除文件{movie.filename}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
为了确认删除请输入<strong>"确认删除"</strong>
</Typography>
<TextField
autoFocus
margin="dense"
label="输入确认文本"
type="text"
fullWidth
variant="standard"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
onKeyPress={handleDeleteKeyPress}
onClick={(e) => e.stopPropagation()}
error={deleteConfirmText && deleteConfirmText !== '确认删除'}
helperText={deleteConfirmText && deleteConfirmText !== '确认删除' ? '请输入"确认删除"' : ''}
/>
</DialogContent>
<DialogActions onClick={handleDialogContentClick}>
<Button
onClick={handleDialogClose}
onMouseDown={(e) => e.stopPropagation()}
disabled={isDeleting}
>
取消
</Button>
<Button
onClick={handleDeleteConfirm}
onMouseDown={(e) => e.stopPropagation()}
variant="contained"
color="error"
disabled={isDeleting || deleteConfirmText !== '确认删除'}
>
{isDeleting ? '删除中...' : '确认删除'}
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={openSnackbar}
autoHideDuration={6000}
@ -200,5 +313,3 @@ const MovieCard = ({ movie, config, onRename }) => {
};
export default MovieCard;