feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user