334 lines
13 KiB
TypeScript
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;
|