feat(edit): 编辑上报细节优化
This commit is contained in:
+88
-1
@@ -1,6 +1,60 @@
|
||||
# Web 前端交接:商品编辑器与 AI 填报
|
||||
|
||||
更新日期:2026-10-04
|
||||
更新日期:2026-10-06
|
||||
|
||||
## 2026-10-06 SKU 文案用户要求与弹窗重设计
|
||||
|
||||
SKU 文案弹窗以「用户描述 / 生成要求」为主要输入(1000 字符,计数、生成时禁用),标明优先采用;当前 SKU 名称独立展示,采集标题/描述/规格移入默认折叠的辅助参考,右侧按标题/标签/描述展示俄文与中文对照,窄屏单列且内容可滚动。用户要求通过既有 `hint` 提交,按 SKU 暂存于页面内;不写采集信息或商品草稿。要求修改会清空旧预览并禁用应用。生成/重新生成/应用状态明确,仍需用户应用并保存。
|
||||
|
||||
Web 请求方法接受共享 `RequestProductAiTask`,复用共享 1000 字符常量;API 字段和校验规则不变。Worker 将用户要求作为独立高优先级输入,明确用户补充或修正优先于采集资料、旧 SKU 和既有文案,辅助 SKU 摘要仅包含当前 SKU,输出格式/俄文/标签约束继续生效。前端通过请求序号隔离关闭或切换 SKU 后完成的旧任务,并核对结果 SKU 与采集/产品/profile 版本。应用当前标题需切换逐 SKU 模式时,保留其他 SKU 原先继承的标题和中文对照,修复旧行为清空其他 SKU 标题的问题。
|
||||
|
||||
Contracts/API(含测试)/Worker/Web 类型检查及构建通过;API 单元测试 2 项、Worker 文案测试 12 项通过,均模拟数据库或供应商,不创建真实任务或收费请求。ego-browser TaskSpace 9 的真实 ProductEditPage + 模拟 API 验证 hint 请求、修改要求使旧结果失效、按 SKU 保留要求、关闭/切换后旧结果不回填、加载禁用输入、当前 SKU 应用/模拟保存、中俄对照、其他 SKU 标题保留、空要求兼容、1000 字符边界及 1280/390px 布局。弹窗在 390px 下宽度/scrollWidth 均 374px;底层编辑页有原有 478px 横向宽度,本轮未扩展修改该布局。临时页面与浏览器已清理,未读写真实商品、调用 AI 供应商或 Ozon,未手动启停维护者服务。Web 原有大包体警告保留。
|
||||
|
||||
## 2026-10-06 SKU 货号以型号为自动前缀
|
||||
|
||||
本轮仅修改 Web 商品编辑器、SKU 信息区及局部样式,新增 `skuOfferId.ts` 与 `SkuOfferIdInput.tsx`,并同步 API/AI 方案与决策说明;HTTP 字段、服务端、数据库、Worker 和扩展无改动。货号控件占整行,型号加连接符为只读前缀,用户只输入 SKU 后缀,表单与保存请求继续持有完整 `offer_id`。手工修改型号同步全部未导入 SKU,包括未挂载的收起项;使用完整表单值,避免丢失采购价、选图等资料。清空型号保留后缀,清空后缀保持空货号。已导入/进行中货号只读并保留历史事实,已有型号锁定规则继续生效。
|
||||
|
||||
加载时匹配当前型号的旧货号拆出后缀;未匹配的旧值完整保留为后缀,补前缀只进入待保存草稿并标记未保存,不做后台批量写入。AI 结果的生成型号前缀先拆出,再按表单型号组合,用户非空后缀不被覆盖。完整货号限制 50 字符;输入长度扣除前缀占用,型号变长不截断已有后缀,超限保存给予明确提示。
|
||||
|
||||
Web 类型检查与生产构建通过,原有大包体警告保留。ego-browser TaskSpace 8 的隔离真实 SkuInfoPanel/Form 验证前缀及后缀编辑、反复修改/清空型号、收起字段联动、旧值完整保留、空后缀、采购价/图片保留、已上架只读、50 字符输入上限和 390/1280px 布局;长型号导致整串 57 字符时,38 字符旧后缀完整保留。临时 QA 页面和浏览器已清理,没有读取/保存真实商品或触发发布;未手动启停维护者现有服务。
|
||||
|
||||
## 2026-10-06 采集与编辑保存支持超过 100 个 SKU
|
||||
|
||||
用户补充的明确字段错误为 `variants: Too big: expected array to have <=100 items`,关闭上一轮待定位事项:根因是插件与 API 共享的采集数组上限为 100,且编辑完整草稿也有独立的 100 项上限,真实 1688 组合超过此值即失败。当前集成会话修改共享契约、API 采集响应统计、扩展/API 回归与 API/交接文档。新增 `PRODUCT_VARIANT_LIMIT=1000`,采集和商品完整保存统一使用;超限中文提示,不静默截断 SKU。采集响应的 `variant_count` 改为实际未删除 SKU 数。首次发布每批 50 个、媒体数量、其他字段约束、工作空间采集商品配额及 Ozon 公开页有界补全规则均保持原边界,无数据库迁移或 Worker/Web 业务逻辑修改。
|
||||
|
||||
扩展回归 15 项通过,验证 1688 200 条明确可售记录完整解析、101/1000 条接受、1001 条拒绝和上架 50/51 条边界。API 采集/商品编辑 16 项通过,新增完整 1000 SKU 的采集入库、编辑初始化、读取末尾 SKU、完整草稿保存、复采保留 ID 与人工修改、超限请求不改变来源版本或 SKU 数;测试数据隔离,无真实采集或 Ozon 发布。Contracts/API(含测试)/Web/Worker/Extension 类型检查和构建通过;Web 原有包体警告保留,差异检查通过。维护者现有 API 开发进程随源码自动重载,未手动启停服务;插件静态产物已重建,需重载扩展并刷新来源页后再次采集。
|
||||
|
||||
## 2026-10-05 采集插件编辑上架进入商品编辑器
|
||||
|
||||
修改扩展 `collector.content.ts`、API `extension.service.ts`、Web 编辑页及 `productEditor` 服务和 API/交接文档。根因是采集响应仍提供测试页路径,且新商品尚未初始化平台编辑配置。采集成功响应改为 `/products/{product_id}/editor?collection_item_id={collection_item_id}`,扩展继续使用响应入口并提示「已打开商品编辑页」。Web 携带采集 ID 调用现有 `editor-contexts`,核对商品 ID 后读取快照;当前页头平台和默认店铺规则沿用既有契约。重复采集进入原商品,既有上下文保留资料和上架状态;「采集商品」仍不打开编辑页,没有自动上架、媒体下载或平台绑定写入采集事务。测试页和旧手工商品入口保留,无新增 HTTP 字段或数据库迁移。
|
||||
|
||||
API 采集/商品编辑集成回归 15 项通过,覆盖新入口实际商品 ID、幂等重放、复采路径稳定以及首次上下文初始化。API 源码/测试类型检查与构建、Web 类型检查/构建、扩展 WXT prepare/类型检查/Chrome MV3 构建通过;Web 原有包体大小警告保留。ego-browser 隔离真实前端服务验证首次入口先初始化后读快照,普通既有入口直接读取,商品不匹配及无默认店铺阻断读取,并沿用当前平台。临时页面和任务浏览器已清理,没有采集/保存真实商品或调用 Ozon,未手动启停维护者服务。扩展新产物位于 `apps/extension/.output/chrome-mv3`,已加载静态扩展的浏览器需重载扩展并刷新来源页以更新成功提示;既有插件已使用响应入口,API 更新即可更改下一次跳转地址。
|
||||
|
||||
## 2026-10-05 已上架 SKU 本地价格重新试算
|
||||
|
||||
本轮跨 Web `SkuPricing`、定价 API、商品编辑读模型、共享契约与接口/交接文档。已上架 SKU 开放「重新试算」和「试算详情」,应用价格前确认:「该 SKU 已上架。本次价格调整仅保存为本地记录,不会同步至 Ozon;如需修改线上价格,请前往 Ozon 后台。」取消不修改价格,确认后更新草稿,仍须保存商品。批量计算和自动试算继续跳过已导入项。
|
||||
|
||||
编辑快照新增 `variants[].listing.store_id`。已上架 SKU 固定读取首次导入店铺的定价目录和币种,即使 profile 的将来目标店铺已变更。API 允许此 SKU 生成预览,跨店铺试算返回 `PRICING_VARIANT_STORE_MISMATCH`;显式 `pricing_selection` 按已有有效期、输入、规则和幂等边界确认新本地报价,普通草稿保存保留原报价。保持首次导入状态、店铺、货号、平台商品 ID 和导入时间,不创建发布或价格同步任务,无数据库迁移、Worker 或扩展业务改动。
|
||||
|
||||
定价保存 13 项、首次发布 11 项、商品编辑 13 项和采集 2 项集成测试通过,覆盖已上架新报价、错误店铺拦截、失效报价原子回滚、幂等重放、普通保存保留报价及零平台任务。Contracts/API/Web/Worker 类型检查与构建通过;Web 存在原有大包体警告。ego-browser 隔离真实组件验证两个入口均可用,取消不改变草稿,确认后实际售价 120 / 划线价 240,始终使用首次导入店铺,批量不自动改价。临时页面与代理浏览器已清理;未保存真实商品或操作 Ozon,没有手动启停维护者服务。
|
||||
|
||||
## 2026-10-05 采集页面地址回填货源链接
|
||||
|
||||
当前集成会话修改 API `extension.service.ts` 新采集商品写入、`product-editor.service.ts`/models 编辑读模型、extension HTTP 集成回归以及 API/交接文档。插件既有解析器已上报 `source.canonicalUrl`,问题是地址只保存于采集记录,未用于商品货源字段。新建商品同时初始化 `purchase_url`;编辑读模型优先使用手工非空链接,空值回退关联采集记录 `source_url`,历史商品无需复采,手动无来源商品仍为空。清空表示恢复来源地址,复采不覆盖手工非空链接。不修改插件、HTTP 字段或数据库,无历史草稿批量写入。
|
||||
|
||||
采集授权/素材 HTTP 2 项与商品编辑 13 项通过,覆盖新 1688 地址初始化、历史空字段回填、Ozon 地址、复采保留手工链接和既有商品编辑边界;API 源码/测试类型检查与构建、Web 类型检查/构建及差异检查通过。在用户当前商品的独立浏览器验证页确认 1688 来源地址已显示、打开按钮启用,输入框与按钮均 35px 且对齐。原用户页面未重载或保存,避免丢失编辑;API 开发进程自动重载,无手动启停。未向真实平台发布,未重新采集或写入真实商品。
|
||||
|
||||
## 2026-10-05 货源链接按钮与输入框等高
|
||||
|
||||
仅修改 Web `ProductOtherSection` 和编辑页局部样式,货源链接 Compact 组增加专用类,按钮按输入框所在 flex 行伸展高度,避免 Input 的 35px 主题高度与 Button 的默认 32px 不一致;添加按钮可访问名称。其他按钮样式和打开链接行为不变,无接口变更。
|
||||
|
||||
Web 类型检查、构建及差异检查通过;ego-browser 隔离真实组件在 620/390/1280px 宽度检查启用与禁用按钮,均与输入框同为 35px 且顶部对齐,打开回调正常。临时页面已清理,未修改真实商品,未启停原有服务。
|
||||
|
||||
## 2026-10-05 部分上架按钮显示进行中状态
|
||||
|
||||
本轮仅修改 Web `ProductPublishActions.tsx` 与交接文档,复用现有任务读取、提交和轮询接口,无 API、Worker、数据库或契约变更。「部分上架」按钮在任务未结束时显示排队中、提交中、等待 Ozon、等待对账或处理中,配合时钟/加载/提醒图标;running 任务按 SKU 状态细分阶段。点击进行中按钮直接打开任务详情,保持可查看,避免同一入口再次创建任务;任务成功、部分成功或失败后恢复正常部分上架选择入口。页面重载沿用现有最近任务恢复逻辑。
|
||||
|
||||
Web 类型检查、生产构建与差异检查通过。ego-browser 隔离真实组件验证页面恢复排队任务、轮询切换提交/等待平台/对账、进行中点击详情不保存或创建任务、失败与成功后恢复入口,以及新任务提交后立即显示排队状态;模拟提交仅一次。临时页面已清理,未向真实 Ozon 发布,未保存真实商品,未手动启停维护者服务。
|
||||
|
||||
## 2026-10-04 SKU 主动重算使用最新默认方案
|
||||
|
||||
@@ -140,3 +194,36 @@ git diff --check
|
||||
- 当前任务可按仓库 `AGENTS.md` 修改相关项目代码;跨领域改动需遵守 [`../../docs/project/development-collaboration-contract.md`](../../docs/project/development-collaboration-contract.md) 并说明影响范围。
|
||||
- 验证需要时可在确认目标进程和端口后启动、停止或重启 API/Worker;完成后说明实际操作与状态。上方 2026-09-11 的验证记录不能替代新改动的检查。
|
||||
- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。
|
||||
|
||||
|
||||
## 2026-10-06 SKU 上传/网络图片按商品隔离
|
||||
|
||||
`useSkuImageLibrary` 必须接收 productId,两个候选 GET 携带 product_id,网络导入和商品上传意图绑定当前商品。变体图片弹窗与套图参考图均已接入;水印协议不变。同商品 SKU 可共用候选,切换商品立即隐藏旧候选并重置指纹和计数,延迟请求使用商品世代隔离。直接上传到 SKU 使用编辑会话序号隔离,关闭/切换后不追加旧结果。历史无归属候选不再全局展示,已有 SKU 选图保留。需先应用数据库迁移 060,API 与 Web 一起更新。浏览器真实 Hook 回归和 Web 类型检查/构建通过;临时 QA 文件与 TaskSpace 10 已清理。
|
||||
|
||||
|
||||
## 2026-10-06 SKU 名称与货号恢复同行
|
||||
|
||||
修复型号前缀控件引入时将货号设置为整行的布局回归。Web `SkuInfoPanel` 将名称与货号放入独立两列基础资料行,`ProductVariantSection.css` 调整行布局和长型号前缀截断宽度;即使其余发布字段在窄容器中切为单列,基础资料仍保持同行。型号前缀、后缀编辑、完整货号与锁定规则不变,无 API/共享契约/数据库变更。Web 类型检查、生产构建和差异检查通过;ego-browser TaskSpace 11 验证 1280/620/390px 名称和货号输入同行、不溢出,并确认后缀修改仍组成完整货号。临时 QA 文件和浏览器已清理,未手动启停服务或修改真实商品,保留既有包体警告。
|
||||
|
||||
|
||||
## 2026-10-06 SKU 候选图片永久删除
|
||||
|
||||
`SkuImageEditorModal` 右侧候选图片的「编辑」菜单最底部新增红色「删除」,二次确认明确同步永久删除七牛文件。左侧「移出已选」仅取消选图。接口确认接受后清理当前商品候选、所有未保存 SKU 选图及本地处理/预览缓存;删除状态轮询成功后才显示存储清理完成,失败可在确认框或弹窗「重试清理」入口恢复,重新打开可读取待处理任务。已保存引用的图片被服务端拒绝,提示先移除引用并保存。
|
||||
|
||||
依赖共享 `ProductImageDeletion` 和 `mediaApi` 商品删除/列表/读取/重试接口,API/Worker 与迁移 061、062 需同步更新,契约见 `docs/api/watermarks-and-assets.md`。Web 类型检查、生产构建通过;ego-browser TaskSpace 13 实际组件验证菜单、二次确认、取消、引用拒绝、失败提示和重试恢复,临时文件与浏览器已清理。后端另以真实七牛对象验证删除后 HEAD 404;没有删除真实用户图片,未手动启停服务,保留原有包体大小警告。
|
||||
|
||||
## 2026-10-06 定价试算费用显示精度
|
||||
|
||||
`PricingCalculator` 的费用明细统一四舍五入显示两位小数(包括零费用),不修改接口原始金额、计算精度或应用到 SKU 的价格数据。本轮仅修改前端显示。Web 类型检查、生产构建和差异检查通过,保留既有包体大小警告;未启停维护者服务。
|
||||
|
||||
|
||||
## 2026-10-06 SKU 颜色多选与已选文本
|
||||
|
||||
`DynamicAttributeFields` 使用共享 `isOzonProductColorAttribute` / `productAttributeValueRules`,颜色属性 10096 在 schema 单值时仍支持多选,保存和发布也采用同一 100 项限制。控件初始化加载颜色字典,按属性 UUID 保留已选文本,搜索、候选分页替换和草稿回填不再让首项退回编号;加载前使用既有俄文颜色名兜底,未知值提示待加载。字典 ID 继续作为提交值,新类目不沿用旧名称缓存。
|
||||
|
||||
共享契约和 API 须一起更新;无迁移。Contracts/API/Web 类型检查及构建、25 项后端编辑/发布回归通过。ego-browser TaskSpace 14 验证真实 Form.List 控件的回填、多选、搜索、重新展开和类目隔离;临时文件与浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。
|
||||
|
||||
|
||||
## 2026-10-06 产品描述复制保留换行
|
||||
|
||||
CopyTextButton 直接读取原始文本写入剪贴板,保留内部换行和空行。通用描述、SKU 描述、中俄对照与 SKU AI 描述预览均增加明确复制按钮;SKU 空白覆盖值按有效文案复制 SPU 描述,未复制 HTML。上报由 Worker 仅对简介属性 4191 转成 Ozon 支持的 br;表单、草稿与快照仍是纯文本。已导入商品不会自动更新。Web 类型检查/构建及 ego-browser TaskSpace 16 真实组件模拟剪贴板验证通过;临时文件和浏览器已清理,未修改真实商品或手动启停服务,保留既有包体警告。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { App, Button } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
/** 直接复制表单或预览原文,保留换行和空行,不从页面排版提取文本。 */
|
||||
export function CopyTextButton({ getText, label }: {
|
||||
getText: () => string | null | undefined;
|
||||
label: string;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
return <Button type="text" icon={<CopyOutlined />} aria-label={label} title={label}
|
||||
onClick={async () => {
|
||||
const text = getText();
|
||||
if (!text) { message.info('暂无可复制的内容'); return; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success('已复制,换行格式已保留');
|
||||
} catch { message.error('复制失败,请选中文本后复制'); }
|
||||
}} />;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export function PricingCalculator({
|
||||
other_cny: '其他',
|
||||
} as Record<string, string>
|
||||
)[k],
|
||||
children: v,
|
||||
children: Number(v).toFixed(2),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Form, InputNumber, Modal, Typography } from 'antd';
|
||||
import { App, Button, Form, InputNumber, Modal, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
@@ -22,7 +22,8 @@ export function skuPricingInput(
|
||||
values: Values,
|
||||
index: number,
|
||||
profileId: string,
|
||||
parameters: PricingParameters
|
||||
parameters: PricingParameters,
|
||||
storeId?: string
|
||||
): PricingPreviewRequest {
|
||||
const v = values.variants[index],
|
||||
base = values.common.default_package_spec,
|
||||
@@ -34,7 +35,7 @@ export function skuPricingInput(
|
||||
height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm),
|
||||
};
|
||||
return {
|
||||
store_id: values.profile.target_store_id,
|
||||
store_id: storeId ?? values.profile.target_store_id,
|
||||
platform_product_category_id: values.profile.platform_product_category_id ?? '',
|
||||
fulfillment: values.profile.fulfillment as 'rfbs',
|
||||
binding: {
|
||||
@@ -78,12 +79,17 @@ export function SkuPricing({
|
||||
onChanged: () => void;
|
||||
children: (slots: PricingSlots) => ReactNode;
|
||||
}) {
|
||||
const { modal: confirmation } = App.useApp();
|
||||
Form.useWatch('variants', { form, preserve: true });
|
||||
Form.useWatch('common', { form, preserve: true });
|
||||
Form.useWatch('profile', { form, preserve: true });
|
||||
const values = form.getFieldsValue(true) as Values,
|
||||
v = values.variants[index],
|
||||
original = snapshot.variants.find((x) => x.id === v?.id);
|
||||
const imported = original?.listing.status === 'imported';
|
||||
const pricingStore = imported
|
||||
? original.listing.store_id
|
||||
: values.profile?.target_store_id;
|
||||
const [catalog, setCatalog] = useState<PricingCatalog | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false),
|
||||
@@ -98,7 +104,7 @@ export function SkuPricing({
|
||||
controller = useRef<AbortController | null>(null),
|
||||
lastRequest = useRef(''),
|
||||
lastBatch = useRef(batch);
|
||||
const store = values.profile?.target_store_id;
|
||||
const store = pricingStore;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setCatalog(null);
|
||||
@@ -126,7 +132,9 @@ export function SkuPricing({
|
||||
const params = fulfillmentSupported
|
||||
? (v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null))
|
||||
: null;
|
||||
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
|
||||
const live = params && v
|
||||
? skuPricingInput(values, index, snapshot.profile.id, params, pricingStore)
|
||||
: null;
|
||||
const liveKey = live ? keyFor(live) : '';
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
@@ -143,7 +151,7 @@ export function SkuPricing({
|
||||
Date.parse(result.preview.expires_at) > now;
|
||||
const savedInput = original?.listing.pricing_draft
|
||||
? {
|
||||
store_id: snapshot.profile.target_store.id,
|
||||
store_id: imported ? original.listing.store_id : snapshot.profile.target_store.id,
|
||||
platform_product_category_id: snapshot.profile.category.id ?? '',
|
||||
fulfillment: snapshot.profile.fulfillment as 'rfbs',
|
||||
binding: {
|
||||
@@ -173,6 +181,21 @@ export function SkuPricing({
|
||||
form.setFieldValue(['variants', index, 'pricing_draft'], p);
|
||||
onChanged();
|
||||
};
|
||||
const localPriceNotice =
|
||||
'该 SKU 已上架。本次价格调整仅保存为本地记录,不会同步至 Ozon;如需修改线上价格,请前往 Ozon 后台。';
|
||||
function confirmPriceChange(action: () => void | Promise<void>) {
|
||||
if (!imported) {
|
||||
void action();
|
||||
return;
|
||||
}
|
||||
confirmation.confirm({
|
||||
title: '调整已上架 SKU 的本地价格',
|
||||
content: localPriceNotice,
|
||||
okText: '仅更新本地记录',
|
||||
cancelText: '取消',
|
||||
onOk: action,
|
||||
});
|
||||
}
|
||||
function apply(
|
||||
input: PricingPreviewRequest,
|
||||
preview: PricingPreview,
|
||||
@@ -216,7 +239,7 @@ export function SkuPricing({
|
||||
catalogOverride?: PricingCatalog
|
||||
) {
|
||||
const sourceCatalog = catalogOverride ?? catalog;
|
||||
if (!sourceCatalog || !v || original?.listing.status === 'imported') return;
|
||||
if (!sourceCatalog || !v) return;
|
||||
if (!fulfillmentSupported) {
|
||||
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
|
||||
return;
|
||||
@@ -231,7 +254,8 @@ export function SkuPricing({
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
parameters
|
||||
parameters,
|
||||
pricingStore
|
||||
),
|
||||
inputKey = keyFor(input);
|
||||
const problem = pricingInputProblem(input);
|
||||
@@ -260,8 +284,9 @@ export function SkuPricing({
|
||||
const latestParameters = latest.variants[index]?.pricing_draft;
|
||||
if (
|
||||
!latestParameters ||
|
||||
keyFor(skuPricingInput(latest, index, snapshot.profile.id, latestParameters)) !==
|
||||
inputKey
|
||||
keyFor(
|
||||
skuPricingInput(latest, index, snapshot.profile.id, latestParameters, pricingStore)
|
||||
) !== inputKey
|
||||
)
|
||||
return;
|
||||
const recommended = p.options.find((o) => o.id === p.recommended_option_id);
|
||||
@@ -292,12 +317,12 @@ export function SkuPricing({
|
||||
}
|
||||
}
|
||||
async function calculateFromDefault() {
|
||||
if (!v || original?.listing.status === 'imported') return;
|
||||
if (!v) return;
|
||||
if (!fulfillmentSupported) {
|
||||
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
|
||||
return;
|
||||
}
|
||||
const requestedStore = form.getFieldValue(['profile', 'target_store_id']);
|
||||
const requestedStore = pricingStore;
|
||||
controller.current?.abort();
|
||||
const seq = ++sequence.current;
|
||||
setBusy(true);
|
||||
@@ -307,7 +332,9 @@ export function SkuPricing({
|
||||
const fresh = await pricingApi.catalog(requestedStore);
|
||||
if (
|
||||
seq !== sequence.current ||
|
||||
form.getFieldValue(['profile', 'target_store_id']) !== requestedStore
|
||||
(imported
|
||||
? original.listing.store_id
|
||||
: form.getFieldValue(['profile', 'target_store_id'])) !== requestedStore
|
||||
)
|
||||
return;
|
||||
setCatalog(fresh);
|
||||
@@ -345,7 +372,7 @@ export function SkuPricing({
|
||||
sequence.current++;
|
||||
controller.current?.abort();
|
||||
setBusy(false);
|
||||
if (auto && live && result?.inputKey !== liveKey) {
|
||||
if (auto && !imported && live && result?.inputKey !== liveKey) {
|
||||
const timer = setTimeout(() => void calculate(true), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -353,10 +380,9 @@ export function SkuPricing({
|
||||
useEffect(() => {
|
||||
if (batch === lastBatch.current) return;
|
||||
lastBatch.current = batch;
|
||||
void calculateFromDefault();
|
||||
if (!imported) void calculateFromDefault();
|
||||
}, [batch]);
|
||||
if (!v) return null;
|
||||
const imported = original?.listing.status === 'imported';
|
||||
const currency = catalog?.store.currency_code ?? original?.listing.currency_code ?? 'CNY';
|
||||
const saleCny =
|
||||
params?.mode === 'fixed_sale' && currency === 'CNY'
|
||||
@@ -454,33 +480,30 @@ export function SkuPricing({
|
||||
<div className="sku-panel-heading">
|
||||
<h5>定价</h5>
|
||||
<div className="sku-panel-actions">
|
||||
{!imported && (
|
||||
<Button
|
||||
loading={busy}
|
||||
type="primary"
|
||||
onClick={() => void calculateFromDefault()}
|
||||
>
|
||||
重新试算
|
||||
</Button>
|
||||
)}
|
||||
{!imported && (
|
||||
<Button
|
||||
disabled={!catalog || !params}
|
||||
onClick={() => {
|
||||
restoreInheritance.current = false;
|
||||
setModal(
|
||||
skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
params!
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
试算详情
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
loading={busy}
|
||||
type="primary"
|
||||
onClick={() => confirmPriceChange(calculateFromDefault)}
|
||||
>
|
||||
重新试算
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!catalog || !params}
|
||||
onClick={() => {
|
||||
restoreInheritance.current = false;
|
||||
setModal(
|
||||
skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
params!,
|
||||
pricingStore
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
试算详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sku-pricing-fields">
|
||||
@@ -502,7 +525,7 @@ export function SkuPricing({
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
confirmPriceChange(() => calculate(true));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -562,6 +585,7 @@ export function SkuPricing({
|
||||
<span>{feedback}</span>
|
||||
</div>
|
||||
<div className="sku-pricing-meta">
|
||||
{imported && <span>{localPriceNotice}</span>}
|
||||
<span>
|
||||
{policyName}
|
||||
{option ? ` · ${option.transport_name}` : ''}
|
||||
@@ -631,10 +655,12 @@ export function SkuPricing({
|
||||
previous={result?.option ?? original?.listing.pricing_result}
|
||||
catalog={catalog}
|
||||
onCancel={() => setModal(null)}
|
||||
onApply={(input, p, id) => {
|
||||
apply(input, p, id, true);
|
||||
setModal(null);
|
||||
}}
|
||||
onApply={(input, p, id) =>
|
||||
confirmPriceChange(() => {
|
||||
apply(input, p, id, true);
|
||||
setModal(null);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -231,6 +231,10 @@
|
||||
.editor-short-input.ant-input {
|
||||
width: 200px;
|
||||
}
|
||||
.editor-supply-url .ant-btn {
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* 「采集信息变化」弹窗内容(页面 modal 渲染) */
|
||||
.source-diff-list {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
@@ -41,6 +41,7 @@ import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
|
||||
import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer';
|
||||
import type { SkuEditorImage } from './components/SkuImageCard';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
|
||||
import { composeSkuOfferId, lockedSkuOfferIds, skuOfferSuffix, syncSkuOfferIds } from './skuOfferId';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
@@ -455,10 +456,13 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
|
||||
*/
|
||||
const ProductEditPage = () => {
|
||||
const { id: productId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const collectionItemId = searchParams.get('collection_item_id');
|
||||
const { currentPlatform } = usePlatform();
|
||||
const { message, modal } = AntdApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<EditorFormValues>();
|
||||
const offerModelRef = useRef<string | null>(null);
|
||||
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
|
||||
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
|
||||
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
|
||||
@@ -520,6 +524,9 @@ const ProductEditPage = () => {
|
||||
const [skuCopyLoading, setSkuCopyLoading] = useState(false);
|
||||
const [skuCopyResult, setSkuCopyResult] = useState<AiCopyOutput | null>(null);
|
||||
const [skuCopyError, setSkuCopyError] = useState<string | null>(null);
|
||||
const [skuCopyRequirements, setSkuCopyRequirements] = useState<Record<string, string>>({});
|
||||
const skuCopyTaskId = useRef<string | null>(null);
|
||||
const skuCopyRequestId = useRef(0);
|
||||
const blocker = useBlocker(dirty);
|
||||
const sourceSpecsApplied = useRef(false);
|
||||
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
||||
@@ -555,26 +562,31 @@ const ProductEditPage = () => {
|
||||
if (!productId || !currentPlatform) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await productEditorApi.read(productId, currentPlatform.id);
|
||||
const next = await productEditorApi.read(productId, currentPlatform.id, collectionItemId);
|
||||
setSnapshot(next);
|
||||
setCollectionRecord(null);
|
||||
setSourceDraft(next.source_material);
|
||||
setSourceOverriddenFields(next.source_material_state.overridden_fields);
|
||||
sourceSpecsApplied.current = false;
|
||||
form.setFieldsValue(formValues(next, currentPlatform.code));
|
||||
const values = formValues(next, currentPlatform.code);
|
||||
const variants = syncSkuOfferIds(
|
||||
values.variants, values.common.model, values.common.model, lockedSkuOfferIds(next)
|
||||
);
|
||||
offerModelRef.current = values.common.model;
|
||||
form.setFieldsValue({ ...values, variants });
|
||||
setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant');
|
||||
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.profile.title_mode === 'variant');
|
||||
setCopyTranslations(next.copy_translations);
|
||||
setDirty(false);
|
||||
setDirty(variants.some((variant, index) => variant.offer_id !== values.variants[index].offer_id));
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPlatform, form, message, productId]);
|
||||
}, [collectionItemId, currentPlatform, form, message, productId]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -906,6 +918,10 @@ const ProductEditPage = () => {
|
||||
message.error(invalidPackage);
|
||||
return false;
|
||||
}
|
||||
if (draft.variants.some((variant) => (variant.offer_id?.length ?? 0) > 50)) {
|
||||
message.error('完整货号(含型号前缀)最多 50 字符,请缩短型号或 SKU 后缀');
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
@@ -1029,7 +1045,8 @@ const ProductEditPage = () => {
|
||||
const values = form.getFieldsValue(true) as EditorFormValues;
|
||||
const profile = { ...values.profile };
|
||||
const common = { ...values.common };
|
||||
const variants = [...values.variants];
|
||||
let variants = [...values.variants];
|
||||
const lockedOffers = lockedSkuOfferIds(snapshot);
|
||||
if (result.category && !profile.platform_product_category_id) {
|
||||
profile.platform_product_category_id = result.category.category_id;
|
||||
profile.category_source = 'ai_suggestion';
|
||||
@@ -1060,9 +1077,16 @@ const ProductEditPage = () => {
|
||||
}
|
||||
for (const offer of result.offers ?? []) {
|
||||
const index = variants.findIndex((variant) => variant.id === offer.variant_id);
|
||||
if (index >= 0 && !variants[index].offer_id)
|
||||
variants[index] = { ...variants[index], offer_id: offer.offer_id };
|
||||
if (index >= 0 && !variants[index].offer_id && !lockedOffers.has(offer.variant_id))
|
||||
variants[index] = {
|
||||
...variants[index],
|
||||
offer_id: composeSkuOfferId(
|
||||
values.common.model, skuOfferSuffix(offer.offer_id, result.model ?? common.model)
|
||||
),
|
||||
};
|
||||
}
|
||||
variants = syncSkuOfferIds(variants, values.common.model, common.model, lockedOffers);
|
||||
offerModelRef.current = common.model;
|
||||
const copy = result.spu_copy;
|
||||
if (copy?.title && profile.title_mode !== 'variant' && !profile.title) {
|
||||
profile.title = copy.title.ru;
|
||||
@@ -1118,36 +1142,49 @@ const ProductEditPage = () => {
|
||||
};
|
||||
const generateSkuCopy = async () => {
|
||||
if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return;
|
||||
const variantId = skuCopyVariantId;
|
||||
const hint = (skuCopyRequirements[variantId] ?? '').trim();
|
||||
const requestId = ++skuCopyRequestId.current;
|
||||
setSkuCopyLoading(true);
|
||||
setSkuCopyError(null);
|
||||
setSkuCopyResult(null);
|
||||
skuCopyTaskId.current = null;
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
if (requestId !== skuCopyRequestId.current) return;
|
||||
// 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。
|
||||
const formAtStart = JSON.stringify(
|
||||
normalizedValues(form.getFieldsValue(true) as EditorFormValues)
|
||||
);
|
||||
const task = await productEditorApi.aiSkuCopy(
|
||||
snapshot.profile.id,
|
||||
skuCopyVariantId,
|
||||
currentPlatform.id
|
||||
variantId,
|
||||
currentPlatform.id,
|
||||
hint ? { hint } : {}
|
||||
);
|
||||
if (requestId !== skuCopyRequestId.current) return;
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
if (requestId !== skuCopyRequestId.current) return;
|
||||
if (completed.status === 'failed' || !completed.result)
|
||||
throw new Error(completed.error_message ?? 'SKU 文案生成失败');
|
||||
if ((completed.result as ProductAiSkuCopyResult).variant_id !== variantId)
|
||||
throw new Error('生成结果与当前 SKU 不匹配,请重新生成');
|
||||
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
if (requestId !== skuCopyRequestId.current) return;
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
latest.source_material.source_snapshot_revision !== completed.input_revision.source_snapshot ||
|
||||
JSON.stringify(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart
|
||||
)
|
||||
throw new Error('商品已被其他操作更新,请重新生成');
|
||||
skuCopyTaskId.current = completed.id;
|
||||
setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy);
|
||||
} catch (error) {
|
||||
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
if (requestId === skuCopyRequestId.current)
|
||||
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
} finally {
|
||||
setSkuCopyLoading(false);
|
||||
if (requestId === skuCopyRequestId.current) setSkuCopyLoading(false);
|
||||
}
|
||||
};
|
||||
const applySkuCopy = () => {
|
||||
@@ -1156,12 +1193,29 @@ const ProductEditPage = () => {
|
||||
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
|
||||
if (index < 0) return;
|
||||
const copy = skuCopyResult;
|
||||
if (copy.title && !usesVariantTitle) changeTitleMode('variant');
|
||||
const switchesTitleMode = !!copy.title && !usesVariantTitle;
|
||||
const inheritedTitle = switchesTitleMode
|
||||
? form.getFieldValue(['profile', 'title']) as string | null
|
||||
: null;
|
||||
if (switchesTitleMode) {
|
||||
const inheritedTranslation = copyTranslations.find((item) =>
|
||||
item.variant_id === null && item.field === 'title' && item.russian_value === inheritedTitle
|
||||
);
|
||||
changeTitleMode('variant');
|
||||
// 只替换当前 SKU;其他 SKU 原先继承的标题与对照在模式切换后继续保留。
|
||||
if (inheritedTranslation) {
|
||||
const inheritedVariants = variants.filter((variant, current) => current !== index && variant.id && !variant.title);
|
||||
setCopyTranslations((previous) => [
|
||||
...previous.filter((item) => item.field !== 'title' || !inheritedVariants.some((variant) => variant.id === item.variant_id)),
|
||||
...inheritedVariants.map((variant) => ({ ...inheritedTranslation, variant_id: variant.id })),
|
||||
]);
|
||||
}
|
||||
}
|
||||
form.setFieldValue(
|
||||
'variants',
|
||||
variants.map((variant, current) =>
|
||||
current !== index
|
||||
? variant
|
||||
? inheritedTitle && !variant.title ? { ...variant, title: inheritedTitle } : variant
|
||||
: {
|
||||
...variant,
|
||||
title: copy.title?.ru ?? variant.title,
|
||||
@@ -1179,7 +1233,15 @@ const ProductEditPage = () => {
|
||||
setSkuCopyVariantId(null);
|
||||
message.success('已应用到当前 SKU,保存草稿后生效');
|
||||
};
|
||||
const skuCopyTaskId = useRef<string | null>(null);
|
||||
const closeSkuCopy = () => {
|
||||
// 关闭后仍在运行的任务可以完成,但不能写入后来打开的另一个 SKU 弹窗。
|
||||
skuCopyRequestId.current += 1;
|
||||
skuCopyTaskId.current = null;
|
||||
setSkuCopyVariantId(null);
|
||||
setSkuCopyLoading(false);
|
||||
setSkuCopyResult(null);
|
||||
setSkuCopyError(null);
|
||||
};
|
||||
const validSupplyUrl =
|
||||
typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null;
|
||||
const canOpenSupplyUrl = !!validSupplyUrl;
|
||||
@@ -1448,7 +1510,17 @@ const ProductEditPage = () => {
|
||||
wrapperCol={{ flex: '0 1 680px' }}
|
||||
onFinish={save}
|
||||
requiredMark
|
||||
onValuesChange={() => setDirty(true)}
|
||||
onValuesChange={(changed: Partial<EditorFormValues>) => {
|
||||
if (changed.common && 'model' in changed.common) {
|
||||
// 收起的 SKU 没有挂载字段,必须从完整表单读取,避免同步时丢失其资料。
|
||||
const values = form.getFieldsValue(true) as EditorFormValues;
|
||||
form.setFieldValue('variants', syncSkuOfferIds(
|
||||
values.variants, offerModelRef.current, values.common.model, lockedSkuOfferIds(snapshot)
|
||||
));
|
||||
offerModelRef.current = values.common.model;
|
||||
}
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<header className="editor-header">
|
||||
<Button
|
||||
@@ -1569,6 +1641,8 @@ const ProductEditPage = () => {
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseCommonTitle={useCommonTitle}
|
||||
onAiCopy={(variantId) => {
|
||||
skuCopyRequestId.current += 1;
|
||||
setSkuCopyLoading(false);
|
||||
setSkuCopyVariantId(variantId);
|
||||
setSkuCopyResult(null);
|
||||
setSkuCopyError(null);
|
||||
@@ -1677,12 +1751,20 @@ const ProductEditPage = () => {
|
||||
.map((item) => `${item.axis_key}: ${item.value_name}`)
|
||||
.join(';') ?? ''
|
||||
}
|
||||
requirements={skuCopyVariantId ? skuCopyRequirements[skuCopyVariantId] ?? '' : ''}
|
||||
loading={skuCopyLoading}
|
||||
result={skuCopyResult}
|
||||
error={skuCopyError}
|
||||
onGenerate={() => void generateSkuCopy()}
|
||||
onRequirementsChange={(value) => {
|
||||
if (!skuCopyVariantId || skuCopyLoading) return;
|
||||
setSkuCopyRequirements((previous) => ({ ...previous, [skuCopyVariantId]: value }));
|
||||
setSkuCopyResult(null);
|
||||
setSkuCopyError(null);
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onApply={applySkuCopy}
|
||||
onClose={() => setSkuCopyVariantId(null)}
|
||||
onClose={closeSkuCopy}
|
||||
/>
|
||||
<Modal
|
||||
className="ai-category-modal"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, InputNumber, Select, Tooltip, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
||||
import { isOzonProductColorAttribute as isProductColor, productAttributeValueRules } from '@atlas/contracts';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
@@ -10,7 +11,6 @@ import type {
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
const productColorValueLimit = 100;
|
||||
|
||||
/**
|
||||
* Ozon 商品颜色字典(attribute_id=10096)的 value_id → SVG 色值与俄文颜色名。
|
||||
@@ -75,11 +75,6 @@ const ozonProductColorByValueId: Record<string, { color: string | null; russianN
|
||||
'61574': { color: '#000000', russianName: 'чёрный' },
|
||||
};
|
||||
|
||||
/** 商品颜色允许一个 SKU 同时选择多个颜色,作为类目单值配置的业务例外。 */
|
||||
function isProductColor(definition: ProductAttributeSchema) {
|
||||
return definition.name.trim() === '商品颜色';
|
||||
}
|
||||
|
||||
/** 颜色展示严格按 Ozon 字典值 ID 取色,未知 ID 使用中性色以避免根据名称臆测。 */
|
||||
function colorSwatch(valueId: string) {
|
||||
const definition = ozonProductColorByValueId[valueId];
|
||||
@@ -171,9 +166,31 @@ function DynamicAttributeField({
|
||||
onAttributeChanged: Props['onAttributeChanged'];
|
||||
}) {
|
||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||
const [colorLabels, setColorLabels] = useState<{
|
||||
attributeId: string;
|
||||
values: Record<string, string>;
|
||||
}>({ attributeId: definition.attribute_id, values: {} });
|
||||
const onLoadRef = useRef(onLoadDictionaryValues);
|
||||
onLoadRef.current = onLoadDictionaryValues;
|
||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||
onSyncRef.current = onSyncDictionaryValues;
|
||||
useEffect(() => setDictionaryQuery(''), [definition.attribute_id]);
|
||||
useEffect(() => {
|
||||
if (isProductColor(definition) && definition.dictionary_id)
|
||||
onLoadRef.current(definition.attribute_id, '');
|
||||
}, [definition.attribute_id, definition.dictionary_id, definition.platform_attribute_id]);
|
||||
useEffect(() => {
|
||||
if (!isProductColor(definition)) return;
|
||||
// 搜索结果会替换候选页,已选颜色名称必须独立保留,且不能沿用其他类目的字典。
|
||||
setColorLabels(current => ({
|
||||
attributeId: definition.attribute_id,
|
||||
values: {
|
||||
...(current.attributeId === definition.attribute_id ? current.values : {}),
|
||||
...Object.fromEntries((dictionaryValues[definition.attribute_id] ?? [])
|
||||
.map(value => [value.value_id, value.value])),
|
||||
},
|
||||
}));
|
||||
}, [definition.attribute_id, definition.platform_attribute_id, dictionaryValues]);
|
||||
useEffect(() => {
|
||||
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
|
||||
const timer = window.setTimeout(
|
||||
@@ -203,14 +220,22 @@ function DynamicAttributeField({
|
||||
onAttributeChanged?.();
|
||||
};
|
||||
const lastColorSelection = useRef<string | null>(null);
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
|
||||
const { allowsMultipleValues, maxValueCount } = productAttributeValueRules(definition);
|
||||
const isRequired =
|
||||
definition.is_required || forceRequiredNames?.includes(definition.name.trim()) === true;
|
||||
const maxValueCount = isProductColor(definition)
|
||||
? productColorValueLimit
|
||||
: definition.max_value_count;
|
||||
const currentDictionaryValues = dictionaryValues[definition.attribute_id] ?? [];
|
||||
const displayDictionaryValues = isProductColor(definition)
|
||||
? [...currentDictionaryValues, ...attributeValues
|
||||
.filter(valueId => !currentDictionaryValues.some(value => value.value_id === valueId))
|
||||
.map(valueId => ({
|
||||
value_id: valueId,
|
||||
value: (colorLabels.attributeId === definition.attribute_id ? colorLabels.values[valueId] : undefined)
|
||||
?? ozonProductColorByValueId[valueId]?.russianName
|
||||
?? '颜色名称待加载',
|
||||
}))]
|
||||
: currentDictionaryValues;
|
||||
const dictionaryOptions = definition.dictionary_id
|
||||
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
|
||||
? displayDictionaryValues.map((value) => ({
|
||||
value: value.value_id,
|
||||
label: isProductColor(definition) ? (
|
||||
<ProductColorOption valueId={value.value_id} value={value.value} />
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ImageSuiteReferencePicker({
|
||||
null
|
||||
);
|
||||
const upload = async (file: File): Promise<SkuEditorImage> => {
|
||||
const asset = await uploadProductImage(file),
|
||||
const asset = await uploadProductImage(file, productId),
|
||||
access = await mediaApi.createPreviewUrl(asset.id);
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
@@ -50,7 +50,7 @@ export function ImageSuiteReferencePicker({
|
||||
origin: 'upload',
|
||||
};
|
||||
};
|
||||
const library = useSkuImageLibrary(open, variantId, draft, upload);
|
||||
const library = useSkuImageLibrary(open, productId, variantId, draft, upload);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(selected);
|
||||
@@ -58,7 +58,7 @@ export function ImageSuiteReferencePicker({
|
||||
setPreview(null);
|
||||
dragIndex.current = null;
|
||||
}
|
||||
}, [open, variantId]);
|
||||
}, [open, productId, variantId]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import { CopyTextButton } from '@/components/CopyTextButton';
|
||||
import './ProductAttributeSection.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
@@ -136,6 +137,7 @@ export function ProductAttributeSection({
|
||||
<Form.Item name={['profile', 'description']} noStyle>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
<CopyTextButton label="复制通用产品描述" getText={() => form.getFieldValue(['profile', 'description'])} />
|
||||
{!usesVariantDescription && (
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -37,6 +37,65 @@
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.sku-copy-ai-context {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 4px 0 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sku-copy-ai-requirements {
|
||||
padding: 16px;
|
||||
border: 1px solid #e7ddff;
|
||||
border-radius: 12px;
|
||||
background: #faf8ff;
|
||||
}
|
||||
.sku-copy-ai-requirements-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-copy-ai-help {
|
||||
color: #77718a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.sku-copy-ai-requirements .ant-input-textarea-show-count {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.sku-copy-ai-reference {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sku-copy-ai-result-field {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #eeeaf5;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sku-copy-ai-language {
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.sku-copy-ai-result-field p:not(.sku-copy-ai-language) {
|
||||
margin: 0;
|
||||
}
|
||||
.sku-copy-ai-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-copy-ai-modal .ant-modal-body {
|
||||
max-height: 68vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.copy-comparison-head,
|
||||
.copy-comparison-row,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Alert, Button, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { Alert, Button, Collapse, Input, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { PRODUCT_AI_HINT_MAX_LENGTH } from '@atlas/contracts';
|
||||
import type { AiCopyOutput, ProductCopyField } from '@atlas/contracts';
|
||||
import './ProductCopyAiDialogs.css';
|
||||
import { CopyTextButton } from '@/components/CopyTextButton';
|
||||
|
||||
type ComparisonRow = {
|
||||
field: ProductCopyField;
|
||||
@@ -52,11 +54,13 @@ export function ProductCopyComparisonModal({
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.inherited && <Tag color="blue">继承 SPU</Tag>}
|
||||
<div className="copy-comparison-text">{row.russian || '—'}</div>
|
||||
{row.field === 'description' && <CopyTextButton label="复制俄文产品描述" getText={() => row.russian} />}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.stale && <Tag color="orange">对照待更新</Tag>}
|
||||
<div className="copy-comparison-text">{row.chinese || '暂无中文对照'}</div>
|
||||
{row.field === 'description' && <CopyTextButton label="复制中文产品描述" getText={() => row.chinese} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -71,10 +75,12 @@ export function SkuCopyAiModal({
|
||||
sourceTitle,
|
||||
sourceDescription,
|
||||
specifications,
|
||||
requirements,
|
||||
loading,
|
||||
result,
|
||||
error,
|
||||
onGenerate,
|
||||
onRequirementsChange,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
@@ -83,46 +89,81 @@ export function SkuCopyAiModal({
|
||||
sourceTitle: string;
|
||||
sourceDescription: string;
|
||||
specifications: string;
|
||||
requirements: string;
|
||||
loading: boolean;
|
||||
result: AiCopyOutput | null;
|
||||
error: string | null;
|
||||
onGenerate: () => void;
|
||||
onRequirementsChange: (value: string) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`AI 生成当前 SKU 文案 · ${variantName}`}
|
||||
title="AI 生成当前 SKU 文案"
|
||||
className="sku-copy-ai-modal"
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button onClick={onGenerate} loading={loading}>
|
||||
AI 生成
|
||||
</Button>
|
||||
<Button type="primary" disabled={!result || loading} onClick={onApply}>
|
||||
应用到当前 SKU
|
||||
</Button>
|
||||
</Space>
|
||||
<div className="sku-copy-ai-footer">
|
||||
<Typography.Text type="secondary">仅应用到当前 SKU,保存草稿后生效。</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button type={result ? 'default' : 'primary'} onClick={onGenerate} loading={loading}>
|
||||
{loading ? '正在生成' : result ? '重新生成' : 'AI 生成'}
|
||||
</Button>
|
||||
<Button type="primary" disabled={!result || loading} onClick={onApply}>
|
||||
应用到当前 SKU
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="sku-copy-ai-context">
|
||||
<Tag color="blue">当前 SKU</Tag>
|
||||
<Typography.Text strong>{variantName}</Typography.Text>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert type="warning" showIcon message={error} className="sku-copy-ai-error" />
|
||||
)}
|
||||
<div className="sku-copy-ai-layout">
|
||||
<div>
|
||||
<Typography.Title level={5}>中文来源与 SKU 信息</Typography.Title>
|
||||
<Typography.Text strong>SKU 名称</Typography.Text>
|
||||
<p>{variantName}</p>
|
||||
<Typography.Text strong>来源标题</Typography.Text>
|
||||
<p>{sourceTitle || '未采集'}</p>
|
||||
<Typography.Text strong>来源描述</Typography.Text>
|
||||
<p className="sku-copy-ai-source">{sourceDescription || '未采集'}</p>
|
||||
<Typography.Text strong>SKU 规格</Typography.Text>
|
||||
<p>{specifications || '未采集'}</p>
|
||||
<div className="sku-copy-ai-requirements">
|
||||
<div className="sku-copy-ai-requirements-heading">
|
||||
<label htmlFor="sku-copy-requirements">用户描述 / 生成要求</label>
|
||||
<Tag color="purple">优先采用</Tag>
|
||||
</div>
|
||||
<p className="sku-copy-ai-help">你的描述与要求将优先采用,采集资料仅用于补充。</p>
|
||||
<Input.TextArea
|
||||
id="sku-copy-requirements"
|
||||
aria-label="用户描述 / 生成要求"
|
||||
value={requirements}
|
||||
disabled={loading}
|
||||
maxLength={PRODUCT_AI_HINT_MAX_LENGTH}
|
||||
showCount
|
||||
autoSize={{ minRows: 6, maxRows: 10 }}
|
||||
placeholder="例如:这是红色款,高度 25cm;标题突出收藏用途,不要出现赠品和包邮。描述用简洁俄文,分点说明。"
|
||||
onChange={(event) => onRequirementsChange(event.target.value)}
|
||||
/>
|
||||
<p className="sku-copy-ai-help">可补充或修正商品信息,指定卖点、写作风格和禁用词。留空则依据当前 SKU 与采集资料生成。</p>
|
||||
</div>
|
||||
<Collapse
|
||||
className="sku-copy-ai-reference"
|
||||
items={[{
|
||||
key: 'source',
|
||||
label: '采集资料与 SKU 信息 · 辅助参考',
|
||||
children: <>
|
||||
<Typography.Text strong>来源标题</Typography.Text>
|
||||
<p>{sourceTitle || '未采集'}</p>
|
||||
<Typography.Text strong>来源描述</Typography.Text>
|
||||
<p className="sku-copy-ai-source">{sourceDescription || '未采集'}</p>
|
||||
<Typography.Text strong>SKU 规格</Typography.Text>
|
||||
<p>{specifications || '未采集'}</p>
|
||||
</>,
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>当前 SKU 的生成结果</Typography.Title>
|
||||
@@ -133,22 +174,26 @@ export function SkuCopyAiModal({
|
||||
)}
|
||||
{!loading && !result && (
|
||||
<Typography.Text type="secondary">
|
||||
点击下方「AI 生成」后在此预览。
|
||||
填写描述或要求后点击「AI 生成」,在此查看俄文文案与中文对照。
|
||||
</Typography.Text>
|
||||
)}
|
||||
{result && (
|
||||
<div className="sku-copy-ai-result">
|
||||
<Typography.Text strong>标题</Typography.Text>
|
||||
<p>{result.title?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.title?.zh || ''}</p>
|
||||
<Typography.Text strong>主题标签</Typography.Text>
|
||||
<p>{result.tags?.map((tag) => tag.ru).join('、') || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">
|
||||
{result.tags?.map((tag) => tag.zh).join('、') || ''}
|
||||
</p>
|
||||
<Typography.Text strong>产品描述</Typography.Text>
|
||||
<p>{result.description?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.description?.zh || ''}</p>
|
||||
{[
|
||||
{ field: 'title', ru: result.title?.ru, zh: result.title?.zh },
|
||||
{ field: 'tags', ru: result.tags?.map((tag) => tag.ru).join(' '), zh: result.tags?.map((tag) => tag.zh).join('、') },
|
||||
{ field: 'description', ru: result.description?.ru, zh: result.description?.zh },
|
||||
].map((row) => (
|
||||
<div className="sku-copy-ai-result-field" key={row.field}>
|
||||
<Typography.Text strong>{labels[row.field as ProductCopyField]}</Typography.Text>
|
||||
<p className="sku-copy-ai-language">俄文</p>
|
||||
<p>{row.ru || '未生成'}</p>
|
||||
{row.field === 'description' && <CopyTextButton label="复制生成的俄文产品描述" getText={() => row.ru} />}
|
||||
<p className="sku-copy-ai-language">中文对照</p>
|
||||
<p className="sku-copy-ai-zh">{row.zh || '未生成'}</p>
|
||||
{row.field === 'description' && <CopyTextButton label="复制生成的中文产品描述" getText={() => row.zh} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
||||
</div>
|
||||
<div className="editor-form-grid">
|
||||
<Form.Item label="货源链接" className="span-two">
|
||||
<Space.Compact block>
|
||||
<Space.Compact block className="editor-supply-url">
|
||||
<Form.Item
|
||||
name={['common', 'purchase_url']}
|
||||
noStyle
|
||||
@@ -32,6 +32,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
||||
<Tooltip title="打开货源链接">
|
||||
<Button
|
||||
style={{ minWidth: 40 }}
|
||||
aria-label="打开货源链接"
|
||||
icon={<ExportOutlined />}
|
||||
disabled={!canOpenSupplyUrl}
|
||||
onClick={onOpenSupplyUrl}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LoadingOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
@@ -43,6 +44,15 @@ const jobLabels: Record<string, string> = {
|
||||
reconciling: '等待对账',
|
||||
};
|
||||
|
||||
function activeJobLabel(job: ProductPublishJob) {
|
||||
if (job.status !== 'running') return jobLabels[job.status];
|
||||
if (job.items.some((item) => item.status === 'reconciling')) return '等待对账';
|
||||
if (job.items.some((item) => item.status === 'submitting')) return '提交中';
|
||||
if (job.items.some((item) => item.status === 'polling')) return '等待 Ozon';
|
||||
if (job.items.some((item) => item.status === 'queued')) return '排队中';
|
||||
return jobLabels.running;
|
||||
}
|
||||
|
||||
/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */
|
||||
export function ProductPublishActions({
|
||||
snapshot,
|
||||
@@ -218,6 +228,13 @@ export function ProductPublishActions({
|
||||
: statusTone === 'progress'
|
||||
? <ClockCircleOutlined />
|
||||
: <ExclamationCircleOutlined />;
|
||||
const activeJob = job && !terminal(job) ? job : null;
|
||||
const progressLabel = activeJob ? activeJobLabel(activeJob) : null;
|
||||
const showJob = () => {
|
||||
setStage('result');
|
||||
setOpen(true);
|
||||
setError('');
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */}
|
||||
@@ -225,11 +242,7 @@ export function ProductPublishActions({
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-publish-status editor-publish-status--${statusTone}`}
|
||||
onClick={() => {
|
||||
setStage('result');
|
||||
setOpen(true);
|
||||
setError('');
|
||||
}}
|
||||
onClick={showJob}
|
||||
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
|
||||
>
|
||||
<span className="editor-publish-status-icon" aria-hidden="true">
|
||||
@@ -259,10 +272,26 @@ export function ProductPublishActions({
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void start(true)}
|
||||
disabled={busy || (!activeJob && disabled)}
|
||||
icon={
|
||||
activeJob
|
||||
? progressLabel === '等待对账'
|
||||
? <ExclamationCircleOutlined />
|
||||
: progressLabel === '排队中'
|
||||
? <ClockCircleOutlined />
|
||||
: <LoadingOutlined spin />
|
||||
: undefined
|
||||
}
|
||||
title={activeJob ? `${activeJob.item_count} 个 SKU 正在上架,点击查看详情` : undefined}
|
||||
onClick={() => {
|
||||
// 进行中的按钮作为详情入口,避免用户把同一任务再次提交。
|
||||
if (activeJob) showJob();
|
||||
else void start(true);
|
||||
}}
|
||||
>
|
||||
部分上架
|
||||
<span aria-live="polite">
|
||||
{progressLabel ? `部分上架 · ${progressLabel}` : '部分上架'}
|
||||
</span>
|
||||
</Button>
|
||||
</Space>
|
||||
<Modal
|
||||
|
||||
@@ -646,3 +646,28 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sku-info-basic-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sku-info-offer-id .ant-input-group-addon {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
|
||||
.sku-info-offer-id .ant-input {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.sku-offer-prefix {
|
||||
display: inline-block;
|
||||
max-width: min(180px, 16cqw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -542,6 +542,7 @@ export function ProductVariantSection({
|
||||
form={form}
|
||||
fieldName={field.name}
|
||||
sourceId={sourceId}
|
||||
offerIdLocked={['imported', 'submitting'].includes(original?.listing.status ?? '')}
|
||||
usesVariantPackageWeight={
|
||||
usesVariantPackageWeight
|
||||
}
|
||||
@@ -651,8 +652,17 @@ export function ProductVariantSection({
|
||||
onImagesChanged();
|
||||
setImageEditorTarget(null);
|
||||
}}
|
||||
onImageDeleted={(image) => {
|
||||
const variants = form.getFieldValue('variants') as VariantValues[];
|
||||
if (image.assetId) variants.forEach((variant, index) => {
|
||||
form.setFieldValue(['variants', index, 'media_asset_ids'], (variant.media_asset_ids ?? []).filter(id => id !== image.assetId));
|
||||
});
|
||||
setLocalImagesByVariant(current => Object.fromEntries(Object.entries(current).map(([key, items]) =>
|
||||
[key, items.filter(item => item.id !== image.id && (!image.assetId || item.assetId !== image.assetId))])));
|
||||
onImagesChanged();
|
||||
}}
|
||||
onUploadFile={async (file) => {
|
||||
const asset = await uploadProductImage(file);
|
||||
const asset = await uploadProductImage(file, snapshot.product.id);
|
||||
const url = URL.createObjectURL(file);
|
||||
localObjectUrls.current.push(url);
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
|
||||
import { App as AntdApp, Alert, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { CollectionItemInspection, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
|
||||
import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { mediaApi } from '@/services/media';
|
||||
import { watermarksApi } from '@/services/watermarks';
|
||||
@@ -31,6 +31,7 @@ type Props = {
|
||||
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
|
||||
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
|
||||
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
|
||||
onImageDeleted?: (image: SkuEditorImage) => void;
|
||||
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -56,11 +57,27 @@ export function SkuImageEditorModal({
|
||||
onApplyWatermark,
|
||||
onResolveAsset,
|
||||
onDownloadImage,
|
||||
onImageDeleted,
|
||||
}: Props) {
|
||||
const { message } = AntdApp.useApp();
|
||||
const { message, modal } = AntdApp.useApp();
|
||||
const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
|
||||
const [markedIds, setMarkedIds] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [deletionJobs, setDeletionJobs] = useState<ProductImageDeletion[]>([]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setDeletionJobs([]);
|
||||
const refresh = () => void mediaApi.listPendingImageDeletions(productId)
|
||||
.then(jobs => { if (active) setDeletionJobs(jobs); }).catch(() => undefined);
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 4000);
|
||||
return () => { active = false; window.clearInterval(interval); };
|
||||
}, [open, productId]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [removedImages, setRemovedImages] = useState<SkuEditorImage[]>([]);
|
||||
const deleteDialog = useRef<{ destroy: () => void } | null>(null);
|
||||
useEffect(() => { setRemovedImages([]); }, [productId]);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [processingThreeFour, setProcessingThreeFour] = useState(false);
|
||||
const [processingWatermark, setProcessingWatermark] = useState(false);
|
||||
@@ -92,6 +109,7 @@ export function SkuImageEditorModal({
|
||||
const editorSession = useRef(0);
|
||||
const downloadLock = useRef(false);
|
||||
const {
|
||||
removeAsset,
|
||||
networkUrls,
|
||||
setNetworkUrls,
|
||||
networkAdding,
|
||||
@@ -101,7 +119,7 @@ export function SkuImageEditorModal({
|
||||
uploadFile,
|
||||
uploadToRight,
|
||||
addNetworkImages,
|
||||
} = useSkuImageLibrary(open, skuKey, images, onUploadFile);
|
||||
} = useSkuImageLibrary(open, productId, skuKey, images, onUploadFile);
|
||||
const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>(
|
||||
null
|
||||
);
|
||||
@@ -122,10 +140,12 @@ export function SkuImageEditorModal({
|
||||
return () => {
|
||||
if (editorSession.current === session) {
|
||||
editorSession.current += 1;
|
||||
deleteDialog.current?.destroy();
|
||||
deleteDialog.current = null;
|
||||
defaultWatermarkRequest.current = null;
|
||||
}
|
||||
};
|
||||
}, [open, skuKey]);
|
||||
}, [open, productId, skuKey]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
@@ -394,7 +414,54 @@ export function SkuImageEditorModal({
|
||||
{ key: 'delete', label: '移出已选', danger: true },
|
||||
],
|
||||
};
|
||||
const imageMenu = (image: SkuEditorImage): MenuProps => ({
|
||||
const requestImageDelete = (image: SkuEditorImage) => {
|
||||
const session = editorSession.current;
|
||||
let deletionId: string | null = null;
|
||||
deleteDialog.current = modal.confirm({
|
||||
title: '删除这张图片?',
|
||||
content: '删除后将从当前商品图片库和已选区域移除,七牛中已存储的图片文件也会永久删除。此操作无法撤销。',
|
||||
okText: '删除', cancelText: '取消', okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
if (session !== editorSession.current) return;
|
||||
processingLock.current = true; setDeleting(true);
|
||||
try {
|
||||
let result = deletionId
|
||||
? await mediaApi.retryImageDeletion(productId, deletionId)
|
||||
: await mediaApi.deleteProductImage(productId, image.assetId ? { asset_id: image.assetId } : { collection_image_id: image.transferId });
|
||||
deletionId = result.id;
|
||||
if (session === editorSession.current) {
|
||||
setRemovedImages(current => [...current, image]);
|
||||
setImages(current => current.filter(item => !sameImage(item, image)));
|
||||
setMarkedIds(current => current.filter(id => id !== image.id));
|
||||
if (image.assetId) {
|
||||
removeAsset(image.assetId);
|
||||
setAiImages(current => current.filter(item => item.assetId !== image.assetId));
|
||||
setProcessedRecords(current => current.filter(item => item.output_asset.id !== image.assetId));
|
||||
}
|
||||
setPreview(null);
|
||||
setWatermarkUndo(current => Object.fromEntries(Object.entries(current).filter(([id, value]) =>
|
||||
id !== image.id && (!image.assetId || value?.assetId !== image.assetId))));
|
||||
onImageDeleted?.(image);
|
||||
}
|
||||
for (let attempt = 0; result.status === 'queued' && attempt < 60; attempt += 1) {
|
||||
await new Promise<void>(resolve => window.setTimeout(resolve, 1000));
|
||||
result = await mediaApi.readImageDeletion(productId, result.id);
|
||||
}
|
||||
if (result.status === 'failed') throw new Error('七牛图片清理失败,请点击删除按钮重试');
|
||||
if (session === editorSession.current) {
|
||||
if (result.status === 'succeeded') message.success('图片已删除,存储文件已清理');
|
||||
else message.info('图片删除已提交,存储文件仍在清理中');
|
||||
}
|
||||
} catch (error) {
|
||||
if (session === editorSession.current) message.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
} finally {
|
||||
if (session === editorSession.current) { processingLock.current = false; setDeleting(false); }
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const imageMenu = (image: SkuEditorImage, canDelete = false): MenuProps => ({
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
if (processingLock.current) return;
|
||||
@@ -404,6 +471,7 @@ export function SkuImageEditorModal({
|
||||
else if (key === 'watermark') void addWatermark([image]);
|
||||
else if (key === 'AI 编辑') setAiTarget(image);
|
||||
else if (key === 'undo_watermark') undoWatermark(image);
|
||||
else if (key === 'delete_image') requestImageDelete(image);
|
||||
else pendingAction(String(key));
|
||||
},
|
||||
items: [
|
||||
@@ -413,10 +481,12 @@ export function SkuImageEditorModal({
|
||||
{ key: 'three_four', label: '一键 3:4' },
|
||||
{ key: 'cover', label: '设为封图' },
|
||||
{ key: 'download', label: '下载' },
|
||||
...(canDelete ? [{ type: 'divider' as const }, { key: 'delete_image', label: '删除', danger: true }] : []),
|
||||
],
|
||||
});
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files || processingLock.current) return;
|
||||
const session = editorSession.current;
|
||||
const available = MAX_IMAGES - images.length;
|
||||
const accepted = Array.from(files).filter((file) => {
|
||||
const error = uploadFileError(file);
|
||||
@@ -425,14 +495,17 @@ export function SkuImageEditorModal({
|
||||
});
|
||||
if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
|
||||
for (const file of accepted.slice(0, available)) {
|
||||
if (session !== editorSession.current) return;
|
||||
try {
|
||||
const image = await uploadFile(file);
|
||||
if (session !== editorSession.current) return;
|
||||
setImages((current) =>
|
||||
current.some((item) => item.assetId === image.assetId)
|
||||
? current
|
||||
: [...current, image]
|
||||
);
|
||||
} catch (error) {
|
||||
if (session !== editorSession.current) return;
|
||||
message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -440,7 +513,7 @@ export function SkuImageEditorModal({
|
||||
};
|
||||
const sourceImages = (role: string): SkuEditorImage[] =>
|
||||
collectionImages
|
||||
.filter((image) => image.role === role)
|
||||
.filter((image) => image.role === role && !removedImages.some(removed => removed.transferId === image.id || (removed.assetId && removed.assetId === image.asset_id)))
|
||||
.map((image) => ({
|
||||
id: `collection:${image.id}`,
|
||||
url: (image.asset_id && previewUrls[image.asset_id]) ?? image.source_url,
|
||||
@@ -456,8 +529,9 @@ export function SkuImageEditorModal({
|
||||
}));
|
||||
const showPreview = (items: SkuEditorImage[], index: number) =>
|
||||
setPreview({ images: items, index });
|
||||
const renderSourceImages = (items: SkuEditorImage[]) =>
|
||||
items.length ? (
|
||||
const renderSourceImages = (candidates: SkuEditorImage[]) => {
|
||||
const items = candidates.filter(image => !removedImages.some(removed => sameImage(removed, image)));
|
||||
return items.length ? (
|
||||
<div className="sku-image-source-grid">
|
||||
{items.map((image, index) => {
|
||||
const selected = images.some((item) => sameImage(item, image));
|
||||
@@ -467,7 +541,7 @@ export function SkuImageEditorModal({
|
||||
image={image}
|
||||
mode="source"
|
||||
selected={selected}
|
||||
menu={imageMenu(image)}
|
||||
menu={imageMenu(image, true)}
|
||||
onToggle={() => addImage(image)}
|
||||
onPreview={() => showPreview(items, index)}
|
||||
/>
|
||||
@@ -477,7 +551,9 @@ export function SkuImageEditorModal({
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
|
||||
);
|
||||
const processedImages = processedRecords.map((record): SkuEditorImage => ({
|
||||
};
|
||||
const visibleProcessedRecords = processedRecords.filter(record => !removedImages.some(image => image.assetId === record.output_asset.id));
|
||||
const processedImages = visibleProcessedRecords.map((record): SkuEditorImage => ({
|
||||
id: `asset:${record.output_asset.id}`,
|
||||
url: record.output_asset.preview_url,
|
||||
label: record.kind === 'watermark' ? '水印处理图' : '3:4 处理图',
|
||||
@@ -489,16 +565,16 @@ export function SkuImageEditorModal({
|
||||
if (processedError) return <Empty description="图片处理记录加载失败">
|
||||
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
|
||||
</Empty>;
|
||||
if (!processedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
|
||||
if (!visibleProcessedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
|
||||
return <div className="sku-image-source-grid">
|
||||
{processedRecords.map((record, index) => {
|
||||
{visibleProcessedRecords.map((record, index) => {
|
||||
const image = processedImages[index];
|
||||
return <div className="sku-image-processed-entry" key={record.id}>
|
||||
<SkuImageCard
|
||||
image={image}
|
||||
mode="source"
|
||||
selected={images.some((item) => sameImage(item, image))}
|
||||
menu={imageMenu(image)}
|
||||
menu={imageMenu(image, true)}
|
||||
onToggle={() => addImage(image)}
|
||||
onPreview={() => showPreview(processedImages, index)}
|
||||
/>
|
||||
@@ -535,19 +611,19 @@ export function SkuImageEditorModal({
|
||||
}
|
||||
width="min(1480px, 96vw)"
|
||||
onCancel={() => {
|
||||
if (!confirming && !processingThreeFour && !processingWatermark) onCancel();
|
||||
if (!confirming && !deleting && !processingThreeFour && !processingWatermark) onCancel();
|
||||
}}
|
||||
closable={!confirming && !processingThreeFour && !processingWatermark}
|
||||
maskClosable={!confirming && !processingThreeFour && !processingWatermark}
|
||||
closable={!confirming && !deleting && !processingThreeFour && !processingWatermark}
|
||||
maskClosable={!confirming && !deleting && !processingThreeFour && !processingWatermark}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel} disabled={confirming || processingThreeFour || processingWatermark}>
|
||||
<Button key="cancel" onClick={onCancel} disabled={confirming || deleting || processingThreeFour || processingWatermark}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={confirming}
|
||||
disabled={uploading || processingThreeFour || processingWatermark}
|
||||
disabled={uploading || deleting || processingThreeFour || processingWatermark}
|
||||
onClick={() => void confirmSelection()}
|
||||
>
|
||||
确认
|
||||
@@ -555,6 +631,14 @@ export function SkuImageEditorModal({
|
||||
]}
|
||||
centered
|
||||
>
|
||||
{deletionJobs.length > 0 && <Alert type={deletionJobs.some(job => job.status === 'failed') ? 'warning' : 'info'}
|
||||
title={deletionJobs.some(job => job.status === 'failed') ? '部分图片的存储文件清理失败,可重试' : '图片存储文件正在清理中'}
|
||||
action={<>{deletionJobs.filter(job => job.status === 'failed').map(job => <Button key={job.id} size="small" onClick={async () => {
|
||||
try {
|
||||
const result = await mediaApi.retryImageDeletion(productId, job.id);
|
||||
setDeletionJobs(current => current.map(item => item.id === job.id ? result : item));
|
||||
} catch (error) { message.error(apiErrorMessage(error)); }
|
||||
}}>重试清理</Button>)}</>} style={{ marginBottom: 12 }} />}
|
||||
<div className="sku-image-editor-columns">
|
||||
<section className="sku-image-editor-left" aria-label="已选 SKU 图片">
|
||||
<div className="sku-image-editor-toolbar">
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
} from '@atlas/contracts';
|
||||
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
import { CopyTextButton } from '@/components/CopyTextButton';
|
||||
import { SkuOfferIdInput } from './SkuOfferIdInput';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type NamePath = Array<string | number>;
|
||||
@@ -16,6 +18,7 @@ type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
fieldName: number;
|
||||
sourceId: string | null;
|
||||
offerIdLocked: boolean;
|
||||
usesVariantPackageWeight: boolean;
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseCommonPackageWeight: () => void;
|
||||
@@ -62,6 +65,7 @@ export function SkuInfoPanel({
|
||||
form,
|
||||
fieldName,
|
||||
sourceId,
|
||||
offerIdLocked,
|
||||
usesVariantPackageWeight,
|
||||
usesVariantPackageDimensions,
|
||||
onUseCommonPackageWeight,
|
||||
@@ -86,6 +90,7 @@ export function SkuInfoPanel({
|
||||
const profile = Form.useWatch('profile', { form, preserve: true }) as
|
||||
EditorFormValues['profile'] | undefined;
|
||||
const ownTags = Form.useWatch(['variants', fieldName, 'tags'], { form, preserve: true }) ?? [];
|
||||
const model = Form.useWatch(['common', 'model'], { form, preserve: true });
|
||||
const tagsProblem = ozonHashtagProblem(
|
||||
usesVariantTags && ownTags.length ? ownTags : profile?.tags ?? []
|
||||
);
|
||||
@@ -95,17 +100,21 @@ export function SkuInfoPanel({
|
||||
<h5>规格与发布信息</h5>
|
||||
</div>
|
||||
<div className="sku-info-group-title">SKU 基础资料</div>
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[fieldName, 'offer_id']}
|
||||
label="货号"
|
||||
layout="vertical"
|
||||
rules={[{ max: 50, message: '货号最多 50 字符' }]}
|
||||
>
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
<div className="sku-info-basic-row">
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[fieldName, 'offer_id']}
|
||||
label="货号"
|
||||
className="sku-info-offer-id"
|
||||
layout="vertical"
|
||||
rules={[{ max: 50, message: '完整货号(含型号前缀)最多 50 字符' }]}
|
||||
extra={offerIdLocked ? '已上架或正在上架,货号已锁定' : '型号为自动前缀,修改型号时后缀保留;完整货号最多 50 字符。'}
|
||||
>
|
||||
<SkuOfferIdInput model={model} locked={offerIdLocked} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
|
||||
<>
|
||||
<div className="sku-info-group-title">包装规格</div>
|
||||
@@ -271,6 +280,12 @@ export function SkuInfoPanel({
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<CopyTextButton label="复制当前 SKU 产品描述" getText={() => {
|
||||
const own = usesVariantDescription
|
||||
? form.getFieldValue(['variants', fieldName, 'description']) as string | null | undefined
|
||||
: null;
|
||||
return own?.trim() ? own : form.getFieldValue(['profile', 'description']);
|
||||
}} />
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Input } from 'antd';
|
||||
import { composeSkuOfferId, skuOfferPrefix, skuOfferSuffix } from '../skuOfferId';
|
||||
|
||||
type Props = {
|
||||
value?: string | null;
|
||||
onChange?: (value: string | null) => void;
|
||||
model: string | null | undefined;
|
||||
locked: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** 控件仅编辑后缀,表单和现有 API 始终持有完整货号。 */
|
||||
export function SkuOfferIdInput({ value, onChange, model, locked, id }: Props) {
|
||||
if (locked) return <Input id={id} value={value ?? ''} readOnly aria-label="已锁定货号" />;
|
||||
const prefix = skuOfferPrefix(model);
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="货号后缀"
|
||||
addonBefore={<span className="sku-offer-prefix" title={prefix || '请先填写型号'}>{prefix || '待填写型号'}</span>}
|
||||
value={skuOfferSuffix(value, model)}
|
||||
maxLength={Math.max(0, 50 - prefix.length)}
|
||||
placeholder="只需输入 SKU 后缀"
|
||||
onChange={(event) => onChange?.(composeSkuOfferId(model, event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,10 @@ function networkImageUrl(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 工作空间候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
|
||||
/** 当前商品候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
|
||||
export function useSkuImageLibrary(
|
||||
open: boolean,
|
||||
productId: string,
|
||||
skuKey: string,
|
||||
selectedImages: SkuEditorImage[],
|
||||
onUploadFile: (file: File) => Promise<SkuEditorImage>
|
||||
@@ -38,22 +39,40 @@ export function useSkuImageLibrary(
|
||||
const [uploadedImages, setUploadedImages] = useState<SkuEditorImage[]>([]);
|
||||
const [uploadCount, setUploadCount] = useState(0);
|
||||
const fingerprints = useRef(new Set<string>());
|
||||
const removedIds = useRef(new Set<string>());
|
||||
const networkRequest = useRef(0);
|
||||
const scope = useRef({ productId, generation: 0 });
|
||||
const [libraryProductId, setLibraryProductId] = useState(productId);
|
||||
// 在商品切换的首帧就隔离旧结果,也防止快速切回同一商品时接收更早的请求。
|
||||
if (scope.current.productId !== productId) {
|
||||
scope.current = { productId, generation: scope.current.generation + 1 };
|
||||
}
|
||||
const generation = scope.current.generation;
|
||||
const isCurrentProduct = () => scope.current.generation === generation;
|
||||
|
||||
useEffect(() => {
|
||||
setLibraryProductId(productId);
|
||||
setNetworkImages([]);
|
||||
setUploadedImages([]);
|
||||
setUploadCount(0);
|
||||
fingerprints.current.clear();
|
||||
removedIds.current.clear();
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
networkRequest.current += 1;
|
||||
setNetworkUrls('');
|
||||
setNetworkAdding(false);
|
||||
}, [open, skuKey]);
|
||||
}, [open, productId, skuKey]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void mediaApi
|
||||
.listProductUploads()
|
||||
.listProductUploads(productId)
|
||||
.then((uploads) => {
|
||||
if (!active) return;
|
||||
const loaded = uploads.map((asset) => ({
|
||||
if (!active || !isCurrentProduct()) return;
|
||||
const loaded = uploads.filter(asset => !removedIds.current.has(asset.id)).map((asset) => ({
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: '上传图片',
|
||||
@@ -68,7 +87,7 @@ export function useSkuImageLibrary(
|
||||
]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
if (active && isCurrentProduct()) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
});
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 8 * 60_000);
|
||||
@@ -76,16 +95,16 @@ export function useSkuImageLibrary(
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [open, message]);
|
||||
}, [open, productId, message]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
const refresh = () =>
|
||||
void mediaApi
|
||||
.listProductImports()
|
||||
.listProductImports(productId)
|
||||
.then((imports) => {
|
||||
if (!active) return;
|
||||
const loaded = imports.map((asset) => ({
|
||||
if (!active || !isCurrentProduct()) return;
|
||||
const loaded = imports.filter(asset => !removedIds.current.has(asset.id)).map((asset) => ({
|
||||
id: `asset:${asset.id}`,
|
||||
url: asset.preview_url,
|
||||
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
|
||||
@@ -101,7 +120,7 @@ export function useSkuImageLibrary(
|
||||
]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
if (active && isCurrentProduct()) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
|
||||
});
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 8 * 60_000);
|
||||
@@ -109,14 +128,14 @@ export function useSkuImageLibrary(
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [open, message]);
|
||||
}, [open, productId, message]);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploadCount((count) => count + 1);
|
||||
try {
|
||||
return await onUploadFile(file);
|
||||
} finally {
|
||||
setUploadCount((count) => count - 1);
|
||||
if (isCurrentProduct()) setUploadCount((count) => Math.max(0, count - 1));
|
||||
}
|
||||
};
|
||||
const uploadToRight = async (file: File) => {
|
||||
@@ -133,12 +152,14 @@ export function useSkuImageLibrary(
|
||||
fingerprints.current.add(fingerprint);
|
||||
try {
|
||||
const image = await uploadFile(file);
|
||||
if (!isCurrentProduct()) return Upload.LIST_IGNORE;
|
||||
setUploadedImages((current) =>
|
||||
current.some((item) => item.assetId === image.assetId)
|
||||
? current
|
||||
: [image, ...current]
|
||||
);
|
||||
} catch (uploadError) {
|
||||
if (!isCurrentProduct()) return Upload.LIST_IGNORE;
|
||||
fingerprints.current.delete(fingerprint);
|
||||
message.error(`${file.name} 上传失败:${apiErrorMessage(uploadError)}`);
|
||||
}
|
||||
@@ -171,9 +192,9 @@ export function useSkuImageLibrary(
|
||||
}
|
||||
setNetworkAdding(true);
|
||||
const results = await Promise.allSettled(
|
||||
candidates.map((url) => mediaApi.importProductImage({ url }))
|
||||
candidates.map((url) => mediaApi.importProductImage({ url, product_id: productId }))
|
||||
);
|
||||
if (request !== networkRequest.current) return;
|
||||
if (request !== networkRequest.current || !isCurrentProduct()) return;
|
||||
const valid = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
);
|
||||
@@ -202,11 +223,17 @@ export function useSkuImageLibrary(
|
||||
};
|
||||
|
||||
return {
|
||||
removeAsset: (id: string) => {
|
||||
removedIds.current.add(id);
|
||||
setUploadedImages(current => current.filter(image => image.assetId !== id));
|
||||
setNetworkImages(current => current.filter(image => image.assetId !== id));
|
||||
fingerprints.current.clear();
|
||||
},
|
||||
networkUrls,
|
||||
setNetworkUrls,
|
||||
networkAdding,
|
||||
networkImages,
|
||||
uploadedImages,
|
||||
networkImages: libraryProductId === productId ? networkImages : [],
|
||||
uploadedImages: libraryProductId === productId ? uploadedImages : [],
|
||||
uploading: uploadCount > 0,
|
||||
uploadFile,
|
||||
uploadToRight,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
|
||||
export function skuOfferPrefix(model: string | null | undefined): string {
|
||||
const value = model?.trim();
|
||||
return value ? `${value}-` : '';
|
||||
}
|
||||
|
||||
/** 只移除明确匹配的型号前缀,旧自定义货号的内容完整保留为后缀。 */
|
||||
export function skuOfferSuffix(offerId: string | null | undefined, model: string | null | undefined): string {
|
||||
const value = offerId?.trim() ?? '';
|
||||
const prefix = skuOfferPrefix(model);
|
||||
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
export function composeSkuOfferId(model: string | null | undefined, suffix: string): string | null {
|
||||
const value = suffix.trim();
|
||||
return value ? `${skuOfferPrefix(model)}${value}` : null;
|
||||
}
|
||||
|
||||
export function lockedSkuOfferIds(snapshot: ProductEditorSnapshot | null): Set<string> {
|
||||
return new Set((snapshot?.variants ?? [])
|
||||
.filter((variant) => ['imported', 'submitting'].includes(variant.listing.status))
|
||||
.map((variant) => variant.id));
|
||||
}
|
||||
|
||||
/** 收起的 SKU 同样同步前缀;已导入或进行中的货号属于发布事实,不改写。 */
|
||||
export function syncSkuOfferIds<T extends { id: string | null; offer_id: string | null }>(
|
||||
variants: T[],
|
||||
previousModel: string | null | undefined,
|
||||
nextModel: string | null | undefined,
|
||||
lockedIds: ReadonlySet<string>,
|
||||
): T[] {
|
||||
return variants.map((variant) => {
|
||||
if (variant.id && lockedIds.has(variant.id)) return variant;
|
||||
const offerId = composeSkuOfferId(nextModel, skuOfferSuffix(variant.offer_id, previousModel));
|
||||
return offerId === variant.offer_id ? variant : { ...variant, offer_id: offerId };
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
CreateMediaUploadIntentRequest,
|
||||
MediaAccessUrl,
|
||||
MediaAsset,
|
||||
DeleteProductImageRequest,
|
||||
ProductImageDeletion,
|
||||
MediaUploadIntent,
|
||||
ProductUploadImage,
|
||||
ProductImportedImage,
|
||||
@@ -21,6 +23,13 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||
export const mediaApi = {
|
||||
listPendingImageDeletions: (productId: string) => api.get<ProductImageDeletion[]>(`/products/${productId}/image-deletions`),
|
||||
deleteProductImage: (productId: string, payload: DeleteProductImageRequest) =>
|
||||
api.post<ProductImageDeletion>(`/products/${productId}/image-deletions`, payload,
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||
readImageDeletion: (productId: string, id: string) => api.get<ProductImageDeletion>(`/products/${productId}/image-deletions/${id}`),
|
||||
retryImageDeletion: (productId: string, id: string) => api.post<ProductImageDeletion>(`/products/${productId}/image-deletions/${id}/retry`, {},
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
|
||||
api.post<MediaAiEdit>(`/media-assets/${assetId}/ai-edits`, payload,
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||
@@ -47,8 +56,8 @@ export const mediaApi = {
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
|
||||
readWatermarkDerivative: (derivativeId: string) =>
|
||||
api.get<WatermarkDerivative>(`/media-watermark-derivatives/${derivativeId}`),
|
||||
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
|
||||
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
|
||||
listProductUploads: (productId: string) => api.get<ProductUploadImage[]>(`/media-assets/product-uploads?product_id=${encodeURIComponent(productId)}`),
|
||||
listProductImports: (productId: string) => api.get<ProductImportedImage[]>(`/media-assets/product-imports?product_id=${encodeURIComponent(productId)}`),
|
||||
importProductImage: (payload: ImportProductImageRequest) =>
|
||||
api.post<ProductImportedImage>('/media-assets/product-imports', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
@@ -165,17 +174,19 @@ export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
|
||||
}
|
||||
|
||||
/** 商品图片沿用素材直传协议,完成服务端校验后才可写入 SKU 草稿。 */
|
||||
export async function uploadProductImage(file: File): Promise<MediaAsset> {
|
||||
return uploadImage(file, 'product_image');
|
||||
export async function uploadProductImage(file: File, productId: string): Promise<MediaAsset> {
|
||||
return uploadImage(file, 'product_image', productId);
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
file: File,
|
||||
purpose: CreateMediaUploadIntentRequest['purpose']
|
||||
purpose: CreateMediaUploadIntentRequest['purpose'],
|
||||
productId?: string
|
||||
): Promise<MediaAsset> {
|
||||
const checksumSha256 = await sha256Hex(file);
|
||||
const intent = await mediaApi.createUploadIntent({
|
||||
purpose,
|
||||
...(productId ? { product_id: productId } : {}),
|
||||
filename: file.name,
|
||||
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
||||
byte_size: file.size,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
ProductAiTask,
|
||||
RequestProductAiTask,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
ProductPublishJob,
|
||||
@@ -29,10 +30,21 @@ export const productEditorApi = {
|
||||
},
|
||||
}
|
||||
),
|
||||
read: (productId: string, platformId: string) =>
|
||||
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||
read: async (
|
||||
productId: string,
|
||||
platformId: string,
|
||||
collectionItemId?: string | null
|
||||
): Promise<ProductEditorSnapshot> => {
|
||||
// 插件首次进入时尚无平台配置,沿用采集箱的初始化流程并校验商品归属。
|
||||
if (collectionItemId) {
|
||||
const context = await productEditorApi.enter(collectionItemId, platformId);
|
||||
if (context.product_id !== productId)
|
||||
throw new Error('采集记录与商品不匹配,请从采集箱重新进入编辑页');
|
||||
}
|
||||
return api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}),
|
||||
});
|
||||
},
|
||||
aiAutofill: (profileId: string, platformId: string) =>
|
||||
api.post<{ ai_task_id: string; status: 'queued' }>(
|
||||
`/product-platform-profiles/${profileId}/ai/autofills`,
|
||||
@@ -44,10 +56,10 @@ export const productEditorApi = {
|
||||
},
|
||||
}
|
||||
),
|
||||
aiSkuCopy: (profileId: string, variantId: string, platformId: string) =>
|
||||
aiSkuCopy: (profileId: string, variantId: string, platformId: string, input: RequestProductAiTask = {}) =>
|
||||
api.post<{ ai_task_id: string; status: 'queued' }>(
|
||||
`/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`,
|
||||
{},
|
||||
input,
|
||||
{
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
|
||||
Reference in New Issue
Block a user