diff --git a/apps/web/src/pages/products/components/ProductVariantSection.css b/apps/web/src/pages/products/components/ProductVariantSection.css
index 1d5ff50..910dca6 100644
--- a/apps/web/src/pages/products/components/ProductVariantSection.css
+++ b/apps/web/src/pages/products/components/ProductVariantSection.css
@@ -159,30 +159,49 @@
margin-left: auto;
}
-/* 展开区:图片与规格使用同层级面板,宽度不足时再改为上下排列。 */
+/* 展开区:先展示 SKU 规格,再展示该 SKU 的图片选择区。 */
.sku-card-expanded {
display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(360px, 1fr);
- gap: 20px;
- margin-top: 16px;
- padding-top: 16px;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 12px;
+ margin-top: 12px;
+ padding-top: 12px;
border-top: 1px solid #f0edf3;
}
.sku-media-panel,
.sku-spec-panel {
- padding: 18px;
- border: 1px solid #eeeaf3;
- border-radius: 10px;
- background: #fcfbfd;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.sku-media-panel {
+ width: 100%;
}
.sku-media-panel h5.ant-typography,
.sku-spec-panel h5.ant-typography {
- margin: 0 0 12px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0 0 10px;
+ text-align: center;
+ font-size: 16px;
+ line-height: 24px;
+}
+.sku-media-panel h5.ant-typography::before,
+.sku-media-panel h5.ant-typography::after,
+.sku-spec-panel h5.ant-typography::before,
+.sku-spec-panel h5.ant-typography::after {
+ content: '';
+ flex: 1;
+ border-top: 1px dashed #d9d4df;
}
.sku-media-grid {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px;
+ /* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ grid-auto-flow: row;
+ gap: 8px;
align-items: start;
}
.sku-media-item {
@@ -193,7 +212,7 @@
align-self: start;
overflow: hidden;
border: 1px solid #ece8f0;
- border-radius: 8px;
+ border-radius: 6px;
background: #f5f3f7;
}
.sku-media-item--main {
@@ -202,8 +221,8 @@
}
.sku-media-main-badge {
position: absolute;
- top: 6px;
- left: 6px;
+ top: 4px;
+ left: 4px;
z-index: 1;
padding: 2px 6px;
border-radius: 4px;
@@ -214,8 +233,8 @@
}
.sku-media-item .ant-btn {
position: absolute;
- right: 6px;
- bottom: 6px;
+ right: 4px;
+ bottom: 4px;
z-index: 1;
}
.sku-media-item.selected {
@@ -239,7 +258,7 @@
align-self: start;
align-items: center;
justify-content: center;
- border-radius: 8px;
+ border-radius: 6px;
color: #90889c;
font-size: 13px;
}
@@ -248,13 +267,13 @@
grid-row: span 2;
}
.sku-spec-panel {
- padding-bottom: 6px;
+ order: -1;
}
.sku-spec-panel .ant-form-item {
- margin-bottom: 16px;
+ margin-bottom: 10px;
}
.sku-spec-panel .ant-form-item-label {
- flex: 0 0 86px !important;
+ flex: 0 0 78px !important;
text-align: left;
}
.sku-spec-panel .ant-form-item-label > label {
@@ -294,15 +313,7 @@
min-width: 180px;
}
-@media (max-width: 860px) {
- .sku-card-expanded {
- grid-template-columns: 1fr;
- }
-}
@media (max-width: 560px) {
- .sku-media-grid {
- grid-auto-rows: 72px;
- }
.sku-inline-dimensions {
grid-template-columns: 1fr;
}
diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx
index 50dddf8..1053695 100644
--- a/apps/web/src/pages/products/components/ProductVariantSection.tsx
+++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx
@@ -354,8 +354,8 @@ export function ProductVariantSection({
{imageCandidates.length ? (
{imageCandidates.map((candidate, imageIndex) => {
- // 仅"转存成功"的候选可直接勾选;
- // 其余按转存状态展示对应按钮。
+ // 仅"转存成功"的候选可直接勾选;待转存图片仅保留预览,
+ // 避免在此处重复提供后续素材选择流程的入口。
const selectable =
candidate.status ===
'succeeded' &&
@@ -399,7 +399,8 @@ export function ProductVariantSection({
? '已选择'
: '选择'}
- ) : (
+ ) : candidate.status ===
+ 'pending_selection' ? null : (
)}
diff --git a/docs/product/README.md b/docs/product/README.md
index f6505e3..3a3288c 100644
--- a/docs/product/README.md
+++ b/docs/product/README.md
@@ -23,5 +23,6 @@
## 分册
- [页面与交互](./product-ai-editor.md)
+- [Ozon 动态属性与 SKU 填报改造方案](./ozon-dynamic-attributes-and-sku-form-plan.md)
- [数据库领域模型](./data-model.md)
- [服务端 API 契约](./api.md)
diff --git a/docs/product/ozon-dynamic-attributes-and-sku-form-plan.md b/docs/product/ozon-dynamic-attributes-and-sku-form-plan.md
new file mode 100644
index 0000000..a594133
--- /dev/null
+++ b/docs/product/ozon-dynamic-attributes-and-sku-form-plan.md
@@ -0,0 +1,202 @@
+# Ozon 动态属性与 SKU 填报改造方案
+
+> 状态:待实施设计,记录于 2026-09-18。本文描述目标交互、数据归属和校验边界;在对应 API 契约、共享类型与实现完成更新前,不代表当前接口已经具备这些行为。
+
+## 1. 目标与原则
+
+Ozon 商品的必填属性由目标门店、`description_category_id` 与 `type_id` 对应的实时类目 schema 决定。编辑器不得为儿童玩具、服装、食品等类目分别硬编码字段,而应使用 Ozon 返回的 `is_required`、`is_aspect`、`dictionary_id`、`is_collection`、`max_value_count`、`type` 与复杂属性信息驱动展示、填报和发布校验。
+
+页面继续以 Atlas 商品和 SKU 为编辑主体:主要信息保留稳定的商品级字段,Ozon 类目属性按通用属性和 SKU 变体属性拆分。保存草稿允许信息不完整;首次上架必须由服务端按当时有效的类目 schema 重新校验。
+
+## 2. 页面字段归属
+
+### 2.1 主要信息
+
+以下字段必须继续放在「主要信息」区,不随动态属性区迁移:
+
+- 上架店铺;
+- 产品类目;
+- 标题;
+- 型号;
+- 品牌;
+- 默认包装重量与默认包装尺寸。
+
+默认包装规格是父商品级默认值。每个 SKU 默认继承该值;当某个 SKU 的包装不同,可在 SKU 卡片中设置包装重量和包装尺寸覆盖值。SKU 未填写覆盖值不表示缺失,而是继续继承主要信息中的默认包装规格。
+
+品牌和型号既是主要信息,也是可供采集、AI 与多平台复用的 Atlas 标准字段。如果 Ozon 当前类目同时返回对应平台属性,发布适配器应建立显式映射并确保只有一个最终事实来源;字典型品牌必须由用户确认 Ozon 字典值,不能把自由文本直接冒充字典 ID。
+
+### 2.2 产品属性
+
+「产品属性」区展示当前 Ozon 类目中 `is_aspect=false` 的平台属性。展示规则如下:
+
+- `is_required=true` 的属性默认展开并排在前面;选填属性可按分组折叠和搜索。
+- 必填字段只使用表单标准样式:在 Label 前显示红色 `*`。不显示「Ozon 必填」标签,也不在区块标题展示完成数量。
+- 属性名称使用 Ozon 返回的本地化名称,并展示 `description` 作为填写说明。
+- 有字典的属性只能从 Ozon 字典值中选择;自由输入不能替代字典值。
+- 多值属性限制为 `max_value_count`,单值属性只能提交一个值。
+- 整数、十进制、布尔、文本和字典分别使用适合的输入控件。
+- 复杂属性按 Ozon 的复杂属性结构分组展示,发布时进入 `complex_attributes`,不得扁平化为普通属性。
+
+例如,儿童玩具类目把「最小儿童年龄」和「最大儿童年龄」标为必填时,两个字段应直接显示在产品属性区,并在各自 Label 前显示红色 `*`。如果它们是字典属性,必须选择 Ozon 返回的年龄值,而不是任意输入文本。
+
+### 2.3 SKU 设置
+
+Ozon schema 中 `is_aspect=true` 的属性放入每个 SKU 卡片的「Ozon 变体属性」区域,不再按 SKU 名称集中堆放在产品属性区。每张 SKU 卡片包含:
+
+- SKU 名称与当前平台上架状态;
+- 卖家货号 `offer_id`;
+- SKU 条形码;
+- Ozon 变体属性,例如颜色、尺码;
+- 采购价、售价和划线价;
+- SKU 图片;
+- SKU 实物规格;
+- 对父商品默认包装规格的可选覆盖。
+
+SKU 中的 Ozon 必填变体属性同样只在 Label 前显示红色 `*`。父商品默认包装规格与 SKU 覆盖值应明确显示继承关系,例如「继承默认值:320 g」和「为此 SKU 单独设置」。
+
+采集得到的 `specification_values` 仅作为跨平台的来源规格和预填建议,不直接作为 Ozon 发布属性。能可靠匹配时,可预填相应的 Ozon `is_aspect` 属性并标记为待确认;无法可靠匹配时保持为空。Ozon 变体属性是发布 payload 的唯一事实来源,避免 Atlas 规格轴和 Ozon 属性出现两套不一致值。
+
+## 3. Schema 获取与展示状态
+
+选择目标门店和末级类目后,系统使用门店缓存记录中的 `description_category_id + type_id` 获取属性 schema:
+
+1. 优先读取当前门店的本地 schema 缓存。
+2. 缓存不存在或超过约定有效期时,创建异步同步任务并显示「正在更新 Ozon 类目要求」。
+3. 同步成功后重新构建动态属性表单。
+4. 页面显示类目路径、商品类型和最后同步时间,并提供失败后的重试入口。
+
+属性 schema 响应除现有字段外,计划补充:
+
+```ts
+type ProductAttributeSchema = {
+ platform_attribute_id: string;
+ complex_id: string;
+ name: string;
+ description: string | null;
+ group_id: string | null;
+ group_name: string | null;
+ value_type: 'text' | 'integer' | 'decimal' | 'boolean' | 'dictionary';
+ is_required: boolean;
+ is_collection: boolean;
+ max_value_count: number;
+ dictionary_id: string | null;
+ is_aspect: boolean;
+ synced_at: string;
+};
+```
+
+切换类目后,新 schema 中仍可匹配的值可以保留;不再匹配的旧属性进入未映射属性区,不能进入新的发布 payload,也不应仅由前端直接清空而丢失。
+
+## 4. 属性值表达
+
+草稿契约不再用同一个 `string[]` 同时表达自由文本和字典值,计划改为有判别字段的值结构:
+
+```ts
+type ProductAttributeValue =
+ | { kind: 'dictionary'; dictionary_value_id: string }
+ | { kind: 'scalar'; value: string };
+```
+
+服务端生成 Ozon payload 时遵守以下规则:
+
+- 字典属性根据缓存中的 `dictionary_value_id` 解析对应显示值,不能信任客户端伪造的字典名称。
+- 自由输入属性写入文本值,并按 Ozon 请求格式设置相应的空字典标识。
+- 服务端校验字典值属于当前门店、当前类目和当前属性。
+- 属性值数量、类型和复杂属性结构均由服务端复核。
+
+## 5. 新增 SKU 与属性契约
+
+当前属性草稿在顶层使用 `variant_id` 关联 SKU,新建或复制 SKU 在第一次保存前只有 `client_key`,因此无法在同一次编辑中稳定关联其 Ozon 变体属性。目标请求结构调整为:
+
+```ts
+type SaveEditorDraft = {
+ common_attributes: ProductAttributeDraft[];
+ variants: Array<{
+ id: string | null;
+ client_key: string | null;
+ offer_id: string | null;
+ barcode: string | null;
+ attributes: ProductAttributeDraft[];
+ // 其他 SKU 字段省略
+ }>;
+};
+```
+
+这样新增 SKU 可以在一次原子保存中同时创建 SKU、保存条形码和 Ozon 变体属性,不需要先保存并刷新页面。该调整属于破坏性共享契约变更,实施时必须先更新 `docs/api/product-editor-and-publishing.md`,再同步修改 `@atlas/contracts`、API、数据库映射、Web 和测试。
+
+SKU 条形码应保存到当前平台 listing 维度;现有商品级条形码可在迁移期作为默认值保留,但发布时必须解析出每个 SKU 的最终条形码。
+
+## 6. 草稿校验与上架校验
+
+### 6.1 保存草稿
+
+保存草稿允许缺少 Ozon 必填属性、货号、价格、图片或完整包装规格。已经填写的值仍必须满足类型、数量、字典归属和 SKU 归属约束。页面可以提示缺失项,但不得因为动态必填属性为空而阻止用户保存进度。
+
+因此,动态属性的红色 `*` 是上架要求提示,不直接等同于「保存草稿」按钮的阻断规则。未加载属性面板和已经加载属性面板时,保存行为必须一致。
+
+### 6.2 首次上架
+
+实现并复用统一的服务端发布就绪校验:
+
+```text
+GET /api/product-platform-profiles/{profile_id}/validation
+```
+
+该校验至少覆盖:
+
+- 类目和 Ozon 商品类型有效;
+- schema 已同步且未超过约定有效期;
+- 所有通用必填属性有合法值;
+- 每个待上架 SKU 的必填变体属性有合法值;
+- 字典值、单/多值数量、数值类型与复杂属性结构有效;
+- SKU 的 `offer_id`、条形码、价格、图片和有效包装规格符合发布要求;
+- 已上架 SKU 返回 `not_publishable_again`,不作为普通字段缺失处理。
+
+`POST /publish-jobs` 必须调用同一套校验,不能信任浏览器校验结果。保存草稿和创建发布任务应继续保持各自的事务与幂等边界。
+
+## 7. Ozon 发布错误回填
+
+本地 schema 无法覆盖 Ozon 所有条件校验。Ozon 拒绝某个 import item 时,系统应:
+
+- 通过 `offer_id` 定位 SKU;
+- 在可能时通过 `attribute_id` 定位产品属性或 SKU 变体属性;
+- 将错误展示在对应字段附近,并在发布任务中保存脱敏后的平台错误;
+- 无法映射到字段的错误显示在对应 SKU 卡片顶部;
+- 提供重新同步当前类目 schema 的操作。
+
+发布任务需固化使用的类目、类型、schema 同步版本、属性值和最终 Ozon 请求摘要,保证失败排查和重试可审计。
+
+## 8. 实施顺序与验收
+
+### P0:展示与校验边界
+
+- 必填属性默认展示,Label 前显示红色 `*`。
+- 产品属性与 SKU 变体属性分区。
+- 保存草稿允许不完整,发布校验阻断缺失项。
+- 服务端补齐通用和逐 SKU 必填属性检查。
+
+### P1:SKU 数据结构
+
+- 属性草稿调整为 `common_attributes + variants[].attributes`。
+- 新 SKU 使用 `client_key` 在首次保存时关联属性。
+- 条形码迁到平台 SKU listing。
+- Ozon 变体属性成为发布的唯一事实来源。
+- 字典值改为结构化表达。
+
+### P2:发布闭环
+
+- 实现 validation 接口及发布接口复用。
+- 按 SKU 生成 `/v3/product/import` 的 `items[]`。
+- 正确生成普通属性与复杂属性。
+- 固化发布快照并将 Ozon 错误回填到 SKU/属性。
+- 增加 schema 过期、同步和重试状态。
+
+验收至少覆盖以下场景:
+
+1. 儿童玩具类目要求最小和最大儿童年龄时,两项均展示红色 `*`,缺少任一项不能上架。
+2. 两个颜色 SKU 分别要求颜色,任一 SKU 缺值时只阻断该 SKU。
+3. 新增或复制 SKU 无需先保存刷新,即可填写并保存其 Ozon 变体属性。
+4. SKU 未覆盖包装规格时继承主要信息默认值,覆盖后只影响该 SKU。
+5. 类目切换后旧属性不进入新 payload,同时保留为待处理的未映射数据。
+6. 客户端伪造字典 ID 时服务端返回字段级 `422`。
+7. 本地校验通过但 Ozon 拒绝时,错误能回填到对应 SKU 或属性。
diff --git a/docs/product/product-ai-editor.md b/docs/product/product-ai-editor.md
index 9a4ef3b..b6fcb44 100644
--- a/docs/product/product-ai-editor.md
+++ b/docs/product/product-ai-editor.md
@@ -8,12 +8,14 @@
```text
页头:返回 · 商品标题 · 当前平台 · 目标门店 · 当前平台上架状态 · 来源
-├─ 商品通用信息
-│ ├─ 类目、标题、品牌、型号、主题标签、简介
-│ ├─ 条形码、采买地址、货源备注
-│ └─ 当前平台/市场类目的动态属性
+├─ 主要信息
+│ ├─ 类目、标题、型号、品牌
+│ └─ 默认包装重量与默认包装尺寸
+├─ 产品属性
+│ ├─ 主题标签、简介、采买地址与货源备注
+│ └─ 当前平台/市场的商品级动态属性
├─ SKU 商品:卡片(默认)/ 紧凑列表
-│ └─ N 个 SKU
+│ └─ N 个 SKU;维护货号、条形码、图片、价格、变体属性及可选包装覆盖
└─ 固定操作栏
├─ 左:AI 类目 · AI 标题/标签/描述 · AI 套图生成
└─ 右:取消并关闭 · 保存草稿 · 选择未上架 SKU 并首次上架
@@ -29,9 +31,11 @@
前端根据当前目标门店、本地同步的商品类目 schema 渲染属性控件、必填提示、字典选择、多值限制及变体属性。门店 Ozon 凭据验证成功后,Worker 使用该门店凭据同步中文类目树;缓存为空时编辑器显示准备中状态,用户不能在编辑页发起树同步。选中类目但 schema 缺失时,仍可显式同步该类目属性。字典控件先搜索本地缓存,输入至少两个字符仍无结果时,可显式从 Ozon 搜索并缓存命中值。`is_aspect=true` 在编辑器中按 SKU 展示,其余属性按商品展示;保存仍由服务端复核 schema 与字典值。选择类目和属性与商品草稿同一次保存,`platform_type_id` 从服务端门店类目缓存取得。类目变更或 schema 下线后,失配属性保留为未映射草稿,不能自动进入发布请求。
+类目、标题、型号、品牌以及默认包装重量和默认包装尺寸固定放在「主要信息」区。SKU 默认继承父商品包装规格,仅在包装不同的情况下填写覆盖值。平台动态必填字段统一使用表单标准表现,在 Label 前显示红色 `*`;不追加「Ozon 必填」标签,也不在区块标题显示完成数量。更完整的目标交互、属性值表达、SKU 关联和发布校验改造见 [`ozon-dynamic-attributes-and-sku-form-plan.md`](./ozon-dynamic-attributes-and-sku-form-plan.md)。
+
## 3. SKU 卡片和列表
-卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、采购价、售价、划线价、定价按钮、两组规格与变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
+卡片默认展示:序号、SKU 名称、当前平台上架状态、最多 3 张图片缩略图(可切换为 5)、货号、条形码、采购价、售价、划线价、定价按钮、两组规格与 Ozon 变体属性。列表仅是紧凑视图,字段和数据完全相同。货号由用户填写,不自动复用来源 SKU;缺少货号的 SKU 可保存但不能被选择首次上架。SKU 包装规格为空时继承主要信息中的默认包装规格;只有该 SKU 包装不同才填写覆盖值。首期不支持视频上传、编辑或发布;视频能力待 Ozon 对应接口和审核规则确定后再开启。
SKU 名称只用于展示;采集 SKU 用稳定 `source_variant_key` 关联,手工 SKU 用本地 UUID。规格字段 `null` 仅表示没有覆盖父商品默认值,绝不能据此判断是否单规格或缺少 SKU。