Files
atlas-seller-kit/apps/web/src/pages/system/SystemCatalogPage.tsx
T

273 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App as AntdApp, Button, Card, Empty, Select, Space, Statistic,
Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import {
platformCatalogAdminApi,
type AdminPlatform, type CatalogAttribute, type CatalogCategory,
type CatalogRun, type SyncRequest,
} from '@/services/platformCatalogAdmin';
import CatalogSourceModal from './components/CatalogSourceModal';
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
import PlatformAttributesTable from './components/PlatformAttributesTable';
import PlatformCategoryTree from './components/PlatformCategoryTree';
import './SystemCatalogPage.css';
function dateTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未同步';
}
const statusLabels: Record<string, { label: string; color: string }> = {
queued: { label: '排队中', color: 'gold' },
running: { label: '同步中', color: 'blue' },
succeeded: { label: '成功', color: 'green' },
failed: { label: '失败', color: 'red' },
cancelled: { label: '已取消', color: 'default' },
};
const scopeLabels = { tree: '类目树', attributes: '类型属性', dictionary: '字典候选值' };
export default function SystemCatalogPage() {
const { message } = AntdApp.useApp();
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
const [platformId, setPlatformId] = useState<string | null>(null);
const [categories, setCategories] = useState<CatalogCategory[]>([]);
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
const [runs, setRuns] = useState<CatalogRun[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedAttribute, setSelectedAttribute] = useState<CatalogAttribute | null>(null);
const [sourceOpen, setSourceOpen] = useState(false);
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
const [loadingAttributes, setLoadingAttributes] = useState(false);
const [syncingScope, setSyncingScope] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const previousRunStatuses = useRef<Map<string, string>>(new Map());
const selectedCategoryIdRef = useRef<string | null>(null);
const currentPlatform = useMemo(
() => platforms.find((item) => item.id === platformId) ?? null,
[platforms, platformId],
);
const selectedCategory = useMemo(
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId],
);
const loadPlatforms = useCallback(async () => {
setLoadingPlatforms(true);
try {
const items = await platformCatalogAdminApi.platforms();
setPlatforms(items);
setPlatformId((previous) => previous && items.some((item) => item.id === previous)
? previous : (items.find((item) => item.code.toUpperCase() === 'OZON')?.id ?? items[0]?.id ?? null));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingPlatforms(false);
}
}, [message]);
const loadCategories = useCallback(async (id: string) => {
setLoadingCategories(true);
try {
setCategories(await platformCatalogAdminApi.categories(id));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingCategories(false);
}
}, [message]);
const loadAttributes = useCallback(async (id: string, categoryId: string) => {
setLoadingAttributes(true);
try {
setAttributes(await platformCatalogAdminApi.attributes(id, categoryId));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingAttributes(false);
}
}, [message]);
const loadRuns = useCallback(async (id: string) => {
try {
const next = await platformCatalogAdminApi.runs(id);
const completed = next.some((run) => run.status === 'succeeded' &&
previousRunStatuses.current.has(run.id) &&
previousRunStatuses.current.get(run.id) !== 'succeeded');
previousRunStatuses.current = new Map(next.map((run) => [run.id, run.status]));
setRuns(next);
if (completed) {
setRefreshToken((value) => value + 1);
void loadPlatforms();
void loadCategories(id);
if (selectedCategoryIdRef.current) void loadAttributes(id, selectedCategoryIdRef.current);
}
} catch {
// 轮询失败时保留最后一次任务列表,页面手动刷新仍可重试。
}
}, [loadPlatforms, loadCategories, loadAttributes]);
useEffect(() => { void loadPlatforms(); }, [loadPlatforms]);
useEffect(() => {
if (!platformId) return;
setSelectedCategoryId(null);
selectedCategoryIdRef.current = null;
setSelectedAttribute(null);
setCategories([]);
setAttributes([]);
previousRunStatuses.current.clear();
void loadCategories(platformId);
void loadRuns(platformId);
}, [platformId, loadCategories, loadRuns]);
useEffect(() => {
if (!platformId || !selectedCategoryId) return;
void loadAttributes(platformId, selectedCategoryId);
}, [platformId, selectedCategoryId, loadAttributes]);
useEffect(() => {
if (!platformId) return;
const timer = window.setInterval(() => void loadRuns(platformId), 5000);
return () => window.clearInterval(timer);
}, [platformId, loadRuns]);
const sync = async (input: SyncRequest) => {
if (!platformId) return;
setSyncingScope(input.scope);
try {
await platformCatalogAdminApi.sync(platformId, input);
message.success('同步任务已排队');
await loadRuns(platformId);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setSyncingScope(null);
}
};
const saveSource = async (input: { client_id: string; api_key: string }) => {
if (!platformId) return;
try {
const result = await platformCatalogAdminApi.configureSource(platformId, input);
message.success(result.changed
? '同步来源已保存,类目树同步已排队'
: '同步来源没有变化');
await loadPlatforms();
await loadRuns(platformId);
} catch (error) {
message.error(apiErrorMessage(error));
throw error;
}
};
const runColumns: ColumnsType<CatalogRun> = [
{ title: '范围', dataIndex: 'scope', key: 'scope', render: (scope: CatalogRun['scope']) => scopeLabels[scope] },
{ title: '触发', dataIndex: 'trigger', key: 'trigger', render: (value: string) =>
value === 'scheduled' ? '定时' : value === 'source_configured' ? '配置来源' : '手动' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: string) =>
<Tag color={statusLabels[value]?.color}>{statusLabels[value]?.label ?? value}</Tag> },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: dateTime },
{ title: '结果', key: 'result', render: (_value, run) => run.error_message ??
(run.status === 'succeeded' ? Object.entries(run.result)
.map(([key, value]) => `${key}: ${String(value)}`).join(' · ') : '—') },
];
return <div className="system-catalog-page">
<div className="system-catalog-heading">
<div>
<Typography.Title level={3}>平台类目与属性</Typography.Title>
<Typography.Text type="secondary">按平台查看商品类型、属性定义与候选值,并维护同步状态。</Typography.Text>
</div>
<Space wrap>
<Select
className="system-catalog-platform-select"
loading={loadingPlatforms}
placeholder="选择平台"
value={platformId}
options={platforms.map((item) => ({ value: item.id, label: item.name }))}
onChange={setPlatformId}
/>
<Button icon={<ReloadOutlined />} onClick={() => {
void loadPlatforms();
if (platformId) { void loadCategories(platformId); void loadRuns(platformId); }
if (platformId && selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
}}>刷新</Button>
</Space>
</div>
{!currentPlatform ? <Card><Empty description="暂无平台" /></Card> : <>
{currentPlatform.summary.last_error && <Alert
type="warning" showIcon message="最近一次类目树同步失败"
description={currentPlatform.summary.last_error}
/>}
<div className="system-catalog-summary">
<Card><Statistic title="已缓存类目节点" value={currentPlatform.summary.category_count} /></Card>
<Card><Statistic title="同步来源" value={currentPlatform.source.configured
? currentPlatform.source.client_id_hint ?? '已配置' : '未配置'} /></Card>
<Card><Statistic title="类目树最近成功" value={dateTime(currentPlatform.summary.last_tree_sync_at)} /></Card>
</div>
<div className="system-catalog-actions">
<Space wrap>
<Button icon={<KeyOutlined />} onClick={() => setSourceOpen(true)}
disabled={currentPlatform.code.toUpperCase() !== 'OZON'}>
配置同步来源
</Button>
<Button type="primary" icon={<CloudSyncOutlined />}
disabled={!currentPlatform.source.configured}
loading={syncingScope === 'tree'}
onClick={() => void sync({ scope: 'tree' })}>
同步类目树
</Button>
<Button icon={<CloudSyncOutlined />}
disabled={!selectedCategory || !currentPlatform.source.configured}
loading={syncingScope === 'attributes'}
onClick={() => selectedCategoryId && void sync({ scope: 'attributes', category_id: selectedCategoryId })}>
同步当前类型属性
</Button>
</Space>
<Typography.Text type="secondary">同步在后台执行,失败时保留上次成功的数据。</Typography.Text>
</div>
<div className="system-catalog-browser">
<Card title="类目 / 商品类型" className="system-catalog-category-card"
extra={<Typography.Text type="secondary">{categories.length} 个节点</Typography.Text>}>
<PlatformCategoryTree
categories={categories} loading={loadingCategories}
selectedId={selectedCategoryId}
onSelect={(category) => {
selectedCategoryIdRef.current = category.id;
setSelectedCategoryId(category.id);
}}
/>
</Card>
<Card title={selectedCategory ? `${selectedCategory.name} · 属性定义` : '属性定义'}
className="system-catalog-attribute-card"
extra={selectedCategory && <Typography.Text type="secondary">
类目 ID {selectedCategory.external_category_id} · 类型 ID {selectedCategory.external_type_id}
</Typography.Text>}>
<PlatformAttributesTable
attributes={attributes} loading={loadingAttributes}
selectedCategoryName={selectedCategory?.name ?? null}
onValues={setSelectedAttribute}
/>
</Card>
</div>
<Card title="最近同步任务" extra={<Button type="link" onClick={() => void loadRuns(currentPlatform.id)}>刷新任务</Button>}>
<Table<CatalogRun> rowKey="id" size="small" columns={runColumns}
dataSource={runs} pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 700 }} locale={{ emptyText: '暂无同步任务' }} />
</Card>
<CatalogSourceModal platform={currentPlatform} open={sourceOpen}
onClose={() => setSourceOpen(false)} onSave={saveSource} />
<DictionaryValuesDrawer
platformId={currentPlatform.id} attribute={selectedAttribute}
onClose={() => setSelectedAttribute(null)} refreshToken={refreshToken}
onSync={(attribute) => sync({ scope: 'dictionary', attribute_id: attribute.id })}
/>
</>}
</div>;
}