Expand seller store functionality

This commit is contained in:
R524809
2026-09-07 17:51:11 +08:00
parent 4c0f3a4a79
commit ceb644a3f9
35 changed files with 32883 additions and 364 deletions
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Card, Collapse, Descriptions, Spin, Table, Tag, Typography } from 'antd';
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. */
@@ -11,35 +12,220 @@ export default function CollectionInspectionPage() {
const { id } = useParams<{ id: string }>();
const [record, setRecord] = useState<CollectionItemInspection>();
const [error, setError] = useState<string>();
useEffect(() => { if (!id) return; void collectionApi.inspection(id).then(setRecord).catch((reason) => setError(apiErrorMessage(reason))); }, [id]);
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: '类型', dataIndex: 'role' }, { 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>;
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>
);
}
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>; }
+6 -3
View File
@@ -1,16 +1,19 @@
/**
* access token 仅在内存中短暂持有(docs/api/conventions.md §1),
* 不得写入 localStorage 等持久化存储;页面刷新后由 /auth/refresh 通过
* HttpOnly cookie 恢复会话(接入会话恢复逻辑时实现)。
* 正常 Web 调用仍只从内存读取 token。为自托管采集插件互操作,维护者
* 明确批准将当前短期 token 镜像到本站 localStorage;见
* docs/project/extension-login-alternatives.md,不得扩大到其他调用方。
*/
let accessToken: string | null = null;
export const extensionWebAccessStorageKey = 'atlas.extension.web-access';
export const authToken = {
get: () => accessToken,
set: (token: string) => {
accessToken = token;
localStorage.setItem(extensionWebAccessStorageKey, JSON.stringify({ accessToken: token }));
},
clear: () => {
accessToken = null;
localStorage.removeItem(extensionWebAccessStorageKey);
},
};