Files
video-gen/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx
T
2026-06-30 17:06:45 +08:00

334 lines
13 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
import {
deleteHomeMaterialAsset,
getHomeMaterialAssetStatus,
getHomeMaterialAssets,
regenerateHomeMaterialWatermark,
} from '../../api';
import type {
HomeMaterialAsset,
HomeMaterialAssetQueryParams,
HomeMaterialCategory,
HomeMaterialWatermark,
HomeMaterialWatermarkConfig,
HomeMaterialTextWatermarkConfig,
} from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
import WatermarkEditor from './WatermarkEditor';
interface Props {
categories: HomeMaterialCategory[];
watermarks: HomeMaterialWatermark[];
reloadKey?: number;
}
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
processing: { text: '处理中', color: 'processing' },
success: { text: '成功', color: 'success' },
failed: { text: '失败', color: 'error' },
};
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
text: '民众普康 AI',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
rotateDeg: -30,
gapX: 220,
gapY: 140,
staggered: true,
};
const defaultConfig: HomeMaterialWatermarkConfig = {
watermarkType: 'image',
watermarkId: null,
opacityLevel: 6,
position: 'bottom_right',
customXRatio: null,
customYRatio: null,
sizeMode: 'ratio',
widthRatio: 0.18,
widthPx: null,
marginX: 24,
marginY: 24,
textWatermark: defaultTextWatermark,
};
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
return {
text: raw?.text || defaultTextWatermark.text,
opacityLevel: raw?.opacityLevel ?? raw?.opacity_level ?? defaultTextWatermark.opacityLevel,
fontSizePx: raw?.fontSizePx ?? raw?.font_size_px ?? defaultTextWatermark.fontSizePx,
color: raw?.color || defaultTextWatermark.color,
rotateDeg: raw?.rotateDeg ?? raw?.rotate_deg ?? defaultTextWatermark.rotateDeg,
gapX: raw?.gapX ?? raw?.gap_x ?? defaultTextWatermark.gapX,
gapY: raw?.gapY ?? raw?.gap_y ?? defaultTextWatermark.gapY,
staggered: raw?.staggered ?? defaultTextWatermark.staggered,
};
}
function normalizeConfig(raw: unknown, fallbackWatermarkId?: string | null): HomeMaterialWatermarkConfig {
const data = (raw || {}) as any;
const watermarkType = data.watermarkType || data.watermark_type || 'image';
return {
watermarkType,
watermarkId: watermarkType === 'repeated_text' ? null : (data.watermarkId || data.watermark_id || fallbackWatermarkId || null),
opacityLevel: data.opacityLevel || data.opacity_level || 6,
position: data.position || 'bottom_right',
customXRatio: data.customXRatio ?? data.custom_x_ratio ?? null,
customYRatio: data.customYRatio ?? data.custom_y_ratio ?? null,
sizeMode: data.sizeMode || data.size_mode || 'ratio',
widthRatio: data.widthRatio ?? data.width_ratio ?? 0.18,
widthPx: data.widthPx ?? data.width_px ?? null,
marginX: data.marginX ?? data.margin_x ?? 24,
marginY: data.marginY ?? data.margin_y ?? 24,
textWatermark: normalizeTextConfig(data.textWatermark || data.text_watermark),
};
}
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
return !!config.watermarkId;
}
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<HomeMaterialAssetQueryParams>({ page: 1, pageSize: 20 });
const [preview, setPreview] = useState<HomeMaterialAsset | null>(null);
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [regenSubmitting, setRegenSubmitting] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await getHomeMaterialAssets(filters);
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [filters, reloadKey]);
useEffect(() => {
const processing = items.filter(i => i.status === 'processing');
if (processing.length === 0) return;
const timer = window.setInterval(async () => {
const statuses = await Promise.all(processing.map(i => getHomeMaterialAssetStatus(i.id).catch(() => null)));
const changed = statuses.some(s => s && s.status !== 'processing');
if (changed) load();
}, 2500);
return () => window.clearInterval(timer);
}, [items]);
const openRegenerate = (row: HomeMaterialAsset) => {
setRegen(row);
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
};
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
return apiUrl(row.coverUrl || '');
};
const getVideoUrl = (row: HomeMaterialAsset) => apiUrl(row.watermarkedUrl || row.originalUrl);
const confirmDelete = (row: HomeMaterialAsset) => {
Modal.confirm({
title: '确认删除该素材?',
content: row.title || row.id,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
async onOk() {
await deleteHomeMaterialAsset(row.id);
message.success('删除成功');
await load();
},
});
};
const submitRegenerate = async () => {
if (!regen || regenSubmitting) return;
if (!isValidConfig(regenConfig)) {
message.warning(regenConfig.watermarkType === 'repeated_text' ? '请输入重复文字水印内容' : '请选择水印图片');
return;
}
setRegenSubmitting(true);
try {
await regenerateHomeMaterialWatermark(regen.id, {
watermark_type: regenConfig.watermarkType,
watermark_id: regenConfig.watermarkType === 'image' ? regenConfig.watermarkId : null,
opacity_level: regenConfig.opacityLevel,
position: regenConfig.position,
custom_x_ratio: regenConfig.customXRatio,
custom_y_ratio: regenConfig.customYRatio,
size_mode: regenConfig.sizeMode,
width_ratio: regenConfig.widthRatio,
width_px: regenConfig.widthPx,
margin_x: regenConfig.marginX,
margin_y: regenConfig.marginY,
text_watermark: regenConfig.watermarkType === 'repeated_text' && regenConfig.textWatermark ? {
text: regenConfig.textWatermark.text,
opacity_level: regenConfig.textWatermark.opacityLevel,
font_size_px: regenConfig.textWatermark.fontSizePx,
color: regenConfig.textWatermark.color,
rotate_deg: regenConfig.textWatermark.rotateDeg,
gap_x: regenConfig.textWatermark.gapX,
gap_y: regenConfig.textWatermark.gapY,
staggered: regenConfig.textWatermark.staggered,
} : null,
wait: false,
});
message.success('已提交重新生成');
setRegen(null);
await load();
} finally {
setRegenSubmitting(false);
}
};
return (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
allowClear
style={{ width: 180 }}
placeholder="行业"
value={filters.categoryId}
onChange={(v) => setFilters(f => ({ ...f, categoryId: v, page: 1 }))}
options={categories.map(c => ({ label: c.name, value: c.id }))}
/>
<Select
allowClear
style={{ width: 140 }}
placeholder="素材类型"
value={filters.mediaType}
onChange={(v) => setFilters(f => ({ ...f, mediaType: v, page: 1 }))}
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
/>
<Select
allowClear
style={{ width: 140 }}
placeholder="处理状态"
value={filters.status}
onChange={(v) => setFilters(f => ({ ...f, status: v, page: 1 }))}
options={Object.entries(statusMap).map(([value, item]) => ({ value, label: item.text }))}
/>
<Button onClick={load}>刷新</Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
scroll={{ x: 1180 }}
columns={[
{
title: '预览',
width: 110,
render: (_: unknown, row: HomeMaterialAsset) => {
const imageUrl = getPreviewImageUrl(row);
if (row.mediaType === 'image') {
return imageUrl ? <Image width={86} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} src={imageUrl} /> : '-';
}
const videoUrl = getVideoUrl(row);
const posterUrl = apiUrl(row.coverUrl || '');
return videoUrl ? (
<video
src={videoUrl}
poster={posterUrl || undefined}
muted
preload="metadata"
style={{ width: 100, height: 64, objectFit: 'cover', borderRadius: 6, background: '#000' }}
/>
) : '-';
},
},
{ title: '标题', dataIndex: 'title', render: (v: string | null) => v || <span style={{ color: '#999' }}>未命名素材</span> },
{ title: '行业', dataIndex: 'categoryName' },
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
{
title: '水印',
render: (_: unknown, row: HomeMaterialAsset) => {
const cfg = normalizeConfig(row.watermarkConfig, row.watermarkId);
return cfg.watermarkType === 'repeated_text' ? '重复文字水印' : (row.watermarkName || '图片水印');
},
},
{ title: '排序', dataIndex: 'sortOrder' },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
{
title: '操作',
width: 150,
align: 'center' as const,
render: (_: unknown, row: HomeMaterialAsset) => (
<Space size={8}>
<Button size="small" onClick={() => setPreview(row)}>查看</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
],
onClick: ({ key }) => {
if (key === 'regenerate') openRegenerate(row);
if (key === 'delete') confirmDelete(row);
},
}}
>
<Button size="small" icon={<MoreOutlined />}>更多</Button>
</Dropdown>
</Space>
),
},
]}
/>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
{preview && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
{preview.mediaType === 'image' ? (
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
) : (
<video src={getVideoUrl(preview)} poster={apiUrl(preview.coverUrl || '') || undefined} controls style={{ width: '100%', background: '#000' }} />
)}
</Space>
)}
</Modal>
<Modal
title="重新生成水印"
open={!!regen}
onCancel={() => !regenSubmitting && setRegen(null)}
onOk={submitRegenerate}
confirmLoading={regenSubmitting}
okButtonProps={{ disabled: regenSubmitting || !isValidConfig(regenConfig) }}
cancelButtonProps={{ disabled: regenSubmitting }}
width={980}
destroyOnHidden
>
{regen && (
<WatermarkEditor
value={regenConfig}
onChange={setRegenConfig}
watermarks={watermarks}
mediaUrl={apiUrl(regen.originalUrl || regen.watermarkedUrl)}
mediaType={regen.mediaType}
/>
)}
</Modal>
</>
);
};
export default HomeMaterialAssetTable;