增强文件和视频上传功能:添加上传成功和失败的反馈信息,检查响应状态以确保上传结果的准确性;更新素材管理模块,优化删除和编辑功能,增加确认删除的弹窗,提升用户体验。
This commit is contained in:
243
Touchkebao/src/components/Upload/AudioUpload/index.module.scss
Normal file
243
Touchkebao/src/components/Upload/AudioUpload/index.module.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
.videoUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 覆盖 antd Upload 组件的默认样式
|
||||
:global {
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text .ant-upload-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.videoUploadButton {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: clamp(50px, 6vw, 48px);
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: clamp(10px, 1.5vw, 14px);
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.videoIcon {
|
||||
width: clamp(28px, 5vw, 40px);
|
||||
height: clamp(28px, 5vw, 40px);
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.videoInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.videoName {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.videoUploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.videoUploadContainer.error {
|
||||
.videoUploadButton {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
385
Touchkebao/src/components/Upload/AudioUpload/index.tsx
Normal file
385
Touchkebao/src/components/Upload/AudioUpload/index.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message, Progress, Button, Modal } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
SoundOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface AudioUploadProps {
|
||||
value?: string | string[]; // 支持单个字符串或字符串数组
|
||||
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
showPreview?: boolean; // 是否显示预览
|
||||
maxCount?: number; // 最大上传数量,默认为1
|
||||
}
|
||||
|
||||
const AudioUpload: React.FC<AudioUploadProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
maxSize = 50,
|
||||
showPreview = true,
|
||||
maxCount = 1,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// 处理单个字符串或字符串数组
|
||||
const urls = Array.isArray(value) ? value : [value];
|
||||
const files: UploadFile[] = urls.map((url, index) => ({
|
||||
uid: `file-${index}`,
|
||||
name: `audio-${index + 1}`,
|
||||
status: "done",
|
||||
url: url || "",
|
||||
}));
|
||||
setFileList(files);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isAudio = file.type.startsWith("audio/");
|
||||
if (!isAudio) {
|
||||
message.error("只能上传音频文件!");
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(`音频大小不能超过${maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
// 更新 fileList,确保所有 URL 都是字符串
|
||||
const updatedFileList = info.fileList.map(file => {
|
||||
let url = "";
|
||||
|
||||
if (file.url) {
|
||||
url = file.url;
|
||||
} else if (file.response) {
|
||||
// 处理响应对象
|
||||
if (typeof file.response === "string") {
|
||||
url = file.response;
|
||||
} else if (file.response.data) {
|
||||
url =
|
||||
typeof file.response.data === "string"
|
||||
? file.response.data
|
||||
: file.response.data.url || "";
|
||||
} else if (file.response.url) {
|
||||
url = file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
|
||||
setFileList(updatedFileList);
|
||||
|
||||
// 处理上传状态
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
// 模拟上传进度
|
||||
const progress = Math.min(99, Math.random() * 100);
|
||||
setUploadProgress(progress);
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
uploadedUrl =
|
||||
typeof info.file.response.data === "string"
|
||||
? info.file.response.data
|
||||
: info.file.response.data.url || "";
|
||||
} else if (info.file.response.url) {
|
||||
uploadedUrl = info.file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("音频上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个音频模式
|
||||
onChange?.(uploadedUrl);
|
||||
} else {
|
||||
// 多个音频模式
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到音频URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
message.error("上传失败,请重试");
|
||||
} else if (info.file.status === "removed") {
|
||||
if (maxCount === 1) {
|
||||
onChange?.("");
|
||||
} else {
|
||||
// 多个音频模式,移除对应的音频
|
||||
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const removedIndex = info.fileList.findIndex(
|
||||
f => f.uid === info.file.uid,
|
||||
);
|
||||
if (removedIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== removedIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = (file?: UploadFile) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个音频文件吗?",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
if (maxCount === 1) {
|
||||
setFileList([]);
|
||||
onChange?.("");
|
||||
} else if (file) {
|
||||
// 多个音频模式,删除指定音频
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
|
||||
if (fileIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== fileIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
message.success("音频已删除");
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
// 预览音频
|
||||
const handlePreview = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
// 获取文件大小显示
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 自定义上传按钮
|
||||
const uploadButton = (
|
||||
<div className={style.videoUploadButton}>
|
||||
{loading ? (
|
||||
<div className={style.uploadingContainer}>
|
||||
<div className={style.uploadingIcon}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
<div className={style.uploadingText}>上传中...</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#1890ff"
|
||||
className={style.uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.uploadContent}>
|
||||
<div className={style.uploadIcon}>
|
||||
<CloudUploadOutlined />
|
||||
</div>
|
||||
<div className={style.uploadText}>
|
||||
<div className={style.uploadTitle}>
|
||||
{maxCount === 1
|
||||
? "上传音频"
|
||||
: `上传音频 (${fileList.length}/${maxCount})`}
|
||||
</div>
|
||||
<div className={style.uploadSubtitle}>
|
||||
支持 MP3、WAV、AAC 等格式,最大 {maxSize}MB
|
||||
{maxCount > 1 && `,最多上传 ${maxCount} 个音频`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义文件列表项
|
||||
const customItemRender = (
|
||||
originNode: React.ReactElement,
|
||||
file: UploadFile,
|
||||
) => {
|
||||
if (file.status === "uploading") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
strokeColor="#1890ff"
|
||||
className={style.itemProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(file.url || "")}
|
||||
className={style.previewBtn}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return originNode;
|
||||
};
|
||||
|
||||
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||
|
||||
return (
|
||||
<div className={`${style.videoUploadContainer} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
headers={{
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}}
|
||||
action={action}
|
||||
multiple={maxCount > 1}
|
||||
fileList={fileList}
|
||||
accept="audio/*"
|
||||
listType="text"
|
||||
showUploadList={{
|
||||
showPreviewIcon: false,
|
||||
showRemoveIcon: false,
|
||||
showDownloadIcon: false,
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
maxCount={maxCount}
|
||||
itemRender={customItemRender}
|
||||
>
|
||||
{fileList.length >= maxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
|
||||
{/* 音频预览模态框 */}
|
||||
<Modal
|
||||
title="音频预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
>
|
||||
<div className={style.videoPreview}>
|
||||
<audio controls style={{ width: "100%" }} src={previewUrl}>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioUpload;
|
||||
@@ -176,12 +176,17 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("文件上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
@@ -195,6 +200,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("文件上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个文件模式
|
||||
onChange?.(uploadedUrl);
|
||||
@@ -208,6 +214,8 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到文件URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
|
||||
@@ -108,12 +108,17 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("视频上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
@@ -127,6 +132,7 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("视频上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个视频模式
|
||||
onChange?.(uploadedUrl);
|
||||
@@ -140,6 +146,8 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到视频URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
|
||||
@@ -15,7 +15,9 @@ export interface ContentItem {
|
||||
|
||||
// 链接数据类型
|
||||
export interface LinkData {
|
||||
title: string;
|
||||
url: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export interface MaterialAddRequest {
|
||||
@@ -50,7 +52,7 @@ export function getMaterialDetails(id: string) {
|
||||
|
||||
// 素材管理-删除
|
||||
export function deleteMaterial(id: string) {
|
||||
return request("/v1/kefu/content/material/del", { id }, "GET");
|
||||
return request("/v1/kefu/content/material/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 素材管理-更新
|
||||
@@ -106,7 +108,7 @@ export function getSensitiveWordDetails(id: string) {
|
||||
|
||||
// 违禁词管理-删除
|
||||
export function deleteSensitiveWord(id: string) {
|
||||
return request("/v1/kefu/content/sensitiveWord/del", { id }, "GET");
|
||||
return request("/v1/kefu/content/sensitiveWord/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 违禁词管理-更新
|
||||
@@ -161,7 +163,7 @@ export function getKeywordDetails(id: string) {
|
||||
|
||||
// 关键词回复-删除
|
||||
export function deleteKeyword(id: string) {
|
||||
return request("/v1/kefu/content/keywords/del", { id }, "GET");
|
||||
return request("/v1/kefu/content/keywords/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 关键词回复-更新
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { Button, Input, Card, message, Switch, Tag } from "antd";
|
||||
import { Button, Input, Card, message, Modal } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
FileTextOutlined,
|
||||
FileImageOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "../../index.module.scss";
|
||||
import {
|
||||
getMaterialList,
|
||||
deleteMaterial,
|
||||
setMaterialStatus,
|
||||
type MaterialListParams,
|
||||
} from "../../api";
|
||||
import MaterialModal from "../modals/MaterialModal";
|
||||
@@ -40,232 +40,234 @@ interface MaterialItem {
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const MaterialManagement = forwardRef<any, {}>((props, ref) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [materialsList, setMaterialsList] = useState<MaterialItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
|
||||
const [editingMaterialId, setEditingMaterialId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const MaterialManagement = forwardRef<any, Record<string, never>>(
|
||||
(props, ref) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [materialsList, setMaterialsList] = useState<MaterialItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
|
||||
const [editingMaterialId, setEditingMaterialId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "文本":
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
case "图片":
|
||||
return <FileImageOutlined className={styles.typeIcon} />;
|
||||
case "视频":
|
||||
return <PlayCircleOutlined className={styles.typeIcon} />;
|
||||
default:
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
}
|
||||
};
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "文本":
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
case "图片":
|
||||
return <FileImageOutlined className={styles.typeIcon} />;
|
||||
case "视频":
|
||||
return <PlayCircleOutlined className={styles.typeIcon} />;
|
||||
default:
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "文本":
|
||||
return "#fa8c16";
|
||||
case "图片":
|
||||
return "#52c41a";
|
||||
case "视频":
|
||||
return "#1890ff";
|
||||
default:
|
||||
return "#8c8c8c";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = async (params?: MaterialListParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getMaterialList(params || {});
|
||||
if (response) {
|
||||
setMaterialsList(response.list || []);
|
||||
} else {
|
||||
// 获取素材列表
|
||||
const fetchMaterials = async (params?: MaterialListParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getMaterialList(params || {});
|
||||
if (response) {
|
||||
setMaterialsList(response.list || []);
|
||||
} else {
|
||||
setMaterialsList([]);
|
||||
message.error(response?.message || "获取素材列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
setMaterialsList([]);
|
||||
message.error(response?.message || "获取素材列表失败");
|
||||
message.error("获取素材列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
setMaterialsList([]);
|
||||
message.error("获取素材列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
fetchMaterials,
|
||||
}));
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
fetchMaterials,
|
||||
}));
|
||||
|
||||
// 素材管理相关函数
|
||||
const handleDeleteMaterial = async (id: number) => {
|
||||
try {
|
||||
const response = await deleteMaterial(id.toString());
|
||||
if (response) {
|
||||
setMaterialsList(prev => prev.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
} else {
|
||||
message.error(response?.message || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除失败:", error);
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
// 素材管理相关函数
|
||||
const handleDeleteMaterial = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个素材吗?删除后无法恢复。",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteMaterial(id.toString());
|
||||
setMaterialsList(prev => prev.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 切换素材状态
|
||||
const handleToggleMaterialStatus = async (id: number, checked: boolean) => {
|
||||
try {
|
||||
const response = await setMaterialStatus({ id: id.toString() });
|
||||
if (response) {
|
||||
setMaterialsList(prev =>
|
||||
prev.map(item =>
|
||||
item.id === id ? { ...item, status: checked ? 1 : 0 } : item,
|
||||
),
|
||||
);
|
||||
message.success(checked ? "启用成功" : "禁用成功");
|
||||
} else {
|
||||
message.error(response?.message || "状态更新失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("状态更新失败:", error);
|
||||
message.error("状态更新失败");
|
||||
}
|
||||
};
|
||||
// 编辑素材
|
||||
const handleEditMaterial = (id: number) => {
|
||||
setEditingMaterialId(id);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑素材
|
||||
const handleEditMaterial = (id: number) => {
|
||||
setEditingMaterialId(id);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
// 编辑弹窗成功回调
|
||||
const handleEditSuccess = () => {
|
||||
fetchMaterials(); // 重新获取数据
|
||||
};
|
||||
|
||||
// 编辑弹窗成功回调
|
||||
const handleEditSuccess = () => {
|
||||
fetchMaterials(); // 重新获取数据
|
||||
};
|
||||
// 搜索处理函数
|
||||
const handleSearch = (value: string) => {
|
||||
fetchMaterials({ keyword: value });
|
||||
};
|
||||
|
||||
// 搜索处理函数
|
||||
const handleSearch = (value: string) => {
|
||||
fetchMaterials({ keyword: value });
|
||||
};
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.materialContent}>
|
||||
<div className={styles.searchSection}>
|
||||
<Search
|
||||
placeholder="搜索素材..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className={styles.materialContent}>
|
||||
<div className={styles.searchSection}>
|
||||
<Search
|
||||
placeholder="搜索素材..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.materialGrid}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : materialsList.length === 0 ? (
|
||||
<div className={styles.empty}>暂无素材数据</div>
|
||||
) : (
|
||||
materialsList.map(item => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={styles.materialCard}
|
||||
hoverable
|
||||
onClick={() => handleEditMaterial(item.id)}
|
||||
>
|
||||
<div className={styles.thumbnail}>
|
||||
{item.cover ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
<div className={styles.materialGrid}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : materialsList.length === 0 ? (
|
||||
<div className={styles.empty}>暂无素材数据</div>
|
||||
) : (
|
||||
materialsList.map(item => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={styles.materialCard}
|
||||
hoverable
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<FormOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleEditMaterial(item.id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.cover}
|
||||
alt={item.title}
|
||||
编辑
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDeleteMaterial(item.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
className={styles.thumbnail}
|
||||
onClick={() => handleEditMaterial(item.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{item.cover ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.cover}
|
||||
alt={item.title}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
color: "white",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{item.type}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
color: "white",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "500",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f5f5f5",
|
||||
color: "#999",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{item.type}
|
||||
{getTypeIcon(item.type)}
|
||||
<span style={{ marginTop: "8px", fontSize: "12px" }}>
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f5f5f5",
|
||||
color: "#999",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{getTypeIcon(item.type)}
|
||||
<span style={{ marginTop: "8px", fontSize: "12px" }}>
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.title}>{item.title}</div>
|
||||
<div className={styles.meta}>
|
||||
<div>创建人: {item.userName}</div>
|
||||
<div>{item.createTime}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<MaterialModal
|
||||
visible={editModalVisible}
|
||||
mode="edit"
|
||||
materialId={editingMaterialId}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setEditingMaterialId(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.title}>{item.title}</div>
|
||||
<div className={styles.meta}>
|
||||
<div>创建人: {item.userName}</div>
|
||||
<div>{item.createTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<MaterialModal
|
||||
visible={editModalVisible}
|
||||
mode="edit"
|
||||
materialId={editingMaterialId}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setEditingMaterialId(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MaterialManagement.displayName = "MaterialManagement";
|
||||
|
||||
export default MaterialManagement;
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import type { ContentItem } from "../../api";
|
||||
import AudioUpload from "@/components/Upload/AudioUpload";
|
||||
import type { ContentItem, LinkData } from "../../api";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
@@ -93,7 +94,7 @@ const ContentManager: React.FC<ContentManagerProps> = ({
|
||||
// 根据新类型重置数据
|
||||
let newData: any;
|
||||
if (newType === "link") {
|
||||
newData = "";
|
||||
newData = { title: "", url: "", cover: "" };
|
||||
} else {
|
||||
newData = "";
|
||||
}
|
||||
@@ -181,7 +182,10 @@ const ContentManager: React.FC<ContentManagerProps> = ({
|
||||
return (
|
||||
<VideoUpload
|
||||
value={item.data as string}
|
||||
onChange={url => updateItemData(index, url)}
|
||||
onChange={url => {
|
||||
const videoUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, videoUrl);
|
||||
}}
|
||||
maxSize={50}
|
||||
showPreview={true}
|
||||
/>
|
||||
@@ -190,7 +194,10 @@ const ContentManager: React.FC<ContentManagerProps> = ({
|
||||
return (
|
||||
<FileUpload
|
||||
value={item.data as string}
|
||||
onChange={url => updateItemData(index, url)}
|
||||
onChange={url => {
|
||||
const fileUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, fileUrl);
|
||||
}}
|
||||
maxSize={10}
|
||||
showPreview={true}
|
||||
acceptTypes={["excel", "word", "ppt", "pdf", "txt"]}
|
||||
@@ -198,23 +205,48 @@ const ContentManager: React.FC<ContentManagerProps> = ({
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<FileUpload
|
||||
<AudioUpload
|
||||
value={item.data as string}
|
||||
onChange={url => updateItemData(index, url)}
|
||||
onChange={url => {
|
||||
const audioUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, audioUrl);
|
||||
}}
|
||||
maxSize={50}
|
||||
showPreview={true}
|
||||
acceptTypes={["mp3", "wav", "aac", "m4a", "ogg"]}
|
||||
/>
|
||||
);
|
||||
case "link": {
|
||||
const linkData = (item.data as LinkData) || {
|
||||
title: "",
|
||||
url: "",
|
||||
cover: "",
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={item.data as string}
|
||||
onChange={e => updateItemData(index, e.target.value)}
|
||||
value={linkData.title}
|
||||
onChange={e =>
|
||||
updateItemData(index, { ...linkData, title: e.target.value })
|
||||
}
|
||||
placeholder="链接标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={linkData.url}
|
||||
onChange={e =>
|
||||
updateItemData(index, { ...linkData, url: e.target.value })
|
||||
}
|
||||
placeholder="链接URL"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<ImageUpload
|
||||
count={1}
|
||||
accept="image/*"
|
||||
value={linkData.cover ? [linkData.cover] : []}
|
||||
onChange={urls =>
|
||||
updateItemData(index, { ...linkData, cover: urls[0] || "" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,14 +55,17 @@ const MaterialModal: React.FC<MaterialModalProps> = ({
|
||||
[form],
|
||||
);
|
||||
|
||||
// 当弹窗打开且为编辑模式时,获取详情
|
||||
// 当弹窗打开时处理数据
|
||||
useEffect(() => {
|
||||
if (visible && mode === "edit" && materialId) {
|
||||
fetchMaterialDetails(materialId);
|
||||
} else if (visible && mode === "add") {
|
||||
// 添加模式时重置表单
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
if (visible) {
|
||||
if (mode === "edit" && materialId) {
|
||||
// 编辑模式:获取详情
|
||||
fetchMaterialDetails(materialId);
|
||||
} else if (mode === "add") {
|
||||
// 添加模式:重置表单
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
}
|
||||
}
|
||||
}, [visible, mode, materialId, fetchMaterialDetails, form]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user