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 (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -197,3 +197,66 @@ func PostRename(c *gin.Context) {
|
|||||||
response["message"] = "Rename successful"
|
response["message"] = "Rename successful"
|
||||||
c.JSON(http.StatusOK, response)
|
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/category", GetCategoryList)
|
||||||
movie.GET("/list", MovieList)
|
movie.GET("/list", MovieList)
|
||||||
movie.POST("/rename", PostRename)
|
movie.POST("/rename", PostRename)
|
||||||
|
movie.POST("/delete", PostDelete)
|
||||||
|
|
||||||
eg.Run("0.0.0.0:4444")
|
eg.Run("0.0.0.0:4444")
|
||||||
}
|
}
|
||||||
@ -71,6 +71,30 @@ func (m *Movie) RenameVideo(newName string) error {
|
|||||||
return nil
|
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 Movies []*Movie // 存储所有电影信息的全局切片
|
||||||
var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典
|
var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典
|
||||||
var MovieDictLock sync.Mutex // 保护MovieDict的并发访问
|
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 IconButton from '@mui/material/IconButton';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import Snackbar from '@mui/material/Snackbar';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
|
||||||
import ConfigContext from './Config';
|
import ConfigContext from './Config';
|
||||||
import MovieCard from './components/MovieCard';
|
import MovieCard from './components/MovieCard';
|
||||||
@ -55,6 +57,7 @@ const Main = () => {
|
|||||||
const [movies, setMovies] = useState([]);
|
const [movies, setMovies] = useState([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 });
|
const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 });
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
const [activeCategory, setActiveCategory] = useState(DEFAULT_CATEGORY);
|
const [activeCategory, setActiveCategory] = useState(DEFAULT_CATEGORY);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -410,7 +413,7 @@ const Main = () => {
|
|||||||
}, [activeCategory, persistedParams.categoryHistory, setPersistedParams, fetchMovies]);
|
}, [activeCategory, persistedParams.categoryHistory, setPersistedParams, fetchMovies]);
|
||||||
|
|
||||||
|
|
||||||
// MODIFIED handleRename
|
// handleRename
|
||||||
const handleRename = useCallback(async (oldName, newName) => {
|
const handleRename = useCallback(async (oldName, newName) => {
|
||||||
const scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position
|
const scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position
|
||||||
|
|
||||||
@ -432,7 +435,26 @@ const Main = () => {
|
|||||||
console.error('重命名错误:', error);
|
console.error('重命名错误:', error);
|
||||||
throw error; // Re-throw for MovieCard to handle Snackbar
|
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(() => {
|
const handleMovieCardClick = useCallback(() => {
|
||||||
if (window.history.state && window.history.state.appState) {
|
if (window.history.state && window.history.state.appState) {
|
||||||
@ -463,6 +485,10 @@ const Main = () => {
|
|||||||
if (e.key === 'Enter') handleSearchSubmit();
|
if (e.key === 'Enter') handleSearchSubmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSuccessSnackbarClose = () => {
|
||||||
|
setSuccessMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && (
|
const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && (
|
||||||
<Pagination
|
<Pagination
|
||||||
count={pagination.pages}
|
count={pagination.pages}
|
||||||
@ -523,7 +549,7 @@ const Main = () => {
|
|||||||
style={{ textDecoration: 'none' }}
|
style={{ textDecoration: 'none' }}
|
||||||
onClick={handleMovieCardClick}
|
onClick={handleMovieCardClick}
|
||||||
>
|
>
|
||||||
<MovieCard movie={item} config={config} onRename={handleRename} />
|
<MovieCard movie={item} config={config} onRename={handleRename} onDelete={handleDelete} />
|
||||||
</Link>
|
</Link>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
@ -531,6 +557,18 @@ const Main = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{paginationComponent}
|
{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>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,13 +15,17 @@ import {
|
|||||||
Alert
|
Alert
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { styled } from '@mui/system';
|
import { styled } from '@mui/system';
|
||||||
|
|
||||||
const MovieCard = ({ movie, config, onRename }) => {
|
const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [openDialog, setOpenDialog] = useState(false);
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||||
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [openSnackbar, setOpenSnackbar] = useState(false);
|
const [openSnackbar, setOpenSnackbar] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const truncateFilename = (filename, maxLength) => {
|
const truncateFilename = (filename, maxLength) => {
|
||||||
return filename.length > maxLength
|
return filename.length > maxLength
|
||||||
@ -40,12 +44,21 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
setOpenDialog(true);
|
setOpenDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setDeleteDialog(true);
|
||||||
|
setDeleteConfirmText('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleDialogClose = (e) => {
|
const handleDialogClose = (e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
setOpenDialog(false);
|
setOpenDialog(false);
|
||||||
|
setDeleteDialog(false);
|
||||||
|
setDeleteConfirmText('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameSubmit = async (e) => {
|
const handleRenameSubmit = async (e) => {
|
||||||
@ -71,7 +84,6 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
await onRename(movie.filename, fullNewName);
|
await onRename(movie.filename, fullNewName);
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
||||||
// 统一错误处理,优先显示后端返回的 message,其次显示 error.message,最后显示默认错误
|
// 统一错误处理,优先显示后端返回的 message,其次显示 error.message,最后显示默认错误
|
||||||
setError(
|
setError(
|
||||||
error.response?.data?.message ||
|
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 = () => {
|
const handleCloseSnackbar = () => {
|
||||||
setOpenSnackbar(false);
|
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) => {
|
const handleDialogContentClick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
@ -124,13 +166,29 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 5,
|
top: 5,
|
||||||
right: 5,
|
right: 35, // 调整位置为删除按钮留出空间
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</IconButton>
|
</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
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="120"
|
height="120"
|
||||||
@ -145,6 +203,7 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</StyledCard>
|
</StyledCard>
|
||||||
|
|
||||||
|
{/* 重命名对话框 */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={openDialog}
|
open={openDialog}
|
||||||
onClose={handleDialogClose}
|
onClose={handleDialogClose}
|
||||||
@ -185,6 +244,60 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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
|
<Snackbar
|
||||||
open={openSnackbar}
|
open={openSnackbar}
|
||||||
autoHideDuration={6000}
|
autoHideDuration={6000}
|
||||||
@ -200,5 +313,3 @@ const MovieCard = ({ movie, config, onRename }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default MovieCard;
|
export default MovieCard;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user