feat(edit): 编辑页面前端样式修改
This commit is contained in:
@@ -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%;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
||||
/* 右侧「采集的商品信息」面板:编辑容器为页面工作区右列(.editor-aside)。 */
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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<ProductAiSourceField, 'title' | 'description'>) => void;
|
||||
onUpdate: (field: Extract<ProductAiSourceField, 'title' | 'description'>, value: string) => void;
|
||||
onUpdate: (
|
||||
field: Extract<ProductAiSourceField, 'title' | 'description'>,
|
||||
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<string>();
|
||||
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 (
|
||||
<aside className="editor-aside">
|
||||
<Card className="collected-info-panel" bordered={false}>
|
||||
@@ -43,7 +79,9 @@ export function CollectedProductInfoPanel({
|
||||
<Typography.Title level={4}>采集的商品信息</Typography.Title>
|
||||
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
|
||||
</div>
|
||||
<Tag>第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集</Tag>
|
||||
<Tag>
|
||||
第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集
|
||||
</Tag>
|
||||
</div>
|
||||
{snapshot.source_material_state.has_source_updates && (
|
||||
<Alert
|
||||
@@ -102,12 +140,30 @@ export function CollectedProductInfoPanel({
|
||||
<div className="collected-field">
|
||||
<span>采集基础数据</span>
|
||||
<div className="collected-summary">
|
||||
<div><b>价格</b><strong>{summary.price}</strong></div>
|
||||
<div><b>品牌</b><strong>{summary.brand}</strong></div>
|
||||
<div><b>商品尺寸</b><strong>{summary.physicalDimensions}</strong></div>
|
||||
<div><b>商品重量</b><strong>{summary.physicalWeight}</strong></div>
|
||||
<div><b>包装尺寸</b><strong>{summary.packageDimensions}</strong></div>
|
||||
<div><b>包装重量</b><strong>{summary.packageWeight}</strong></div>
|
||||
<div>
|
||||
<b>价格</b>
|
||||
<strong>{summary.price}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<b>品牌</b>
|
||||
<strong>{summary.brand}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<b>商品尺寸</b>
|
||||
<strong>{summary.physicalDimensions}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<b>商品重量</b>
|
||||
<strong>{summary.physicalWeight}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<b>包装尺寸</b>
|
||||
<strong>{summary.packageDimensions}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<b>包装重量</b>
|
||||
<strong>{summary.packageWeight}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
@@ -116,12 +172,43 @@ export function CollectedProductInfoPanel({
|
||||
<div className="collected-attributes">
|
||||
{collected.attributes.map((attribute) => (
|
||||
<div key={`${attribute.key}:${attribute.value}`}>
|
||||
<b>{attribute.name}</b><span>{attribute.value}</span>
|
||||
<b>{attribute.name}</b>
|
||||
<span>{attribute.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">本次采集未取得规格或参数。</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
本次采集未取得规格或参数。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
<span>采集图片</span>
|
||||
{imageGroups.length ? (
|
||||
<Collapse
|
||||
className="collected-image-groups"
|
||||
ghost
|
||||
size="small"
|
||||
defaultActiveKey={['main']}
|
||||
items={imageGroups.map(({ key, label, items }) => ({
|
||||
key,
|
||||
label: `${label}(${items.length})`,
|
||||
children: (
|
||||
<div className="collected-image-grid">
|
||||
{items.map((item) => (
|
||||
<Image
|
||||
key={item.id}
|
||||
src={item.source_url}
|
||||
alt={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">本次采集未取得图片。</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
|
||||
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
|
||||
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<EditorFormValues>;
|
||||
snapshot: ProductEditorSnapshot;
|
||||
selectedCategoryId: string | null | undefined;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
activeAttributeSchema: ProductAttributeSchema[];
|
||||
attributeSchemaLoading: boolean;
|
||||
attributeSchemaError: string | null;
|
||||
catalogSyncing: string | null;
|
||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||
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<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
setDictionarySearches({});
|
||||
}, [selectedCategoryId]);
|
||||
return (
|
||||
<Card className="editor-section" bordered={false}>
|
||||
<SectionTitle
|
||||
number="02"
|
||||
title="产品属性"
|
||||
description="用于当前平台发布与 AI 文案生成。"
|
||||
/>
|
||||
<Form.Item name={['profile', 'tags']} label="主题标签">
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['common', 'barcode']} label="条形码">
|
||||
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="JSON 富内容">
|
||||
<Input.TextArea
|
||||
value={richContent}
|
||||
rows={7}
|
||||
placeholder="JSON 富内容将在富内容能力接入后保存与发布"
|
||||
onChange={(event) => setRichContent(event.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div id="dynamic-attributes" className="dynamic-attributes">
|
||||
<div className="dynamic-attributes-heading">
|
||||
<div>
|
||||
<Typography.Title level={5}>更多特征</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
根据所选类目填写 Ozon 平台属性
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
onToggleExpanded();
|
||||
window.requestAnimationFrame(() =>
|
||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
{expanded ? '收起更多特征' : '展开更多特征'}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded &&
|
||||
(!selectedCategoryId ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="请选择产品类目后填写平台属性"
|
||||
/>
|
||||
) : attributeSchemaLoading ? (
|
||||
<div className="dynamic-attributes-loading">
|
||||
<Spin size="small" />
|
||||
<span>正在读取类目属性…</span>
|
||||
</div>
|
||||
) : !activeAttributeSchema.length ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>
|
||||
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
loading={
|
||||
catalogSyncing === `attributes:${selectedCategoryId}`
|
||||
}
|
||||
onClick={onSyncAttributeSchema}
|
||||
>
|
||||
同步此类目属性
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Form.List name="attributes">
|
||||
{(fields) => (
|
||||
<div className="dynamic-attribute-list">
|
||||
{activeAttributeSchema.flatMap((definition) => {
|
||||
// aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。
|
||||
const scopes = definition.is_aspect
|
||||
? snapshot.variants.map((variant) => ({
|
||||
scope: 'variant' as const,
|
||||
variantId: variant.id,
|
||||
suffix: variant.display_name || '未命名 SKU',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
scope: 'common' as const,
|
||||
variantId: null,
|
||||
suffix: null,
|
||||
},
|
||||
];
|
||||
return scopes.map(({ scope, variantId, suffix }) => {
|
||||
const field = fields.find((candidate) => {
|
||||
const draft = form.getFieldValue([
|
||||
'attributes',
|
||||
candidate.name,
|
||||
]) as AttributeDraft | undefined;
|
||||
return (
|
||||
draft?.scope === scope &&
|
||||
draft?.variant_id === variantId &&
|
||||
draft?.platform_attribute_id ===
|
||||
definition.platform_attribute_id &&
|
||||
draft?.complex_id === definition.complex_id
|
||||
);
|
||||
});
|
||||
const label = suffix
|
||||
? `${definition.name} · ${suffix}`
|
||||
: definition.name;
|
||||
// Attribute rows are created together with
|
||||
// the schema response. A transient render
|
||||
// may still occur before Form.List receives
|
||||
// the new field metadata.
|
||||
if (!field) return null;
|
||||
// 字典属性:选项来自页面预取的字典缓存;搜索时经
|
||||
// onLoadDictionaryValues 远程过滤,无结果时可通过
|
||||
// notFoundContent 的按钮发起 Ozon 同步。
|
||||
const dictionaryOptions = definition.dictionary_id
|
||||
? (
|
||||
dictionaryValues[definition.dictionary_id] ??
|
||||
[]
|
||||
).map((value) => ({
|
||||
value: value.value_id,
|
||||
label: value.value,
|
||||
}))
|
||||
: [];
|
||||
const dictionaryQuery = definition.dictionary_id
|
||||
? (dictionarySearches[definition.dictionary_id] ??
|
||||
'')
|
||||
: '';
|
||||
const valueName = [field.name, 'values'] as NamePath;
|
||||
const scalarValueName = [...valueName, 0] as NamePath;
|
||||
const valueRules = definition.is_required
|
||||
? [
|
||||
{
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: unknown
|
||||
) => {
|
||||
const entries = Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
typeof entry === 'string' &&
|
||||
entry.trim()
|
||||
)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(
|
||||
new Error(
|
||||
`请填写${definition.name}`
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item
|
||||
name={
|
||||
definition.is_collection
|
||||
? valueName
|
||||
: scalarValueName
|
||||
}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
mode={
|
||||
definition.is_collection
|
||||
? 'multiple'
|
||||
: undefined
|
||||
}
|
||||
options={dictionaryOptions}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={(query) => {
|
||||
if (!definition.dictionary_id)
|
||||
return;
|
||||
setDictionarySearches(
|
||||
(current) => ({
|
||||
...current,
|
||||
[definition.dictionary_id!]:
|
||||
query,
|
||||
})
|
||||
);
|
||||
onLoadDictionaryValues(
|
||||
definition.dictionary_id,
|
||||
query
|
||||
);
|
||||
}}
|
||||
notFoundContent={
|
||||
dictionaryQuery.trim().length >=
|
||||
2 ? (
|
||||
<Button
|
||||
type="link"
|
||||
loading={
|
||||
catalogSyncing ===
|
||||
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
|
||||
}
|
||||
onMouseDown={(event) =>
|
||||
event.preventDefault()
|
||||
}
|
||||
onClick={() =>
|
||||
definition.dictionary_id &&
|
||||
onSyncDictionaryValues(
|
||||
definition.dictionary_id,
|
||||
dictionaryQuery.trim()
|
||||
)
|
||||
}
|
||||
>
|
||||
从 Ozon 搜索“
|
||||
{dictionaryQuery.trim()}”
|
||||
</Button>
|
||||
) : (
|
||||
'输入至少 2 个字符后可从 Ozon 搜索'
|
||||
)
|
||||
}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'boolean' ? (
|
||||
<Form.Item
|
||||
name={scalarValueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'true',
|
||||
label: '是',
|
||||
},
|
||||
{
|
||||
value: 'false',
|
||||
label: '否',
|
||||
},
|
||||
]}
|
||||
placeholder={`选择${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.is_collection ? (
|
||||
<Form.Item
|
||||
name={valueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加多个值"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name={scalarValueName}
|
||||
rules={valueRules}
|
||||
noStyle
|
||||
>
|
||||
<Input
|
||||
inputMode={
|
||||
definition.value_type ===
|
||||
'integer' ||
|
||||
definition.value_type === 'decimal'
|
||||
? 'decimal'
|
||||
: undefined
|
||||
}
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={label}
|
||||
required={definition.is_required}
|
||||
className="dynamic-attribute-item"
|
||||
>
|
||||
<Space.Compact block>
|
||||
{input}
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[
|
||||
'attributes',
|
||||
field.name,
|
||||
'values',
|
||||
],
|
||||
[]
|
||||
)
|
||||
}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
))}
|
||||
</div>
|
||||
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
|
||||
{snapshot.unmapped_attributes.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`有 ${snapshot.unmapped_attributes.length} 项旧类目属性待处理`}
|
||||
description={snapshot.unmapped_attributes
|
||||
.map(
|
||||
(attribute) =>
|
||||
`${attribute.original_name}:${sourceValue(attribute.values)}`
|
||||
)
|
||||
.join(';')}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string | number>;
|
||||
type CategoryOption = {
|
||||
@@ -10,21 +12,6 @@ type CategoryOption = {
|
||||
children?: CategoryOption[];
|
||||
};
|
||||
|
||||
function SectionTitle() {
|
||||
return (
|
||||
<div className="editor-section-heading">
|
||||
<div>
|
||||
<Typography.Title level={4}>
|
||||
<span className="editor-section-number">01</span>主要信息
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
上架所需的核心资料。包装信息用于定价、物流与发布。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageFields({ name }: { name: NamePath }) {
|
||||
return (
|
||||
<div className="package-fields">
|
||||
@@ -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 (
|
||||
<Card className="editor-section" bordered={false}>
|
||||
<SectionTitle />
|
||||
<SectionTitle
|
||||
number="01"
|
||||
title="主要信息"
|
||||
description="上架所需的核心资料。包装信息用于定价、物流与发布。"
|
||||
/>
|
||||
<div className="editor-form-grid">
|
||||
<Form.Item
|
||||
name={['profile', 'target_store_id']}
|
||||
|
||||
@@ -7,10 +7,11 @@ type Props = {
|
||||
onOpenSupplyUrl: () => void;
|
||||
};
|
||||
|
||||
/** 「04 其它」区块:货源链接与备注;已上架 SKU 仅保存 Atlas 草稿,不回写平台商品。 */
|
||||
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
|
||||
return (
|
||||
<Card className="editor-section other-section" bordered={false}>
|
||||
<div className="editor-section-heading">
|
||||
<Card className="editor-section" bordered={false}>
|
||||
<div className="editor-section-heading" style={{ marginBottom: 8 }}>
|
||||
<div>
|
||||
<Typography.Title level={4}>
|
||||
<span className="editor-section-number">04</span>其它
|
||||
@@ -20,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
||||
</div>
|
||||
<div className="editor-form-grid">
|
||||
<Form.Item label="货源链接" className="span-two">
|
||||
<Space.Compact block className="supply-link-control">
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name={['common', 'purchase_url']}
|
||||
noStyle
|
||||
@@ -30,7 +31,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
||||
</Form.Item>
|
||||
<Tooltip title="打开货源链接">
|
||||
<Button
|
||||
className="supply-open-button"
|
||||
style={{ minWidth: 40 }}
|
||||
icon={<ExportOutlined />}
|
||||
disabled={!canOpenSupplyUrl}
|
||||
onClick={onOpenSupplyUrl}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/* 「03 变体设置」区块:SKU 卡片、展开后的图片选择与规格面板。 */
|
||||
|
||||
/* SKU 卡片内的输入圆角与 antd 输入框保持一致 */
|
||||
.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 {
|
||||
position: relative;
|
||||
border: 1px solid #e8e4ee;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* 用 .sku-card.ant-card 提高权重:内层卡片同时命中外层
|
||||
.editor-section .ant-card-body(页面级)规则,同级特异性会按加载顺序被覆盖 */
|
||||
.sku-card.ant-card > .ant-card-body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
/* 卡片主体:左侧主图缩略图(贴边、跨"名称 + 货号"两行),右侧内容列 */
|
||||
.sku-card-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-card-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
/* 顶部留出序号悬浮徽章的下半部分 */
|
||||
padding-top: 4px;
|
||||
}
|
||||
.sku-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 28px;
|
||||
}
|
||||
/* 序号:悬浮在卡片左上角,垂直方向一半露出卡片外 */
|
||||
.sku-card-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: -10px;
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #eee9fe;
|
||||
color: #7151df;
|
||||
font-weight: 700;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
/* 摘要行:名称 / 主图 / 货号 / 采购价 / 销售价 / 净利润 / 展开按钮。
|
||||
随宽度变化先压缩各字段的输入宽度,放不下时整行自动换行(不做断点隐藏)。 */
|
||||
.sku-card-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sku-card-summary .ant-form-item {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.sku-card-summary .ant-input,
|
||||
.sku-card-summary textarea.ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-card-field,
|
||||
.sku-card-value {
|
||||
min-width: 0;
|
||||
}
|
||||
/* SKU 名称:无标签,直接内联在序号右侧,占据顶行剩余宽度 */
|
||||
.sku-card-name {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sku-card-field {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
.sku-price-field {
|
||||
flex: 1 1 130px;
|
||||
}
|
||||
.sku-card-field > span,
|
||||
.sku-card-value > span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
width: 75px;
|
||||
aspect-ratio: 3 / 4;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
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-card-value {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.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: flex-start;
|
||||
margin-top: 23px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 展开区:左侧采集图片、右侧规格面板 */
|
||||
.sku-card-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr);
|
||||
gap: 32px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
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-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;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sku-card-expanded {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.sku-media-grid {
|
||||
grid-auto-rows: 72px;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-inline-dimensions > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Form, Image, Input, Popconfirm, Space, Tag, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductVariantSection.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type VariantValues = EditorFormValues['variants'][number];
|
||||
type SnapshotVariant = ProductEditorSnapshot['variants'][number];
|
||||
type NamePath = Array<string | number>;
|
||||
|
||||
/** SKU 上架状态 → 标签文案与颜色;未知状态原样展示。 */
|
||||
function statusTag(status: string) {
|
||||
const label =
|
||||
(
|
||||
{
|
||||
not_listed: '未上架',
|
||||
submitting: '处理中',
|
||||
imported: '已上架',
|
||||
failed: '上架失败',
|
||||
} as Record<string, string>
|
||||
)[status] ?? status;
|
||||
const color = (
|
||||
{ imported: 'success', failed: 'error', submitting: 'processing' } as Record<string, string>
|
||||
)[status];
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从采集证据中筛选该 SKU 可选的图片候选:未标注来源变体的图片视为
|
||||
* 公共图(所有 SKU 可见),其余需 source_variant_key 匹配;按 id 去重。
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** 长×宽×高行内输入组(单位 mm)。 */
|
||||
function InlineDimensions({ name }: { name: NamePath }) {
|
||||
return (
|
||||
<div className="sku-inline-dimensions">
|
||||
<Form.Item name={[...name, 'length_mm']} noStyle>
|
||||
<Input type="number" min="0" step="any" placeholder="长" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'width_mm']} noStyle>
|
||||
<Input type="number" min="0" step="any" placeholder="宽" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'height_mm']} noStyle>
|
||||
<Input type="number" min="0" step="any" placeholder="高" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-unit">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
snapshot: ProductEditorSnapshot;
|
||||
collectionRecord: CollectionItemInspection | null;
|
||||
materializingTransferId: string | null;
|
||||
onMaterialize: (transferId: string) => void;
|
||||
onDirty: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的
|
||||
* 采集图片选择、包装与商品规格、规格轴编辑。
|
||||
*
|
||||
* 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片
|
||||
* 展开状态与图片选择交互。
|
||||
*/
|
||||
export function ProductVariantSection({
|
||||
form,
|
||||
snapshot,
|
||||
collectionRecord,
|
||||
materializingTransferId,
|
||||
onMaterialize,
|
||||
onDirty,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
return (
|
||||
<Card className="editor-section sku-section" bordered={false}>
|
||||
<SectionTitle
|
||||
number="03"
|
||||
title="变体设置"
|
||||
description="卡片用于快速核对与填写;展开后可维护图片、规格轴和该 SKU 的覆盖规格。"
|
||||
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
|
||||
/>
|
||||
<Form.List name="variants">
|
||||
{(fields, { add, remove }) => {
|
||||
// 新建变体默认值:id 为 null 表示服务端尚不存在,
|
||||
// client_key 作为保存前的临时标识。
|
||||
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 (
|
||||
<div className="sku-card-list">
|
||||
{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);
|
||||
// 选中/取消候选图片;setFieldValue 不触发 Form 的
|
||||
// onValuesChange,因此需通过 onDirty 手动标记未保存。
|
||||
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
|
||||
);
|
||||
onDirty();
|
||||
};
|
||||
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 (
|
||||
<Card className="sku-card" key={field.key} bordered={false}>
|
||||
<Form.Item name={[field.name, 'id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'client_key']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[field.name, 'copy_from_variant_id']}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'media_asset_ids']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="sku-card-main">
|
||||
<div className="sku-main-thumbnail">
|
||||
{selectedImages[0] ? (
|
||||
<Image
|
||||
preview={false}
|
||||
src={selectedImages[0]}
|
||||
alt="SKU 主图"
|
||||
/>
|
||||
) : (
|
||||
<span>暂无主图</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sku-card-content">
|
||||
<div className="sku-card-top">
|
||||
<span className="sku-card-index">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Form.Item
|
||||
name={[field.name, 'display_name']}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea
|
||||
className="sku-card-name"
|
||||
autoSize={{ minRows: 1, maxRows: 2 }}
|
||||
placeholder="SKU 名称"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={4}>
|
||||
{original &&
|
||||
statusTag(original.listing.status)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={copyVariant}
|
||||
>
|
||||
复制 SKU
|
||||
</Button>
|
||||
{fields.length > 1 && (
|
||||
<Popconfirm
|
||||
title="删除 SKU?"
|
||||
description={
|
||||
original?.listing.status ===
|
||||
'imported'
|
||||
? '只会移除 Atlas 草稿与后续可发布集合,不会删除平台商品。'
|
||||
: '删除后该 SKU 不再出现在当前商品结构中。'
|
||||
}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => remove(field.name)}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<div className="sku-card-summary">
|
||||
<div className="sku-card-field">
|
||||
<span>货号</span>
|
||||
<Form.Item
|
||||
name={[field.name, 'offer_id']}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>采购价(CNY)</span>
|
||||
<Form.Item
|
||||
name={[field.name, 'purchase_cost_cny']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
pattern: /^\d+(\.\d{1,4})?$/,
|
||||
message: '请输入金额',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="—" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>
|
||||
销售价(
|
||||
{original?.listing.currency_code ?? '₽'}
|
||||
)
|
||||
</span>
|
||||
<Input
|
||||
value={
|
||||
original?.listing.selling_price ??
|
||||
''
|
||||
}
|
||||
placeholder="—"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-value">
|
||||
<span>净利润</span>
|
||||
<strong>—</strong>
|
||||
</div>
|
||||
<Button
|
||||
className="sku-expand-button"
|
||||
type="text"
|
||||
icon={
|
||||
<DownOutlined
|
||||
rotate={expanded ? 180 : 0}
|
||||
/>
|
||||
}
|
||||
onClick={toggle}
|
||||
>
|
||||
{expanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="sku-card-expanded">
|
||||
<div className="sku-media-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 图片
|
||||
</Typography.Title>
|
||||
{imageCandidates.length ? (
|
||||
<div className="sku-media-grid">
|
||||
{imageCandidates.map((candidate) => {
|
||||
// 仅"转存成功"的候选可直接勾选;
|
||||
// 其余按转存状态展示对应按钮。
|
||||
const selectable =
|
||||
candidate.status ===
|
||||
'succeeded' &&
|
||||
!!candidate.asset_id;
|
||||
const selected =
|
||||
selectable &&
|
||||
selectedAssetIds.includes(
|
||||
candidate.asset_id!
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`sku-media-item ${selected ? 'selected' : ''}`}
|
||||
key={candidate.id}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
candidate.source_url
|
||||
}
|
||||
alt="采集候选图片"
|
||||
/>
|
||||
{selectable ? (
|
||||
<Button
|
||||
size="small"
|
||||
type={
|
||||
selected
|
||||
? 'primary'
|
||||
: 'default'
|
||||
}
|
||||
onClick={() =>
|
||||
toggleImage(
|
||||
candidate.asset_id!
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected
|
||||
? '已选择'
|
||||
: '选择'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
loading={
|
||||
materializingTransferId ===
|
||||
candidate.id
|
||||
}
|
||||
disabled={
|
||||
candidate.status ===
|
||||
'queued' ||
|
||||
candidate.status ===
|
||||
'running' ||
|
||||
candidate.status ===
|
||||
'failed'
|
||||
}
|
||||
onClick={() =>
|
||||
void onMaterialize(
|
||||
candidate.id
|
||||
)
|
||||
}
|
||||
>
|
||||
{candidate.status ===
|
||||
'pending_selection'
|
||||
? '转存后选择'
|
||||
: candidate.status ===
|
||||
'failed'
|
||||
? '转存失败'
|
||||
: '转存中'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sku-media-empty">
|
||||
暂无采集图片
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sku-spec-panel">
|
||||
<Typography.Title level={5}>
|
||||
SKU 规格
|
||||
</Typography.Title>
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'package_spec',
|
||||
'shipping_weight_g',
|
||||
]}
|
||||
label="包装重量"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
addonAfter="g"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="包装尺寸">
|
||||
<InlineDimensions
|
||||
name={[field.name, 'package_spec']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'physical_spec',
|
||||
'net_weight_g',
|
||||
]}
|
||||
label="商品重量"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
addonAfter="g"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="商品尺寸">
|
||||
<InlineDimensions
|
||||
name={[field.name, 'physical_spec']}
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* 规格轴来自采集结果:颜色轴展示为"商品颜色" */}
|
||||
<Form.List
|
||||
name={[field.name, 'specification_values']}
|
||||
>
|
||||
{(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 (
|
||||
<Form.Item
|
||||
key={specField.key}
|
||||
name={[
|
||||
specField.name,
|
||||
'value_name',
|
||||
]}
|
||||
label={label}
|
||||
>
|
||||
<Input placeholder="未填写" />
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<Form.Item label="商品颜色">
|
||||
<Input
|
||||
placeholder="未填写"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
</Form.List>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="add-sku-row">
|
||||
<Button
|
||||
className="add-sku-button"
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addVariant}
|
||||
>
|
||||
添加 SKU 变体
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="editor-section-heading">
|
||||
<div>
|
||||
<Typography.Title level={4}>
|
||||
{number && <span className="editor-section-number">{number}</span>}
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{description && <Typography.Text type="secondary">{description}</Typography.Text>}
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user