This commit is contained in:
2026-07-11 12:38:03 +08:00
parent cba3450596
commit c64cf06c33
31 changed files with 703 additions and 1971 deletions
@@ -1,186 +0,0 @@
import React from 'react';
import { Button, DatePicker, Input, Space } from 'antd';
import {
CheckOutlined,
ClearOutlined,
DeleteOutlined,
DownloadOutlined,
SearchOutlined,
UploadOutlined,
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
interface UnifiedFilterBarProps {
// 搜索
searchValue: string;
searchPlaceholder?: string;
onSearchChange: (value: string) => void;
onSearch: () => void;
// 日期
showDate?: boolean;
dateValue?: Dayjs | null;
onDateChange?: (date: Dayjs | null) => void;
datePlaceholder?: string;
// 批量操作
batchCount: number;
totalCount: number;
onSelectAll: () => void;
onClearSelection: () => void;
onDelete: () => void;
onDownload?: () => void;
onPush?: () => void;
pushLoading?: boolean;
pushText?: string;
// 右侧额外操作
extraActions?: React.ReactNode;
}
const btnBase: React.CSSProperties = {
borderRadius: 8,
fontWeight: 600,
height: 36,
};
const btnSecondary: React.CSSProperties = {
...btnBase,
background: '#f8f9fc',
border: '1px solid #e2e8f0',
color: '#334155',
};
const btnDanger: React.CSSProperties = {
...btnBase,
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
border: 'none',
color: '#fff',
};
const btnPrimary: React.CSSProperties = {
...btnBase,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
color: '#fff',
};
const UnifiedFilterBar: React.FC<UnifiedFilterBarProps> = ({
searchValue,
searchPlaceholder = '搜索...',
onSearchChange,
onSearch,
showDate = false,
dateValue,
onDateChange,
datePlaceholder = '选择日期',
batchCount,
totalCount,
onSelectAll,
onClearSelection,
onDelete,
onDownload,
onPush,
pushLoading = false,
pushText = '推送至账户',
extraActions,
}) => {
const allSelected = batchCount === totalCount && totalCount > 0;
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}
>
{/* 左侧:搜索 + 日期 */}
<Space size={8} wrap>
<Input.Search
placeholder={searchPlaceholder}
allowClear
value={searchValue}
onChange={(e) => {
const val = e.target.value;
onSearchChange(val);
if (!val) onSearch();
}}
onSearch={onSearch}
style={{ width: 240, borderRadius: 8 }}
enterButton
/>
{showDate && (
<>
<DatePicker
value={dateValue || undefined}
onChange={(date) => onDateChange?.(date)}
format="YYYY-MM-DD"
placeholder={datePlaceholder}
style={{ width: 160, borderRadius: 8 }}
allowClear
/>
</>
)}
</Space>
{/* 右侧:批量操作 + 额外操作 */}
<Space size={8} wrap>
{/* 全选 / 取消选择 */}
<Button
icon={allSelected ? <ClearOutlined /> : <CheckOutlined />}
onClick={allSelected ? onClearSelection : onSelectAll}
style={btnSecondary}
>
{allSelected ? '取消全选' : '全选'}
</Button>
{/* 删除 */}
<Button
danger
icon={<DeleteOutlined />}
onClick={onDelete}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnDanger}
>
{batchCount > 0 && `(${batchCount})`}
</Button>
{/* 下载 */}
{onDownload && (
<Button
icon={<DownloadOutlined />}
onClick={onDownload}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnSecondary, opacity: 0.5 } : btnPrimary}
>
{batchCount > 0 && `(${batchCount})`}
</Button>
)}
{/* 推送 */}
{onPush && (
<Button
type="primary"
icon={<UploadOutlined />}
onClick={onPush}
loading={pushLoading}
disabled={batchCount === 0}
style={batchCount === 0 ? { ...btnBase, opacity: 0.5 } : btnPrimary}
>
{pushLoading ? '推送中...' : `${pushText} ${batchCount > 0 ? `(${batchCount})` : ''}`}
</Button>
)}
{/* 额外操作按钮 */}
{extraActions}
</Space>
</div>
);
};
export default UnifiedFilterBar;
@@ -30,7 +30,6 @@ interface UploadSelectorProps {
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
}
const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -53,7 +52,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
@@ -245,7 +243,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedAudioCount={usedAudioCount}
maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration}
hideLimitHint={hideLimitHint}
/>
<PrivatePortraitAssetPicker
@@ -120,7 +120,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyOnHidden={false}
destroyInactiveTabPane={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
@@ -13,7 +13,7 @@ interface Props {
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />;
return (
<Space orientation="vertical" style={{ width: '100%' }} size={10}>
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{items.map((project) => {
const active = selectedId === project.id;
return (
@@ -122,14 +122,7 @@ const RealPersonLibraryPanel: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={16}>
<Col xs={24} lg={5}>
@@ -162,7 +155,7 @@ const RealPersonLibraryPanel: React.FC = () => {
onOk={validateSession ? undefined : handleCreate}
okText="开始认证并创建"
confirmLoading={creating}
mask={{ closable: !polling }}
maskClosable={!polling}
>
{!validateSession ? (
<Form form={form} layout="vertical">
@@ -338,7 +338,7 @@ const VirtualMaterialPanel: React.FC = () => {
<Card
key={asset.id}
hoverable
styles={{ body: { padding: 12 } }}
bodyStyle={{ padding: 12 }}
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
@@ -392,14 +392,7 @@ const VirtualMaterialPanel: React.FC = () => {
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">使</Typography.Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={[10, 10]}>
<Col xs={24} lg={5} >
@@ -453,24 +446,11 @@ const VirtualMaterialPanel: React.FC = () => {
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
<Space wrap>
<Button
type="primary"
icon={<UploadOutlined />}
disabled={!selectedProjectId}
onClick={() => setUploadOpen(true)}
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
>
/
</Button>
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button>
{selectedProjectId && (
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
<Button
danger
icon={<DeleteOutlined />}
style={{ borderRadius: 8, fontWeight: 600 }}
>
</Button>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
@@ -484,15 +464,14 @@ const VirtualMaterialPanel: React.FC = () => {
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadAssets(1, assetPageSize)}
style={{ width: 240, borderRadius: 8 }}
enterButton
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="素材状态"
value={assetStatus}
onChange={(value) => setAssetStatus(value)}
style={{ width: 140, borderRadius: 8 }}
style={{ width: 150 }}
options={[
{ value: 'Processing', label: '处理中' },
{ value: 'Active', label: '可用' },
@@ -504,19 +483,13 @@ const VirtualMaterialPanel: React.FC = () => {
placeholder="素材类型"
value={assetType}
onChange={(value) => setAssetType(value)}
style={{ width: 120, borderRadius: 8 }}
style={{ width: 130 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button
type="primary"
onClick={() => loadAssets(1, assetPageSize)}
style={{ borderRadius: 8, fontWeight: 600, borderColor: 'transparent' }}
>
</Button>
<Button onClick={() => loadAssets(1, assetPageSize)}></Button>
</Space>
<Spin spinning={assetLoading}>
@@ -590,7 +563,7 @@ const VirtualMaterialPanel: React.FC = () => {
</Space>
</Modal>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnHidden>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
{previewType === 'Video' ? (
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Input, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { Button, Empty, Input, List, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import {
getPrivatePortraitProjects,
@@ -251,36 +251,33 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
</Space>
<Spin spinning={loadingProjects}>
{projects.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" />
) : (
<div>
{projects.map((item) => (
<div
key={item.id}
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
<List
dataSource={projects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
))}
</div>
)}
</List.Item>
)}
/>
</Spin>
</div>
@@ -173,93 +173,45 @@ const UploadResourceHistoryPanel: React.FC = () => {
return (
<div>
{/* 统一筛选栏:媒体类型 + 搜索 + 批量操作 */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
borderRadius: 12,
background: '#fff',
border: '1px solid #f0f0f5',
}}>
{/* 左侧:媒体类型按钮 */}
<Space size={8} wrap>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14, marginRight: 4 }}></Typography.Text>
<Button
type={resourceType === '' ? 'primary' : 'default'}
onClick={() => { setResourceType(''); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === '' ? 'transparent' : '#e2e8f0', color: resourceType === '' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'image' ? 'primary' : 'default'}
onClick={() => { setResourceType('image'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'image' ? 'transparent' : '#e2e8f0', color: resourceType === 'image' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'video' ? 'primary' : 'default'}
onClick={() => { setResourceType('video'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'video' ? 'transparent' : '#e2e8f0', color: resourceType === 'video' ? '#fff' : '#64748b' }}
>
</Button>
<Button
type={resourceType === 'audio' ? 'primary' : 'default'}
onClick={() => { setResourceType('audio'); setPage(1); }}
style={{ borderRadius: 8, fontWeight: 600, borderColor: resourceType === 'audio' ? 'transparent' : '#e2e8f0', color: resourceType === 'audio' ? '#fff' : '#64748b' }}
>
</Button>
</Space>
{/* 右侧:搜索 + 批量操作 */}
<Space size={8} wrap>
<Input.Search
<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)}
onSearch={() => { setPage(1); load(); }}
style={{ width: 240, borderRadius: 8 }}
enterButton
onPressEnter={() => {
setPage(1);
load();
}}
/>
{/* <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> */}
</Space>
<Space>
{batchMode ? (
<>
<Button
onClick={selectAll}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#334155' }}
>
{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}
</Button>
<Button
danger
onClick={handleBatchDelete}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #ef4444, #dc2626)', border: 'none', color: '#fff' }}
>
{selectedIds.size > 0 && `(${selectedIds.size})`}
</Button>
<Button
onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}
style={{ borderRadius: 8, fontWeight: 600, background: '#f8f9fc', border: '1px solid #e2e8f0', color: '#64748b' }}
>
</Button>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}></Button>
</>
) : (
<Button
type="primary"
onClick={() => setBatchMode(true)}
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}
>
</Button>
<Button onClick={() => setBatchMode(true)}></Button>
)}
</Space>
</div>
@@ -280,7 +232,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
const checked = selectedIds.has(item.id);
return (
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, 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', cursor: batchMode ? 'pointer' : 'default' }} onClick={() => batchMode && toggle(item.id)}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)}
{batchMode && <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>
@@ -26,72 +26,15 @@ interface UploadResourceHistoryPickerProps {
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
}
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^data:|blob:/i.test(url)) return url;
if (/^https?:\/\//i.test(url)) {
const parsed = new URL(url);
if (parsed.pathname.startsWith('/uploads')) {
return parsed.pathname + parsed.search;
}
return url;
}
if (/^(https?:|data:|blob:)/i.test(url)) return url;
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const validateImageDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `图片宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `图片高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `图片宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
return null;
};
const validateVideoDimensions = (width: number, height: number): string | null => {
if (width < 300 || width > 6000) return `视频宽度需在 300~6000px 之间,当前为 ${width}px`;
if (height < 300 || height > 6000) return `视频高度需在 300~6000px 之间,当前为 ${height}px`;
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) return `视频宽高比需在 0.4~2.5 之间,当前为 ${ratio.toFixed(2)}`;
const totalPixels = width * height;
if (totalPixels < 409600) return `视频总像素数过小(${width}×${height}=${totalPixels}),需 ≥ 640×640=409600`;
if (totalPixels > 8295044) return `视频总像素数过大(${width}×${height}=${totalPixels}),需 ≤ 3326×2494=8295044`;
return null;
};
const getItemValidationError = (item: UploadResourceHistoryItem): string | null => {
const itemAny = item as any;
const width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
const height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
if (item.resourceType === 'image') {
if (width > 0 && height > 0) {
return validateImageDimensions(width, height);
}
} else if (item.resourceType === 'video') {
if (width > 0 && height > 0) {
return validateVideoDimensions(width, height);
}
}
return null;
};
const getImageDimensions = (url: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = url;
});
};
const typeIcon = (type: string) => {
if (type === 'video') return <VideoCameraOutlined />;
if (type === 'audio') return <AudioOutlined />;
@@ -121,7 +64,6 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
}) => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState('');
@@ -233,42 +175,11 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
if (open) setCheckedMap(new Map());
}, [open]);
const toggle = async (item: UploadResourceHistoryItem) => {
const toggle = (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中');
return;
}
const itemAny = item as any;
let width = itemAny.width || itemAny.videoWidth || itemAny.imageWidth || 0;
let height = itemAny.height || itemAny.videoHeight || itemAny.imageHeight || 0;
if (item.resourceType === 'image') {
if (width <= 0 || height <= 0) {
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
if (preview) {
const dims = await getImageDimensions(preview);
width = dims.width;
height = dims.height;
}
}
if (width > 0 && height > 0) {
const error = validateImageDimensions(width, height);
if (error) {
message.error(`${item.fileName || '图片'}${error}`);
return;
}
}
} else if (item.resourceType === 'video') {
if (width > 0 && height > 0) {
const error = validateVideoDimensions(width, height);
if (error) {
message.error(`${item.fileName || '视频'}${error}`);
return;
}
}
}
setCheckedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id);
@@ -338,21 +249,19 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
/>
<Button icon={<ReloadOutlined />} onClick={loadGroups}></Button>
</Space>
{!hideLimitHint && (
<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 : '-'}
<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>
<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>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
@@ -401,9 +310,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const selectedDisabled = selectedIdSet.has(item.id);
const validationError = getItemValidationError(item);
const disabled = selectedDisabled || !!validationError;
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
@@ -419,7 +326,6 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
title={validationError ? validationError : undefined}
>
<div style={{ background: '#f1f5f9' }}>
{item.resourceType === 'image' ? (
@@ -434,7 +340,6 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<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}
{validationError && <Tag color="red" style={{ margin: 0 }}></Tag>}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
@@ -447,12 +352,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
<CheckOutlined />
</div>
)}
{selectedDisabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
{validationError && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(239, 68, 68, 0.9)', color: '#fff', padding: '4px 8px', fontSize: 11, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{validationError}
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
);
})}