232 lines
8.8 KiB
TypeScript
232 lines
8.8 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useParams } from 'react-router-dom';
|
||
import { Alert, Card, Collapse, Descriptions, Image, Spin, Table, Tag, Typography } from 'antd';
|
||
import type { CollectionItemInspection } from '@atlas/contracts';
|
||
import { collectionApi } from '@/services/collection';
|
||
import { apiErrorMessage } from '@/services/api';
|
||
import { mediaApi } from '@/services/media';
|
||
|
||
/** Temporary parser inspection surface. This is deliberately read-only: it lets
|
||
* product and parser work be checked before these values become editor fields. */
|
||
export default function CollectionInspectionPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const [record, setRecord] = useState<CollectionItemInspection>();
|
||
const [error, setError] = useState<string>();
|
||
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
void collectionApi
|
||
.inspection(id)
|
||
.then(setRecord)
|
||
.catch((reason) => setError(apiErrorMessage(reason)));
|
||
}, [id]);
|
||
|
||
const assetIds = useMemo(
|
||
() => [
|
||
...new Set(
|
||
(record?.asset_transfers ?? [])
|
||
.filter((transfer) => transfer.status === 'succeeded' && !!transfer.asset_id)
|
||
.map((transfer) => transfer.asset_id as string)
|
||
),
|
||
],
|
||
[record]
|
||
);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
setPreviewUrls({});
|
||
if (!assetIds.length) return;
|
||
void loadPreviewUrls(assetIds).then((urls) => {
|
||
if (!disposed) setPreviewUrls(urls);
|
||
});
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [assetIds]);
|
||
|
||
const variantsById = useMemo(
|
||
() => new Map((record?.variants ?? []).map((variant) => [variant.id, variant])),
|
||
[record]
|
||
);
|
||
|
||
if (error)
|
||
return <Alert type="error" message="无法读取采集数据" description={error} showIcon />;
|
||
if (!record)
|
||
return (
|
||
<div style={{ padding: 64, textAlign: 'center' }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<Card title="采集数据测试页" extra={<Tag color="blue">只读测试</Tag>}>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="这里展示扩展实际提交、服务端实际保存的数据,不代表已确认的商品编辑字段。"
|
||
/>
|
||
<Descriptions
|
||
bordered
|
||
size="small"
|
||
column={2}
|
||
items={[
|
||
{ key: 'title', label: '商品标题', children: record.title ?? '未命名' },
|
||
{
|
||
key: 'source',
|
||
label: '采集来源',
|
||
children: `${record.source.platform_code ?? '手动'} · ${record.source.url ?? '—'}`,
|
||
},
|
||
{ key: 'status', label: '采集状态', children: record.collection_status },
|
||
{ key: 'parser', label: '解析器版本', children: record.parser_version ?? '—' },
|
||
{ key: 'sku', label: '已创建 SKU', children: record.variants.length },
|
||
{ key: 'assets', label: '素材任务', children: record.asset_transfers.length },
|
||
]}
|
||
/>
|
||
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||
SKU 草稿
|
||
</Typography.Title>
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="id"
|
||
dataSource={record.variants}
|
||
columns={[
|
||
{
|
||
title: '来源 SKU ID',
|
||
dataIndex: 'source_variant_key',
|
||
render: (value) => value ?? '默认 SKU(待确认)',
|
||
},
|
||
{
|
||
title: '来源货号',
|
||
dataIndex: 'source_sku',
|
||
render: (value) => value ?? '—',
|
||
},
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'display_name',
|
||
render: (value) => value ?? '—',
|
||
},
|
||
]}
|
||
/>
|
||
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||
素材转存任务
|
||
</Typography.Title>
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="id"
|
||
dataSource={record.asset_transfers}
|
||
columns={[
|
||
{
|
||
title: '素材预览',
|
||
width: 108,
|
||
render: (_, transfer) => {
|
||
const url = transfer.asset_id
|
||
? previewUrls[transfer.asset_id]
|
||
: undefined;
|
||
if (url)
|
||
return (
|
||
<Image
|
||
width={72}
|
||
height={72}
|
||
src={url}
|
||
alt="已转存采集素材"
|
||
style={{ objectFit: 'cover' }}
|
||
/>
|
||
);
|
||
return (
|
||
<Typography.Text type="secondary">
|
||
{transfer.status === 'succeeded' ? '预览加载中…' : '待转存'}
|
||
</Typography.Text>
|
||
);
|
||
},
|
||
},
|
||
{ title: '类型', dataIndex: 'role' },
|
||
{
|
||
title: '关联 SKU',
|
||
render: (_, transfer) => {
|
||
const variant = transfer.source_variant_id
|
||
? variantsById.get(transfer.source_variant_id)
|
||
: undefined;
|
||
return variant?.source_sku ?? variant?.source_variant_key ?? '商品级';
|
||
},
|
||
},
|
||
{ title: '状态', dataIndex: 'status' },
|
||
{
|
||
title: '来源 URL',
|
||
dataIndex: 'source_url',
|
||
ellipsis: true,
|
||
render: (value) => (
|
||
<Typography.Link href={value} target="_blank">
|
||
查看来源
|
||
</Typography.Link>
|
||
),
|
||
},
|
||
{
|
||
title: '错误',
|
||
dataIndex: 'error_payload',
|
||
render: (value) =>
|
||
value ? (
|
||
<Typography.Text type="danger">
|
||
{JSON.stringify(value)}
|
||
</Typography.Text>
|
||
) : (
|
||
'—'
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
<Collapse
|
||
style={{ marginTop: 24 }}
|
||
items={[
|
||
{
|
||
key: 'normalized',
|
||
label: '规范化采集载荷(normalized_payload)',
|
||
children: <Json value={record.normalized_payload} />,
|
||
},
|
||
{
|
||
key: 'evidence',
|
||
label: '原始证据(raw_evidence)',
|
||
children: <Json value={record.raw_evidence} />,
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
async function loadPreviewUrls(assetIds: string[]) {
|
||
const urls: Record<string, string> = {};
|
||
let cursor = 0;
|
||
const worker = async () => {
|
||
while (cursor < assetIds.length) {
|
||
const assetId = assetIds[cursor++];
|
||
try {
|
||
urls[assetId] = (await mediaApi.createPreviewUrl(assetId)).url;
|
||
} catch {
|
||
// The task row remains inspectable through its source URL and error state.
|
||
}
|
||
}
|
||
};
|
||
await Promise.all(Array.from({ length: Math.min(4, assetIds.length) }, () => worker()));
|
||
return urls;
|
||
}
|
||
|
||
function Json({ value }: { value: unknown }) {
|
||
return (
|
||
<pre
|
||
style={{
|
||
margin: 0,
|
||
overflow: 'auto',
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{JSON.stringify(value, null, 2)}
|
||
</pre>
|
||
);
|
||
}
|