feat(server): 添加删除视频文件的功能
新增 `/movie/delete` 接口用于删除指定视频文件及其缩略图,并从全局电影列表中移除记录。 同时完善了前端删除交互,包括确认对话框、成功提示及错误处理。 - 后端: - 新增 `PostDelete` 处理函数,支持通过文件名删除视频 - 实现 `Movie.DeleteVideo()` 方法,用于删除视频文件和关联缩略图 - 更新路由注册,增加 `/delete` 接口 - 前端: - 新增删除确认弹窗与交互逻辑 - 添加删除成功的 Snackbar 提示 - 调整 MovieCard 按钮布局以容纳删除按钮 - 传递并使用 onDelete 回调函数处理删除操作 该功能增强了系统的文件管理能力,使用户能够安全地移除不需要的视频内容。
This commit is contained in:
parent
b17e9dea28
commit
1a058a31c8
@ -3,9 +3,9 @@ package main
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -197,3 +197,66 @@ 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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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的并发访问
|
||||
|
||||
44
src/Main.jsx
44
src/Main.jsx
@ -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,6 +557,18 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user