383 lines
17 KiB
TypeScript
383 lines
17 KiB
TypeScript
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, getUploadResourceHistory } from '../../api';
|
|
import type { UploadResourceHistoryItem } from '../../types';
|
|
import CustomAudioPlayer from './CustomAudioPlayer';
|
|
|
|
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;
|
|
maxImageCount?: number;
|
|
maxVideoCount?: number;
|
|
usedImageCount?: number;
|
|
usedVideoCount?: number;
|
|
maxVideoDuration?: number;
|
|
usedVideoDuration?: number;
|
|
maxAudioCount?: number;
|
|
usedAudioCount?: number;
|
|
maxAudioDuration?: number;
|
|
usedAudioDuration?: number;
|
|
}
|
|
|
|
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 = '选择历史上传素材',
|
|
maxImageCount,
|
|
maxVideoCount,
|
|
usedImageCount,
|
|
usedVideoCount,
|
|
maxVideoDuration,
|
|
usedVideoDuration,
|
|
maxAudioCount,
|
|
usedAudioCount,
|
|
maxAudioDuration,
|
|
usedAudioDuration,
|
|
}) => {
|
|
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
|
const [keyword, setKeyword] = useState('');
|
|
const [groupPage, setGroupPage] = useState(1);
|
|
const [groupPageSize, setGroupPageSize] = useState(10);
|
|
const [itemPage, setItemPage] = useState(1);
|
|
const [itemPageSize, setItemPageSize] = useState(10);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingGroups, setLoadingGroups] = useState(false);
|
|
const [groups, setGroups] = useState<any[]>([]);
|
|
const [totalDays, setTotalDays] = useState(0);
|
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
|
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']), [JSON.stringify(allowedTypes)]);
|
|
|
|
const selectedImages = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'image').length, [checkedMap]);
|
|
const selectedVideos = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'video').length, [checkedMap]);
|
|
const selectedVideoDuration = useMemo(() => Array.from(checkedMap.values())
|
|
.filter(item => item.resourceType === 'video')
|
|
.reduce((sum, item) => sum + (item.durationSeconds || 0), 0), [checkedMap]);
|
|
const selectedAudios = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'audio').length, [checkedMap]);
|
|
const selectedAudioDuration = useMemo(() => Array.from(checkedMap.values())
|
|
.filter(item => item.resourceType === 'audio')
|
|
.reduce((sum, item) => sum + (item.durationSeconds || 0), 0), [checkedMap]);
|
|
|
|
const maxAvailableImages = useMemo(() => maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity, [maxImageCount, usedImageCount]);
|
|
const maxAvailableVideos = useMemo(() => maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity, [maxVideoCount, usedVideoCount]);
|
|
const maxAvailableDuration = useMemo(() => maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity, [maxVideoDuration, usedVideoDuration]);
|
|
const maxAvailableAudios = useMemo(() => maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : Infinity, [maxAudioCount, usedAudioCount]);
|
|
const maxAvailableAudioDuration = useMemo(() => maxAudioDuration !== undefined && usedAudioDuration !== undefined ? maxAudioDuration - usedAudioDuration : Infinity, [maxAudioDuration, usedAudioDuration]);
|
|
|
|
const imageExceeded = useMemo(() => selectedImages > maxAvailableImages, [selectedImages, maxAvailableImages]);
|
|
const videoExceeded = useMemo(() => selectedVideos > maxAvailableVideos, [selectedVideos, maxAvailableVideos]);
|
|
const durationExceeded = useMemo(() => selectedVideoDuration > maxAvailableDuration, [selectedVideoDuration, maxAvailableDuration]);
|
|
const audioExceeded = useMemo(() => selectedAudios > maxAvailableAudios, [selectedAudios, maxAvailableAudios]);
|
|
const audioDurationExceeded = useMemo(() => selectedAudioDuration > maxAvailableAudioDuration, [selectedAudioDuration, maxAvailableAudioDuration]);
|
|
|
|
const loadGroups = useCallback(async () => {
|
|
if (!open) return;
|
|
setLoadingGroups(true);
|
|
try {
|
|
const res = await getUploadResourceHistory({
|
|
resourceType: resourceType || undefined,
|
|
keyword: keyword || undefined,
|
|
page: groupPage,
|
|
pageSize: groupPageSize,
|
|
scene: 'picker',
|
|
});
|
|
if (groupPage === 1) {
|
|
setGroups(res.groups || []);
|
|
} else {
|
|
setGroups((prev) => [...prev, ...(res.groups || [])]);
|
|
}
|
|
setTotalDays(res.totalDays || 0);
|
|
} catch (error: any) {
|
|
message.error(error?.message || '加载历史上传素材失败');
|
|
if (groupPage === 1) {
|
|
setGroups([]);
|
|
}
|
|
setTotalDays(0);
|
|
} finally {
|
|
setLoadingGroups(false);
|
|
}
|
|
}, [effectiveAllowedTypes, keyword, open, groupPage, groupPageSize, resourceType]);
|
|
|
|
const loadItems = useCallback(async (date: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await getUploadResourceHistoryItems(date, {
|
|
resourceType: resourceType || undefined,
|
|
keyword: keyword || undefined,
|
|
page: itemPage,
|
|
pageSize: 8,
|
|
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);
|
|
}
|
|
}, [effectiveAllowedTypes, keyword, itemPage, itemPageSize, resourceType]);
|
|
|
|
useEffect(() => {
|
|
if (open) loadGroups();
|
|
}, [open, groupPage, loadGroups]);
|
|
|
|
useEffect(() => {
|
|
if (selectedDate) {
|
|
setItemPage(1);
|
|
}
|
|
}, [selectedDate]);
|
|
|
|
useEffect(() => {
|
|
if (selectedDate) {
|
|
loadItems(selectedDate);
|
|
}
|
|
}, [selectedDate, itemPage, loadItems]);
|
|
|
|
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={1000}
|
|
centered
|
|
destroyOnHidden
|
|
footer={[
|
|
<Button key="cancel" onClick={onClose}>取消</Button>,
|
|
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }} disabled={imageExceeded || videoExceeded || durationExceeded || audioExceeded || audioDurationExceeded}>
|
|
应用选择 ({checkedMap.size})
|
|
</Button>,
|
|
]}
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Space wrap>
|
|
<Select
|
|
value={resourceType}
|
|
options={typeOptions}
|
|
style={{ width: 120 }}
|
|
onChange={(value) => {
|
|
setResourceType(value);
|
|
setGroupPage(1);
|
|
}}
|
|
/>
|
|
<Input
|
|
allowClear
|
|
value={keyword}
|
|
prefix={<SearchOutlined />}
|
|
placeholder="搜索文件名"
|
|
style={{ width: 220 }}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
onPressEnter={() => {
|
|
setGroupPage(1);
|
|
loadGroups();
|
|
}}
|
|
/>
|
|
<Button icon={<ReloadOutlined />} onClick={loadGroups}>刷新</Button>
|
|
</Space>
|
|
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
|
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
|
</span>
|
|
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
|
|
视频还可选取 {selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} 个({selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}秒)
|
|
</span>
|
|
{maxAudioCount !== undefined && (
|
|
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
|
|
音频还可选取 {selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} 个({selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}秒)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
|
|
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa', overflowY: 'auto' }}>
|
|
<Text strong style={{ fontSize: 14 }}>日期列表</Text>
|
|
<Spin spinning={loadingGroups}>
|
|
{groups.length === 0 ? (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日期" style={{ padding: '40px 0' }} />
|
|
) : (
|
|
<div style={{ marginTop: 12 }}>
|
|
{groups.map((group) => (
|
|
<div
|
|
key={group.generatedDate}
|
|
onClick={() => setSelectedDate(group.generatedDate)}
|
|
style={{
|
|
padding: '10px 12px',
|
|
borderRadius: 10,
|
|
marginBottom: 6,
|
|
cursor: 'pointer',
|
|
border: selectedDate === group.generatedDate ? '1px solid #8b5cf6' : '1px solid transparent',
|
|
background: selectedDate === group.generatedDate ? '#f5f3ff' : '#fff',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 13, fontWeight: 500, color: '#334155' }}>{group.generatedDate}</div>
|
|
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>共 {group.total} 个素材</div>
|
|
</div>
|
|
))}
|
|
{totalDays > groups.length && (
|
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
|
<Button size="small" onClick={() => setGroupPage(groupPage + 1)}>加载更多</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Spin>
|
|
</div>
|
|
|
|
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, overflowY: 'auto' }}>
|
|
<Spin spinning={loading}>
|
|
{!selectedDate ? (
|
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择日期" style={{ padding: '80px 0' }} />
|
|
) : items.length === 0 ? (
|
|
<Empty description="该日期暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
|
|
{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={{ background: '#f1f5f9' }}>
|
|
{item.resourceType === 'image' ? (
|
|
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: 130, objectFit: 'cover' }} />
|
|
) : item.resourceType === 'video' ? (
|
|
<video src={preview} muted style={{ width: '100%', height: 130, objectFit: 'cover' }} />
|
|
) : (
|
|
<CustomAudioPlayer src={preview} size="small" />
|
|
)}
|
|
</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>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
|
|
<Pagination
|
|
current={itemPage}
|
|
pageSize={itemPageSize}
|
|
total={total}
|
|
showSizeChanger
|
|
pageSizeOptions={[10, 20, 50, 100]}
|
|
onChange={(nextPage, nextSize) => {
|
|
setItemPage(nextPage);
|
|
setItemPageSize(nextSize);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Spin>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default UploadResourceHistoryPicker;
|