From 3156a7516f2e1ea6b1bcced3e3adfd39e727e53c Mon Sep 17 00:00:00 2001 From: R524809 Date: Tue, 15 Sep 2026 18:55:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E7=BC=96=E8=BE=91=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=89=8D=E7=AB=AF=E6=A0=B7=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/products/ProductEditPage.css | 637 +--------- .../src/pages/products/ProductEditPage.tsx | 1100 ++--------------- .../components/CollectedProductInfoPanel.css | 185 +++ .../components/CollectedProductInfoPanel.tsx | 109 +- .../components/ProductAttributeSection.css | 42 + .../components/ProductAttributeSection.tsx | 405 ++++++ .../components/ProductMainInfoSection.css | 52 + .../components/ProductMainInfoSection.tsx | 31 +- .../components/ProductOtherSection.tsx | 9 +- .../components/ProductVariantSection.css | 279 +++++ .../components/ProductVariantSection.tsx | 552 +++++++++ .../products/components/SectionTitle.css | 32 + .../products/components/SectionTitle.tsx | 26 + 13 files changed, 1815 insertions(+), 1644 deletions(-) create mode 100644 apps/web/src/pages/products/components/CollectedProductInfoPanel.css create mode 100644 apps/web/src/pages/products/components/ProductAttributeSection.css create mode 100644 apps/web/src/pages/products/components/ProductAttributeSection.tsx create mode 100644 apps/web/src/pages/products/components/ProductMainInfoSection.css create mode 100644 apps/web/src/pages/products/components/ProductVariantSection.css create mode 100644 apps/web/src/pages/products/components/ProductVariantSection.tsx create mode 100644 apps/web/src/pages/products/components/SectionTitle.css create mode 100644 apps/web/src/pages/products/components/SectionTitle.tsx diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index 619467f..3ef0fa5 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -1,8 +1,18 @@ +/* + * 商品编辑页骨架样式。 + * + * 页面级布局(页头 / 工作区 / 底部操作条)在此维护;各区块组件的专属样式 + * 位于 components/*.css 并由组件自行引入。以下两组是跨组件共享的基础样式: + * - .editor-section*:区块卡片外框(主要信息 / 产品属性 / 变体设置 / 其它共用) + * - .editor-form-grid / .editor-short-input:表单栅格与短输入框(多个区块共用) + */ .product-editor { max-width: 1500px; margin: 0 auto; padding-bottom: 92px; } + +/* 页头 */ .editor-header { margin: -4px 0 20px; } @@ -22,13 +32,6 @@ font-size: 24px; letter-spacing: -0.02em; } -.editor-eyebrow { - color: #7c3aed; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} .editor-header-context { display: flex; align-items: center; @@ -36,9 +39,11 @@ gap: 8px; white-space: nowrap; } + +/* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */ .editor-workspace { display: grid; - grid-template-columns: minmax(0, 1fr) 410px; + grid-template-columns: minmax(0, 1fr) 450px; align-items: start; gap: 20px; margin-top: 16px; @@ -49,6 +54,8 @@ flex-direction: column; gap: 16px; } + +/* 区块卡片公共外框 */ .editor-section.ant-card { border: 1px solid #e9e7ef; border-radius: 14px; @@ -57,38 +64,12 @@ .editor-section .ant-card-body { padding: 25px 28px; } -.editor-section-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - margin-bottom: 22px; -} -.editor-section-heading h4.ant-typography { - margin: 2px 0 3px; - font-size: 20px; -} -.editor-section-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 25px; - height: 25px; - margin-right: 9px; - border-radius: 7px; - background: #ede9fe; - color: #7c3aed; - font-size: 13px; - font-weight: 800; - vertical-align: 2px; -} -.attribute-item span, -.field-label { - display: block; - margin-bottom: 4px; - color: #77718a; - font-size: 12px; +.editor-section .ant-form-item-label > label { + color: #344054; + font-weight: 600; } + +/* 表单栅格(主要信息与其它区块使用) */ .editor-form-grid { display: grid; grid-template-columns: 1fr; @@ -100,423 +81,12 @@ .editor-form-grid .span-two { grid-column: span 2; } -.editor-section .ant-form-item-label > label { - color: #344054; - font-weight: 600; -} -.package-fields { - margin-top: 22px; - padding-top: 20px; - border-top: 1px solid #f0eef4; -} -.package-fields > .package-weight-field { - width: max-content; - max-width: 100%; -} -.package-fields > .package-weight-field .ant-input-group-wrapper { - width: 150px; -} -.package-fields > .package-weight-field .ant-input { - width: 100%; -} -.package-dimensions { - margin-bottom: 0; -} -.package-dimensions-controls { - display: grid; - grid-template-columns: 130px 22px 130px 22px 130px auto; - align-items: center; - gap: 7px; - width: max-content; -} -.package-dimension-separator { - color: #98a2b3; - font-size: 23px; - font-weight: 600; - text-align: center; -} -.package-dimension-unit { - color: #667085; - line-height: 32px; -} -.editor-category-popup .ant-cascader-menu { - /* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */ - height: 324px !important; - max-height: 324px; -} -.editor-category-popup .ant-cascader-menu-item { - display: flex; - align-items: center; -} -.supply-link-control .ant-btn { - min-width: 40px; -} -.category-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border: 1px solid #eae7f0; - border-radius: 10px; - background: #fcfbfd; -} -.category-row strong { - margin-right: 8px; -} -.attribute-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - margin-top: 14px; -} -.attribute-item { - min-width: 0; - padding: 11px 13px; - border: 1px solid #eeeaf3; - border-radius: 8px; -} -.attribute-item strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.category-select-footer { - display: flex; - justify-content: flex-end; - padding: 4px 8px 6px; - border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0); -} -.dynamic-attributes { - margin-top: 24px; - padding-top: 22px; - border-top: 1px solid #eeeaf4; -} -.dynamic-attributes h5.ant-typography { - margin: 0; - font-size: 16px; -} -.dynamic-attributes-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; -} -.dynamic-attributes-heading .ant-typography-secondary { - display: block; - margin-top: 4px; -} -.dynamic-attributes-heading .ant-btn { - flex: none; -} +/* 型号 / 品牌 / 条形码等短输入框 */ .editor-short-input.ant-input { width: 170px; } -.dynamic-attributes-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 132px; - color: #7a7485; -} -.dynamic-attribute-list { - display: grid; - grid-template-columns: 1fr; -} -.dynamic-attribute-item .ant-space-compact { - width: 100%; -} -.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text { - min-width: 56px; -} -.copy-subsection { - margin-top: 24px; - padding-top: 24px; - border-top: 1px solid #eeeaf4; -} -.copy-subsection .editor-section-heading { - margin-bottom: 16px; -} -.copy-store-row { - display: grid; - grid-template-columns: minmax(240px, 1fr) 170px; - gap: 16px; -} -.sku-section .ant-input { - border-radius: 8px; -} -.sku-section textarea.ant-input { - resize: none; -} -.sku-section .ant-input-group-addon { - border-radius: 0 8px 8px 0; -} -.sku-section .ant-input-group .ant-input { - border-radius: 8px 0 0 8px; -} -.sku-card-list { - display: flex; - flex-direction: column; - gap: 12px; -} -.sku-card.ant-card { - border: 1px solid #e8e4ee; - border-radius: 12px; - box-shadow: none; -} -.sku-card .ant-card-body { - padding: 16px 18px; -} -.sku-card-top { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 28px; -} -.sku-card-index { - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - border-radius: 50%; - background: #eee9fe; - color: #7151df; - font-weight: 700; -} -.sku-card-summary { - display: grid; - grid-template-columns: minmax(210px, 1.55fr) 58px minmax(132px, 1fr) 104px 112px 82px auto; - gap: 14px; - align-items: start; - margin-top: 12px; -} -.sku-card-summary .ant-form-item { - margin: 0; -} -.sku-card-field, -.sku-card-value { - min-width: 0; -} -.sku-main-thumbnail { - display: flex; - width: 42px; - aspect-ratio: 3 / 4; - align-items: center; - justify-content: center; - overflow: hidden; - border: 1px solid #ece8f0; - border-radius: 8px; - background: #f4f2f7; - color: #90889c; - font-size: 10px; - text-align: center; -} -.sku-main-thumbnail .ant-image, -.sku-main-thumbnail img { - width: 100%; - height: 100%; -} -.sku-main-thumbnail img { - object-fit: cover; -} -.sku-image-field { - justify-self: center; -} -.sku-card-field > span, -.sku-card-value > span { - display: block; - margin-bottom: 4px; - color: #77718a; - font-size: 12px; -} -.sku-card-value strong { - display: block; - overflow: hidden; - color: #40394f; - font-size: 14px; - text-overflow: ellipsis; - white-space: nowrap; -} -.sku-card-value strong { - min-height: 32px; - padding-top: 5px; - line-height: 22px; -} -.sku-expand-button { - align-self: start; - justify-self: end; - margin-top: 23px; -} -.sku-card-expanded { - display: grid; - grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr); - gap: 32px; - margin-top: 20px; - padding-top: 20px; - border-top: 1px solid #f0edf3; -} -.sku-media-panel h5.ant-typography, -.sku-spec-panel h5.ant-typography { - margin: 0 0 12px; -} -.sku-media-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - align-items: start; -} -.sku-media-secondary { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} -.sku-media-item { - position: relative; - min-width: 0; - aspect-ratio: 3 / 4; - overflow: hidden; - border: 1px solid #ece8f0; - border-radius: 8px; - background: #f5f3f7; -} -.sku-media-item .ant-btn { - position: absolute; - right: 6px; - bottom: 6px; - z-index: 1; -} -.sku-media-item.selected { - border: 2px solid #7c3aed; -} -.sku-media-item .ant-image, -.sku-media-item img { - width: 100%; - height: 100%; -} -.sku-media-item img { - object-fit: contain; -} -.sku-media-empty { - display: flex; - grid-column: 1 / -1; - min-height: 190px; - align-items: center; - justify-content: center; - border: 1px dashed #d9d4df; - border-radius: 8px; - color: #90889c; - font-size: 13px; -} -.sku-spec-panel { - padding: 18px 18px 6px; - border: 1px solid #eeeaf3; - border-radius: 10px; - background: #fcfbfd; -} -.sku-spec-panel .ant-form-item { - margin-bottom: 16px; -} -.sku-spec-panel .ant-form-item-label { - flex: 0 0 86px !important; -} -.sku-spec-panel .ant-form-item-label > label { - font-weight: 600; -} -.sku-spec-panel .ant-form-item-control { - min-width: 0; -} -.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper { - max-width: 220px; -} -.sku-inline-dimensions { - display: grid; - grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px; - gap: 6px; - align-items: center; -} -.sku-dimension-separator { - color: #98a2b3; - font-size: 16px; - font-weight: 600; - text-align: center; -} -.sku-dimension-unit { - color: #77718a; - font-size: 13px; - text-align: right; -} -.add-sku-row { - display: flex; - justify-content: center; - padding-top: 4px; -} -.add-sku-button { - min-width: 180px; -} -.editor-aside { - display: flex; - flex-direction: column; - gap: 16px; -} -.collected-info-panel.ant-card { - border: 1px solid #e9e7ef; - border-radius: 14px; - box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025); -} -.collected-info-panel .ant-card-body { - min-height: 820px; - padding: 20px; -} -.collected-info-panel h4.ant-typography { - margin: 0; -} -.collected-info-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 17px; -} -.collected-info-heading .ant-typography-secondary { - font-size: 12px; -} -.source-update-alert { - margin-bottom: 16px; -} -.source-update-alert .ant-alert-description { - font-size: 12px; -} -.collected-field { - margin-top: 16px; -} -.collected-field:first-of-type { - margin-top: 0; -} -.collected-field > span { - display: block; - margin-bottom: 5px; - color: #77718a; - font-size: 12px; -} -.collected-field-label { - display: flex; - min-height: 24px; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 5px; - color: #77718a; - font-size: 12px; -} -.collected-field-label .ant-btn { - height: 22px; - padding: 0; - font-size: 12px; -} + +/* 「采集信息变化」弹窗内容(页面 modal 渲染) */ .source-diff-list { display: flex; flex-direction: column; @@ -531,79 +101,8 @@ color: #667085; font-size: 13px; } -.collected-field > strong { - display: block; - color: #352f43; - line-height: 1.55; -} -.collected-field .ant-typography { - margin: 0; - white-space: pre-wrap; - color: #50485d; - font-size: 13px; - line-height: 1.65; -} -.collected-summary { - display: grid; - grid-template-columns: 1fr 1fr; - overflow: hidden; - border: 1px solid #ece9f1; - border-radius: 8px; -} -.collected-summary > div { - min-width: 0; - padding: 9px 10px; - border-right: 1px solid #f0edf3; - border-bottom: 1px solid #f0edf3; -} -.collected-summary > div:nth-child(2n) { - border-right: 0; -} -.collected-summary > div:nth-last-child(-n + 2) { - border-bottom: 0; -} -.collected-summary b { - display: block; - margin-bottom: 3px; - color: #77718a; - font-size: 12px; - font-weight: 500; -} -.collected-summary strong { - display: block; - overflow: hidden; - color: #352f43; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} -.collected-attributes { - border: 1px solid #ece9f1; - border-radius: 8px; -} -.collected-attributes > div { - display: grid; - grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr); - gap: 12px; - padding: 9px 10px; - border-bottom: 1px solid #f0edf3; - font-size: 12px; - line-height: 1.45; -} -.collected-attributes > div:last-child { - border-bottom: 0; -} -.collected-attributes b { - color: #6b6376; - font-weight: 600; -} -.collected-attributes span { - color: #352f43; - word-break: break-word; -} -.other-section .editor-section-heading { - margin-bottom: 8px; -} + +/* 底部操作条 */ .editor-footer { position: sticky; bottom: 0; @@ -623,23 +122,43 @@ .footer-ai-actions { display: flex; } + @media (max-width: 1180px) { + /* 窄屏:采集面板移到主编辑列下方 */ .editor-workspace { grid-template-columns: 1fr; } - .editor-aside { - position: static; +} +/* + * ≤700px:整张表单切换为纵向布局(标签在上、控件在下)。 + * 更宽时保持"标签 | 控件"同行,随宽度缩小优先压缩控件长度 + * (控件列 flex 已允许收缩,包装尺寸等输入组各自带 minmax 下限)。 + * labelCol/wrapperCol 以内联样式注入 flex,故此处需要 !important 覆盖。 + */ +@media (max-width: 700px) { + .product-editor .ant-form-item-row { + flex-direction: column; + row-gap: 4px; } - .collected-info-panel { - max-width: 100%; + .product-editor .ant-form-item-row > .ant-form-item-label { + flex: 0 0 auto !important; + max-width: 100% !important; + padding: 0; + text-align: left; } - .collected-info-panel .ant-card-body { - min-height: 0; + .product-editor .ant-form-item-row > .ant-form-item-control { + flex: 1 1 auto !important; + width: 100%; + max-width: 100% !important; + min-width: 0; + } + /* SKU 规格面板的固定标签宽在纵向布局下放开 */ + .sku-spec-panel .ant-form-item-label { + flex: 0 0 auto !important; } } @media (max-width: 860px) { .editor-header-main, - .editor-section-heading, .editor-footer { align-items: flex-start; flex-direction: column; @@ -650,66 +169,14 @@ .editor-form-grid .span-two { grid-column: span 1; } - .package-fields > .package-weight-field, - .package-dimensions-controls { - width: 100%; - } - .package-dimensions-controls { - grid-template-columns: 1fr; - } - .package-dimension-separator { - display: none; - } - .copy-store-row { - grid-template-columns: 1fr; - } - .attribute-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .sku-card-summary { - grid-template-columns: minmax(150px, 1fr) 54px repeat(2, minmax(115px, 1fr)) auto; - } - .sku-card-value { - display: none; - } - .sku-card-expanded { - grid-template-columns: 1fr; - } } @media (max-width: 560px) { .editor-section .ant-card-body { padding: 20px 16px; } - .category-row, .editor-footer { align-items: stretch; } - .category-row { - flex-direction: column; - } - .attribute-grid { - grid-template-columns: 1fr; - } - .sku-card-summary { - grid-template-columns: minmax(0, 1fr) 48px auto; - gap: 8px; - } - .sku-card-field { - display: none; - } - .sku-main-thumbnail { - width: 48px; - height: 48px; - } - .sku-media-grid { - grid-auto-rows: 72px; - } - .sku-inline-dimensions { - grid-template-columns: 1fr; - } - .sku-inline-dimensions > span { - display: none; - } .editor-footer .ant-space { display: flex; width: 100%; diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index f513309..7ba8444 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1,32 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useBlocker, useNavigate, useParams } from 'react-router-dom'; -import { - Alert, - App as AntdApp, - Button, - Card, - Cascader, - Empty, - Form, - Image, - Input, - Popconfirm, - Select, - Space, - Spin, - Tag, - Tooltip, - Typography, -} from 'antd'; +import { Alert, App as AntdApp, Button, Form, Space, Spin, Tag, Typography } from 'antd'; import { ArrowLeftOutlined, - CopyOutlined, - DeleteOutlined, - DownOutlined, - EditOutlined, - ExportOutlined, FileTextOutlined, - PlusOutlined, RobotOutlined, SaveOutlined, TagsOutlined, @@ -49,16 +26,19 @@ import { CollectedProductInfoPanel, type CollectedProductSummary, } from './components/CollectedProductInfoPanel'; +import { ProductAttributeSection } from './components/ProductAttributeSection'; import { ProductMainInfoSection } from './components/ProductMainInfoSection'; import { ProductOtherSection } from './components/ProductOtherSection'; +import { ProductVariantSection } from './components/ProductVariantSection'; import './ProductEditPage.css'; -type VariantValues = SaveProductEditorDraftRequest['variants'][number]; type EditorFormValues = SaveProductEditorDraftRequest; type AttributeDraft = NonNullable[number]; -type NamePath = Array; -type SnapshotVariant = ProductEditorSnapshot['variants'][number]; +/** + * 按 schema 补齐表单中缺失的属性草稿:aspect(变体维度)属性逐个 SKU 一条, + * 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。 + */ function missingAttributeDrafts( attributes: AttributeDraft[], schema: ProductAttributeSchema[], @@ -96,6 +76,7 @@ function missingAttributeDrafts( return missing; } +/** 服务端快照 → antd 表单初值;已有变体不携带 client_key(仅新建/复制时生成)。 */ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { return { ai_source_material: snapshot.source_material, @@ -159,6 +140,10 @@ type CategoryTreeOption = { children?: CategoryTreeOption[]; }; +/** + * 把扁平类目列表构建为 Cascader 树,并记录每个类目的祖先路径: + * Cascader 的受控值是"路径数组",提交时取末位作为类目 id。 + */ function categoryTree(options: ProductEditorCategoryOption[]) { const nodes = new Map(); const paths = new Map(); @@ -199,6 +184,7 @@ function categoryTree(options: ProductEditorCategoryOption[]) { return { options: roots, paths }; } +/** 保存前归一化:文本 trim、空串转 null、丢弃没有任何取值的属性草稿。 */ function normalizedValues(values: EditorFormValues): EditorFormValues { const physical = < T extends { @@ -274,30 +260,13 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { }; } -function statusTag(status: string) { - const label = - ( - { - not_listed: '未上架', - submitting: '处理中', - imported: '已上架', - failed: '上架失败', - } as Record - )[status] ?? status; - const color = ( - { imported: 'success', failed: 'error', submitting: 'processing' } as Record - )[status]; - return {label}; -} -function sourceValue(value: unknown): string { - if (typeof value === 'string') return value || '—'; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.map(sourceValue).join('、') || '—'; - return value ? JSON.stringify(value) : '—'; -} - +/** 采集属性的本地规范化形态(自由文本),用于摘要解析与规格自动填充。 */ type CollectedAttribute = { key: string; name: string; value: string }; +/** + * 从采集属性中解析侧栏摘要(价格/品牌/商品与包装的尺寸、重量)。 + * 来源是自由文本,按中英文关键词匹配(含包装词缀区分),并做单位换算。 + */ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSummary { const find = (matches: (label: string) => boolean) => facts.attributes.find((attribute) => @@ -334,6 +303,7 @@ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSumma }; } +/** 解析重量文本为克数(kg/千克/公斤 自动 ×1000);解析失败返回 null。 */ function numberInGrams(value: string): string | null { const match = value.replace(',', '.').match(/\d+(?:\.\d+)?/); if (!match) return null; @@ -342,6 +312,7 @@ function numberInGrams(value: string): string | null { return /(?:kg|千克|公斤)/i.test(value) ? String(amount * 1000) : String(amount); } +/** 解析"长×宽×高"文本为毫米三元组(cm/厘米 自动 ×10);不足三维或解析失败返回 null。 */ function millimetres(value: string): [string, string, string] | null { const parts = value.replace(',', '.').match(/\d+(?:\.\d+)?/g); if (!parts || parts.length < 3) return null; @@ -352,6 +323,7 @@ function millimetres(value: string): [string, string, string] | null { : null; } +/** 从采集属性中识别商品/包装各自的尺寸与重量字段,用于首次自动填充默认规格。 */ function detectedSpecs(attributes: CollectedAttribute[]) { const result: Record = {}; for (const attribute of attributes) { @@ -376,135 +348,35 @@ function detectedSpecs(attributes: CollectedAttribute[]) { return result; } -function variantImageCandidates( - recordValue: CollectionItemInspection | null, - variant: SnapshotVariant -) { - if (!recordValue) return []; - const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item])); - return recordValue.asset_transfers - .filter((transfer) => { - if (transfer.media_type !== 'image') return false; - if (!transfer.source_variant_id) return true; - return ( - sourceVariants.get(transfer.source_variant_id)?.source_variant_key === - variant.source_variant_key - ); - }) - .filter( - (transfer, index, transfers) => - transfers.findIndex((item) => item.id === transfer.id) === index - ); -} - -function PackageFields({ name }: { name: NamePath }) { - return ( -
- - - - -
- - - - × - - - - × - - - - mm -
-
-
- ); -} - -function InlineDimensions({ name }: { name: NamePath }) { - return ( -
- - - - × - - - - × - - - - mm -
- ); -} - -function SectionTitle({ - number, - title, - description, - extra, -}: { - number: string; - title: string; - description?: string; - extra?: React.ReactNode; -}) { - return ( -
-
- - {number && {number}} - {title} - - {description && {description}} -
- {extra} -
- ); -} - -/** 商品 AI 编辑的前端工作台;AI、动态 schema 与素材读取会在后续服务端任务接入。 */ +/** + * 商品编辑工作台页面。 + * + * 本组件承担全部数据编排:编辑器快照的加载与保存、类目缓存、类目属性 + * schema 与字典值、采集证据与素材转存;「主要信息 / 产品属性 / 变体设置 / + * 其它」四个区块与右侧采集信息面板均为展示组件,通过 props 接收数据与回调。 + */ const ProductEditPage = () => { const { id: productId } = useParams<{ id: string }>(); const { currentPlatform } = usePlatform(); const { message, modal } = AntdApp.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); + // 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。 const supplyUrl = Form.useWatch(['common', 'purchase_url'], form); const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form); const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form); - const draftVariants = Form.useWatch('variants', form) ?? []; + // 编辑器快照与采集来源:sourceDraft 是用户可能改写过的采集素材副本。 const [snapshot, setSnapshot] = useState(null); const [collectionRecord, setCollectionRecord] = useState(null); const [sourceDraft, setSourceDraft] = useState(null); const [sourceOverriddenFields, setSourceOverriddenFields] = useState( [] ); - const [richContent, setRichContent] = useState(''); + // 类目缓存:随店铺切换与目录同步(catalogRefresh)重新拉取。 const [categoryOptions, setCategoryOptions] = useState([]); const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false); const [categorySearch, setCategorySearch] = useState(''); + // 类目属性 schema 与字典值:仅在展开"更多特征"后按需加载。 const [attributeSchema, setAttributeSchema] = useState([]); const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState(null); const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState< @@ -515,14 +387,12 @@ const ProductEditPage = () => { const [dictionaryValues, setDictionaryValues] = useState< Record >({}); - const [dictionarySearches, setDictionarySearches] = useState>({}); const [catalogRefresh, setCatalogRefresh] = useState(0); const [catalogSyncing, setCatalogSyncing] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [materializingTransferId, setMaterializingTransferId] = useState(null); const [dirty, setDirty] = useState(false); - const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => new Set()); const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); @@ -534,6 +404,7 @@ const ProductEditPage = () => { : categorySearch.trim() ? '未找到匹配的类目' : '暂无可选择的类目'; + // 仅当 schema 属于当前所选类目时才可用,避免类目切换瞬间用旧 schema 渲染。 const activeAttributeSchema = useMemo( () => attributeSchemaCategoryId === selectedCategoryId @@ -542,6 +413,7 @@ const ProductEditPage = () => { [attributeSchema, attributeSchemaCategoryId, selectedCategoryId] ); + /** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */ const load = useCallback(async () => { if (!productId || !currentPlatform) return; setLoading(true); @@ -563,6 +435,7 @@ const ProductEditPage = () => { useEffect(() => { void load(); }, [load]); + // 类目缓存:切换店铺或目录同步完成后重新拉取。 useEffect(() => { if (!snapshot || !currentPlatform) return; let active = true; @@ -587,6 +460,8 @@ const ProductEditPage = () => { active = false; }; }, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]); + // 属性 schema:展开"更多特征"时才加载,同一类目与刷新版本只请求一次; + // 未选中类目时清空全部属性相关状态。 useEffect(() => { if (!snapshot || !currentPlatform || !selectedCategoryId) { setAttributeSchema([]); @@ -611,7 +486,6 @@ const ProductEditPage = () => { setAttributeSchema([]); setAttributeSchemaCategoryId(null); setDictionaryValues({}); - setDictionarySearches({}); void productEditorApi .attributeSchema( snapshot.profile.id, @@ -656,6 +530,7 @@ const ProductEditPage = () => { selectedStoreId, snapshot, ]); + // 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。 useEffect(() => { if (!snapshot || !currentPlatform || !selectedCategoryId) return; const dictionaryIds = [ @@ -722,6 +597,8 @@ const ProductEditPage = () => { }; }, [currentPlatform, snapshot]); const collected = sourceDraft ?? snapshot?.source_material ?? null; + // 首次拿到采集数据时自动填充默认规格:只补空字段,绝不覆盖已有值; + // sourceSpecsApplied 保证整个页面生命周期只执行一次。 useEffect(() => { if (sourceSpecsApplied.current || !collected) return; const specs = detectedSpecs(collected.attributes); @@ -785,6 +662,7 @@ const ProductEditPage = () => { }, [blocker, modal]); const leave = () => navigate('/collection'); + /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { if (!productId || !currentPlatform || !snapshot) return; setSaving(true); @@ -827,6 +705,7 @@ const ProductEditPage = () => { const openSupplyUrl = () => { if (validSupplyUrl) window.open(validSupplyUrl, '_blank', 'noopener,noreferrer'); }; + /** 编辑采集素材字段:与来源最新值比对,维护"已覆盖字段"列表以支持恢复。 */ const updateSourceField = ( field: Extract, value: string @@ -885,6 +764,7 @@ const ProductEditPage = () => { okText: '知道了', }); }; + /** 轮询目录同步 operation 直至终态(最多 20 次 × 1s);超时返回 null。 */ const waitForCatalogOperation = async (operationId: string) => { for (let attempt = 0; attempt < 20; attempt += 1) { if (attempt) await wait(1_000); @@ -893,6 +773,7 @@ const ProductEditPage = () => { } return null; }; + /** 发起一次 Ozon 目录同步并跟踪其异步 operation;成功后递增 catalogRefresh 触发缓存重拉。 */ const runCatalogSync = async ( syncKey: string, action: () => Promise<{ operation: { id: string } }>, @@ -943,9 +824,8 @@ const ProductEditPage = () => { ); setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items })); }; - const syncDictionaryValues = (dictionaryId: string) => { + const syncDictionaryValues = (dictionaryId: string, query: string) => { if (!snapshot || !currentPlatform || !selectedCategoryId) return; - const query = (dictionarySearches[dictionaryId] ?? '').trim(); if (query.length < 2) { message.info('请输入至少 2 个字符后再从 Ozon 搜索'); return; @@ -963,6 +843,7 @@ const ProductEditPage = () => { '字典值已同步' ).then(() => void loadDictionaryValues(dictionaryId, query)); }; + /** 把采集素材转存为可引用资产;完成后刷新采集证据,让对应 SKU 的图片立即可选。 */ const materializeCandidate = async (transferId: string) => { if (!snapshot) return; setMaterializingTransferId(transferId); @@ -1043,6 +924,7 @@ const ProductEditPage = () => { ))} + {/* 主编辑区:01-04 区块纵向排列,右侧为采集信息面板 */}
{ onCategorySearch={setCategorySearch} /> - - - - - - - setRichContent(event.target.value)} - /> - -
-
-
- 更多特征 - - 根据所选类目填写 Ozon 平台属性 - -
- -
- {moreFeaturesExpanded && - (!selectedCategoryId ? ( - - ) : attributeSchemaLoading ? ( -
- - 正在读取类目属性… -
- ) : !activeAttributeSchema.length ? ( - - - {attributeSchemaError ?? - '该类目的属性 schema 尚未同步'} - - - - } - /> - ) : ( - - {(fields) => ( -
- {activeAttributeSchema.flatMap((definition) => { - const scopes = definition.is_aspect - ? snapshot.variants.map((variant) => ({ - scope: 'variant' as const, - variantId: variant.id, - suffix: - variant.display_name || - '未命名 SKU', - })) - : [ - { - scope: 'common' as const, - variantId: null, - suffix: null, - }, - ]; - return scopes.map( - ({ scope, variantId, suffix }) => { - const field = fields.find( - (candidate) => { - const draft = - form.getFieldValue([ - 'attributes', - candidate.name, - ]) as - | AttributeDraft - | undefined; - return ( - draft?.scope === scope && - draft?.variant_id === - variantId && - draft?.platform_attribute_id === - definition.platform_attribute_id && - draft?.complex_id === - definition.complex_id - ); - } - ); - const label = suffix - ? `${definition.name} · ${suffix}` - : definition.name; - // Attribute rows are created together - // with the schema response. A transient - // render may still occur before Form.List - // receives the new field metadata. - if (!field) return null; - const dictionaryOptions = - definition.dictionary_id - ? ( - dictionaryValues[ - definition - .dictionary_id - ] ?? [] - ).map((value) => ({ - value: value.value_id, - label: value.value, - })) - : []; - const dictionaryQuery = - definition.dictionary_id - ? (dictionarySearches[ - definition.dictionary_id - ] ?? '') - : ''; - const valueName = [ - field.name, - 'values', - ] as NamePath; - const scalarValueName = [ - ...valueName, - 0, - ] as NamePath; - const valueRules = - definition.is_required - ? [ - { - validator: ( - _rule: unknown, - value: unknown - ) => { - const entries = - Array.isArray( - value - ) - ? value - : [value]; - return entries.some( - (entry) => - typeof entry === - 'string' && - entry.trim() - ) - ? Promise.resolve() - : Promise.reject( - new Error( - `请填写${definition.name}` - ) - ); - }, - }, - ] - : undefined; - const input = - definition.value_type === - 'dictionary' ? ( - - - - ) : definition.is_collection ? ( - - - - ); - return ( - - - {input} - - - - ); - } - ); - })} -
- )} -
- ))} -
- {snapshot.unmapped_attributes.length > 0 && ( - - `${attribute.original_name}:${sourceValue(attribute.values)}` - ) - .join(';')} - /> - )} -
+ setMoreFeaturesExpanded((expanded) => !expanded)} + activeAttributeSchema={activeAttributeSchema} + attributeSchemaLoading={attributeSchemaLoading} + attributeSchemaError={attributeSchemaError} + catalogSyncing={catalogSyncing} + dictionaryValues={dictionaryValues} + onLoadDictionaryValues={(dictionaryId, query) => + void loadDictionaryValues(dictionaryId, query) + } + onSyncDictionaryValues={syncDictionaryValues} + onSyncAttributeSchema={syncAttributeSchema} + /> - - {snapshot.variants.length} 个 SKU} - /> - - {(fields, { add, remove }) => { - const addVariant = () => - add({ - id: null, - client_key: crypto.randomUUID(), - copy_from_variant_id: null, - display_name: null, - source_sku: null, - specification_values: [], - purchase_cost_cny: null, - physical_spec: { - length_mm: null, - width_mm: null, - height_mm: null, - net_weight_g: null, - status: null, - }, - package_spec: { - length_mm: null, - width_mm: null, - height_mm: null, - shipping_weight_g: null, - status: null, - }, - offer_id: null, - media_asset_ids: [], - } satisfies VariantValues); - return ( -
- {fields.map((field, index) => { - const sourceId = form.getFieldValue([ - 'variants', - field.name, - 'id', - ]) as string | null; - const original = snapshot.variants.find( - (variant) => variant.id === sourceId - ); - const variantValue = form.getFieldValue([ - 'variants', - field.name, - ]) as VariantValues; - const expanded = expandedVariantKeys.has(field.key); - const imageCandidates = original - ? variantImageCandidates(collectionRecord, original) - : []; - const selectedAssetIds = - draftVariants[field.name]?.media_asset_ids ?? []; - const selectedImages = selectedAssetIds - .map( - (assetId) => - imageCandidates.find( - (candidate) => - candidate.asset_id === assetId - )?.source_url ?? - original?.media.find( - (media) => media.asset_id === assetId - )?.source_url - ) - .filter((url): url is string => !!url); - const toggleImage = (assetId: string) => { - const next = selectedAssetIds.includes(assetId) - ? selectedAssetIds.filter( - (id) => id !== assetId - ) - : [...selectedAssetIds, assetId]; - form.setFieldValue( - ['variants', field.name, 'media_asset_ids'], - next - ); - setDirty(true); - }; - const copyVariant = () => - add({ - ...variantValue, - id: null, - client_key: crypto.randomUUID(), - copy_from_variant_id: original?.id ?? null, - display_name: variantValue.display_name - ? `${variantValue.display_name} 副本` - : 'SKU 副本', - offer_id: null, - media_asset_ids: [], - }); - const toggle = () => - setExpandedVariantKeys((previous) => { - const next = new Set(previous); - if (next.has(field.key)) next.delete(field.key); - else next.add(field.key); - return next; - }); - return ( - - - - - -
- - {index + 1} - - - {original && - statusTag(original.listing.status)} - - {fields.length > 1 && ( - - remove(field.name) - } - > - - - )} - -
-
-
- SKU 名称 - - - -
-
- 主图 -
- {selectedImages[0] ? ( - SKU 主图 - ) : ( - 暂无主图 - )} -
-
-
- 货号 - - - -
-
- 采购价 - - - -
-
- 销售价 - -
-
- 净利润 - — -
- -
- {expanded && ( -
-
- - SKU 图片 - - {imageCandidates.length ? ( -
- {imageCandidates.map( - (candidate) => { - const selectable = - candidate.status === - 'succeeded' && - !!candidate.asset_id; - const selected = - selectable && - selectedAssetIds.includes( - candidate.asset_id! - ); - return ( -
- 采集候选图片 - {selectable ? ( - - ) : ( - - )} -
- ); - } - )} -
- ) : ( -
- 暂无采集图片 -
- )} -
-
- - SKU 规格 - - - - - - - - - - - - - - - {(specFields) => - specFields.length ? ( - <> - {specFields.map( - (specField) => { - const axisKey = - form.getFieldValue( - [ - 'variants', - field.name, - 'specification_values', - specField.name, - 'axis_key', - ] - ) as - | string - | undefined; - const label = - /color|颜色/i.test( - axisKey ?? - '' - ) - ? '商品颜色' - : axisKey || - '商品规格'; - return ( - - - - ); - } - )} - - ) : ( - - - - ) - } - -
-
- )} -
- ); - })} -
- -
-
- ); - }} -
-
+ setDirty(true)} + /> { span { + display: block; + margin-bottom: 5px; + color: #77718a; + font-size: 12px; +} +.collected-field-label { + display: flex; + min-height: 24px; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + color: #77718a; + font-size: 12px; +} +.collected-field-label .ant-btn { + height: 22px; + padding: 0; + font-size: 12px; +} +.collected-field > strong { + display: block; + color: #352f43; + line-height: 1.55; +} +.collected-field .ant-typography { + margin: 0; + white-space: pre-wrap; + color: #50485d; + font-size: 13px; + line-height: 1.65; +} + +/* 采集基础数据(两列网格摘要) */ +.collected-summary { + display: grid; + grid-template-columns: 1fr 1fr; + overflow: hidden; + border: 1px solid #ece9f1; + border-radius: 8px; +} +.collected-summary > div { + min-width: 0; + padding: 9px 10px; + border-right: 1px solid #f0edf3; + border-bottom: 1px solid #f0edf3; +} +.collected-summary > div:nth-child(2n) { + border-right: 0; +} +.collected-summary > div:nth-last-child(-n + 2) { + border-bottom: 0; +} +.collected-summary b { + display: block; + margin-bottom: 3px; + color: #77718a; + font-size: 12px; + font-weight: 500; +} +.collected-summary strong { + display: block; + overflow: hidden; + color: #352f43; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 规格 / 参数列表 */ +.collected-attributes { + border: 1px solid #ece9f1; + border-radius: 8px; +} +.collected-attributes > div { + display: grid; + grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr); + gap: 12px; + padding: 9px 10px; + border-bottom: 1px solid #f0edf3; + font-size: 12px; + line-height: 1.45; +} +.collected-attributes > div:last-child { + border-bottom: 0; +} +.collected-attributes b { + color: #6b6376; + font-weight: 600; +} +.collected-attributes span { + color: #352f43; + word-break: break-word; +} + +/* 采集图片:按 主图 / SKU 图 / 详情图 分组的折叠区域(缩略图点击可放大)。 + 标题与图片区左右内边距清零,与「采集图片」标签水平对齐。 + antd v6 自带的 header/body 内边距优先级更高,这里需要提高选择器权重覆盖。 */ +.collected-image-groups.ant-collapse .ant-collapse-item > .ant-collapse-header, +.collected-image-groups .ant-collapse-header { + padding: 0 !important; + color: #77718a; + font-size: 12px; +} +/* 展开箭头自带 4px 左边距,清零后箭头与「采集图片」标签左缘对齐 */ +.collected-image-groups .ant-collapse-header > .ant-collapse-expand-icon { + margin-inline-start: 0 !important; +} +.collected-image-groups.ant-collapse .ant-collapse-item > .ant-collapse-panel > .ant-collapse-body, +.collected-image-groups .ant-collapse-body { + padding: 2px 0 10px; +} +.collected-image-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} +.collected-image-grid .ant-image { + display: block; + overflow: hidden; + border: 1px solid #ece8f0; + border-radius: 6px; + background: #f5f3f7; +} +.collected-image-grid img { + display: block; + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; +} +@media (max-width: 1180px) { + /* 窄屏下面板退化为工作区下方整行,不再固定高度 */ + .editor-aside { + position: static; + } + .collected-info-panel { + max-width: 100%; + } + .collected-info-panel .ant-card-body { + min-height: 0; + } +} diff --git a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx index 4019f2a..98f3fbf 100644 --- a/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx @@ -1,9 +1,11 @@ -import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd'; +import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd'; import type { + CollectionItemInspection, ProductAiSourceField, ProductAiSourceMaterial, ProductEditorSnapshot, } from '@atlas/contracts'; +import './CollectedProductInfoPanel.css'; export type CollectedProductSummary = { price: string; @@ -17,17 +19,30 @@ export type CollectedProductSummary = { type Props = { snapshot: ProductEditorSnapshot; collected: ProductAiSourceMaterial | null; + collectionRecord: CollectionItemInspection | null; summary: CollectedProductSummary; overriddenFields: ProductAiSourceField[]; onRestore: (field: Extract) => void; - onUpdate: (field: Extract, value: string) => void; + onUpdate: ( + field: Extract, + value: string + ) => void; onShowDifferences: () => void; onAcceptLatest: () => void; }; +/** + * 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述, + * 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图 + * 分组折叠展示的采集图片(只读,点击可放大预览)。 + * + * 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口); + * 编辑、恢复、采用最新等动作均通过回调交由页面处理。 + */ export function CollectedProductInfoPanel({ snapshot, collected, + collectionRecord, summary, overriddenFields, onRestore, @@ -35,6 +50,27 @@ export function CollectedProductInfoPanel({ onShowDifferences, onAcceptLatest, }: Props) { + // 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在 + // 画廊与详情富文本里,转存记录会以不同角色各存一条)。 + const imageGroups = ( + [ + { key: 'main', label: '主图', role: 'source_main' }, + { key: 'sku', label: 'SKU 图', role: 'source_sku' }, + { key: 'detail', label: '详情图', role: 'source_detail' }, + ] as const + ) + .map(({ key, label, role }) => { + const seen = new Set(); + const items: CollectionItemInspection['asset_transfers'] = []; + for (const transfer of collectionRecord?.asset_transfers ?? []) { + if (transfer.media_type !== 'image' || transfer.role !== role) continue; + if (seen.has(transfer.source_url)) continue; + seen.add(transfer.source_url); + items.push(transfer); + } + return { key, label, items }; + }) + .filter((group) => group.items.length > 0); return (
- 第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集 + + 第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集 + {snapshot.source_material_state.has_source_updates && ( 采集基础数据
-
价格{summary.price}
-
品牌{summary.brand}
-
商品尺寸{summary.physicalDimensions}
-
商品重量{summary.physicalWeight}
-
包装尺寸{summary.packageDimensions}
-
包装重量{summary.packageWeight}
+
+ 价格 + {summary.price} +
+
+ 品牌 + {summary.brand} +
+
+ 商品尺寸 + {summary.physicalDimensions} +
+
+ 商品重量 + {summary.physicalWeight} +
+
+ 包装尺寸 + {summary.packageDimensions} +
+
+ 包装重量 + {summary.packageWeight} +
@@ -116,12 +172,43 @@ export function CollectedProductInfoPanel({
{collected.attributes.map((attribute) => (
- {attribute.name}{attribute.value} + {attribute.name} + {attribute.value}
))}
) : ( - 本次采集未取得规格或参数。 + + 本次采集未取得规格或参数。 + + )} +
+
+ 采集图片 + {imageGroups.length ? ( + ({ + key, + label: `${label}(${items.length})`, + children: ( +
+ {items.map((item) => ( + {label} + ))} +
+ ), + }))} + /> + ) : ( + 本次采集未取得图片。 )}
diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.css b/apps/web/src/pages/products/components/ProductAttributeSection.css new file mode 100644 index 0000000..0bfdd36 --- /dev/null +++ b/apps/web/src/pages/products/components/ProductAttributeSection.css @@ -0,0 +1,42 @@ +/* 「02 产品属性」区块:按所选类目动态渲染的平台属性表单("更多特征")。 */ +.dynamic-attributes { + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid #eeeaf4; +} +.dynamic-attributes h5.ant-typography { + margin: 0; + font-size: 16px; +} +.dynamic-attributes-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.dynamic-attributes-heading .ant-typography-secondary { + display: block; + margin-top: 4px; +} +.dynamic-attributes-heading .ant-btn { + flex: none; +} +.dynamic-attributes-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 132px; + color: #7a7485; +} +.dynamic-attribute-list { + display: grid; + grid-template-columns: 1fr; +} +.dynamic-attribute-item .ant-space-compact { + width: 100%; +} +.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text { + min-width: 56px; +} diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.tsx b/apps/web/src/pages/products/components/ProductAttributeSection.tsx new file mode 100644 index 0000000..830c144 --- /dev/null +++ b/apps/web/src/pages/products/components/ProductAttributeSection.tsx @@ -0,0 +1,405 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd'; +import type { FormInstance } from 'antd'; +import { EditOutlined } from '@ant-design/icons'; +import type { + ProductAttributeDictionaryValue, + ProductAttributeSchema, + ProductEditorSnapshot, + SaveProductEditorDraftRequest, +} from '@atlas/contracts'; +import { SectionTitle } from './SectionTitle'; +import './ProductAttributeSection.css'; + +type EditorFormValues = SaveProductEditorDraftRequest; +type AttributeDraft = NonNullable[number]; +type NamePath = Array; + +/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */ +function sourceValue(value: unknown): string { + if (typeof value === 'string') return value || '—'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(sourceValue).join('、') || '—'; + return value ? JSON.stringify(value) : '—'; +} + +type Props = { + form: FormInstance; + snapshot: ProductEditorSnapshot; + selectedCategoryId: string | null | undefined; + expanded: boolean; + onToggleExpanded: () => void; + activeAttributeSchema: ProductAttributeSchema[]; + attributeSchemaLoading: boolean; + attributeSchemaError: string | null; + catalogSyncing: string | null; + dictionaryValues: Record; + onLoadDictionaryValues: (dictionaryId: string, query: string) => void; + onSyncDictionaryValues: (dictionaryId: string, query: string) => void; + onSyncAttributeSchema: () => void; +}; + +/** + * 「02 产品属性」区块:主题标签、产品描述、条形码、JSON 富内容, + * 以及按所选类目动态渲染的平台属性表单("更多特征")。 + * + * schema、字典值与同步状态由页面拉取后经 props 传入;本组件只负责 + * 渲染与交互,字典搜索、Ozon 同步等动作通过回调交回页面处理。 + */ +export function ProductAttributeSection({ + form, + snapshot, + selectedCategoryId, + expanded, + onToggleExpanded, + activeAttributeSchema, + attributeSchemaLoading, + attributeSchemaError, + catalogSyncing, + dictionaryValues, + onLoadDictionaryValues, + onSyncDictionaryValues, + onSyncAttributeSchema, +}: Props) { + // 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。 + const [richContent, setRichContent] = useState(''); + // 各字典下拉框的当前搜索词(仅本地状态);切换类目时清空,避免残留到新类目。 + const [dictionarySearches, setDictionarySearches] = useState>({}); + useEffect(() => { + setDictionarySearches({}); + }, [selectedCategoryId]); + return ( + + + + + + + setRichContent(event.target.value)} + /> + +
+
+
+ 更多特征 + + 根据所选类目填写 Ozon 平台属性 + +
+ +
+ {expanded && + (!selectedCategoryId ? ( + + ) : attributeSchemaLoading ? ( +
+ + 正在读取类目属性… +
+ ) : !activeAttributeSchema.length ? ( + + + {attributeSchemaError ?? '该类目的属性 schema 尚未同步'} + + + + } + /> + ) : ( + + {(fields) => ( +
+ {activeAttributeSchema.flatMap((definition) => { + // aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。 + const scopes = definition.is_aspect + ? snapshot.variants.map((variant) => ({ + scope: 'variant' as const, + variantId: variant.id, + suffix: variant.display_name || '未命名 SKU', + })) + : [ + { + scope: 'common' as const, + variantId: null, + suffix: null, + }, + ]; + return scopes.map(({ scope, variantId, suffix }) => { + const field = fields.find((candidate) => { + const draft = form.getFieldValue([ + 'attributes', + candidate.name, + ]) as AttributeDraft | undefined; + return ( + draft?.scope === scope && + draft?.variant_id === variantId && + draft?.platform_attribute_id === + definition.platform_attribute_id && + draft?.complex_id === definition.complex_id + ); + }); + const label = suffix + ? `${definition.name} · ${suffix}` + : definition.name; + // Attribute rows are created together with + // the schema response. A transient render + // may still occur before Form.List receives + // the new field metadata. + if (!field) return null; + // 字典属性:选项来自页面预取的字典缓存;搜索时经 + // onLoadDictionaryValues 远程过滤,无结果时可通过 + // notFoundContent 的按钮发起 Ozon 同步。 + const dictionaryOptions = definition.dictionary_id + ? ( + dictionaryValues[definition.dictionary_id] ?? + [] + ).map((value) => ({ + value: value.value_id, + label: value.value, + })) + : []; + const dictionaryQuery = definition.dictionary_id + ? (dictionarySearches[definition.dictionary_id] ?? + '') + : ''; + const valueName = [field.name, 'values'] as NamePath; + const scalarValueName = [...valueName, 0] as NamePath; + const valueRules = definition.is_required + ? [ + { + validator: ( + _rule: unknown, + value: unknown + ) => { + const entries = Array.isArray(value) + ? value + : [value]; + return entries.some( + (entry) => + typeof entry === 'string' && + entry.trim() + ) + ? Promise.resolve() + : Promise.reject( + new Error( + `请填写${definition.name}` + ) + ); + }, + }, + ] + : undefined; + const input = + definition.value_type === 'dictionary' ? ( + + + + ) : definition.is_collection ? ( + + + + ); + return ( + + + {input} + + + + ); + }); + })} +
+ )} +
+ ))} +
+ {/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */} + {snapshot.unmapped_attributes.length > 0 && ( + + `${attribute.original_name}:${sourceValue(attribute.values)}` + ) + .join(';')} + /> + )} +
+ ); +} diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.css b/apps/web/src/pages/products/components/ProductMainInfoSection.css new file mode 100644 index 0000000..d369ffa --- /dev/null +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.css @@ -0,0 +1,52 @@ +/* 「01 主要信息」区块:默认包装规格与类目下拉弹层。 */ + +/* 默认包装规格(区块底部的重量 + 长宽高输入组) */ +.package-fields { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid #f0eef4; +} +.package-dimensions { + margin-bottom: 0; +} +/* 长×宽×高输入组:随容器宽度收缩(压缩到下限后再由窄屏断点改为纵向堆叠) */ +.package-dimensions-controls { + display: grid; + grid-template-columns: minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax( + 64px, + 130px + ) auto; + align-items: center; + gap: 7px; + max-width: 100%; +} +.package-dimension-separator { + color: #98a2b3; + font-size: 23px; + font-weight: 600; + text-align: center; +} +.package-dimension-unit { + color: #667085; + line-height: 32px; +} + +/* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */ +.editor-category-popup .ant-cascader-menu { + /* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */ + height: 324px !important; + max-height: 324px; +} +.editor-category-popup .ant-cascader-menu-item { + display: flex; + align-items: center; +} +@media (max-width: 860px) { + .package-dimensions-controls { + width: 100%; + grid-template-columns: 1fr; + } + .package-dimension-separator { + display: none; + } +} diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx index 99733cd..3b7cc54 100644 --- a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx @@ -1,6 +1,8 @@ -import { Card, Cascader, Form, Input, Select, Typography } from 'antd'; +import { Card, Cascader, Form, Input, Select } from 'antd'; import type { FormInstance } from 'antd'; import type { ProductEditorSnapshot } from '@atlas/contracts'; +import { SectionTitle } from './SectionTitle'; +import './ProductMainInfoSection.css'; type NamePath = Array; type CategoryOption = { @@ -10,21 +12,6 @@ type CategoryOption = { children?: CategoryOption[]; }; -function SectionTitle() { - return ( -
-
- - 01主要信息 - - - 上架所需的核心资料。包装信息用于定价、物流与发布。 - -
-
- ); -} - function PackageFields({ name }: { name: NamePath }) { return (
@@ -65,6 +52,12 @@ type Props = { onCategorySearch: (query: string) => void; }; +/** + * 「01 主要信息」区块:上架店铺、产品类目、标题/型号/品牌与默认包装规格。 + * + * 切换店铺会清空类目与属性草稿;切换类目会清空属性草稿并记录类目来源, + * 避免跨类目残留脏数据。 + */ export function ProductMainInfoSection({ form, snapshot, @@ -76,7 +69,11 @@ export function ProductMainInfoSection({ }: Props) { return ( - +
void; }; +/** 「04 其它」区块:货源链接与备注;已上架 SKU 仅保存 Atlas 草稿,不回写平台商品。 */ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) { return ( - -
+ +
04其它 @@ -20,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
- + + {fields.length > 1 && ( + remove(field.name)} + > + + + )} + +
+
+
+ 货号 + + + +
+
+ 采购价(CNY) + + + +
+
+ + 销售价( + {original?.listing.currency_code ?? '₽'} + ) + + +
+
+ 净利润 + — +
+ +
+
+
+ {expanded && ( +
+
+ + SKU 图片 + + {imageCandidates.length ? ( +
+ {imageCandidates.map((candidate) => { + // 仅"转存成功"的候选可直接勾选; + // 其余按转存状态展示对应按钮。 + const selectable = + candidate.status === + 'succeeded' && + !!candidate.asset_id; + const selected = + selectable && + selectedAssetIds.includes( + candidate.asset_id! + ); + return ( +
+ 采集候选图片 + {selectable ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : ( +
+ 暂无采集图片 +
+ )} +
+
+ + SKU 规格 + + + + + + + + + + + + + + {/* 规格轴来自采集结果:颜色轴展示为"商品颜色" */} + + {(specFields) => + specFields.length ? ( + <> + {specFields.map((specField) => { + const axisKey = + form.getFieldValue([ + 'variants', + field.name, + 'specification_values', + specField.name, + 'axis_key', + ]) as + string | undefined; + const label = + /color|颜色/i.test( + axisKey ?? '' + ) + ? '商品颜色' + : axisKey || + '商品规格'; + return ( + + + + ); + })} + + ) : ( + + + + ) + } + +
+
+ )} +
+ ); + })} +
+ +
+
+ ); + }} + +
+ ); +} diff --git a/apps/web/src/pages/products/components/SectionTitle.css b/apps/web/src/pages/products/components/SectionTitle.css new file mode 100644 index 0000000..c221271 --- /dev/null +++ b/apps/web/src/pages/products/components/SectionTitle.css @@ -0,0 +1,32 @@ +/* 编辑区块标题(SectionTitle):编号徽标 + 标题 + 副标题,各区块共用。 */ +.editor-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 22px; +} +.editor-section-heading h4.ant-typography { + margin: 2px 0 3px; + font-size: 20px; +} +.editor-section-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 25px; + height: 25px; + margin-right: 9px; + border-radius: 7px; + background: #ede9fe; + color: #7c3aed; + font-size: 13px; + font-weight: 800; + vertical-align: 2px; +} +@media (max-width: 860px) { + .editor-section-heading { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/apps/web/src/pages/products/components/SectionTitle.tsx b/apps/web/src/pages/products/components/SectionTitle.tsx new file mode 100644 index 0000000..ed592a8 --- /dev/null +++ b/apps/web/src/pages/products/components/SectionTitle.tsx @@ -0,0 +1,26 @@ +import { Typography } from 'antd'; +import type { ReactNode } from 'react'; +import './SectionTitle.css'; + +type Props = { + number: string; + title: string; + description?: string; + extra?: ReactNode; +}; + +/** 编辑区块标题:编号 + 标题 + 副标题 + 右侧附加内容,各区块共用。 */ +export function SectionTitle({ number, title, description, extra }: Props) { + return ( +
+
+ + {number && {number}} + {title} + + {description && {description}} +
+ {extra} +
+ ); +}