feat(category): 类目树、类目属性、属性字典维护系统构建完成

This commit is contained in:
R524809
2026-09-20 15:11:32 +08:00
parent 388429dbd0
commit 880e441bc6
48 changed files with 5127 additions and 1159 deletions
+76 -127
View File
@@ -93,7 +93,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
store_product_category_id: snapshot.profile.category.id,
platform_product_category_id: snapshot.profile.category.id,
category_source: snapshot.profile.category.source as
'source_capture' | 'ai_suggestion' | 'manual' | null,
},
@@ -138,6 +138,7 @@ type CategoryTreeOption = {
value: string;
label: string;
isLeaf?: boolean;
disabled?: boolean;
children?: CategoryTreeOption[];
};
@@ -162,12 +163,15 @@ function categoryTree(options: ProductEditorCategoryOption[]) {
if (parent) parent.children!.push(node);
else roots.push(node);
}
// Cascader navigation nodes must never be disabled: disabled folders cannot
// be opened. `changeOnSelect` remains false (the default), so the component
// only commits a value for a leaf node.
// 导航节点不能禁用,否则无法展开;只有停用或不可发布的叶子节点禁用。
for (const category of options) {
const node = nodes.get(category.id)!;
node.isLeaf = !node.children?.length;
node.disabled = node.isLeaf && (
!category.is_active ||
!category.external_type_id ||
category.external_type_id === '0'
);
}
for (const category of options) {
const path: string[] = [];
@@ -363,8 +367,7 @@ const ProductEditPage = () => {
const [form] = Form.useForm<EditorFormValues>();
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form);
const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form);
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
// 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
@@ -372,7 +375,7 @@ const ProductEditPage = () => {
const [sourceOverriddenFields, setSourceOverriddenFields] = useState<ProductAiSourceField[]>(
[]
);
// 类目缓存:随店铺切换与目录同步(catalogRefresh)重新拉取。
// 类目缓存属于平台,同平台切换店铺不会重新加载或清空类目选择。
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
@@ -389,14 +392,13 @@ const ProductEditPage = () => {
>({});
const [dictionaryCursors, setDictionaryCursors] = useState<Record<string, string | null>>({});
const dictionaryQueries = useRef<Record<string, string>>({});
const dictionaryInitialSyncs = useRef(new Set<string>());
const dictionaryPageLoads = useRef(new Set<string>());
const dictionaryContext = `${snapshot?.profile.id ?? ''}:${selectedStoreId ?? ''}:${selectedCategoryId ?? ''}`;
const dictionaryPollAttempts = useRef<Record<string, number>>({});
const schemaPollAttempts = useRef(0);
const dictionaryContext = `${snapshot?.profile.id ?? ''}:${selectedCategoryId ?? ''}`;
const dictionaryContextRef = useRef(dictionaryContext);
dictionaryContextRef.current = dictionaryContext;
const [catalogRefresh, setCatalogRefresh] = useState(0);
const [catalogSyncing, setCatalogSyncing] = useState<string | null>(null);
const catalogSyncToken = useRef<symbol | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
@@ -406,9 +408,9 @@ const ProductEditPage = () => {
const sourceSpecsApplied = useRef(false);
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
const categoryNotFoundContent = categoryOptionsLoading
? '正在读取该店铺的类目缓存…'
? '正在读取平台类目缓存…'
: categoryOptions.length === 0
? '该店铺还没有可用类目缓存,请先验证店铺或稍后刷新'
? '该平台还没有可用类目缓存,请联系系统管理员同步'
: categorySearch.trim()
? '未找到匹配的类目'
: '暂无可选择的类目';
@@ -448,7 +450,7 @@ const ProductEditPage = () => {
useEffect(() => {
void load();
}, [load]);
// 类目缓存:切换店铺或目录同步完成后重新拉取。
// 类目树在平台级共享,只随平台或编辑上下文变化重新读取。
useEffect(() => {
if (!snapshot || !currentPlatform) return;
let active = true;
@@ -456,7 +458,7 @@ const ProductEditPage = () => {
setCategoryOptions([]);
setCategorySearch('');
void productEditorApi
.categoryOptions(snapshot.profile.id, currentPlatform.id, undefined, selectedStoreId)
.categoryOptions(snapshot.profile.id, currentPlatform.id)
.then((options) => {
if (active) {
setCategoryOptions(options);
@@ -472,14 +474,15 @@ const ProductEditPage = () => {
return () => {
active = false;
};
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
// 切换店铺或类目后,不复用旧类目的字典搜索、游标和自动同步记录。
}, [currentPlatform, snapshot]);
// 切换类目后,不复用旧类型的字典搜索、游标和请求记录。
useEffect(() => {
dictionaryQueries.current = {};
dictionaryInitialSyncs.current.clear();
dictionaryPageLoads.current.clear();
dictionaryPollAttempts.current = {};
schemaPollAttempts.current = 0;
setDictionaryCursors({});
}, [selectedCategoryId, selectedStoreId, snapshot?.profile.id]);
}, [selectedCategoryId, snapshot?.profile.id]);
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
useEffect(() => {
if (!snapshot || !currentPlatform || !selectedCategoryId) {
@@ -505,14 +508,10 @@ const ProductEditPage = () => {
setDictionaryValues({});
setDictionaryCursors({});
void productEditorApi
.attributeSchema(
snapshot.profile.id,
currentPlatform.id,
selectedCategoryId,
selectedStoreId
)
.then((schema) => {
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId)
.then((result) => {
if (active) {
const schema = result.items;
const currentAttributes = (form.getFieldValue('attributes') ??
[]) as AttributeDraft[];
const missing = missingAttributeDrafts(currentAttributes, schema, snapshot);
@@ -523,6 +522,17 @@ const ProductEditPage = () => {
setAttributeSchemaCategoryId(selectedCategoryId);
setAttributeSchemaCatalogRefresh(catalogRefresh);
setAttributeSchemaLoading(false);
if (result.cache.status === 'expired' || result.cache.status === 'missing') {
schemaPollAttempts.current += 1;
if (schemaPollAttempts.current < 20) {
setAttributeSchemaError('正在通过高优先级任务更新 Ozon 类目属性…');
window.setTimeout(() => {
if (active) setCatalogRefresh((version) => version + 1);
}, result.cache.retry_after_ms ?? 1_000);
} else {
setAttributeSchemaError('Ozon 类目属性更新仍未完成,请稍后重试');
}
} else schemaPollAttempts.current = 0;
}
})
.catch((error) => {
@@ -544,37 +554,35 @@ const ProductEditPage = () => {
currentPlatform,
form,
selectedCategoryId,
selectedStoreId,
snapshot,
]);
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
useEffect(() => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
const dictionaryIds = [
const attributeIds = [
...new Set(
editableAttributeSchema
.map((attribute) => attribute.dictionary_id)
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
.filter((attribute) => !!attribute.dictionary_id)
.map((attribute) => attribute.attribute_id)
),
];
if (!dictionaryIds.length) {
if (!attributeIds.length) {
setDictionaryValues({});
setDictionaryCursors({});
return;
}
let active = true;
void Promise.all(
dictionaryIds.map(
async (dictionaryId) =>
attributeIds.map(
async (attributeId) =>
[
dictionaryId,
attributeId,
await productEditorApi.attributeDictionary(
snapshot.profile.id,
currentPlatform.id,
dictionaryId,
attributeId,
undefined,
selectedCategoryId,
selectedStoreId
selectedCategoryId
),
] as const
)
@@ -612,7 +620,6 @@ const ProductEditPage = () => {
currentPlatform,
editableAttributeSchema,
selectedCategoryId,
selectedStoreId,
snapshot,
]);
useEffect(() => {
@@ -798,7 +805,7 @@ const ProductEditPage = () => {
okText: '知道了',
});
};
/** 轮询目录同步 operation 直至终态(最多 20 次 × 1s);超时返回 null。 */
/** 轮询工作空间 operation 直至终态(最多 20 次 × 1s);用于素材转存。 */
const waitForCatalogOperation = async (operationId: string) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (attempt) await wait(1_000);
@@ -807,78 +814,33 @@ const ProductEditPage = () => {
}
return null;
};
/** 跟踪 Ozon 目录同步;schema 成功后刷新目录,字典只重读对应选项。 */
const runCatalogSync = async (
syncKey: string,
action: () => Promise<{ operation: { id: string } }>,
successMessage: string | null,
refreshCatalog = true
) => {
const token = Symbol(syncKey);
catalogSyncToken.current = token;
setCatalogSyncing(syncKey);
try {
const queued = await action();
const completed = await waitForCatalogOperation(queued.operation.id);
if (completed?.status === 'succeeded') {
if (successMessage) message.success(successMessage);
if (refreshCatalog) setCatalogRefresh((version) => version + 1);
return true;
} else if (completed?.status === 'failed') {
message.error(completed.error?.message ?? 'Ozon 同步失败,请稍后重试');
} else if (completed?.status === 'cancelled') {
message.warning(completed.error?.message ?? '同步已取消,请重新发起');
} else {
message.info('同步仍在队列中,稍后刷新页面即可读取最新缓存');
}
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
if (catalogSyncToken.current === token) {
catalogSyncToken.current = null;
setCatalogSyncing(null);
}
}
return false;
};
const syncAttributeSchema = () => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
void runCatalogSync(
`attributes:${selectedCategoryId}`,
() =>
productEditorApi.syncAttributeSchema(
snapshot.profile.id,
currentPlatform.id,
selectedCategoryId
),
'该类目的属性已同步'
);
setCatalogRefresh((version) => version + 1);
};
const loadDictionaryValues = async (dictionaryId: string, query = '', cursor?: string) => {
const loadDictionaryValues = async (attributeId: string, query = '', cursor?: string) => {
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
const requestContext = dictionaryContext;
const requestKey = `${requestContext}:${dictionaryId}:${query}:${cursor ?? ''}`;
const requestKey = `${requestContext}:${attributeId}:${query}:${cursor ?? ''}`;
if (dictionaryPageLoads.current.has(requestKey)) return;
dictionaryPageLoads.current.add(requestKey);
if (!cursor) dictionaryQueries.current[dictionaryId] = query;
if (!cursor) dictionaryQueries.current[attributeId] = query;
try {
const page = await productEditorApi.attributeDictionary(
snapshot.profile.id,
currentPlatform.id,
dictionaryId,
attributeId,
query,
selectedCategoryId,
selectedStoreId,
cursor
);
if (
dictionaryContextRef.current !== requestContext ||
dictionaryQueries.current[dictionaryId] !== query
dictionaryQueries.current[attributeId] !== query
) return;
setDictionaryValues((current) => ({
...current,
[dictionaryId]: cursor
? [...(current[dictionaryId] ?? []), ...page.items].filter(
[attributeId]: cursor
? [...(current[attributeId] ?? []), ...page.items].filter(
(value, index, all) =>
all.findIndex((item) => item.value_id === value.value_id) === index
)
@@ -886,50 +848,37 @@ const ProductEditPage = () => {
}));
setDictionaryCursors((current) => ({
...current,
[dictionaryId]: page.next_cursor,
[attributeId]: page.next_cursor,
}));
const pollKey = `${attributeId}:${query}`;
if (page.cache.status === 'expired' || page.cache.status === 'missing') {
dictionaryPollAttempts.current[pollKey] =
(dictionaryPollAttempts.current[pollKey] ?? 0) + 1;
if (dictionaryPollAttempts.current[pollKey] < 20) {
window.setTimeout(() => {
if (dictionaryContextRef.current === requestContext)
void loadDictionaryValues(attributeId, query, cursor);
}, page.cache.retry_after_ms ?? 1_000);
}
} else dictionaryPollAttempts.current[pollKey] = 0;
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
dictionaryPageLoads.current.delete(requestKey);
}
};
const syncDictionaryValues = async (dictionaryId: string, query: string) => {
const syncDictionaryValues = async (attributeId: string, query: string) => {
if (!snapshot || !currentPlatform || !selectedCategoryId || query.length === 1)
return false;
const requestContext = dictionaryContext;
const succeeded = await runCatalogSync(
`dictionary:${dictionaryId}:${query}`,
() =>
productEditorApi.syncAttributeDictionary(
snapshot.profile.id,
currentPlatform.id,
dictionaryId,
selectedCategoryId,
query
),
null,
false
);
if (
succeeded && dictionaryContextRef.current === requestContext &&
dictionaryQueries.current[dictionaryId] === query
) void loadDictionaryValues(dictionaryId, query);
return succeeded;
await loadDictionaryValues(attributeId, query);
return true;
};
const openDictionaryValues = (dictionaryId: string) => {
void loadDictionaryValues(dictionaryId, '');
if (dictionaryInitialSyncs.current.has(dictionaryId)) return;
dictionaryInitialSyncs.current.add(dictionaryId);
const requestContext = dictionaryContext;
void syncDictionaryValues(dictionaryId, '').then((succeeded) => {
if (!succeeded && dictionaryContextRef.current === requestContext)
dictionaryInitialSyncs.current.delete(dictionaryId);
});
const openDictionaryValues = (attributeId: string) => {
void loadDictionaryValues(attributeId, '');
};
const loadMoreDictionaryValues = (dictionaryId: string, query: string) => {
const cursor = dictionaryCursors[dictionaryId];
if (cursor) void loadDictionaryValues(dictionaryId, query, cursor);
const loadMoreDictionaryValues = (attributeId: string, query: string) => {
const cursor = dictionaryCursors[attributeId];
if (cursor) void loadDictionaryValues(attributeId, query, cursor);
};
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
const materializeCandidate = async (transferId: string) => {
@@ -1038,7 +987,7 @@ const ProductEditPage = () => {
activeAttributeSchema={activeAttributeSchema}
attributeSchemaLoading={attributeSchemaLoading}
attributeSchemaError={attributeSchemaError}
catalogSyncing={catalogSyncing}
catalogSyncing={null}
dictionaryValues={dictionaryValues}
onLoadDictionaryValues={(dictionaryId, query) =>
void loadDictionaryValues(dictionaryId, query)
@@ -1055,7 +1004,7 @@ const ProductEditPage = () => {
collectionRecord={collectionRecord}
materializingTransferId={materializingTransferId}
activeAttributeSchema={activeAttributeSchema}
catalogSyncing={catalogSyncing}
catalogSyncing={null}
dictionaryValues={dictionaryValues}
onMaterialize={materializeCandidate}
onLoadDictionaryValues={(dictionaryId, query) =>
@@ -64,20 +64,20 @@ function DynamicAttributeField({
const [dictionaryQuery, setDictionaryQuery] = useState('');
const onSyncRef = useRef(onSyncDictionaryValues);
onSyncRef.current = onSyncDictionaryValues;
useEffect(() => setDictionaryQuery(''), [definition.dictionary_id]);
useEffect(() => setDictionaryQuery(''), [definition.attribute_id]);
useEffect(() => {
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
const timer = window.setTimeout(
() => onSyncRef.current(definition.dictionary_id!, dictionaryQuery.trim()),
() => onSyncRef.current(definition.attribute_id, dictionaryQuery.trim()),
400
);
return () => window.clearTimeout(timer);
}, [definition.dictionary_id, dictionaryQuery]);
}, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]);
const valueName = ['attributes', index, 'values'] as NamePath;
const scalarValueName = [...valueName, 0] as NamePath;
const dictionaryOptions = definition.dictionary_id
? (dictionaryValues[definition.dictionary_id] ?? []).map((value) => ({
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
value: value.value_id,
label: value.value,
}))
@@ -95,20 +95,20 @@ function DynamicAttributeField({
onOpenChange={(open) => {
if (!open || !definition.dictionary_id) return;
setDictionaryQuery('');
onOpenDictionaryValues(definition.dictionary_id);
onOpenDictionaryValues(definition.attribute_id);
}}
onSearch={(query) => {
if (!definition.dictionary_id) return;
const needle = query.trim();
setDictionaryQuery(needle);
onLoadDictionaryValues(definition.dictionary_id, needle);
onLoadDictionaryValues(definition.attribute_id, needle);
}}
onPopupScroll={(event) => {
if (!definition.dictionary_id) return;
const target = event.currentTarget;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
onLoadMoreDictionaryValues(
definition.dictionary_id,
definition.attribute_id,
dictionaryQuery.trim()
);
}}
@@ -118,20 +118,20 @@ function DynamicAttributeField({
type="link"
loading={
catalogSyncing ===
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
`dictionary:${definition.attribute_id}:${dictionaryQuery.trim()}`
}
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
definition.dictionary_id &&
onSyncDictionaryValues(
definition.dictionary_id,
definition.attribute_id,
dictionaryQuery.trim()
)
}
>
从 Ozon 搜索“{dictionaryQuery.trim()}”
</Button>
) : catalogSyncing === `dictionary:${definition.dictionary_id}:` ? (
) : catalogSyncing === `dictionary:${definition.attribute_id}:` ? (
'正在从 Ozon 加载选项…'
) : (
'暂无选项,输入至少 2 个字符可从 Ozon 搜索'
@@ -155,10 +155,10 @@ export function ProductAttributeSection({
</span>
<Button
type="link"
loading={catalogSyncing === `attributes:${selectedCategoryId}`}
loading={attributeSchemaLoading}
onClick={onSyncAttributeSchema}
>
同步此类目属性
重试更新
</Button>
</Space>
}
@@ -9,6 +9,7 @@ type CategoryOption = {
value: string;
label: string;
isLeaf?: boolean;
disabled?: boolean;
children?: CategoryOption[];
};
@@ -51,8 +52,8 @@ type Props = {
/**
* 「01 主要信息」区块:上架店铺、产品类目、标题/型号/品牌与默认包装规格。
*
* 切换店铺会清空类目与属性草稿;切换类目会清空属性草稿并记录类目来源,
* 避免跨类目残留脏数据。
* 类目属于平台级共享参考数据,切换同平台店铺不会清空类目;切换类目时
* 清空动态属性草稿并记录类目来源,避免跨类型残留脏数据。
*/
export function ProductMainInfoSection({
form,
@@ -83,15 +84,10 @@ export function ProductMainInfoSection({
value: store.id,
label: `${store.name}(${store.currency_code})`,
}))}
onChange={() => {
form.setFieldValue(['profile', 'store_product_category_id'], null);
form.setFieldValue(['profile', 'category_source'], null);
form.setFieldValue('attributes', []);
}}
/>
</Form.Item>
<Form.Item
name={['profile', 'store_product_category_id']}
name={['profile', 'platform_product_category_id']}
label="产品类目"
className="span-full"
rules={[{ required: true, message: '请选择产品类目' }]}
@@ -105,7 +101,7 @@ export function ProductMainInfoSection({
classNames={{ popup: { root: 'editor-category-popup' } }}
showSearch
onSearch={onCategorySearch}
placeholder="选择当前店铺可发布的产品类目"
placeholder="选择当前平台的产品类目"
options={categoryOptions}
notFoundContent={categoryNotFoundContent}
getPopupContainer={(node) => node.parentElement ?? document.body}
+188 -28
View File
@@ -1,32 +1,192 @@
.system-catalog-page { display: grid; gap: 16px; }
.system-catalog-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.system-catalog-heading .ant-typography { margin-bottom: 2px; }
.system-catalog-platform-select { min-width: 190px; }
.system-catalog-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.system-catalog-summary .ant-card-body { padding: 16px 20px; }
.system-catalog-summary .ant-statistic-content { font-size: 19px; }
.system-catalog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.system-catalog-browser { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); gap: 16px; align-items: stretch; }
.system-catalog-category-card, .system-catalog-attribute-card { min-width: 0; }
.system-catalog-tree-panel { display: grid; gap: 12px; max-height: 620px; overflow: auto; }
.system-catalog-tree-panel .ant-tree { min-width: 0; }
.system-catalog-tree-label { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; flex-wrap: wrap; white-space: normal; }
.system-catalog-tree-label .ant-typography { font-size: 12px; }
.system-catalog-attribute-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
.system-catalog-attribute-name .ant-typography:last-child { font-size: 12px; }
.system-catalog-attribute-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.system-catalog-attribute-tags .ant-tag { margin: 0; }
.system-catalog-values-link { padding-inline: 0; }
.system-catalog-values-content { width: 100%; }
.system-catalog-wrap { white-space: normal; overflow-wrap: anywhere; }
.system-catalog-panel-loading { display: grid; min-height: 160px; place-items: center; }
.system-catalog-source-info { margin-bottom: 16px; }
.system-catalog-page {
display: grid;
gap: 16px;
}
.system-catalog-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.system-catalog-heading .ant-typography {
margin-bottom: 2px;
}
.system-catalog-platform-select {
min-width: 190px;
}
.system-catalog-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.system-catalog-summary .ant-card-body {
padding: 16px 20px;
}
.system-catalog-summary .ant-statistic-content {
font-size: 19px;
}
.system-catalog-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.system-catalog-page .ant-input-number {
width: 96px;
}
.system-catalog-policy-modal .ant-input-number {
width: 100%;
}
.system-catalog-policy-info {
margin-bottom: 20px;
}
.system-catalog-policy-loading {
display: grid;
min-height: 220px;
place-items: center;
}
.system-catalog-cache-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 18px;
margin-bottom: 16px;
}
.system-catalog-cache-summary > span {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.system-catalog-cache-summary .ant-tag {
margin-inline-end: 0;
}
.system-catalog-batch-actions {
margin-bottom: 10px;
}
.system-catalog-active-batch-note {
margin: 0 0 12px;
}
.system-catalog-batch-table {
margin-top: 6px;
}
.system-catalog-batch-progress {
display: grid;
gap: 2px;
min-width: 260px;
}
.system-catalog-batch-progress .ant-progress {
margin: 0;
}
.system-catalog-browser {
display: grid;
grid-template-columns: minmax(280px, 34%) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.system-catalog-category-card,
.system-catalog-attribute-card {
min-width: 0;
}
.system-catalog-tree-panel {
display: flex;
flex-direction: column;
height: 620px;
min-height: 0;
}
.system-catalog-tree-search {
flex: 0 0 auto;
padding-bottom: 12px;
background: #fff;
z-index: 1;
}
.system-catalog-tree-scroll {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.system-catalog-tree-scroll .ant-tree {
min-width: 0;
}
.system-catalog-tree-label {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
flex-wrap: wrap;
white-space: normal;
}
.system-catalog-tree-label .ant-typography {
font-size: 12px;
}
.system-catalog-attribute-name {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.system-catalog-attribute-name .ant-typography:last-child {
font-size: 12px;
}
.system-catalog-attribute-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.system-catalog-attribute-tags .ant-tag {
margin: 0;
}
.system-catalog-values-link {
padding-inline: 0;
}
.system-catalog-dictionary-cell {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.system-catalog-dictionary-cell .ant-tag {
margin-inline-end: 0;
}
.system-catalog-values-content {
width: 100%;
}
.system-catalog-wrap {
white-space: normal;
overflow-wrap: anywhere;
}
.system-catalog-panel-loading {
display: grid;
min-height: 160px;
place-items: center;
}
.system-catalog-source-info {
margin-bottom: 16px;
}
@media (max-width: 1100px) {
.system-catalog-browser { grid-template-columns: 1fr; }
.system-catalog-tree-panel { max-height: 340px; }
.system-catalog-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.system-catalog-browser {
grid-template-columns: 1fr;
}
.system-catalog-tree-panel {
height: 340px;
}
}
@media (max-width: 700px) {
.system-catalog-heading { align-items: stretch; flex-direction: column; }
.system-catalog-summary { grid-template-columns: 1fr; }
.system-catalog-platform-select { flex: 1; }
.system-catalog-heading {
align-items: stretch;
flex-direction: column;
}
.system-catalog-summary {
grid-template-columns: 1fr;
}
.system-catalog-platform-select {
flex: 1;
}
}
+384 -167
View File
@@ -1,17 +1,35 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App as AntdApp, Button, Card, Empty, Select, Space, Statistic,
Table, Tag, Typography,
Alert,
App as AntdApp,
Button,
Card,
Empty,
Select,
Space,
Statistic,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons';
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import {
platformCatalogAdminApi,
type AdminPlatform, type CatalogAttribute, type CatalogCategory,
type CatalogRun, type SyncRequest,
type AdminPlatform,
type AttributeBatchMode,
type AttributeSyncBatch,
type AttributeSyncSummary,
type CatalogAttribute,
type CatalogCategory,
type CatalogRefreshPolicy,
type CatalogRefreshPolicyInput,
type CatalogRun,
type SyncRequest,
} from '@/services/platformCatalogAdmin';
import AttributeSyncControlCard from './components/AttributeSyncControlCard';
import CatalogCacheStatusTag from './components/CatalogCacheStatusTag';
import CatalogRefreshPolicyModal from './components/CatalogRefreshPolicyModal';
import CatalogSourceModal from './components/CatalogSourceModal';
import CatalogSyncRunsTable from './components/CatalogSyncRunsTable';
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
import PlatformAttributesTable from './components/PlatformAttributesTable';
import PlatformCategoryTree from './components/PlatformCategoryTree';
@@ -21,16 +39,6 @@ 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[]>([]);
@@ -38,24 +46,36 @@ export default function SystemCatalogPage() {
const [categories, setCategories] = useState<CatalogCategory[]>([]);
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
const [runs, setRuns] = useState<CatalogRun[]>([]);
const [refreshPolicy, setRefreshPolicy] = useState<CatalogRefreshPolicy | null>(null);
const [attributeSummary, setAttributeSummary] = useState<AttributeSyncSummary | null>(null);
const [attributeBatches, setAttributeBatches] = useState<AttributeSyncBatch[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedAttribute, setSelectedAttribute] = useState<CatalogAttribute | null>(null);
const [selectedAttributeId, setSelectedAttributeId] = useState<string | null>(null);
const [sourceOpen, setSourceOpen] = useState(false);
const [refreshPolicyOpen, setRefreshPolicyOpen] = useState(false);
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
const [loadingAttributes, setLoadingAttributes] = useState(false);
const [loadingManagement, setLoadingManagement] = useState(false);
const [refreshingRuns, setRefreshingRuns] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [syncingScope, setSyncingScope] = useState<string | null>(null);
const [startingBatchMode, setStartingBatchMode] = useState<AttributeBatchMode | 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],
[platforms, platformId]
);
const selectedCategory = useMemo(
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId],
[categories, selectedCategoryId]
);
const selectedAttribute = useMemo(
() => attributes.find((item) => item.id === selectedAttributeId) ?? null,
[attributes, selectedAttributeId]
);
const loadPlatforms = useCallback(async () => {
@@ -63,8 +83,13 @@ export default function SystemCatalogPage() {
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));
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 {
@@ -72,68 +97,117 @@ export default function SystemCatalogPage() {
}
}, [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);
const loadCategories = useCallback(
async (id: string) => {
setLoadingCategories(true);
try {
setCategories(await platformCatalogAdminApi.categories(id));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoadingCategories(false);
}
} catch {
// 轮询失败时保留最后一次任务列表,页面手动刷新仍可重试。
}
}, [loadPlatforms, loadCategories, loadAttributes]);
},
[message]
);
useEffect(() => { void loadPlatforms(); }, [loadPlatforms]);
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 loadManagement = useCallback(
async (id: string, reportError = true) => {
if (reportError) setLoadingManagement(true);
try {
const [policy, summary, batches] = await Promise.all([
platformCatalogAdminApi.refreshPolicy(id),
platformCatalogAdminApi.attributeSyncSummary(id),
platformCatalogAdminApi.attributeSyncBatches(id),
]);
setRefreshPolicy(policy);
setAttributeSummary(summary);
setAttributeBatches(batches);
} catch (error) {
if (reportError) message.error(apiErrorMessage(error));
} finally {
if (reportError) setLoadingManagement(false);
}
},
[message]
);
const loadRuns = useCallback(
async (id: string, reportError = false) => {
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);
void loadManagement(id, false);
if (selectedCategoryIdRef.current) {
void loadAttributes(id, selectedCategoryIdRef.current);
}
}
return true;
} catch (error) {
// 自动轮询失败时保留最后一次结果;手动刷新必须给管理员明确反馈。
if (reportError) message.error(apiErrorMessage(error));
return false;
}
},
[loadPlatforms, loadCategories, loadAttributes, loadManagement, message]
);
useEffect(() => {
void loadPlatforms();
}, [loadPlatforms]);
useEffect(() => {
if (!platformId) return;
setSelectedCategoryId(null);
selectedCategoryIdRef.current = null;
setSelectedAttribute(null);
setSelectedAttributeId(null);
setCategories([]);
setAttributes([]);
setRefreshPolicy(null);
setAttributeSummary(null);
setAttributeBatches([]);
previousRunStatuses.current.clear();
void loadCategories(platformId);
void loadRuns(platformId);
}, [platformId, loadCategories, loadRuns]);
void loadManagement(platformId);
}, [platformId, loadCategories, loadRuns, loadManagement]);
useEffect(() => {
if (!platformId || !selectedCategoryId) return;
setSelectedAttributeId(null);
void loadAttributes(platformId, selectedCategoryId);
}, [platformId, selectedCategoryId, loadAttributes]);
useEffect(() => {
if (!platformId) return;
const timer = window.setInterval(() => void loadRuns(platformId), 5000);
const timer = window.setInterval(() => {
void loadRuns(platformId);
void loadManagement(platformId, false);
}, 5000);
return () => window.clearInterval(timer);
}, [platformId, loadRuns]);
}, [platformId, loadRuns, loadManagement]);
const sync = async (input: SyncRequest) => {
if (!platformId) return;
@@ -153,120 +227,263 @@ export default function SystemCatalogPage() {
if (!platformId) return;
try {
const result = await platformCatalogAdminApi.configureSource(platformId, input);
message.success(result.changed
? '同步来源已保存,类目树同步已排队'
: '同步来源没有变化');
await loadPlatforms();
await loadRuns(platformId);
message.success(
result.changed ? '同步来源已保存,类目树同步已排队' : '同步来源没有变化'
);
await Promise.all([loadPlatforms(), 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(' · ') : '—') },
];
const saveRefreshPolicy = async (input: CatalogRefreshPolicyInput) => {
if (!platformId) return false;
setSavingPolicy(true);
try {
setRefreshPolicy(await platformCatalogAdminApi.updateRefreshPolicy(platformId, input));
message.success('缓存新鲜度设置已保存');
await Promise.all([
loadManagement(platformId, false),
loadCategories(platformId),
selectedCategoryId
? loadAttributes(platformId, selectedCategoryId)
: Promise.resolve(),
]);
return true;
} catch (error) {
message.error(apiErrorMessage(error));
return false;
} finally {
setSavingPolicy(false);
}
};
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>
const startAttributeBatch = async (mode: AttributeBatchMode) => {
if (!platformId) return;
setStartingBatchMode(mode);
try {
const batch = await platformCatalogAdminApi.createAttributeSyncBatch(platformId, mode);
setAttributeBatches((current) => [
batch,
...current.filter((item) => item.id !== batch.id),
]);
message.success(`类型属性同步批次已创建,共 ${batch.total_count} 个类型`);
await Promise.all([loadManagement(platformId, false), loadRuns(platformId)]);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setStartingBatchMode(null);
}
};
{!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">
const refreshAll = () => {
void loadPlatforms();
if (!platformId) return;
void loadCategories(platformId);
void loadRuns(platformId);
void loadManagement(platformId);
if (selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
};
const refreshRuns = async (id: string) => {
setRefreshingRuns(true);
try {
if (await loadRuns(id, true)) message.success('同步任务刷新成功');
} finally {
setRefreshingRuns(false);
}
};
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>
<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 })}>
同步当前类型属性
<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={refreshAll}>
刷新
</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);
}}
/>
{!currentPlatform ? (
<Card>
<Empty description="暂无平台" />
</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}
) : (
<>
{currentPlatform.summary.last_error && (
<Alert
type="warning"
showIcon
title="最近一次类目树同步失败"
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>
<Card>
<Statistic
title="下次自动同步"
value={dateTime(currentPlatform.summary.next_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
icon={<SettingOutlined />}
onClick={() => setRefreshPolicyOpen(true)}
>
缓存新鲜度设置
</Button>
<Button
type="primary"
icon={<CloudSyncOutlined />}
disabled={!currentPlatform.source.configured}
loading={syncingScope === 'tree'}
onClick={() => void sync({ scope: 'tree' })}
>
同步类目树
</Button>
</Space>
<Typography.Text type="secondary">
同步由后台 Worker 执行,失败时保留上次成功缓存。
</Typography.Text>
</div>
<AttributeSyncControlCard
summary={attributeSummary}
batches={attributeBatches}
loading={loadingManagement}
startingMode={startingBatchMode}
sourceConfigured={currentPlatform.source.configured}
onStart={startAttributeBatch}
onRefresh={() => void loadManagement(currentPlatform.id)}
/>
</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>;
<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 && (
<Space wrap>
<CatalogCacheStatusTag
status={selectedCategory.attribute_cache_status}
error={selectedCategory.attribute_last_error}
/>
<Typography.Text type="secondary">
类目 ID {selectedCategory.external_category_id} · 类型
ID {selectedCategory.external_type_id}
</Typography.Text>
</Space>
)
}
>
<PlatformAttributesTable
attributes={attributes}
loading={loadingAttributes}
selectedCategoryName={selectedCategory?.name ?? null}
onValues={(attribute) => setSelectedAttributeId(attribute.id)}
/>
</Card>
</div>
<CatalogSyncRunsTable
runs={runs}
refreshing={refreshingRuns}
onRefresh={() => refreshRuns(currentPlatform.id)}
/>
<CatalogSourceModal
platform={currentPlatform}
open={sourceOpen}
onClose={() => setSourceOpen(false)}
onSave={saveSource}
/>
<CatalogRefreshPolicyModal
platformName={currentPlatform.name}
policy={refreshPolicy}
open={refreshPolicyOpen}
loading={loadingManagement}
saving={savingPolicy}
onClose={() => setRefreshPolicyOpen(false)}
onSave={saveRefreshPolicy}
/>
<DictionaryValuesDrawer
platformId={currentPlatform.id}
attribute={selectedAttribute}
onClose={() => setSelectedAttributeId(null)}
refreshToken={refreshToken}
onSync={(attribute) =>
sync({ scope: 'dictionary', attribute_id: attribute.id })
}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,133 @@
import { Button, Card, Empty, Popconfirm, Progress, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloudSyncOutlined, ReloadOutlined } from '@ant-design/icons';
import type {
AttributeBatchMode,
AttributeSyncBatch,
AttributeSyncSummary,
} from '@/services/platformCatalogAdmin';
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
type Props = {
summary: AttributeSyncSummary | null;
batches: AttributeSyncBatch[];
loading: boolean;
startingMode: AttributeBatchMode | null;
sourceConfigured: boolean;
onStart: (mode: AttributeBatchMode) => Promise<void>;
onRefresh: () => void;
};
const batchModeLabels: Record<AttributeBatchMode, string> = {
missing_expired: '未缓存和过期',
missing_stale_expired: '未缓存和不新鲜',
all: '全部类型属性',
};
const batchStatusLabels: 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' },
};
function dateTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
function completedCount(batch: AttributeSyncBatch) {
return batch.succeeded_count + batch.failed_count + batch.cancelled_count;
}
export default function AttributeSyncControlCard({
summary, batches, loading, startingMode, sourceConfigured, onStart, onRefresh,
}: Props) {
const active = !!summary?.active_batch_id;
const missingExpiredCount = (summary?.missing ?? 0) + (summary?.expired ?? 0);
const notFreshCount = missingExpiredCount + (summary?.stale ?? 0);
const disabledBase = loading || active || !sourceConfigured;
const columns: ColumnsType<AttributeSyncBatch> = [
{
title: '范围', dataIndex: 'mode', key: 'mode', width: 170,
render: (mode: AttributeBatchMode) => batchModeLabels[mode],
},
{
title: '进度', key: 'progress',
render: (_value, batch) => {
const completed = completedCount(batch);
const percent = batch.total_count
? Math.round(completed / batch.total_count * 100) : 0;
return <div className="system-catalog-batch-progress">
<Progress percent={percent} size="small"
status={batch.status === 'failed' ? 'exception' : undefined} />
<Typography.Text type="secondary">
成功 {batch.succeeded_count} · 运行 {batch.running_count} · 等待 {batch.pending_count}
{batch.failed_count ? ` · 失败 ${batch.failed_count}` : ''}
</Typography.Text>
</div>;
},
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (status: string) => <Tag color={batchStatusLabels[status]?.color}>
{batchStatusLabels[status]?.label ?? status}
</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180, render: dateTime },
];
return <Card title="类型属性同步" extra={<Button type="link" icon={<ReloadOutlined />}
onClick={onRefresh}>刷新状态</Button>}>
<div className="system-catalog-cache-summary">
<span><CatalogCacheStatusTag status="fresh" />{summary?.fresh ?? 0}</span>
<span><CatalogCacheStatusTag status="stale" />{summary?.stale ?? 0}</span>
<span><CatalogCacheStatusTag status="expired" />{summary?.expired ?? 0}</span>
<span><CatalogCacheStatusTag status="missing" />{summary?.missing ?? 0}</span>
<Typography.Text type="secondary">共 {summary?.total ?? 0} 个活动商品类型</Typography.Text>
</div>
<Space wrap className="system-catalog-batch-actions">
<Button icon={<CloudSyncOutlined />}
disabled={disabledBase || missingExpiredCount === 0}
loading={startingMode === 'missing_expired'}
onClick={() => void onStart('missing_expired')}>
同步未缓存和过期类型属性({missingExpiredCount})
</Button>
<Button icon={<CloudSyncOutlined />}
disabled={disabledBase || notFreshCount === 0}
loading={startingMode === 'missing_stale_expired'}
onClick={() => void onStart('missing_stale_expired')}>
同步未缓存和不新鲜类型属性({notFreshCount})
</Button>
<Popconfirm
title="确认同步全部类型属性?"
description={`将处理 ${summary?.total ?? 0} 个类型,包括仍然新鲜的数据。任务会在后台分批执行,可能持续较长时间。`}
okText="确认开始"
cancelText="取消"
okButtonProps={{ danger: true }}
disabled={disabledBase || !summary?.total}
onConfirm={() => onStart('all')}
>
<Button danger icon={<CloudSyncOutlined />}
disabled={disabledBase || !summary?.total}
loading={startingMode === 'all'}>
同步全部类型属性({summary?.total ?? 0})
</Button>
</Popconfirm>
</Space>
{active && <Typography.Paragraph type="secondary" className="system-catalog-active-batch-note">
当前已有批次执行中;完成前不能创建另一个类型属性批次。
</Typography.Paragraph>}
<Table<AttributeSyncBatch>
rowKey="id"
size="small"
className="system-catalog-batch-table"
columns={columns}
dataSource={batches}
loading={loading && !batches.length}
pagination={false}
scroll={{ x: 760 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无属性同步批次" /> }}
/>
</Card>;
}
@@ -0,0 +1,21 @@
import { Tag, Tooltip } from 'antd';
import type { CatalogCacheStatus } from '@/services/platformCatalogAdmin';
const statusPresentation: Record<CatalogCacheStatus, { label: string; color: string }> = {
fresh: { label: '新鲜', color: 'green' },
stale: { label: '待更新', color: 'gold' },
expired: { label: '已过期', color: 'red' },
missing: { label: '未缓存', color: 'default' },
};
type Props = {
status: CatalogCacheStatus | null | undefined;
error?: string | null;
};
export default function CatalogCacheStatusTag({ status, error }: Props) {
if (!status) return null;
const presentation = statusPresentation[status];
const tag = <Tag color={presentation.color}>{presentation.label}</Tag>;
return error ? <Tooltip title={`最近错误:${error}`}>{tag}</Tooltip> : tag;
}
@@ -0,0 +1,165 @@
import { useEffect } from 'react';
import { Alert, Col, Form, InputNumber, Modal, Row, Spin, Typography } from 'antd';
import type {
CatalogRefreshPolicy,
CatalogRefreshPolicyInput,
} from '@/services/platformCatalogAdmin';
type Props = {
platformName: string;
policy: CatalogRefreshPolicy | null;
open: boolean;
loading: boolean;
saving: boolean;
onClose: () => void;
onSave: (input: CatalogRefreshPolicyInput) => Promise<boolean>;
};
function dateTime(value: string | null | undefined) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '使用默认策略';
}
/** 缓存策略仅在管理员主动打开时展示,避免长期占用目录管理主页面。 */
export default function CatalogRefreshPolicyModal({
platformName,
policy,
open,
loading,
saving,
onClose,
onSave,
}: Props) {
const [form] = Form.useForm<CatalogRefreshPolicyInput>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (policy) form.setFieldsValue(policy);
}, [form, open, policy?.updated_at]);
return (
<Modal
className="system-catalog-policy-modal"
title={`${platformName} · 缓存新鲜度设置`}
open={open}
onCancel={onClose}
onOk={() => void form.submit()}
okText="保存设置"
confirmLoading={saving}
okButtonProps={{ disabled: loading || !policy }}
destroyOnHidden
width={680}
>
<Alert
type="info"
showIcon
className="system-catalog-policy-info"
message="类目树固定每 7 天自动同步"
description="这里仅配置类型属性和属性字典的新鲜、过期天数。保存后不会立即创建批量同步任务。"
/>
{!policy && loading ? (
<div className="system-catalog-policy-loading">
<Spin />
</div>
) : (
<Form<CatalogRefreshPolicyInput>
form={form}
layout="vertical"
disabled={loading && !policy}
requiredMark={false}
onFinish={async (values) => {
if (await onSave(values)) onClose();
}}
>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item
name="attribute_fresh_days"
label="类型属性新鲜天数(天)"
rules={[
{
required: true,
type: 'number',
min: 1,
max: 365,
},
]}
>
<InputNumber min={1} max={365} precision={0} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="attribute_expire_days"
label="类型属性过期天数(天)"
dependencies={['attribute_fresh_days']}
rules={[
{
required: true,
type: 'number',
min: 1,
max: 365,
},
({ getFieldValue }) => ({
validator: (_rule, value: number | undefined) =>
value !== undefined &&
value > Number(getFieldValue('attribute_fresh_days'))
? Promise.resolve()
: Promise.reject(new Error('必须大于新鲜天数')),
}),
]}
>
<InputNumber min={1} max={365} precision={0} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="dictionary_fresh_days"
label="属性字典新鲜天数(天)"
rules={[
{
required: true,
type: 'number',
min: 1,
max: 365,
},
]}
>
<InputNumber min={1} max={365} precision={0} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="dictionary_expire_days"
label="属性字典过期天数(天)"
dependencies={['dictionary_fresh_days']}
rules={[
{
required: true,
type: 'number',
min: 1,
max: 365,
},
({ getFieldValue }) => ({
validator: (_rule, value: number | undefined) =>
value !== undefined &&
value > Number(getFieldValue('dictionary_fresh_days'))
? Promise.resolve()
: Promise.reject(new Error('必须大于新鲜天数')),
}),
]}
>
<InputNumber min={1} max={365} precision={0} />
</Form.Item>
</Col>
</Row>
<Typography.Text type="secondary">
最近更新:{dateTime(policy?.updated_at)}
</Typography.Text>
</Form>
)}
</Modal>
);
}
@@ -0,0 +1,106 @@
import { Button, Card, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { CatalogRun } from '@/services/platformCatalogAdmin';
type Props = {
runs: CatalogRun[];
refreshing: boolean;
onRefresh: () => Promise<void>;
};
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: '字典候选值' };
const triggerLabels: Record<string, string> = {
manual: '手动',
scheduled: '旧定时任务',
weekly: '每周自动',
source_configured: '配置来源',
admin_batch: '管理员批次',
user_access: '用户访问',
publish: '发布校验',
};
function dateTime(value: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
export default function CatalogSyncRunsTable({ runs, refreshing, onRefresh }: Props) {
const columns: ColumnsType<CatalogRun> = [
{
title: '范围',
dataIndex: 'scope',
key: 'scope',
render: (scope: CatalogRun['scope']) => scopeLabels[scope],
},
{
title: '触发',
dataIndex: 'trigger',
key: 'trigger',
render: (value: string) => triggerLabels[value] ?? value,
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 90,
render: (value: CatalogRun['priority']) => (
<Tag color={value === 'high' ? 'red' : 'default'}>
{value === 'high' ? '高' : '低'}
</Tag>
),
},
{
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',
width: 180,
render: dateTime,
},
{
title: '结果',
key: 'result',
render: (_value, run) =>
run.error_message ??
(run.status === 'succeeded' && run.result
? Object.entries(run.result)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(' · ')
: '—'),
},
];
return (
<Card
title="最近同步任务"
extra={
<Button type="link" loading={refreshing} onClick={() => void onRefresh()}>
刷新任务
</Button>
}
>
<Table<CatalogRun>
rowKey="id"
size="small"
columns={columns}
dataSource={runs}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 820 }}
locale={{ emptyText: '暂无同步任务' }}
/>
</Card>
);
}
@@ -3,6 +3,7 @@ import { Alert, Button, Drawer, Empty, Input, Pagination, Space, Spin, Table, Ty
import type { CatalogAttribute, CatalogValue } from '@/services/platformCatalogAdmin';
import { platformCatalogAdminApi } from '@/services/platformCatalogAdmin';
import { apiErrorMessage } from '@/services/api';
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
type Props = {
platformId: string;
@@ -52,7 +53,7 @@ export default function DictionaryValuesDrawer({
return <Drawer
title={attribute ? `${attribute.name} · 候选值` : '候选值'}
width={520}
size={520}
open={!!attribute}
onClose={onClose}
className="system-catalog-values-drawer"
@@ -64,14 +65,25 @@ export default function DictionaryValuesDrawer({
}}
>同步此字典</Button>}
>
{attribute && <Space direction="vertical" size="middle" className="system-catalog-values-content">
{attribute && <Space orientation="vertical" size="middle" className="system-catalog-values-content">
<Typography.Text type="secondary">
属性 ID {attribute.platform_attribute_id} · 字典 ID {attribute.dictionary_id}
</Typography.Text>
<Space wrap>
<CatalogCacheStatusTag
status={attribute.dictionary_cache_status}
error={attribute.dictionary_last_error_message}
/>
<Typography.Text type="secondary">
最近成功:{attribute.dictionary_last_success_at
? new Date(attribute.dictionary_last_success_at).toLocaleString('zh-CN', { hour12: false })
: '尚未同步'}
</Typography.Text>
</Space>
{!isComplete && <Alert
type="info"
showIcon
message="当前为部分候选值缓存"
title="当前为部分候选值缓存"
description="大字典可能还有其他值;列表搜索只查询 Atlas 已缓存的候选值。"
/>}
<Input.Search
@@ -81,7 +93,7 @@ export default function DictionaryValuesDrawer({
onChange={(event) => setDraftKeyword(event.target.value)}
onSearch={(value) => { setKeyword(value.trim()); setPage(1); }}
/>
{error && <Alert type="error" showIcon message={error} />}
{error && <Alert type="error" showIcon title={error} />}
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
: values.length ? <Table<CatalogValue>
rowKey="id"
@@ -1,6 +1,7 @@
import { Button, Empty, Table, Tag, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { CatalogAttribute } from '@/services/platformCatalogAdmin';
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
type Props = {
attributes: CatalogAttribute[];
@@ -38,9 +39,15 @@ export default function PlatformAttributesTable({
{
title: '候选值', key: 'values', width: 118,
render: (_value, row) => row.dictionary_id
? <Button type="link" className="system-catalog-values-link" onClick={() => onValues(row)}>
查看 {row.dictionary_value_count}
</Button>
? <div className="system-catalog-dictionary-cell">
<Button type="link" className="system-catalog-values-link" onClick={() => onValues(row)}>
查看 {row.dictionary_value_count}
</Button>
<CatalogCacheStatusTag
status={row.dictionary_cache_status}
error={row.dictionary_last_error_message}
/>
</div>
: <Typography.Text type="secondary">自由填报</Typography.Text>,
},
];
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, type Key } from 'react';
import { Empty, Input, Spin, Tag, Tree, Typography } from 'antd';
import type { DataNode } from 'antd/es/tree';
import type { CatalogCategory } from '@/services/platformCatalogAdmin';
import CatalogCacheStatusTag from './CatalogCacheStatusTag';
type Props = {
categories: CatalogCategory[];
@@ -13,8 +14,9 @@ type Props = {
/** 目录节点保留展开能力,只有活动商品类型叶子可选择。 */
export default function PlatformCategoryTree({ categories, loading, selectedId, onSelect }: Props) {
const [search, setSearch] = useState('');
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
const categoryById = useMemo(() => new Map(categories.map((item) => [item.id, item])), [categories]);
const treeData = useMemo(() => {
const treeState = useMemo(() => {
const query = search.trim().toLowerCase();
const visible = new Set<string>();
if (query) {
@@ -47,37 +49,54 @@ export default function PlatformCategoryTree({ categories, loading, selectedId,
<span>{category.name}</span>
{category.external_type_id && category.external_type_id !== '0' &&
<Typography.Text type="secondary">#{category.external_type_id}</Typography.Text>}
{category.external_type_id && category.external_type_id !== '0' &&
<CatalogCacheStatusTag
status={category.attribute_cache_status}
error={category.attribute_last_error}
/>}
{!category.is_active && category.external_type_id !== '0' &&
<Tag color="default">不可建品</Tag>}
</span>
),
children: toNodes(category.id),
}));
return toNodes(null);
return {
treeData: toNodes(null),
searchExpandedKeys: query
? [...childrenByParent.keys()].filter((key): key is string => key !== null)
: [],
};
}, [categories, categoryById, search]);
const isSearching = search.trim().length > 0;
return (
<div className="system-catalog-tree-panel">
<Input.Search
allowClear
placeholder="搜索类目、类型或 ID"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
: !treeData.length ? <Empty description="暂无类目;请先同步类目树" />
: <Tree
showLine={{ showLeafIcon: false }}
treeData={treeData}
selectedKeys={selectedId ? [selectedId] : []}
defaultExpandParent={false}
autoExpandParent={!!search}
expandedKeys={search ? [...categoryById.keys()] : undefined}
onSelect={(keys) => {
const category = categoryById.get(String(keys[0] ?? ''));
if (category) onSelect(category);
}}
/>}
<div className="system-catalog-tree-search">
<Input.Search
allowClear
placeholder="搜索类目、类型或 ID"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="system-catalog-tree-scroll">
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
: !treeState.treeData.length ? <Empty description="暂无类目;请先同步类目树" />
: <Tree
showLine={{ showLeafIcon: false }}
treeData={treeState.treeData}
selectedKeys={selectedId ? [selectedId] : []}
autoExpandParent={isSearching}
expandedKeys={isSearching ? treeState.searchExpandedKeys : expandedKeys}
onExpand={(keys) => {
if (!isSearching) setExpandedKeys(keys);
}}
onSelect={(keys) => {
const category = categoryById.get(String(keys[0] ?? ''));
if (category) onSelect(category);
}}
/>}
</div>
</div>
);
}