上传素材管控-历史素材success

This commit is contained in:
2026-07-08 16:25:03 +08:00
parent 657505e63b
commit d085a6d2ec
47 changed files with 5424 additions and 447 deletions
+12 -40
View File
@@ -1,14 +1,15 @@
import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
interface UploadSelectorProps {
children: React.ReactNode;
accept?: string;
onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: any[]) => void;
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
@@ -63,12 +64,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setPortraitPickerOpen(true);
};
const confirmHistorySelection = () => {
onHistorySelect?.([]);
setHistoryModalVisible(false);
setModalVisible(false);
};
const options = [
{
key: 'history',
@@ -192,39 +187,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
</div>
</Modal>
<Modal
title="选择资产素材"
<UploadResourceHistoryPicker
open={historyModalVisible}
onCancel={() => setHistoryModalVisible(false)}
footer={null}
width="80%"
centered
destroyOnHidden
>
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<button
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
border: 'none',
background: '#8b5cf6',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
>
</button>
</div>
</Modal>
onClose={() => setHistoryModalVisible(false)}
onSelect={(items) => {
onHistorySelect?.(items);
setHistoryModalVisible(false);
setModalVisible(false);
}}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
/>
<PrivatePortraitAssetPicker
open={portraitPickerOpen}
@@ -0,0 +1,270 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AudioOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Button, Checkbox, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
import { deleteUpload, deleteUploadResourceHistoryBatch, getUploadResourceHistory, getUploadResourceHistoryItems } from '../../api';
import type { UploadResourceHistoryDayGroup, UploadResourceHistoryItem } from '../../types';
const { Text } = Typography;
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
return <PictureOutlined />;
};
const typeLabel = (type: string) => {
if (type === 'video') return '视频';
if (type === 'audio') return '音频';
return '图片';
};
const bytesText = (bytes?: number | null) => {
const value = Number(bytes || 0);
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${value} B`;
};
const UploadResourceHistoryPanel: React.FC = () => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [groups, setGroups] = useState<UploadResourceHistoryDayGroup[]>([]);
const [totalDays, setTotalDays] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getUploadResourceHistory({
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page,
pageSize,
scene: 'record',
});
setGroups(res.groups || []);
setTotalDays(res.totalDays || 0);
setSelectedIds(new Set());
} catch (error: any) {
message.error(error?.message || '加载历史上传素材失败');
setGroups([]);
setTotalDays(0);
} finally {
setLoading(false);
}
}, [keyword, page, pageSize, resourceType]);
useEffect(() => {
load();
}, [load]);
const toggle = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
const ids = allItems.map((item) => item.id);
setSelectedIds((prev) => (prev.size === ids.length ? new Set() : new Set(ids)));
};
const handleLoadMoreDay = async (group: UploadResourceHistoryDayGroup) => {
const nextPage = (group.page || 1) + 1;
try {
const res = await getUploadResourceHistoryItems(group.generatedDate, {
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page: nextPage,
pageSize: 10,
scene: 'record',
});
setGroups((prev) => prev.map((item) => item.generatedDate === group.generatedDate ? { ...item, page: nextPage, items: [...item.items, ...(res.items || [])] } : item));
} catch (error: any) {
message.error(error?.message || '加载更多失败');
}
};
const handleDeleteOne = (item: UploadResourceHistoryItem) => {
Modal.confirm({
title: '确认删除历史上传素材',
content: `确定删除 ${item.fileName || item.id} 吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deleteUpload(item.resourceUrl);
message.success('删除成功');
load();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
},
});
};
const handleBatchDelete = () => {
if (selectedIds.size === 0) {
message.warning('请先选择要删除的上传素材');
return;
}
if (selectedIds.size > 30) {
message.warning('一次最多删除 30 条上传素材');
return;
}
const ids = Array.from(selectedIds);
Modal.confirm({
title: '确认批量删除历史上传素材',
content: `确定删除选中的 ${ids.length} 条上传素材吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteUploadResourceHistoryBatch(ids);
message.success(`删除成功,释放 ${bytesText(res?.releasedSizeBytes || 0)}`);
load();
} catch (error: any) {
message.error(error?.message || '批量删除失败');
}
},
});
};
const handleDownload = (item: UploadResourceHistoryItem) => {
const link = document.createElement('a');
link.href = buildPreviewUrl(item.resourceUrl);
link.download = item.fileName || item.id;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const renderMedia = (item: UploadResourceHistoryItem, size: 'card' | 'preview' = 'card') => {
const url = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
const style = size === 'card'
? { width: '100%', height: 130, objectFit: 'cover' as const }
: { width: '100%', maxHeight: 620, objectFit: 'contain' as const };
if (item.resourceType === 'image') return <img src={url} alt={item.fileName || item.id} style={style} />;
if (item.resourceType === 'video') return <video src={url} controls={size === 'preview'} muted={size === 'card'} style={style} />;
return <audio src={url} controls style={{ width: '100%' }} />;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Select
value={resourceType}
style={{ width: 120 }}
options={[
{ label: '全部', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
]}
onChange={(value) => {
setResourceType(value);
setPage(1);
}}
/>
<Input
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名或URL"
style={{ width: 240 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1);
load();
}}
/>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Space>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
</Space>
</div>
<Spin spinning={loading}>
{groups.length === 0 ? (
<Empty description="暂无可展示的历史上传素材" style={{ padding: '60px 0' }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
{groups.map((group) => (
<div key={group.generatedDate}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
<Text type="secondary"> {group.total} </Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
{group.items.map((item) => {
const checked = selectedIds.has(item.id);
return (
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)}
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
</div>
<div style={{ padding: 12 }}>
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
<Space size={4} wrap style={{ marginTop: 8 }}>
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Space style={{ marginTop: 12 }}>
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreviewItem(item)}></Button>
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(item)}></Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteOne(item)}></Button>
</Space>
</div>
</div>
);
})}
</div>
{group.items.length < group.total && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<Button onClick={() => handleLoadMoreDay(group)}></Button>
</div>
)}
</div>
))}
</div>
)}
</Spin>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
</div>
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
{previewItem ? renderMedia(previewItem, 'preview') : null}
</Modal>
</div>
);
};
export default UploadResourceHistoryPanel;
@@ -0,0 +1,251 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Button, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
import { getUploadResourceHistoryItems } from '../../api';
import type { UploadResourceHistoryItem } from '../../types';
const { Text } = Typography;
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
interface UploadResourceHistoryPickerProps {
open: boolean;
onClose: () => void;
onSelect: (items: UploadResourceHistoryItem[]) => void;
allowedTypes?: Array<'image' | 'video' | 'audio'>;
selectedIds?: string[];
title?: string;
}
const today = () => new Date().toISOString().slice(0, 10);
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
return <PictureOutlined />;
};
const typeLabel = (type: string) => {
if (type === 'video') return '视频';
if (type === 'audio') return '音频';
return '图片';
};
const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> = ({
open,
onClose,
onSelect,
allowedTypes,
selectedIds = [],
title = '选择历史上传素材',
}) => {
const [date, setDate] = useState(today());
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
const [total, setTotal] = useState(0);
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [allowedTypes]);
const load = useCallback(async () => {
if (!open) return;
setLoading(true);
try {
const res = await getUploadResourceHistoryItems(date, {
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page,
pageSize,
scene: 'picker',
});
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
setItems(filtered);
setTotal(res.total || 0);
} catch (error: any) {
message.error(error?.message || '加载历史上传素材失败');
setItems([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
useEffect(() => {
if (open) load();
}, [open, load]);
useEffect(() => {
if (open) setCheckedMap(new Map());
}, [open]);
const toggle = (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中');
return;
}
setCheckedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id);
else next.set(item.id, item);
return next;
});
};
const confirm = () => {
const selected = Array.from(checkedMap.values());
if (selected.length === 0) {
message.warning('请先选择历史素材');
return;
}
onSelect(selected);
setCheckedMap(new Map());
onClose();
};
const typeOptions = useMemo(() => {
const all = [
{ label: '全部', value: '' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
];
return all.filter((opt) => !opt.value || effectiveAllowedTypes.has(opt.value as any));
}, [effectiveAllowedTypes]);
return (
<Modal
title={title}
open={open}
onCancel={onClose}
width={920}
centered
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
({checkedMap.size})
</Button>,
]}
>
<Space wrap style={{ width: '100%', marginBottom: 16 }}>
<Input
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value || today());
setPage(1);
}}
style={{ width: 160 }}
/>
<Select
value={resourceType}
options={typeOptions}
style={{ width: 120 }}
onChange={(value) => {
setResourceType(value);
setPage(1);
}}
/>
<Input
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名"
style={{ width: 220 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setPage(1);
load();
}}
/>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Spin spinning={loading}>
{items.length === 0 ? (
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
style={{
position: 'relative',
borderRadius: 14,
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
);
})}
</div>
)}
</Spin>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</div>
</Modal>
);
};
export default UploadResourceHistoryPicker;
@@ -0,0 +1,2 @@
export { default as UploadResourceHistoryPicker } from './UploadResourceHistoryPicker';
export { default as UploadResourceHistoryPanel } from './UploadResourceHistoryPanel';