feat(category): 类目树、类目属性、属性字典维护系统构建完成
This commit is contained in:
@@ -8,6 +8,10 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.system-admin-layout > .ant-layout {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-admin-sider {
|
||||
background: var(--sider-bg) !important;
|
||||
}
|
||||
@@ -37,6 +41,7 @@
|
||||
}
|
||||
|
||||
.system-admin-content {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function SystemAdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
if (access === 'checking') {
|
||||
return <div className="system-admin-gate"><Spin tip="正在验证系统管理员权限…" /></div>;
|
||||
return <div className="system-admin-gate"><Spin description="正在验证系统管理员权限…" /></div>;
|
||||
}
|
||||
if (access === 'denied') {
|
||||
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export type CatalogCacheStatus = 'fresh' | 'stale' | 'expired' | 'missing';
|
||||
|
||||
export type AdminPlatform = {
|
||||
id: string; code: string; name: string; status: string;
|
||||
source: { configured: boolean; client_id_hint: string | null; updated_at: string | null };
|
||||
summary: { category_count: number; last_tree_sync_at: string | null; last_error: string | null };
|
||||
summary: {
|
||||
category_count: number; last_tree_sync_at: string | null;
|
||||
next_tree_sync_at: string | null; last_error: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type CatalogCategory = {
|
||||
id: string; parent_id: string | null; external_category_id: string;
|
||||
external_type_id: string | null; name: string; path: string;
|
||||
is_active: boolean; synced_at: string | null;
|
||||
attribute_last_success_at: string | null;
|
||||
attribute_cache_status: CatalogCacheStatus | null;
|
||||
attribute_last_error: string | null;
|
||||
attribute_last_error_at: string | null;
|
||||
};
|
||||
|
||||
export type CatalogAttribute = {
|
||||
@@ -19,6 +28,13 @@ export type CatalogAttribute = {
|
||||
is_aspect: boolean; is_collection: boolean; max_value_count: number;
|
||||
dictionary_id: string | null; category_dependent: boolean;
|
||||
is_active: boolean; synced_at: string | null; dictionary_value_count: number;
|
||||
last_success_at: string | null; cache_status: CatalogCacheStatus;
|
||||
last_error_message: string | null; last_error_at: string | null;
|
||||
dictionary_last_success_at: string | null;
|
||||
dictionary_cache_status: CatalogCacheStatus | null;
|
||||
dictionary_is_complete: boolean | null;
|
||||
dictionary_last_error_message: string | null;
|
||||
dictionary_last_error_at: string | null;
|
||||
};
|
||||
|
||||
export type CatalogValue = {
|
||||
@@ -29,12 +45,35 @@ export type CatalogValue = {
|
||||
export type CatalogRun = {
|
||||
id: string; platform_id: string; scope: 'tree' | 'attributes' | 'dictionary';
|
||||
scope_key: string; category_id: string | null; attribute_id: string | null;
|
||||
trigger: string; status: string; result: Record<string, unknown>;
|
||||
trigger: string; priority: 'high' | 'low'; status: string;
|
||||
result: Record<string, unknown> | null;
|
||||
error_code: string | null; error_message: string | null;
|
||||
attempt_count: number; created_at: string;
|
||||
started_at: string | null; completed_at: string | null;
|
||||
};
|
||||
|
||||
export type CatalogRefreshPolicy = {
|
||||
attribute_fresh_days: number; attribute_expire_days: number;
|
||||
dictionary_fresh_days: number; dictionary_expire_days: number;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type CatalogRefreshPolicyInput = Omit<CatalogRefreshPolicy, 'updated_at'>;
|
||||
|
||||
export type AttributeSyncSummary = {
|
||||
total: number; fresh: number; stale: number; expired: number; missing: number;
|
||||
active_batch_id: string | null; evaluated_at: string;
|
||||
};
|
||||
|
||||
export type AttributeBatchMode = 'missing_expired' | 'missing_stale_expired' | 'all';
|
||||
|
||||
export type AttributeSyncBatch = {
|
||||
id: string; platform_id: string; mode: AttributeBatchMode; status: string;
|
||||
total_count: number; pending_count: number; running_count: number;
|
||||
succeeded_count: number; failed_count: number; cancelled_count: number;
|
||||
created_at: string; started_at: string | null; completed_at: string | null;
|
||||
};
|
||||
|
||||
export type SyncRequest =
|
||||
| { scope: 'tree' }
|
||||
| { scope: 'attributes'; category_id: string }
|
||||
@@ -49,6 +88,14 @@ export const platformCatalogAdminApi = {
|
||||
),
|
||||
categories: (platformId: string) =>
|
||||
api.get<CatalogCategory[]>(`/system/platform-catalog/platforms/${platformId}/categories`),
|
||||
refreshPolicy: (platformId: string) =>
|
||||
api.get<CatalogRefreshPolicy>(
|
||||
`/system/platform-catalog/platforms/${platformId}/refresh-policy`,
|
||||
),
|
||||
updateRefreshPolicy: (platformId: string, input: CatalogRefreshPolicyInput) =>
|
||||
api.put<CatalogRefreshPolicy>(
|
||||
`/system/platform-catalog/platforms/${platformId}/refresh-policy`, input,
|
||||
),
|
||||
attributes: (platformId: string, categoryId: string) =>
|
||||
api.get<CatalogAttribute[]>(
|
||||
`/system/platform-catalog/platforms/${platformId}/categories/${categoryId}/attributes`,
|
||||
@@ -65,4 +112,18 @@ export const platformCatalogAdminApi = {
|
||||
),
|
||||
runs: (platformId: string) =>
|
||||
api.get<CatalogRun[]>(`/system/platform-catalog/platforms/${platformId}/syncs?limit=20`),
|
||||
attributeSyncSummary: (platformId: string) =>
|
||||
api.get<AttributeSyncSummary>(
|
||||
`/system/platform-catalog/platforms/${platformId}/attribute-sync-summary`,
|
||||
),
|
||||
attributeSyncBatches: (platformId: string) =>
|
||||
api.get<AttributeSyncBatch[]>(
|
||||
`/system/platform-catalog/platforms/${platformId}/attribute-sync-batches?limit=10`,
|
||||
),
|
||||
createAttributeSyncBatch: (platformId: string, mode: AttributeBatchMode) =>
|
||||
api.post<AttributeSyncBatch>(
|
||||
`/system/platform-catalog/platforms/${platformId}/attribute-sync-batches`,
|
||||
{ mode },
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductAttributeDictionaryResult,
|
||||
ProductAttributeSchemaResult,
|
||||
ProductCatalogRefreshRun,
|
||||
ProductEditorCategoryOption,
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
Operation,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductEditorDraftResponse,
|
||||
@@ -40,34 +42,22 @@ export const productEditorApi = {
|
||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||
},
|
||||
}),
|
||||
categoryOptions: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
search?: string,
|
||||
storeId?: string | null
|
||||
) =>
|
||||
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
||||
api.get<ProductEditorCategoryOption[]>(
|
||||
`/product-platform-profiles/${profileId}/category-options`,
|
||||
{
|
||||
params: {
|
||||
...(search ? { q: search } : {}),
|
||||
...(storeId ? { store_id: storeId } : {}),
|
||||
},
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}
|
||||
),
|
||||
attributeSchema: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId?: string | null,
|
||||
storeId?: string | null
|
||||
) =>
|
||||
api.get<ProductAttributeSchema[]>(
|
||||
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null) =>
|
||||
api.get<ProductAttributeSchemaResult>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema`,
|
||||
{
|
||||
params: {
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
...(storeId ? { store_id: storeId } : {}),
|
||||
},
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}
|
||||
@@ -76,8 +66,14 @@ export const productEditorApi = {
|
||||
api.get<ProductEditorValidation>(`/product-platform-profiles/${profileId}/validation`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}),
|
||||
publishPreflight: (profileId: string, platformId: string, variantIds: string[]) =>
|
||||
api.post<ProductPublishPreflight>(
|
||||
`/product-platform-profiles/${profileId}/publish-preflights`,
|
||||
{ variant_ids: variantIds },
|
||||
{ headers: { 'X-Atlas-Platform-Id': platformId } }
|
||||
),
|
||||
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||
api.post<{ operation: Operation }>(
|
||||
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||
undefined,
|
||||
{
|
||||
@@ -91,18 +87,16 @@ export const productEditorApi = {
|
||||
attributeDictionary: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
attributeId: string,
|
||||
search?: string,
|
||||
categoryId?: string | null,
|
||||
storeId?: string | null,
|
||||
cursor?: string | null
|
||||
) =>
|
||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||
api.get<ProductAttributeDictionaryResult>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(attributeId)}`,
|
||||
{
|
||||
params: {
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
...(storeId ? { store_id: storeId } : {}),
|
||||
...(search ? { q: search } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
@@ -112,12 +106,12 @@ export const productEditorApi = {
|
||||
syncAttributeDictionary: (
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
attributeId: string,
|
||||
categoryId: string,
|
||||
query: string
|
||||
) =>
|
||||
api.post<{ operation: Operation }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
||||
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(attributeId)}/syncs`,
|
||||
undefined,
|
||||
{
|
||||
params: { category_id: categoryId, q: query },
|
||||
|
||||
Reference in New Issue
Block a user