feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据
This commit is contained in:
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* 01 / 02 / 04 共用的横向表单:Label 与控件列作为最大 780px 的整体居中。
|
||||
* 01 / 02 / 04 共用的横向表单:Label 与控件列作为最大 850px 的整体居中。
|
||||
* 宽度充足时控件不超过 680px;卡片变窄时先吃掉两侧留白,再收缩控件列。
|
||||
* 容器查询以卡片实际宽度为准,不受左侧菜单或采集面板布局的间接影响。
|
||||
*/
|
||||
@@ -99,18 +99,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-horizontal-section .ant-form-item-label {
|
||||
flex: 0 0 100px !important;
|
||||
max-width: 100px !important;
|
||||
flex: 0 0 170px !important;
|
||||
max-width: 170px !important;
|
||||
}
|
||||
.editor-horizontal-section .ant-form-item-control {
|
||||
flex: 0 1 min(680px, calc(100% - 100px)) !important;
|
||||
flex: 0 1 min(680px, calc(100% - 170px)) !important;
|
||||
width: auto;
|
||||
max-width: min(680px, calc(100% - 100px)) !important;
|
||||
max-width: min(680px, calc(100% - 170px)) !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 100px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
|
||||
* 170px 标签列与包装尺寸输入组在 480px 以下已没有足够可读空间;此时才
|
||||
* 切换为纵向表单。该断点读取卡片宽度,非浏览器窗口宽度。
|
||||
*/
|
||||
@container (max-width: 479px) {
|
||||
|
||||
@@ -46,6 +46,7 @@ function missingAttributeDrafts(
|
||||
) {
|
||||
const missing: AttributeDraft[] = [];
|
||||
for (const definition of schema) {
|
||||
if (definition.editor_field) continue;
|
||||
const scopes = definition.is_aspect
|
||||
? snapshot.variants.map((variant) => ({
|
||||
scope: 'variant' as const,
|
||||
@@ -236,9 +237,8 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
description: nullable(values.profile.description),
|
||||
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
|
||||
},
|
||||
// Schema fields are rendered eagerly so the user can fill them immediately.
|
||||
// Do not persist untouched optional fields: the API correctly requires every
|
||||
// stored attribute to contain at least one non-empty value.
|
||||
// schema 字段会预先加入表单供用户直接填写;未触碰的空选填项不落库,
|
||||
// 因为服务端要求每条已提交属性至少包含一个非空值。
|
||||
attributes: (values.attributes ?? []).flatMap((attribute) => {
|
||||
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
|
||||
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
||||
@@ -376,7 +376,7 @@ const ProductEditPage = () => {
|
||||
const [categoryOptions, setCategoryOptions] = useState<ProductEditorCategoryOption[]>([]);
|
||||
const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false);
|
||||
const [categorySearch, setCategorySearch] = useState('');
|
||||
// 类目属性 schema 与字典值:仅在展开"更多特征"后按需加载。
|
||||
// 类目属性 schema 与字典值:选定类目后加载,必填属性始终展示。
|
||||
const [attributeSchema, setAttributeSchema] = useState<ProductAttributeSchema[]>([]);
|
||||
const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState<string | null>(null);
|
||||
const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState<
|
||||
@@ -387,8 +387,16 @@ const ProductEditPage = () => {
|
||||
const [dictionaryValues, setDictionaryValues] = useState<
|
||||
Record<string, ProductAttributeDictionaryValue[]>
|
||||
>({});
|
||||
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 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);
|
||||
@@ -412,6 +420,11 @@ const ProductEditPage = () => {
|
||||
: ([] as ProductAttributeSchema[]),
|
||||
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
|
||||
);
|
||||
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
|
||||
const editableAttributeSchema = useMemo(
|
||||
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
|
||||
[activeAttributeSchema]
|
||||
);
|
||||
|
||||
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
||||
const load = useCallback(async () => {
|
||||
@@ -460,8 +473,14 @@ const ProductEditPage = () => {
|
||||
active = false;
|
||||
};
|
||||
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
||||
// 属性 schema:展开"更多特征"时才加载,同一类目与刷新版本只请求一次;
|
||||
// 未选中类目时清空全部属性相关状态。
|
||||
// 切换店铺或类目后,不复用旧类目的字典搜索、游标和自动同步记录。
|
||||
useEffect(() => {
|
||||
dictionaryQueries.current = {};
|
||||
dictionaryInitialSyncs.current.clear();
|
||||
dictionaryPageLoads.current.clear();
|
||||
setDictionaryCursors({});
|
||||
}, [selectedCategoryId, selectedStoreId, snapshot?.profile.id]);
|
||||
// 属性 schema:同一类目与刷新版本只请求一次;未选中类目时清空相关状态。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||
setAttributeSchema([]);
|
||||
@@ -470,14 +489,12 @@ const ProductEditPage = () => {
|
||||
setAttributeSchemaLoading(false);
|
||||
setAttributeSchemaError(null);
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
return;
|
||||
}
|
||||
// Attributes are an on-demand panel. Once loaded for the selected
|
||||
// category, collapsing and reopening it must reuse the same schema.
|
||||
if (
|
||||
!moreFeaturesExpanded ||
|
||||
(attributeSchemaCategoryId === selectedCategoryId &&
|
||||
attributeSchemaCatalogRefresh === catalogRefresh)
|
||||
attributeSchemaCategoryId === selectedCategoryId &&
|
||||
attributeSchemaCatalogRefresh === catalogRefresh
|
||||
)
|
||||
return;
|
||||
let active = true;
|
||||
@@ -486,6 +503,7 @@ const ProductEditPage = () => {
|
||||
setAttributeSchema([]);
|
||||
setAttributeSchemaCategoryId(null);
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
void productEditorApi
|
||||
.attributeSchema(
|
||||
snapshot.profile.id,
|
||||
@@ -525,7 +543,6 @@ const ProductEditPage = () => {
|
||||
catalogRefresh,
|
||||
currentPlatform,
|
||||
form,
|
||||
moreFeaturesExpanded,
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
snapshot,
|
||||
@@ -535,13 +552,14 @@ const ProductEditPage = () => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
const dictionaryIds = [
|
||||
...new Set(
|
||||
activeAttributeSchema
|
||||
editableAttributeSchema
|
||||
.map((attribute) => attribute.dictionary_id)
|
||||
.filter((dictionaryId): dictionaryId is string => !!dictionaryId)
|
||||
),
|
||||
];
|
||||
if (!dictionaryIds.length) {
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
@@ -563,20 +581,36 @@ const ProductEditPage = () => {
|
||||
)
|
||||
.then((results) => {
|
||||
if (!active) return;
|
||||
setDictionaryValues(
|
||||
Object.fromEntries(results.map(([id, page]) => [id, page.items]))
|
||||
setDictionaryValues((current) =>
|
||||
Object.fromEntries(
|
||||
results.map(([id, page]) => [
|
||||
id,
|
||||
dictionaryQueries.current[id] ? (current[id] ?? []) : page.items,
|
||||
])
|
||||
)
|
||||
);
|
||||
setDictionaryCursors((current) =>
|
||||
Object.fromEntries(
|
||||
results.map(([id, page]) => [
|
||||
id,
|
||||
dictionaryQueries.current[id] ? (current[id] ?? null) : page.next_cursor,
|
||||
])
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setDictionaryValues({});
|
||||
if (active) {
|
||||
setDictionaryValues({});
|
||||
setDictionaryCursors({});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
activeAttributeSchema,
|
||||
catalogRefresh,
|
||||
currentPlatform,
|
||||
editableAttributeSchema,
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
snapshot,
|
||||
@@ -773,19 +807,23 @@ const ProductEditPage = () => {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
/** 发起一次 Ozon 目录同步并跟踪其异步 operation;成功后递增 catalogRefresh 触发缓存重拉。 */
|
||||
/** 跟踪 Ozon 目录同步;schema 成功后刷新目录,字典只重读对应选项。 */
|
||||
const runCatalogSync = async (
|
||||
syncKey: string,
|
||||
action: () => Promise<{ operation: { id: string } }>,
|
||||
successMessage: 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') {
|
||||
message.success(successMessage);
|
||||
setCatalogRefresh((version) => version + 1);
|
||||
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') {
|
||||
@@ -796,8 +834,12 @@ const ProductEditPage = () => {
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setCatalogSyncing(null);
|
||||
if (catalogSyncToken.current === token) {
|
||||
catalogSyncToken.current = null;
|
||||
setCatalogSyncing(null);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const syncAttributeSchema = () => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
@@ -812,25 +854,51 @@ const ProductEditPage = () => {
|
||||
'该类目的属性已同步'
|
||||
);
|
||||
};
|
||||
const loadDictionaryValues = async (dictionaryId: string, query = '') => {
|
||||
const loadDictionaryValues = async (dictionaryId: string, query = '', cursor?: string) => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
const page = await productEditorApi.attributeDictionary(
|
||||
snapshot.profile.id,
|
||||
currentPlatform.id,
|
||||
dictionaryId,
|
||||
query,
|
||||
selectedCategoryId,
|
||||
selectedStoreId
|
||||
);
|
||||
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
|
||||
};
|
||||
const syncDictionaryValues = (dictionaryId: string, query: string) => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
if (query.length < 2) {
|
||||
message.info('请输入至少 2 个字符后再从 Ozon 搜索');
|
||||
return;
|
||||
const requestContext = dictionaryContext;
|
||||
const requestKey = `${requestContext}:${dictionaryId}:${query}:${cursor ?? ''}`;
|
||||
if (dictionaryPageLoads.current.has(requestKey)) return;
|
||||
dictionaryPageLoads.current.add(requestKey);
|
||||
if (!cursor) dictionaryQueries.current[dictionaryId] = query;
|
||||
try {
|
||||
const page = await productEditorApi.attributeDictionary(
|
||||
snapshot.profile.id,
|
||||
currentPlatform.id,
|
||||
dictionaryId,
|
||||
query,
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
cursor
|
||||
);
|
||||
if (
|
||||
dictionaryContextRef.current !== requestContext ||
|
||||
dictionaryQueries.current[dictionaryId] !== query
|
||||
) return;
|
||||
setDictionaryValues((current) => ({
|
||||
...current,
|
||||
[dictionaryId]: cursor
|
||||
? [...(current[dictionaryId] ?? []), ...page.items].filter(
|
||||
(value, index, all) =>
|
||||
all.findIndex((item) => item.value_id === value.value_id) === index
|
||||
)
|
||||
: page.items,
|
||||
}));
|
||||
setDictionaryCursors((current) => ({
|
||||
...current,
|
||||
[dictionaryId]: page.next_cursor,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
dictionaryPageLoads.current.delete(requestKey);
|
||||
}
|
||||
void runCatalogSync(
|
||||
};
|
||||
const syncDictionaryValues = async (dictionaryId: string, query: string) => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId || query.length === 1)
|
||||
return false;
|
||||
const requestContext = dictionaryContext;
|
||||
const succeeded = await runCatalogSync(
|
||||
`dictionary:${dictionaryId}:${query}`,
|
||||
() =>
|
||||
productEditorApi.syncAttributeDictionary(
|
||||
@@ -840,8 +908,28 @@ const ProductEditPage = () => {
|
||||
selectedCategoryId,
|
||||
query
|
||||
),
|
||||
'字典值已同步'
|
||||
).then(() => void loadDictionaryValues(dictionaryId, query));
|
||||
null,
|
||||
false
|
||||
);
|
||||
if (
|
||||
succeeded && dictionaryContextRef.current === requestContext &&
|
||||
dictionaryQueries.current[dictionaryId] === query
|
||||
) void loadDictionaryValues(dictionaryId, query);
|
||||
return succeeded;
|
||||
};
|
||||
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 loadMoreDictionaryValues = (dictionaryId: string, query: string) => {
|
||||
const cursor = dictionaryCursors[dictionaryId];
|
||||
if (cursor) void loadDictionaryValues(dictionaryId, query, cursor);
|
||||
};
|
||||
/** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */
|
||||
const materializeCandidate = async (transferId: string) => {
|
||||
@@ -956,6 +1044,8 @@ const ProductEditPage = () => {
|
||||
void loadDictionaryValues(dictionaryId, query)
|
||||
}
|
||||
onSyncDictionaryValues={syncDictionaryValues}
|
||||
onOpenDictionaryValues={openDictionaryValues}
|
||||
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||
onSyncAttributeSchema={syncAttributeSchema}
|
||||
/>
|
||||
|
||||
@@ -964,7 +1054,16 @@ const ProductEditPage = () => {
|
||||
snapshot={snapshot}
|
||||
collectionRecord={collectionRecord}
|
||||
materializingTransferId={materializingTransferId}
|
||||
activeAttributeSchema={activeAttributeSchema}
|
||||
catalogSyncing={catalogSyncing}
|
||||
dictionaryValues={dictionaryValues}
|
||||
onMaterialize={materializeCandidate}
|
||||
onLoadDictionaryValues={(dictionaryId, query) =>
|
||||
void loadDictionaryValues(dictionaryId, query)
|
||||
}
|
||||
onSyncDictionaryValues={syncDictionaryValues}
|
||||
onOpenDictionaryValues={openDictionaryValues}
|
||||
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||
onDirty={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, Select, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
|
||||
type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
definitions: ProductAttributeSchema[];
|
||||
scope: 'common' | 'variant';
|
||||
variantId: string | null;
|
||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||
catalogSyncing: string | null;
|
||||
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
|
||||
function attributeIndex(
|
||||
attributes: AttributeDraft[],
|
||||
definition: ProductAttributeSchema,
|
||||
scope: Props['scope'],
|
||||
variantId: string | null
|
||||
) {
|
||||
return attributes.findIndex(
|
||||
(attribute) =>
|
||||
attribute.scope === scope &&
|
||||
attribute.variant_id === variantId &&
|
||||
attribute.platform_attribute_id === definition.platform_attribute_id &&
|
||||
attribute.complex_id === definition.complex_id
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染一个平台属性控件;必填星号只表达上架要求,不阻止保存草稿。 */
|
||||
function DynamicAttributeField({
|
||||
definition,
|
||||
index,
|
||||
dictionaryValues,
|
||||
catalogSyncing,
|
||||
onLoadDictionaryValues,
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
}: {
|
||||
definition: ProductAttributeSchema;
|
||||
index: number;
|
||||
dictionaryValues: Props['dictionaryValues'];
|
||||
catalogSyncing: string | null;
|
||||
onLoadDictionaryValues: Props['onLoadDictionaryValues'];
|
||||
onSyncDictionaryValues: Props['onSyncDictionaryValues'];
|
||||
onOpenDictionaryValues: Props['onOpenDictionaryValues'];
|
||||
onLoadMoreDictionaryValues: Props['onLoadMoreDictionaryValues'];
|
||||
}) {
|
||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||
onSyncRef.current = onSyncDictionaryValues;
|
||||
useEffect(() => setDictionaryQuery(''), [definition.dictionary_id]);
|
||||
useEffect(() => {
|
||||
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
|
||||
const timer = window.setTimeout(
|
||||
() => onSyncRef.current(definition.dictionary_id!, dictionaryQuery.trim()),
|
||||
400
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [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) => ({
|
||||
value: value.value_id,
|
||||
label: value.value,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item name={definition.is_collection ? valueName : scalarValueName} noStyle>
|
||||
<Select
|
||||
mode={definition.is_collection ? 'multiple' : undefined}
|
||||
maxCount={definition.is_collection ? definition.max_value_count : undefined}
|
||||
options={dictionaryOptions}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open || !definition.dictionary_id) return;
|
||||
setDictionaryQuery('');
|
||||
onOpenDictionaryValues(definition.dictionary_id);
|
||||
}}
|
||||
onSearch={(query) => {
|
||||
if (!definition.dictionary_id) return;
|
||||
const needle = query.trim();
|
||||
setDictionaryQuery(needle);
|
||||
onLoadDictionaryValues(definition.dictionary_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,
|
||||
dictionaryQuery.trim()
|
||||
);
|
||||
}}
|
||||
notFoundContent={
|
||||
dictionaryQuery.trim().length >= 2 ? (
|
||||
<Button
|
||||
type="link"
|
||||
loading={
|
||||
catalogSyncing ===
|
||||
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
|
||||
}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
definition.dictionary_id &&
|
||||
onSyncDictionaryValues(
|
||||
definition.dictionary_id,
|
||||
dictionaryQuery.trim()
|
||||
)
|
||||
}
|
||||
>
|
||||
从 Ozon 搜索“{dictionaryQuery.trim()}”
|
||||
</Button>
|
||||
) : catalogSyncing === `dictionary:${definition.dictionary_id}:` ? (
|
||||
'正在从 Ozon 加载选项…'
|
||||
) : (
|
||||
'暂无选项,输入至少 2 个字符可从 Ozon 搜索'
|
||||
)
|
||||
}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'boolean' ? (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
]}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.is_collection ? (
|
||||
<Form.Item name={valueName} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
maxCount={definition.max_value_count}
|
||||
tokenSeparators={[',']}
|
||||
placeholder={`输入后回车添加,最多 ${definition.max_value_count} 项`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Input
|
||||
type={
|
||||
definition.value_type === 'integer' || definition.value_type === 'decimal'
|
||||
? 'number'
|
||||
: undefined
|
||||
}
|
||||
step={definition.value_type === 'integer' ? '1' : 'any'}
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={definition.name}
|
||||
required={definition.is_required}
|
||||
className="dynamic-attribute-item"
|
||||
extra={
|
||||
definition.is_collection ? `最多填写 ${definition.max_value_count} 项` : undefined
|
||||
}
|
||||
>
|
||||
<div className="dynamic-attribute-control">{input}</div>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/** 在产品或指定 SKU 的上下文中渲染一组动态属性。 */
|
||||
export function DynamicAttributeFields({
|
||||
form,
|
||||
definitions,
|
||||
scope,
|
||||
variantId,
|
||||
dictionaryValues,
|
||||
catalogSyncing,
|
||||
onLoadDictionaryValues,
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
emptyText,
|
||||
}: Props) {
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const fields = definitions.flatMap((definition) => {
|
||||
const index = attributeIndex(attributes, definition, scope, variantId);
|
||||
return index < 0 ? [] : [{ definition, index }];
|
||||
});
|
||||
|
||||
if (!fields.length)
|
||||
return emptyText ? <Typography.Text type="secondary">{emptyText}</Typography.Text> : null;
|
||||
|
||||
return (
|
||||
<div className="dynamic-attribute-list">
|
||||
{fields.map(({ definition, index }) => (
|
||||
<DynamicAttributeField
|
||||
key={`${scope}:${variantId ?? 'common'}:${definition.platform_attribute_id}:${definition.complex_id}`}
|
||||
definition={definition}
|
||||
index={index}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={onLoadDictionaryValues}
|
||||
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,28 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dynamic-attribute-item .ant-space-compact {
|
||||
/* Ozon 属性名可能很长:Label 使用常规字重并完整换行,不能省略或裁切。 */
|
||||
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
overflow: visible;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
text-align: right;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dynamic-attribute-control,
|
||||
.dynamic-attribute-control .ant-select,
|
||||
.dynamic-attribute-control .ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
@@ -9,12 +9,10 @@ import type {
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import './ProductAttributeSection.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
|
||||
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
|
||||
function sourceValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value || '—';
|
||||
@@ -36,6 +34,8 @@ type Props = {
|
||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncAttributeSchema: () => void;
|
||||
};
|
||||
|
||||
@@ -59,15 +59,27 @@ export function ProductAttributeSection({
|
||||
dictionaryValues,
|
||||
onLoadDictionaryValues,
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onSyncAttributeSchema,
|
||||
}: Props) {
|
||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||
const [richContent, setRichContent] = useState('');
|
||||
// 各字典下拉框的当前搜索词(仅本地状态);切换类目时清空,避免残留到新类目。
|
||||
const [dictionarySearches, setDictionarySearches] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
setDictionarySearches({});
|
||||
}, [selectedCategoryId]);
|
||||
const commonDefinitions = useMemo(
|
||||
() =>
|
||||
activeAttributeSchema.filter(
|
||||
(definition) => !definition.is_aspect && !definition.editor_field
|
||||
),
|
||||
[activeAttributeSchema]
|
||||
);
|
||||
const requiredDefinitions = useMemo(
|
||||
() => commonDefinitions.filter((definition) => definition.is_required),
|
||||
[commonDefinitions]
|
||||
);
|
||||
const optionalDefinitions = useMemo(
|
||||
() => commonDefinitions.filter((definition) => !definition.is_required),
|
||||
[commonDefinitions]
|
||||
);
|
||||
return (
|
||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||
<SectionTitle
|
||||
@@ -100,291 +112,92 @@ export function ProductAttributeSection({
|
||||
<div id="dynamic-attributes" className="dynamic-attributes">
|
||||
<div className="dynamic-attributes-heading">
|
||||
<div>
|
||||
<Typography.Title level={5}>更多特征</Typography.Title>
|
||||
<Typography.Title level={5}>Ozon 类目属性</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
根据所选类目填写 Ozon 平台属性
|
||||
根据所选类目填写商品级平台属性;变体属性在对应 SKU 中填写
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
onToggleExpanded();
|
||||
window.requestAnimationFrame(() =>
|
||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{expanded ? '收起更多特征' : '展开更多特征'}
|
||||
</Button>
|
||||
{optionalDefinitions.length > 0 && (
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
onToggleExpanded();
|
||||
window.requestAnimationFrame(() =>
|
||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{expanded ? '收起选填属性' : '展开选填属性'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{expanded &&
|
||||
(!selectedCategoryId ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="请选择产品类目后填写平台属性"
|
||||
/>
|
||||
) : attributeSchemaLoading ? (
|
||||
<div className="dynamic-attributes-loading">
|
||||
<Spin size="small" />
|
||||
<span>正在读取类目属性…</span>
|
||||
</div>
|
||||
) : !activeAttributeSchema.length ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>
|
||||
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
loading={
|
||||
catalogSyncing === `attributes:${selectedCategoryId}`
|
||||
}
|
||||
onClick={onSyncAttributeSchema}
|
||||
>
|
||||
同步此类目属性
|
||||
</Button>
|
||||
</Space>
|
||||
{!selectedCategoryId ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="请选择产品类目后填写平台属性"
|
||||
/>
|
||||
) : attributeSchemaLoading ? (
|
||||
<div className="dynamic-attributes-loading">
|
||||
<Spin size="small" />
|
||||
<span>正在读取类目属性…</span>
|
||||
</div>
|
||||
) : !activeAttributeSchema.length ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>
|
||||
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
loading={catalogSyncing === `attributes:${selectedCategoryId}`}
|
||||
onClick={onSyncAttributeSchema}
|
||||
>
|
||||
同步此类目属性
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={requiredDefinitions}
|
||||
scope="common"
|
||||
variantId={null}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={onLoadDictionaryValues}
|
||||
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||
emptyText={
|
||||
commonDefinitions.length
|
||||
? undefined
|
||||
: '当前类目没有需要在此填写的商品级属性;变体属性请在 SKU 中填写。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Form.List name="attributes">
|
||||
{(fields) => (
|
||||
<div className="dynamic-attribute-list">
|
||||
{activeAttributeSchema.flatMap((definition) => {
|
||||
// aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。
|
||||
const scopes = definition.is_aspect
|
||||
? snapshot.variants.map((variant) => ({
|
||||
scope: 'variant' as const,
|
||||
variantId: variant.id,
|
||||
suffix: variant.display_name || '未命名 SKU',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
scope: 'common' as const,
|
||||
variantId: null,
|
||||
suffix: null,
|
||||
},
|
||||
];
|
||||
return scopes.map(({ scope, variantId, suffix }) => {
|
||||
const field = fields.find((candidate) => {
|
||||
const draft = form.getFieldValue([
|
||||
'attributes',
|
||||
candidate.name,
|
||||
]) as AttributeDraft | undefined;
|
||||
return (
|
||||
draft?.scope === scope &&
|
||||
draft?.variant_id === variantId &&
|
||||
draft?.platform_attribute_id ===
|
||||
definition.platform_attribute_id &&
|
||||
draft?.complex_id === definition.complex_id
|
||||
);
|
||||
});
|
||||
const label = suffix
|
||||
? `${definition.name} · ${suffix}`
|
||||
: definition.name;
|
||||
// Attribute rows are created together with
|
||||
// the schema response. A transient render
|
||||
// may still occur before Form.List receives
|
||||
// the new field metadata.
|
||||
if (!field) return null;
|
||||
// 字典属性:选项来自页面预取的字典缓存;搜索时经
|
||||
// onLoadDictionaryValues 远程过滤,无结果时可通过
|
||||
// notFoundContent 的按钮发起 Ozon 同步。
|
||||
const dictionaryOptions = definition.dictionary_id
|
||||
? (
|
||||
dictionaryValues[definition.dictionary_id] ??
|
||||
[]
|
||||
).map((value) => ({
|
||||
value: value.value_id,
|
||||
label: value.value,
|
||||
}))
|
||||
: [];
|
||||
const dictionaryQuery = definition.dictionary_id
|
||||
? (dictionarySearches[definition.dictionary_id] ??
|
||||
'')
|
||||
: '';
|
||||
const valueName = [field.name, 'values'] as NamePath;
|
||||
const scalarValueName = [...valueName, 0] as NamePath;
|
||||
const valueRules = definition.is_required
|
||||
? [
|
||||
{
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: unknown
|
||||
) => {
|
||||
const entries = Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
typeof entry === 'string' &&
|
||||
entry.trim()
|
||||
)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(
|
||||
new Error(
|
||||
`请填写${definition.name}`
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item
|
||||
name={
|
||||
definition.is_collection
|
||||
? valueName
|
||||
: scalarValueName
|
||||
}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
mode={
|
||||
definition.is_collection
|
||||
? 'multiple'
|
||||
: undefined
|
||||
}
|
||||
options={dictionaryOptions}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={(query) => {
|
||||
if (!definition.dictionary_id)
|
||||
return;
|
||||
setDictionarySearches(
|
||||
(current) => ({
|
||||
...current,
|
||||
[definition.dictionary_id!]:
|
||||
query,
|
||||
})
|
||||
);
|
||||
onLoadDictionaryValues(
|
||||
definition.dictionary_id,
|
||||
query
|
||||
);
|
||||
}}
|
||||
notFoundContent={
|
||||
dictionaryQuery.trim().length >=
|
||||
2 ? (
|
||||
<Button
|
||||
type="link"
|
||||
loading={
|
||||
catalogSyncing ===
|
||||
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
|
||||
}
|
||||
onMouseDown={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
onClick={() =>
|
||||
definition.dictionary_id &&
|
||||
onSyncDictionaryValues(
|
||||
definition.dictionary_id,
|
||||
dictionaryQuery.trim()
|
||||
)
|
||||
}
|
||||
>
|
||||
从 Ozon 搜索“
|
||||
{dictionaryQuery.trim()}”
|
||||
</Button>
|
||||
) : (
|
||||
'输入至少 2 个字符后可从 Ozon 搜索'
|
||||
)
|
||||
}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'boolean' ? (
|
||||
<Form.Item
|
||||
name={scalarValueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'true',
|
||||
label: '是',
|
||||
},
|
||||
{
|
||||
value: 'false',
|
||||
label: '否',
|
||||
},
|
||||
]}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.is_collection ? (
|
||||
<Form.Item
|
||||
name={valueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加多个值"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name={scalarValueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Input
|
||||
inputMode={
|
||||
definition.value_type ===
|
||||
'integer' ||
|
||||
definition.value_type === 'decimal'
|
||||
? 'decimal'
|
||||
: undefined
|
||||
}
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={label}
|
||||
required={definition.is_required}
|
||||
className="dynamic-attribute-item"
|
||||
>
|
||||
<Space.Compact block>
|
||||
{input}
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[
|
||||
'attributes',
|
||||
field.name,
|
||||
'values',
|
||||
],
|
||||
[]
|
||||
)
|
||||
}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
))}
|
||||
{expanded && optionalDefinitions.length > 0 && (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={optionalDefinitions}
|
||||
scope="common"
|
||||
variantId={null}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={onLoadDictionaryValues}
|
||||
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
|
||||
{snapshot.unmapped_attributes.length > 0 && (
|
||||
|
||||
@@ -15,11 +15,7 @@ type CategoryOption = {
|
||||
function PackageFields({ name }: { name: NamePath }) {
|
||||
return (
|
||||
<div className="package-fields">
|
||||
<Form.Item
|
||||
name={[...name, 'shipping_weight_g']}
|
||||
label="包装重量"
|
||||
rules={[{ required: true, message: '请填写包装重量' }]}
|
||||
>
|
||||
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
|
||||
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="包装尺寸" required className="package-dimensions">
|
||||
|
||||
@@ -148,6 +148,32 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ozon 变体属性与 SKU 摘要同属卡片主体;必填项在折叠状态下也始终可见。 */
|
||||
.sku-attribute-panel {
|
||||
margin-top: 12px;
|
||||
padding: 12px 0 0 87px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-attribute-panel h5.ant-typography {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label {
|
||||
flex: 0 0 120px !important;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label > label {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sku-card-expanded .sku-attribute-panel--optional {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
@@ -314,6 +340,9 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sku-attribute-panel {
|
||||
padding-left: 0;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import type { FormInstance } from 'antd';
|
||||
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import './ProductVariantSection.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
@@ -82,7 +85,14 @@ type Props = {
|
||||
snapshot: ProductEditorSnapshot;
|
||||
collectionRecord: CollectionItemInspection | null;
|
||||
materializingTransferId: string | null;
|
||||
activeAttributeSchema: ProductAttributeSchema[];
|
||||
catalogSyncing: string | null;
|
||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||
onMaterialize: (transferId: string) => void;
|
||||
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onDirty: () => void;
|
||||
};
|
||||
|
||||
@@ -98,13 +108,26 @@ export function ProductVariantSection({
|
||||
snapshot,
|
||||
collectionRecord,
|
||||
materializingTransferId,
|
||||
activeAttributeSchema,
|
||||
catalogSyncing,
|
||||
dictionaryValues,
|
||||
onMaterialize,
|
||||
onLoadDictionaryValues,
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onDirty,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
const requiredAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) => definition.is_aspect && definition.is_required && !definition.editor_field
|
||||
);
|
||||
const optionalAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) => definition.is_aspect && !definition.is_required && !definition.editor_field
|
||||
);
|
||||
return (
|
||||
<Card className="editor-section sku-section" bordered={false}>
|
||||
<SectionTitle
|
||||
@@ -345,90 +368,156 @@ export function ProductVariantSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{requiredAspectDefinitions.length > 0 && (
|
||||
<div className="sku-attribute-panel">
|
||||
<Typography.Title level={5}>
|
||||
Ozon 变体属性
|
||||
</Typography.Title>
|
||||
{sourceId ? (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={requiredAspectDefinitions}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
保存新 SKU 后即可填写其 Ozon 变体属性。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="sku-card-expanded">
|
||||
{optionalAspectDefinitions.length > 0 &&
|
||||
sourceId && (
|
||||
<div className="sku-attribute-panel sku-attribute-panel--optional">
|
||||
<Typography.Title level={5}>
|
||||
Ozon 选填变体属性
|
||||
</Typography.Title>
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={
|
||||
optionalAspectDefinitions
|
||||
}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sku-media-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 图片
|
||||
</Typography.Title>
|
||||
{imageCandidates.length ? (
|
||||
<div className="sku-media-grid">
|
||||
{imageCandidates.map((candidate, imageIndex) => {
|
||||
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
|
||||
// 避免在此处重复提供后续素材选择流程的入口。
|
||||
const selectable =
|
||||
candidate.status ===
|
||||
'succeeded' &&
|
||||
!!candidate.asset_id;
|
||||
const selected =
|
||||
selectable &&
|
||||
selectedAssetIds.includes(
|
||||
candidate.asset_id!
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
|
||||
key={candidate.id}
|
||||
>
|
||||
{imageIndex === 0 && (
|
||||
<span className="sku-media-main-badge">
|
||||
主图
|
||||
</span>
|
||||
)}
|
||||
<Image
|
||||
src={
|
||||
candidate.source_url
|
||||
}
|
||||
alt="采集候选图片"
|
||||
/>
|
||||
{selectable ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={
|
||||
selected
|
||||
? 'primary'
|
||||
: 'default'
|
||||
{imageCandidates.map(
|
||||
(candidate, imageIndex) => {
|
||||
// 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
|
||||
// 避免在此处重复提供后续素材选择流程的入口。
|
||||
const selectable =
|
||||
candidate.status ===
|
||||
'succeeded' &&
|
||||
!!candidate.asset_id;
|
||||
const selected =
|
||||
selectable &&
|
||||
selectedAssetIds.includes(
|
||||
candidate.asset_id!
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''} ${selected ? 'selected' : ''}`}
|
||||
key={candidate.id}
|
||||
>
|
||||
{imageIndex === 0 && (
|
||||
<span className="sku-media-main-badge">
|
||||
主图
|
||||
</span>
|
||||
)}
|
||||
<Image
|
||||
src={
|
||||
candidate.source_url
|
||||
}
|
||||
onClick={() =>
|
||||
toggleImage(
|
||||
candidate.asset_id!
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected
|
||||
? '已选择'
|
||||
: '选择'}
|
||||
</Button>
|
||||
) : candidate.status ===
|
||||
'pending_selection' ? null : (
|
||||
<Button
|
||||
size="small"
|
||||
loading={
|
||||
materializingTransferId ===
|
||||
candidate.id
|
||||
}
|
||||
disabled={
|
||||
candidate.status ===
|
||||
'queued' ||
|
||||
candidate.status ===
|
||||
'running' ||
|
||||
candidate.status ===
|
||||
'failed'
|
||||
}
|
||||
onClick={() =>
|
||||
void onMaterialize(
|
||||
alt="采集候选图片"
|
||||
/>
|
||||
{selectable ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={
|
||||
selected
|
||||
? 'primary'
|
||||
: 'default'
|
||||
}
|
||||
onClick={() =>
|
||||
toggleImage(
|
||||
candidate.asset_id!
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected
|
||||
? '已选择'
|
||||
: '选择'}
|
||||
</Button>
|
||||
) : candidate.status ===
|
||||
'pending_selection' ? null : (
|
||||
<Button
|
||||
size="small"
|
||||
loading={
|
||||
materializingTransferId ===
|
||||
candidate.id
|
||||
)
|
||||
}
|
||||
>
|
||||
{candidate.status === 'failed'
|
||||
? '转存失败'
|
||||
: '转存中'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
disabled={
|
||||
candidate.status ===
|
||||
'queued' ||
|
||||
candidate.status ===
|
||||
'running' ||
|
||||
candidate.status ===
|
||||
'failed'
|
||||
}
|
||||
onClick={() =>
|
||||
void onMaterialize(
|
||||
candidate.id
|
||||
)
|
||||
}
|
||||
>
|
||||
{candidate.status ===
|
||||
'failed'
|
||||
? '转存失败'
|
||||
: '转存中'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{/* 仅提供素材选择入口的视觉占位,实际选择流程后续接入。 */}
|
||||
<Button
|
||||
className="sku-media-picker"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.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; }
|
||||
@media (max-width: 1100px) {
|
||||
.system-catalog-browser { grid-template-columns: 1fr; }
|
||||
.system-catalog-tree-panel { max-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; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, App as AntdApp, Button, Card, Empty, Select, Space, Statistic,
|
||||
Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CloudSyncOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import {
|
||||
platformCatalogAdminApi,
|
||||
type AdminPlatform, type CatalogAttribute, type CatalogCategory,
|
||||
type CatalogRun, type SyncRequest,
|
||||
} from '@/services/platformCatalogAdmin';
|
||||
import CatalogSourceModal from './components/CatalogSourceModal';
|
||||
import DictionaryValuesDrawer from './components/DictionaryValuesDrawer';
|
||||
import PlatformAttributesTable from './components/PlatformAttributesTable';
|
||||
import PlatformCategoryTree from './components/PlatformCategoryTree';
|
||||
import './SystemCatalogPage.css';
|
||||
|
||||
function dateTime(value: string | null) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未同步';
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
queued: { label: '排队中', color: 'gold' },
|
||||
running: { label: '同步中', color: 'blue' },
|
||||
succeeded: { label: '成功', color: 'green' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
};
|
||||
|
||||
const scopeLabels = { tree: '类目树', attributes: '类型属性', dictionary: '字典候选值' };
|
||||
|
||||
export default function SystemCatalogPage() {
|
||||
const { message } = AntdApp.useApp();
|
||||
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
|
||||
const [platformId, setPlatformId] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<CatalogCategory[]>([]);
|
||||
const [attributes, setAttributes] = useState<CatalogAttribute[]>([]);
|
||||
const [runs, setRuns] = useState<CatalogRun[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [selectedAttribute, setSelectedAttribute] = useState<CatalogAttribute | null>(null);
|
||||
const [sourceOpen, setSourceOpen] = useState(false);
|
||||
const [loadingPlatforms, setLoadingPlatforms] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
const [loadingAttributes, setLoadingAttributes] = useState(false);
|
||||
const [syncingScope, setSyncingScope] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const previousRunStatuses = useRef<Map<string, string>>(new Map());
|
||||
const selectedCategoryIdRef = useRef<string | null>(null);
|
||||
|
||||
const currentPlatform = useMemo(
|
||||
() => platforms.find((item) => item.id === platformId) ?? null,
|
||||
[platforms, platformId],
|
||||
);
|
||||
const selectedCategory = useMemo(
|
||||
() => categories.find((item) => item.id === selectedCategoryId) ?? null,
|
||||
[categories, selectedCategoryId],
|
||||
);
|
||||
|
||||
const loadPlatforms = useCallback(async () => {
|
||||
setLoadingPlatforms(true);
|
||||
try {
|
||||
const items = await platformCatalogAdminApi.platforms();
|
||||
setPlatforms(items);
|
||||
setPlatformId((previous) => previous && items.some((item) => item.id === previous)
|
||||
? previous : (items.find((item) => item.code.toUpperCase() === 'OZON')?.id ?? items[0]?.id ?? null));
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setLoadingPlatforms(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const loadCategories = useCallback(async (id: string) => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
setCategories(await platformCatalogAdminApi.categories(id));
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const loadAttributes = useCallback(async (id: string, categoryId: string) => {
|
||||
setLoadingAttributes(true);
|
||||
try {
|
||||
setAttributes(await platformCatalogAdminApi.attributes(id, categoryId));
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setLoadingAttributes(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const loadRuns = useCallback(async (id: string) => {
|
||||
try {
|
||||
const next = await platformCatalogAdminApi.runs(id);
|
||||
const completed = next.some((run) => run.status === 'succeeded' &&
|
||||
previousRunStatuses.current.has(run.id) &&
|
||||
previousRunStatuses.current.get(run.id) !== 'succeeded');
|
||||
previousRunStatuses.current = new Map(next.map((run) => [run.id, run.status]));
|
||||
setRuns(next);
|
||||
if (completed) {
|
||||
setRefreshToken((value) => value + 1);
|
||||
void loadPlatforms();
|
||||
void loadCategories(id);
|
||||
if (selectedCategoryIdRef.current) void loadAttributes(id, selectedCategoryIdRef.current);
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败时保留最后一次任务列表,页面手动刷新仍可重试。
|
||||
}
|
||||
}, [loadPlatforms, loadCategories, loadAttributes]);
|
||||
|
||||
useEffect(() => { void loadPlatforms(); }, [loadPlatforms]);
|
||||
useEffect(() => {
|
||||
if (!platformId) return;
|
||||
setSelectedCategoryId(null);
|
||||
selectedCategoryIdRef.current = null;
|
||||
setSelectedAttribute(null);
|
||||
setCategories([]);
|
||||
setAttributes([]);
|
||||
previousRunStatuses.current.clear();
|
||||
void loadCategories(platformId);
|
||||
void loadRuns(platformId);
|
||||
}, [platformId, loadCategories, loadRuns]);
|
||||
useEffect(() => {
|
||||
if (!platformId || !selectedCategoryId) return;
|
||||
void loadAttributes(platformId, selectedCategoryId);
|
||||
}, [platformId, selectedCategoryId, loadAttributes]);
|
||||
useEffect(() => {
|
||||
if (!platformId) return;
|
||||
const timer = window.setInterval(() => void loadRuns(platformId), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [platformId, loadRuns]);
|
||||
|
||||
const sync = async (input: SyncRequest) => {
|
||||
if (!platformId) return;
|
||||
setSyncingScope(input.scope);
|
||||
try {
|
||||
await platformCatalogAdminApi.sync(platformId, input);
|
||||
message.success('同步任务已排队');
|
||||
await loadRuns(platformId);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setSyncingScope(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSource = async (input: { client_id: string; api_key: string }) => {
|
||||
if (!platformId) return;
|
||||
try {
|
||||
const result = await platformCatalogAdminApi.configureSource(platformId, input);
|
||||
message.success(result.changed
|
||||
? '同步来源已保存,类目树同步已排队'
|
||||
: '同步来源没有变化');
|
||||
await loadPlatforms();
|
||||
await loadRuns(platformId);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const runColumns: ColumnsType<CatalogRun> = [
|
||||
{ title: '范围', dataIndex: 'scope', key: 'scope', render: (scope: CatalogRun['scope']) => scopeLabels[scope] },
|
||||
{ title: '触发', dataIndex: 'trigger', key: 'trigger', render: (value: string) =>
|
||||
value === 'scheduled' ? '定时' : value === 'source_configured' ? '配置来源' : '手动' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (value: string) =>
|
||||
<Tag color={statusLabels[value]?.color}>{statusLabels[value]?.label ?? value}</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: dateTime },
|
||||
{ title: '结果', key: 'result', render: (_value, run) => run.error_message ??
|
||||
(run.status === 'succeeded' ? Object.entries(run.result)
|
||||
.map(([key, value]) => `${key}: ${String(value)}`).join(' · ') : '—') },
|
||||
];
|
||||
|
||||
return <div className="system-catalog-page">
|
||||
<div className="system-catalog-heading">
|
||||
<div>
|
||||
<Typography.Title level={3}>平台类目与属性</Typography.Title>
|
||||
<Typography.Text type="secondary">按平台查看商品类型、属性定义与候选值,并维护同步状态。</Typography.Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Select
|
||||
className="system-catalog-platform-select"
|
||||
loading={loadingPlatforms}
|
||||
placeholder="选择平台"
|
||||
value={platformId}
|
||||
options={platforms.map((item) => ({ value: item.id, label: item.name }))}
|
||||
onChange={setPlatformId}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => {
|
||||
void loadPlatforms();
|
||||
if (platformId) { void loadCategories(platformId); void loadRuns(platformId); }
|
||||
if (platformId && selectedCategoryId) void loadAttributes(platformId, selectedCategoryId);
|
||||
}}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!currentPlatform ? <Card><Empty description="暂无平台" /></Card> : <>
|
||||
{currentPlatform.summary.last_error && <Alert
|
||||
type="warning" showIcon message="最近一次类目树同步失败"
|
||||
description={currentPlatform.summary.last_error}
|
||||
/>}
|
||||
<div className="system-catalog-summary">
|
||||
<Card><Statistic title="已缓存类目节点" value={currentPlatform.summary.category_count} /></Card>
|
||||
<Card><Statistic title="同步来源" value={currentPlatform.source.configured
|
||||
? currentPlatform.source.client_id_hint ?? '已配置' : '未配置'} /></Card>
|
||||
<Card><Statistic title="类目树最近成功" value={dateTime(currentPlatform.summary.last_tree_sync_at)} /></Card>
|
||||
</div>
|
||||
<div className="system-catalog-actions">
|
||||
<Space wrap>
|
||||
<Button icon={<KeyOutlined />} onClick={() => setSourceOpen(true)}
|
||||
disabled={currentPlatform.code.toUpperCase() !== 'OZON'}>
|
||||
配置同步来源
|
||||
</Button>
|
||||
<Button type="primary" icon={<CloudSyncOutlined />}
|
||||
disabled={!currentPlatform.source.configured}
|
||||
loading={syncingScope === 'tree'}
|
||||
onClick={() => void sync({ scope: 'tree' })}>
|
||||
同步类目树
|
||||
</Button>
|
||||
<Button icon={<CloudSyncOutlined />}
|
||||
disabled={!selectedCategory || !currentPlatform.source.configured}
|
||||
loading={syncingScope === 'attributes'}
|
||||
onClick={() => selectedCategoryId && void sync({ scope: 'attributes', category_id: selectedCategoryId })}>
|
||||
同步当前类型属性
|
||||
</Button>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">同步在后台执行,失败时保留上次成功的数据。</Typography.Text>
|
||||
</div>
|
||||
<div className="system-catalog-browser">
|
||||
<Card title="类目 / 商品类型" className="system-catalog-category-card"
|
||||
extra={<Typography.Text type="secondary">{categories.length} 个节点</Typography.Text>}>
|
||||
<PlatformCategoryTree
|
||||
categories={categories} loading={loadingCategories}
|
||||
selectedId={selectedCategoryId}
|
||||
onSelect={(category) => {
|
||||
selectedCategoryIdRef.current = category.id;
|
||||
setSelectedCategoryId(category.id);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<Card title={selectedCategory ? `${selectedCategory.name} · 属性定义` : '属性定义'}
|
||||
className="system-catalog-attribute-card"
|
||||
extra={selectedCategory && <Typography.Text type="secondary">
|
||||
类目 ID {selectedCategory.external_category_id} · 类型 ID {selectedCategory.external_type_id}
|
||||
</Typography.Text>}>
|
||||
<PlatformAttributesTable
|
||||
attributes={attributes} loading={loadingAttributes}
|
||||
selectedCategoryName={selectedCategory?.name ?? null}
|
||||
onValues={setSelectedAttribute}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title="最近同步任务" extra={<Button type="link" onClick={() => void loadRuns(currentPlatform.id)}>刷新任务</Button>}>
|
||||
<Table<CatalogRun> rowKey="id" size="small" columns={runColumns}
|
||||
dataSource={runs} pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
scroll={{ x: 700 }} locale={{ emptyText: '暂无同步任务' }} />
|
||||
</Card>
|
||||
<CatalogSourceModal platform={currentPlatform} open={sourceOpen}
|
||||
onClose={() => setSourceOpen(false)} onSave={saveSource} />
|
||||
<DictionaryValuesDrawer
|
||||
platformId={currentPlatform.id} attribute={selectedAttribute}
|
||||
onClose={() => setSelectedAttribute(null)} refreshToken={refreshToken}
|
||||
onSync={(attribute) => sync({ scope: 'dictionary', attribute_id: attribute.id })}
|
||||
/>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Form, Input, Modal, Typography } from 'antd';
|
||||
import type { AdminPlatform } from '@/services/platformCatalogAdmin';
|
||||
|
||||
type Props = {
|
||||
platform: AdminPlatform | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (input: { client_id: string; api_key: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
/** 密钥仅保留在当前表单内;关闭时清空,不回显已有密钥。 */
|
||||
export default function CatalogSourceModal({ platform, open, onClose, onSave }: Props) {
|
||||
const [form] = Form.useForm<{ client_id: string; api_key: string }>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => { if (!open) form.resetFields(); }, [open, form]);
|
||||
return <Modal
|
||||
title={`${platform?.name ?? '平台'} · 同步来源配置`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
confirmLoading={saving}
|
||||
okText="保存配置"
|
||||
onOk={() => void form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="system-catalog-source-info"
|
||||
message="该凭据仅用于平台共享目录同步"
|
||||
description="不会展示给普通用户,也不会作为客户门店出现在店铺管理中。保存后 API Key 不再回显。"
|
||||
/>
|
||||
{platform?.source.configured && <Typography.Paragraph type="secondary">
|
||||
当前来源:{platform.source.client_id_hint}。更换来源时请同时输入 Client-Id 和新的 API Key。
|
||||
</Typography.Paragraph>}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
onFinish={(input) => {
|
||||
setSaving(true);
|
||||
void onSave(input).then(() => { form.resetFields(); onClose(); })
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
<Form.Item name="client_id" label="Ozon Client-Id" rules={[{ required: true, message: '请输入 Client-Id' }]}>
|
||||
<Input maxLength={128} autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="api_key" label="Ozon Api-Key" rules={[{ required: true, message: '请输入 Api-Key' }]}>
|
||||
<Input.Password maxLength={2048} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Drawer, Empty, Input, Pagination, Space, Spin, Table, Typography } from 'antd';
|
||||
import type { CatalogAttribute, CatalogValue } from '@/services/platformCatalogAdmin';
|
||||
import { platformCatalogAdminApi } from '@/services/platformCatalogAdmin';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
type Props = {
|
||||
platformId: string;
|
||||
attribute: CatalogAttribute | null;
|
||||
onClose: () => void;
|
||||
onSync: (attribute: CatalogAttribute) => Promise<void>;
|
||||
refreshToken: number;
|
||||
};
|
||||
|
||||
export default function DictionaryValuesDrawer({
|
||||
platformId, attribute, onClose, onSync, refreshToken,
|
||||
}: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [draftKeyword, setDraftKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [values, setValues] = useState<CatalogValue[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setKeyword('');
|
||||
setDraftKeyword('');
|
||||
}, [attribute?.id, platformId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!attribute) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void platformCatalogAdminApi.values(platformId, attribute.id, page, keyword)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setValues(result.data);
|
||||
setTotal(result.meta.total);
|
||||
setIsComplete(result.meta.is_complete);
|
||||
})
|
||||
.catch((requestError: unknown) => {
|
||||
if (active) setError(apiErrorMessage(requestError));
|
||||
})
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [attribute, platformId, page, keyword, refreshToken]);
|
||||
|
||||
return <Drawer
|
||||
title={attribute ? `${attribute.name} · 候选值` : '候选值'}
|
||||
width={520}
|
||||
open={!!attribute}
|
||||
onClose={onClose}
|
||||
className="system-catalog-values-drawer"
|
||||
extra={attribute && <Button
|
||||
loading={syncing}
|
||||
onClick={() => {
|
||||
setSyncing(true);
|
||||
void onSync(attribute).finally(() => setSyncing(false));
|
||||
}}
|
||||
>同步此字典</Button>}
|
||||
>
|
||||
{attribute && <Space direction="vertical" size="middle" className="system-catalog-values-content">
|
||||
<Typography.Text type="secondary">
|
||||
属性 ID {attribute.platform_attribute_id} · 字典 ID {attribute.dictionary_id}
|
||||
</Typography.Text>
|
||||
{!isComplete && <Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="当前为部分候选值缓存"
|
||||
description="大字典可能还有其他值;列表搜索只查询 Atlas 已缓存的候选值。"
|
||||
/>}
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索已缓存的名称或值 ID"
|
||||
value={draftKeyword}
|
||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
||||
onSearch={(value) => { setKeyword(value.trim()); setPage(1); }}
|
||||
/>
|
||||
{error && <Alert type="error" showIcon message={error} />}
|
||||
{loading ? <div className="system-catalog-panel-loading"><Spin /></div>
|
||||
: values.length ? <Table<CatalogValue>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={values}
|
||||
columns={[
|
||||
{ title: '候选值', dataIndex: 'value', key: 'value', render: (value: string) =>
|
||||
<span className="system-catalog-wrap">{value}</span> },
|
||||
{ title: '值 ID', dataIndex: 'external_value_id', key: 'id', width: 145 },
|
||||
]}
|
||||
/> : <Empty description="暂无已缓存候选值" />}
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={100}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
showTotal={(count) => `已缓存 ${count} 项`}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Space>}
|
||||
</Drawer>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button, Empty, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { CatalogAttribute } from '@/services/platformCatalogAdmin';
|
||||
|
||||
type Props = {
|
||||
attributes: CatalogAttribute[];
|
||||
loading: boolean;
|
||||
selectedCategoryName: string | null;
|
||||
onValues: (attribute: CatalogAttribute) => void;
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
text: '文本', integer: '整数', decimal: '小数', boolean: '布尔', dictionary: '字典',
|
||||
};
|
||||
|
||||
export default function PlatformAttributesTable({
|
||||
attributes, loading, selectedCategoryName, onValues,
|
||||
}: Props) {
|
||||
const columns: ColumnsType<CatalogAttribute> = [
|
||||
{
|
||||
title: '属性', dataIndex: 'name', key: 'name',
|
||||
render: (_value, row) => <div className="system-catalog-attribute-name">
|
||||
<Tooltip title={row.description ?? undefined}>
|
||||
<Typography.Text>{row.name}</Typography.Text>
|
||||
</Tooltip>
|
||||
<Typography.Text type="secondary">ID {row.platform_attribute_id}</Typography.Text>
|
||||
</div>,
|
||||
},
|
||||
{
|
||||
title: '规则', key: 'rules', width: 178,
|
||||
render: (_value, row) => <div className="system-catalog-attribute-tags">
|
||||
{row.is_required && <Tag color="red">必填</Tag>}
|
||||
{row.is_aspect && <Tag color="purple">SKU 变体</Tag>}
|
||||
{!row.is_active && <Tag>已停用</Tag>}
|
||||
<Tag>{typeLabels[row.value_type] ?? row.value_type}</Tag>
|
||||
</div>,
|
||||
},
|
||||
{
|
||||
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>
|
||||
: <Typography.Text type="secondary">自由填报</Typography.Text>,
|
||||
},
|
||||
];
|
||||
if (!selectedCategoryName) return <Empty description="先在左侧选择商品类型" />;
|
||||
return <Table<CatalogAttribute>
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={attributes}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
locale={{ emptyText: '该类型暂无已同步属性' }}
|
||||
scroll={{ x: 640 }}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Empty, Input, Spin, Tag, Tree, Typography } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { CatalogCategory } from '@/services/platformCatalogAdmin';
|
||||
|
||||
type Props = {
|
||||
categories: CatalogCategory[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (category: CatalogCategory) => void;
|
||||
};
|
||||
|
||||
/** 目录节点保留展开能力,只有活动商品类型叶子可选择。 */
|
||||
export default function PlatformCategoryTree({ categories, loading, selectedId, onSelect }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const categoryById = useMemo(() => new Map(categories.map((item) => [item.id, item])), [categories]);
|
||||
const treeData = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const visible = new Set<string>();
|
||||
if (query) {
|
||||
for (const category of categories) {
|
||||
if (![category.name, category.path, category.external_category_id,
|
||||
category.external_type_id ?? ''].some((value) => value.toLowerCase().includes(query))) continue;
|
||||
let node: CatalogCategory | undefined = category;
|
||||
while (node && !visible.has(node.id)) {
|
||||
visible.add(node.id);
|
||||
node = node.parent_id ? categoryById.get(node.parent_id) : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
const childrenByParent = new Map<string | null, CatalogCategory[]>();
|
||||
for (const category of categories) {
|
||||
if (query && !visible.has(category.id)) continue;
|
||||
const parentId = category.parent_id && categoryById.has(category.parent_id)
|
||||
? category.parent_id : null;
|
||||
const group = childrenByParent.get(parentId) ?? [];
|
||||
group.push(category);
|
||||
childrenByParent.set(parentId, group);
|
||||
}
|
||||
const toNodes = (parentId: string | null): DataNode[] =>
|
||||
(childrenByParent.get(parentId) ?? []).map((category) => ({
|
||||
key: category.id,
|
||||
selectable: category.is_active && category.external_type_id !== null &&
|
||||
category.external_type_id !== '0',
|
||||
title: (
|
||||
<span className="system-catalog-tree-label">
|
||||
<span>{category.name}</span>
|
||||
{category.external_type_id && category.external_type_id !== '0' &&
|
||||
<Typography.Text type="secondary">#{category.external_type_id}</Typography.Text>}
|
||||
{!category.is_active && category.external_type_id !== '0' &&
|
||||
<Tag color="default">不可建品</Tag>}
|
||||
</span>
|
||||
),
|
||||
children: toNodes(category.id),
|
||||
}));
|
||||
return toNodes(null);
|
||||
}, [categories, categoryById, search]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user