feat(edit): 定价试算开发、SKU 样式功能修改

This commit is contained in:
R524809
2026-09-29 17:50:19 +08:00
parent 84b230e69a
commit 0a303414d8
52 changed files with 4278 additions and 1393 deletions
+14 -2
View File
@@ -5,6 +5,10 @@ import { router } from './router';
import { SessionProvider } from '@/services/session';
import './App.css';
// 控件圆角与 SKU 编辑表单一致;页面卡片仍使用原有的基础圆角。
const controlCorners = { borderRadius: 7, borderRadiusSM: 7, borderRadiusLG: 7 };
const fieldStyle = { ...controlCorners, controlHeight: 35 };
function App() {
return (
<ConfigProvider
@@ -12,11 +16,19 @@ function App() {
theme={{
token: {
colorPrimary: '#8b5cf6',
// 全局圆角 10px,输入框/按钮/卡片等同步生效
// 卡片等容器维持现有圆角,交互控件使用下方独立配置。
borderRadius: 10,
// 全局正文字号沿用 Ant Design 默认值
fontSize: 14,
},
components: {
Button: controlCorners,
Input: fieldStyle,
InputNumber: fieldStyle,
Select: fieldStyle,
Cascader: fieldStyle,
TreeSelect: fieldStyle,
DatePicker: fieldStyle,
},
}}
>
<AntdApp>
@@ -0,0 +1,28 @@
import { Input } from 'antd';
import type { InputProps } from 'antd';
const digitsOnly = /^\d+$/;
export function isPositivePackageInteger(value: unknown): value is string {
return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0;
}
export const packageIntegerRule = {
validator: (_: unknown, value: unknown) =>
value === null || value === undefined || value === '' || isPositivePackageInteger(value)
? Promise.resolve()
: Promise.reject(new Error('请输入大于 0 的整数')),
};
/** 保留旧小数原值供人工核对,新输入仅接收数字,避免自动取整造成计费数据变化。 */
export function PackageIntegerInput({ onChange, ...props }: InputProps) {
return (
<Input
{...props}
inputMode="numeric"
onChange={(event) => {
if (/^\d*$/.test(event.target.value)) onChange?.(event);
}}
/>
);
}
@@ -29,8 +29,23 @@ import type {
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorMessage } from '@/services/api';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { PackageIntegerInput } from '@/components/PackageIntegerInput';
import { percentToRate, rateToPercent } from './pricingPercentage';
import { pricingInputProblem } from './pricingInputValidation';
function detachPolicy(p: PricingParameters): PricingParameters {
return {
...p,
pricing_policy_id: null,
fx:
p.fx.mode === 'policy_fixed'
? p.fx.rate
? { mode: 'one_time_override', rate: p.fx.rate, reason: '沿用原方案固定汇率' }
: { mode: 'system' }
: p.fx,
};
}
export function defaultPricing(catalog: PricingCatalog): PricingParameters | null {
const policy = catalog.policies.find(
@@ -63,13 +78,19 @@ export function PricingParametersFields({
onChange,
catalog,
policyEditing = false,
expandFees = false,
}: {
policyEditing?: boolean;
expandFees?: boolean;
value: PricingParameters;
onChange: (p: PricingParameters) => void;
catalog: PricingCatalog;
}) {
const patch = (v: Partial<PricingParameters>) => onChange({ ...p, ...v });
const [feesOpen, setFeesOpen] = useState(false);
useEffect(() => {
if (expandFees) setFeesOpen(true);
}, [expandFees]);
const rates: Array<[keyof PricingParameters, string]> = [
['desired_markup_rate', '目标加价率'],
['list_price_discount_rate', '划线折扣率'],
@@ -100,7 +121,7 @@ export function PricingParametersFields({
mode: p.mode,
selling_price: p.selling_price,
});
else patch({ pricing_policy_id: null });
else onChange(detachPolicy(p));
}}
/>
</div>
@@ -112,13 +133,13 @@ export function PricingParametersFields({
value={p.rate_card_id}
options={catalog.rate_cards.map((x) => ({
value: x.id,
label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`,
label: `${x.name} · ${x.version}${x.development_only ? ' · 待审核费率' : ''}`,
}))}
onChange={(id) => {
const c = catalog.rate_cards.find((x) => x.id === id)!;
patch({
onChange({
...detachPolicy(p),
rate_card_id: id,
pricing_policy_id: null,
transport_method_ids: c.transports.map((t) => t.id),
default_transport_method_id: c.transports[0]?.id ?? null,
});
@@ -148,6 +169,8 @@ export function PricingParametersFields({
目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。
</Typography.Text>
<Collapse
activeKey={feesOpen ? ['fees'] : []}
onChange={(keys) => setFeesOpen(keys.includes('fees'))}
items={[
{
key: 'fees',
@@ -230,10 +253,35 @@ export function PricingParametersFields({
step="1"
precision={2}
addonAfter="%"
value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)}
onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })}
disabled={policyEditing}
value={
p.temporary_commission_rate === null
? null
: rateToPercent(p.temporary_commission_rate)
}
onChange={(v) => {
const rate = v === null ? null : percentToRate(v);
const selected = catalog.policies.find(
(policy) => policy.id === p.pricing_policy_id
);
if (
rate !== null &&
selected &&
!selected.allow_commission_override
)
onChange({
...detachPolicy(p),
temporary_commission_rate: rate,
});
else patch({ temporary_commission_rate: rate });
}}
/>
</label>
{!policyEditing && (
<Typography.Text type="secondary">
仅在缺少正式佣金规则时使用。方案不允许临时佣金时,填写后改为本次自定义参数;请依据已核实的类目费率输入。
</Typography.Text>
)}
</Space>
),
},
@@ -282,6 +330,7 @@ export function PricingCalculator({
const [preview, setPreview] = useState<PricingPreview | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState('');
const [commissionError, setCommissionError] = useState<'missing' | 'forbidden' | null>(null);
const [busy, setBusy] = useState(false);
const sequence = useRef(0);
const [refresh, setRefresh] = useState(0);
@@ -295,10 +344,16 @@ export function PricingCalculator({
controller = new AbortController();
setPreview(null);
setError('');
setCommissionError(null);
setBusy(false);
const problem = pricingInputProblem(input);
if (problem) {
setError(problem);
return () => controller.abort();
}
const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join(';'));
setError('请检查定价方案、运输方式和汇率参数');
return () => controller.abort();
}
const timer = setTimeout(() => {
@@ -309,11 +364,24 @@ export function PricingCalculator({
if (sequence.current === seq) {
setPreview(p);
setSelected(p.recommended_option_id);
setCommissionError(null);
}
})
.catch((e) => {
if (sequence.current === seq && !controller.signal.aborted)
setError(apiErrorMessage(e));
if (sequence.current === seq && !controller.signal.aborted) {
const code = apiErrorCode(e);
if (code === 'PRICING_COMMISSION_UNAVAILABLE') {
setCommissionError('missing');
setError(
input.pricing.temporary_commission_rate === null
? '该类目缺少有效佣金规则,请填写已核实的临时佣金率'
: '当前方案不允许使用临时佣金,请改用本次自定义参数或允许覆盖的方案'
);
} else if (code === 'PRICING_COMMISSION_OVERRIDE_FORBIDDEN') {
setCommissionError('forbidden');
setError('该类目已有正式佣金规则,请清空临时佣金率');
} else setError(apiErrorMessage(e));
}
})
.finally(() => {
if (sequence.current === seq) setBusy(false);
@@ -340,9 +408,10 @@ export function PricingCalculator({
const saved = await pricingApi.createPolicy(input.store_id, {
name: policyName ?? '',
note: null,
allow_commission_override: !!localCatalog.policies.find(
(x) => x.id === p.pricing_policy_id
)?.allow_commission_override,
allow_commission_override:
p.temporary_commission_rate !== null ||
!!localCatalog.policies.find((x) => x.id === p.pricing_policy_id)
?.allow_commission_override,
parameters: {
...p,
pricing_policy_id: null,
@@ -437,16 +506,17 @@ export function PricingCalculator({
<Col span={12} key={k}>
<label>
{label}
<InputNumber<string>
<PackageIntegerInput
aria-label={label}
style={{ width: '100%' }}
stringMode
min="0.001"
value={input.package[k]}
onChange={(v) =>
onChange={(event) =>
setInput({
...input,
package: { ...input.package, [k]: v ?? '' },
package: {
...input.package,
[k]: event.target.value,
},
})
}
/>
@@ -493,6 +563,7 @@ export function PricingCalculator({
<PricingParametersFields
value={input.pricing}
catalog={localCatalog}
expandFees={commissionError !== null}
onChange={(pricing) => setInput({ ...input, pricing })}
/>
</Space>
@@ -513,7 +584,17 @@ export function PricingCalculator({
<Typography.Title level={4} style={{ margin: 0 }}>
试算结果 {busy && <Tag color="processing">计算中</Tag>}
</Typography.Title>
{error && <Alert type="warning" title={error} />}
{error && (
<Alert
type="warning"
title={error}
description={
commissionError === 'missing'
? '左侧“国内费用、汇率与临时佣金”已展开。临时费率仅用于本次试算,不会保存到定价方案。'
: undefined
}
/>
)}
{expired && (
<Alert
type="warning"
@@ -560,6 +641,11 @@ export function PricingCalculator({
</Col>
</Row>
</Card>
{option.commission_details && (
<Alert type="info" title={`平台佣金 ${rateToPercent(option.commission_rate)}% · ${option.commission_details.category_name}`} description={
`${option.commission_details.min_price === null ? '' : `${option.commission_details.min_price} ${option.commission_details.min_inclusive ? '≤' : '<'} `}售价${option.commission_details.max_price === null ? '' : ` ${option.commission_details.max_inclusive ? '≤' : '<'} ${option.commission_details.max_price}`} ${option.commission_details.currency_code} · 版本 ${option.commission_details.version} · ${option.commission_details.source === 'competitor_capture' ? '导入并审核' : '正式规则'}`
} />
)}
{option.warnings.map((w) => (
<Alert key={w} title={w} type="warning" />
))}
+291 -174
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, InputNumber, Modal, Space, Tag, Typography } from 'antd';
import type { ReactNode } from 'react';
import { Button, Form, InputNumber, Modal, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type {
PricingCatalog,
@@ -12,8 +13,9 @@ import type {
} from '@atlas/contracts';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorMessage } from '@/services/api';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { defaultPricing, PricingCalculator } from './PricingCalculator';
import { pricingInputProblem } from './pricingInputValidation';
type Values = SaveProductEditorDraftRequest;
export function skuPricingInput(
values: Values,
@@ -24,6 +26,12 @@ export function skuPricingInput(
const v = values.variants[index],
base = values.common.default_package_spec,
pkg = v.package_spec;
const effectivePackage = {
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
length_mm: pkg.length_mm ?? base.length_mm ?? '',
width_mm: pkg.width_mm ?? base.width_mm ?? '',
height_mm: pkg.height_mm ?? base.height_mm ?? '',
};
return {
store_id: values.profile.target_store_id,
platform_product_category_id: values.profile.platform_product_category_id ?? '',
@@ -35,16 +43,23 @@ export function skuPricingInput(
},
purchase_cost_cny: v.purchase_cost_cny ?? '',
package: {
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
length_mm: pkg.length_mm ?? base.length_mm ?? '',
width_mm: pkg.width_mm ?? base.width_mm ?? '',
height_mm: pkg.height_mm ?? base.height_mm ?? '',
status: (pkg.status ?? base.status) as 'confirmed',
...effectivePackage,
status: Object.values(effectivePackage).every(Boolean)
? 'confirmed'
: ((pkg.status ?? base.status) as 'confirmed'),
},
pricing: parameters,
};
}
const keyFor = pricingInputKey;
type PricingSlots = { status: ReactNode; summary: ReactNode; details: ReactNode };
function cny(value: string | null | undefined) {
if (value === null || value === undefined || value === '') return '—';
const amount = Number(value);
return Number.isFinite(amount) ? `¥ ${amount.toFixed(2)}` : '—';
}
export function SkuPricing({
form,
index,
@@ -52,6 +67,7 @@ export function SkuPricing({
auto,
batch,
onChanged,
children,
}: {
form: FormInstance<Values>;
index: number;
@@ -59,6 +75,7 @@ export function SkuPricing({
auto: boolean;
batch: number;
onChanged: () => void;
children: (slots: PricingSlots) => ReactNode;
}) {
Form.useWatch('variants', form);
Form.useWatch('common', form);
@@ -104,7 +121,10 @@ export function SkuPricing({
},
[]
);
const params = v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null);
const fulfillmentSupported = values.profile?.fulfillment === 'rfbs';
const params = fulfillmentSupported
? (v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null))
: null;
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
const liveKey = live ? keyFor(live) : '';
const liveRef = useRef(liveKey);
@@ -193,10 +213,14 @@ export function SkuPricing({
}
async function calculate(force = false, override?: PricingParameters) {
if (!catalog || !v || original?.listing.status === 'imported') return;
if (!fulfillmentSupported) {
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
return;
}
const current = form.getFieldValue(['variants', index]) as Values['variants'][number];
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
if (!parameters) {
setError('没有已审核物流费率,请先配置费率');
setError('没有可用的物流费率,请检查费率卡和草稿卡使用配置');
return;
}
const input = skuPricingInput(
@@ -206,10 +230,15 @@ export function SkuPricing({
parameters
),
inputKey = keyFor(input);
const problem = pricingInputProblem(input);
if (problem) {
setError(problem);
return;
}
if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return;
const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) {
setError('请补齐采购价、类目,并确认有效包装重量和尺寸');
setError('请检查定价方案、运输方式和汇率参数');
return;
}
setParameters(parameters);
@@ -238,7 +267,13 @@ export function SkuPricing({
apply(input, p, recommended.id, false);
} catch (e) {
if (seq === sequence.current && !controller.current.signal.aborted)
setError(apiErrorMessage(e));
setError(
apiErrorCode(e) === 'PRICING_COMMISSION_UNAVAILABLE'
? input.pricing.temporary_commission_rate === null
? '该类目缺少佣金规则;打开“试算详情”,填写已核实的临时佣金率'
: '当前方案不允许临时佣金;打开“试算详情”,改用本次自定义参数'
: apiErrorMessage(e)
);
} finally {
if (seq === sequence.current) setBusy(false);
}
@@ -271,177 +306,259 @@ export function SkuPricing({
}, [batch]);
if (!v) return null;
const imported = original?.listing.status === 'imported';
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'start', flexWrap: 'wrap', flex: 1 }}>
<div className="sku-card-field sku-price-field">
<span>采购价(CNY)</span>
<InputNumber<string>
aria-label={`SKU ${index + 1} 采购价`}
min="0"
stringMode
precision={2}
value={v.purchase_cost_cny}
onChange={(value) => {
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
onChanged();
}}
onBlur={() => {
if (auto && !imported) void calculate();
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
</div>
<div className="sku-card-field sku-price-field">
<span>
销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'}
)
</span>
<InputNumber<string>
aria-label={`SKU ${index + 1} 销售价`}
min="0.01"
stringMode
precision={2}
disabled={imported || !params}
value={
params?.mode === 'fixed_sale'
? params.selling_price
: (option?.selling_price ?? original?.listing.selling_price)
}
onChange={(value) => {
if (params)
setParameters({ ...params, mode: 'fixed_sale', selling_price: value });
}}
onBlur={() => {
if (auto) void calculate(true);
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
</div>
<div className="sku-card-value">
<span>预估净利润(CNY)</span>
<strong
style={{
color: option && Number(option.profit_cny) < 0 ? '#cf1322' : '#389e0d',
}}
>
{option?.profit_cny ?? '—'}
</strong>
</div>
{!imported && (
<Space>
<Button loading={busy} onClick={() => void calculate(true)}>
计算
</Button>
<Button
disabled={!catalog || !params}
onClick={() => {
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!
)
);
}}
const currency = catalog?.store.currency_code ?? original?.listing.currency_code ?? 'CNY';
const saleCny =
params?.mode === 'fixed_sale' && currency === 'CNY'
? params.selling_price
: (option?.merchandise_value_cny ??
original?.listing.pricing_result?.merchandise_value_cny);
const profitCny = option?.profit_cny;
const statusText = busy
? '计算中'
: error || !fulfillmentSupported
? '定价需处理'
: confirmed
? '定价已确认'
: resultValid
? result?.option.can_confirm === false
? '亏损 · 请调整售价'
: '已试算 · 待保存'
: v.pricing_selection
? '输入已变化 · 需重算'
: '待定价';
const needsAttention =
!!error ||
!fulfillmentSupported ||
(resultValid && result?.option.can_confirm === false) ||
(!!v.pricing_selection && !resultValid);
const policyName =
catalog?.policies.find((policy) => policy.id === params?.pricing_policy_id)?.name ??
'本次参数';
const feedback =
error ||
(!fulfillmentSupported
? '当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式'
: v.pricing_selection && !resultValid && !confirmed
? '定价输入已变化,请重新试算后保存。'
: confirmed
? '当前输入与已保存的试算一致。'
: resultValid
? '试算完成,保存商品后确认本次报价。'
: '填写采购价和包装规格后可开始试算。');
return children({
status: (
<span
className={`sku-pricing-status${needsAttention ? ' sku-pricing-status--warning' : confirmed ? ' sku-pricing-status--confirmed' : ''}`}
>
{statusText}
</span>
),
summary: (
<div className="sku-pricing-summary">
<div>
<span>采购价</span>
<strong>{cny(v.purchase_cost_cny)}</strong>
</div>
<div>
<span>售价</span>
<strong>{cny(saleCny)}</strong>
</div>
<div>
<span>预估净利润</span>
<strong
className={
profitCny && Number(profitCny) < 0
? 'sku-pricing-loss'
: profitCny
? 'sku-pricing-profit'
: ''
}
>
试算详情
</Button>
</Space>
)}
<div style={{ width: '100%' }}>
<Space wrap>
<Tag color={confirmed ? 'success' : resultValid ? 'purple' : 'default'}>
{busy
? '计算中'
: confirmed
? '已确认'
: resultValid
? result?.option.can_confirm === false
? '亏损 · 请调整售价'
: '已试算 · 待保存'
: v.pricing_selection
? '输入已变化 · 需重新试算'
: '待定价'}
</Tag>
{catalog?.rate_cards.find((card) => card.id === params?.rate_card_id)
?.development_only && <Tag color="orange">开发草稿费率 · 不可上架</Tag>}
{params?.mode === 'fixed_sale' && (
<>
<Tag>手动售价</Tag>
{cny(profitCny)}
</strong>
</div>
</div>
),
details: (
<section className="sku-pricing-panel" aria-label={`SKU ${index + 1} 定价`}>
<div className="sku-panel-heading">
<h5>定价</h5>
<div className="sku-panel-actions">
{!imported && (
<Button
size="small"
type="link"
loading={busy}
type="primary"
onClick={() => void calculate(true)}
>
重新试算
</Button>
)}
{!imported && (
<Button
disabled={!catalog || !params}
onClick={() => {
const p = {
...params,
mode: 'target_markup' as const,
selling_price: null,
};
setParameters(p);
void calculate(true, p);
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!
)
);
}}
>
使用建议售价
试算详情
</Button>
</>
)}
<Typography.Text type="secondary">
{catalog?.policies.find((p) => p.id === params?.pricing_policy_id)?.name ??
'本次参数'}
{option ? ` · ${option.transport_name}` : ''}
</Typography.Text>
</Space>
{error && (
<div role="alert" style={{ color: '#ad6800', fontSize: 12 }}>
{error}
)}
</div>
)}
</div>
<Modal
title={`${v.display_name ?? 'SKU'} · 定价试算`}
open={!!modal}
width={1000}
footer={null}
destroyOnHidden
onCancel={() => setModal(null)}
>
{modal && catalog && (
<>
<Typography.Paragraph type="secondary">
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
</Typography.Paragraph>
<Button
size="small"
onClick={() => {
restoreInheritance.current = true;
const base = form.getFieldValue(['common', 'default_package_spec']);
setModal({ ...modal, package: { ...base, status: base.status } });
</div>
<div className="sku-pricing-fields">
<label>
<span>采购价(人民币)</span>
<InputNumber<string>
aria-label={`SKU ${index + 1} 采购价(人民币)`}
min="0"
stringMode
precision={2}
prefix="¥"
value={v.purchase_cost_cny}
onChange={(value) => {
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
onChanged();
}}
>
使用 SPU 包装重新试算
</Button>
<PricingCalculator
key={JSON.stringify(modal)}
initial={modal}
previous={result?.option ?? original?.listing.pricing_result}
catalog={catalog}
onCancel={() => setModal(null)}
onApply={(input, p, id) => {
apply(input, p, id, true);
setModal(null);
onBlur={() => {
if (auto && !imported) void calculate();
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
</>
)}
</Modal>
</div>
);
</label>
<label>
<span>售价(人民币)</span>
{currency === 'CNY' ? (
<InputNumber<string>
aria-label={`SKU ${index + 1} 售价(人民币)`}
min="0.01"
stringMode
precision={2}
prefix="¥"
disabled={imported || !params}
value={
params?.mode === 'fixed_sale'
? params.selling_price
: (option?.selling_price ?? original?.listing.selling_price)
}
onChange={(value) => {
if (params)
setParameters({
...params,
mode: 'fixed_sale',
selling_price: value,
});
}}
onBlur={() => {
if (auto) void calculate(true);
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
) : (
<div className="sku-pricing-readonly">{cny(saleCny)}</div>
)}
</label>
<div>
<span>预估净利润(人民币)</span>
<div
className={`sku-pricing-readonly${profitCny && Number(profitCny) < 0 ? ' sku-pricing-loss' : ' sku-pricing-profit'}`}
>
{cny(profitCny)}
</div>
</div>
</div>
<div
className={`sku-pricing-feedback${needsAttention ? ' sku-pricing-feedback--warning' : confirmed ? ' sku-pricing-feedback--confirmed' : ''}`}
role={error ? 'alert' : 'status'}
>
<span className="sku-pricing-feedback-dot" aria-hidden="true" />
<span>{feedback}</span>
</div>
<div className="sku-pricing-meta">
<span>
{policyName}
{option ? ` · ${option.transport_name}` : ''}
</span>
{currency !== 'CNY' && (
<span>
平台报价为 {currency};人民币售价仅供参考,请在试算详情调整报价。
</span>
)}
{params?.mode === 'fixed_sale' && !imported && (
<Button
type="link"
size="small"
onClick={() => {
const next = {
...params,
mode: 'target_markup' as const,
selling_price: null,
};
setParameters(next);
void calculate(true, next);
}}
>
使用建议售价
</Button>
)}
</div>
<Modal
title={`${v.display_name ?? 'SKU'} · 定价试算`}
open={!!modal}
width={1000}
footer={null}
destroyOnHidden
onCancel={() => setModal(null)}
>
{modal && catalog && (
<>
<Typography.Paragraph type="secondary">
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
</Typography.Paragraph>
<Button
size="small"
onClick={() => {
restoreInheritance.current = true;
const base = form.getFieldValue([
'common',
'default_package_spec',
]);
setModal({
...modal,
package: { ...base, status: base.status },
});
}}
>
使用 SPU 包装重新试算
</Button>
<PricingCalculator
key={JSON.stringify(modal)}
initial={modal}
previous={result?.option ?? original?.listing.pricing_result}
catalog={catalog}
onCancel={() => setModal(null)}
onApply={(input, p, id) => {
apply(input, p, id, true);
setModal(null);
}}
/>
</>
)}
</Modal>
</section>
),
});
}
@@ -0,0 +1,23 @@
import type { PricingPreviewRequest } from '@atlas/contracts';
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
const purchaseCost = /^\d{1,9}(\.\d{1,2})?$/;
export function pricingInputProblem(input: PricingPreviewRequest): string | null {
if (!input.purchase_cost_cny) return '请先填写采购价(CNY)';
if (!purchaseCost.test(input.purchase_cost_cny))
return '采购价格式不正确:整数最多 9 位,小数最多 2 位';
if (!input.platform_product_category_id) return '请先选择商品类目';
if (
![
input.package.shipping_weight_g,
input.package.length_mm,
input.package.width_mm,
input.package.height_mm,
].every(isPositivePackageInteger)
)
return '包装重量和尺寸请输入大于 0 的整数(g / mm)';
if (input.package.status !== 'confirmed')
return '请核对包装重量和尺寸,再点击“核对后确认包装”';
return null;
}
+8 -1
View File
@@ -53,6 +53,11 @@ export default function SystemAdminLayout() {
icon: <DatabaseOutlined />,
label: '平台类目与属性',
},
{
key: '/system/commissions',
icon: <CalculatorOutlined />,
label: '类目佣金',
},
{
key: '/system/pricing-rates',
icon: <CalculatorOutlined />,
@@ -68,7 +73,9 @@ export default function SystemAdminLayout() {
系统管理 /{' '}
{location.pathname.startsWith('/system/pricing-rates')
? '物流费率审核'
: '平台类目与属性'}
: location.pathname.startsWith('/system/commissions')
? '类目佣金'
: '平台类目与属性'}
</span>
<Button
type="text"
+3 -12
View File
@@ -126,7 +126,7 @@ export default function PricingPage() {
catalog && !parameters ? (
<Alert
type="warning"
title="当前平台没有可用于正式试算的物流费率"
title="当前平台没有可用的物流费率"
description={
<Space orientation="vertical" size="small">
{catalog.pending_rate_cards.length > 0 && (
@@ -138,8 +138,8 @@ export default function PricingPage() {
</span>
)}
<span>
待复核费率不能用于正式报价。系统管理员须在「系统管理 →
物流费率审核」录入真实账单样本,复算通过后按生效日期启用。
请检查费率卡状态和草稿卡使用配置。需要核实运费时,系统管理员可在「系统管理 →
物流费率审核」录入真实账单样本并启用已审核版本。
</span>
{adminAccess === 'allowed' && (
<Button
@@ -183,15 +183,6 @@ export default function PricingPage() {
/>
{error && <Alert type="error" title={error} />}{' '}
{!store && !error && <Empty description="请先创建目标门店" />}
{catalog && (
catalog.rate_cards.some((card) => card.development_only) && (
<Alert
type="warning"
title="当前使用开发草稿费率"
description="可用于方案、试算和商品编辑联调;按草稿费率确认的 SKU 不能首次上架,正式审核后需重新试算确认。"
/>
)
)}
{catalog && (
<Tabs
items={[
+136 -103
View File
@@ -27,6 +27,7 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { productEditorApi } from '@/services/productEditor';
import { usePlatform } from '@/services/platform';
import { collectionApi } from '@/services/collection';
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
import {
CollectedProductInfoPanel,
type CollectedProductSummary,
@@ -46,25 +47,6 @@ import './ProductEditPage.css';
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
/** 规格字段有值时才可据此恢复到对应的 SKU 编辑模式。 */
function hasPackageWeight(spec: {
length_mm: string | null;
width_mm: string | null;
height_mm: string | null;
shipping_weight_g: string | null;
}) {
return Boolean(spec.shipping_weight_g);
}
function hasPackageDimensions(spec: {
length_mm: string | null;
width_mm: string | null;
height_mm: string | null;
shipping_weight_g: string | null;
}) {
return Boolean(spec.length_mm && spec.width_mm && spec.height_mm);
}
/**
* 按 schema 补齐表单中缺失的属性草稿:aspect(变体维度)属性逐个 SKU 一条,
* 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。
@@ -108,7 +90,7 @@ function missingAttributeDrafts(
}
/** 服务端快照 → antd 表单初值;已有变体不携带 client_key(仅新建/复制时生成)。 */
function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
function formValues(snapshot: ProductEditorSnapshot, platformCode: string): EditorFormValues {
return {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
@@ -116,10 +98,15 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_spec,
package_weight_mode: snapshot.product.package_weight_mode,
package_dimensions_mode: snapshot.product.package_dimensions_mode,
},
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment'],
fulfillment:
platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown'
? 'rfbs'
: (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']),
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
@@ -258,6 +245,22 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
height_mm: nullable(spec.height_mm),
shipping_weight_g: nullable(spec.shipping_weight_g),
});
const commonPackage = packageSpec(values.common.default_package_spec);
const weightByVariant = values.common.package_weight_mode === 'variant';
const dimensionsByVariant = values.common.package_dimensions_mode === 'variant';
const packageComplete = (
spec: Pick<
typeof commonPackage,
'length_mm' | 'width_mm' | 'height_mm' | 'shipping_weight_g'
>
) =>
Boolean(spec.length_mm && spec.width_mm && spec.height_mm && spec.shipping_weight_g);
commonPackage.status =
commonPackage.status === 'ambiguous'
? 'ambiguous'
: packageComplete(commonPackage)
? 'confirmed'
: 'missing';
return {
...values,
common: {
@@ -268,7 +271,9 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
purchase_url: nullable(values.common.purchase_url),
supply_note: nullable(values.common.supply_note),
default_physical_spec: physical(values.common.default_physical_spec),
default_package_spec: packageSpec(values.common.default_package_spec),
default_package_spec: commonPackage,
package_weight_mode: values.common.package_weight_mode ?? 'common',
package_dimensions_mode: values.common.package_dimensions_mode ?? 'common',
},
profile: {
...values.profile,
@@ -282,21 +287,40 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
}),
variants: values.variants.map((variant) => ({
...variant,
display_name: nullable(variant.display_name),
source_sku: nullable(variant.source_sku),
purchase_cost_cny: nullable(variant.purchase_cost_cny),
offer_id: nullable(variant.offer_id),
title: nullable(variant.title),
specification_values: variant.specification_values.map((item) => ({
axis_key: item.axis_key.trim(),
value_key: item.value_key.trim(),
value_name: item.value_name.trim(),
})),
physical_spec: physical(variant.physical_spec),
package_spec: packageSpec(variant.package_spec),
})),
variants: values.variants.map((variant) => {
const normalizedPackage = packageSpec(variant.package_spec);
if (weightByVariant || dimensionsByVariant) {
const effectivePackage = {
...normalizedPackage,
length_mm: normalizedPackage.length_mm ?? commonPackage.length_mm,
width_mm: normalizedPackage.width_mm ?? commonPackage.width_mm,
height_mm: normalizedPackage.height_mm ?? commonPackage.height_mm,
shipping_weight_g:
normalizedPackage.shipping_weight_g ?? commonPackage.shipping_weight_g,
};
normalizedPackage.status =
normalizedPackage.status === 'ambiguous'
? 'ambiguous'
: packageComplete(effectivePackage)
? 'confirmed'
: 'missing';
}
return {
...variant,
display_name: nullable(variant.display_name),
source_sku: nullable(variant.source_sku),
purchase_cost_cny: nullable(variant.purchase_cost_cny),
offer_id: nullable(variant.offer_id),
title: nullable(variant.title),
specification_values: variant.specification_values.map((item) => ({
axis_key: item.axis_key.trim(),
value_key: item.value_key.trim(),
value_name: item.value_name.trim(),
})),
physical_spec: physical(variant.physical_spec),
package_spec: normalizedPackage,
};
}),
};
}
@@ -374,10 +398,11 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
const dimensions = isDimension ? millimetres(attribute.value) : null;
const weight = isWeight ? numberInGrams(attribute.value) : null;
if (isPackage && dimensions) {
[result.package_length_mm, result.package_width_mm, result.package_height_mm] =
dimensions;
if (isPositivePackageInteger(dimensions[0])) result.package_length_mm = dimensions[0];
if (isPositivePackageInteger(dimensions[1])) result.package_width_mm = dimensions[1];
if (isPositivePackageInteger(dimensions[2])) result.package_height_mm = dimensions[2];
}
if (isPackage && weight) result.shipping_weight_g = weight;
if (isPackage && isPositivePackageInteger(weight)) result.shipping_weight_g = weight;
}
return result;
}
@@ -489,15 +514,9 @@ const ProductEditPage = () => {
setSourceDraft(next.source_material);
setSourceOverriddenFields(next.source_material_state.overridden_fields);
sourceSpecsApplied.current = false;
form.setFieldsValue(formValues(next));
setUsesVariantPackageWeight(
next.variants.length > 0 &&
next.variants.every((variant) => hasPackageWeight(variant.package_spec))
);
setUsesVariantPackageDimensions(
next.variants.length > 0 &&
next.variants.every((variant) => hasPackageDimensions(variant.package_spec))
);
form.setFieldsValue(formValues(next, currentPlatform.code));
setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant');
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
@@ -696,29 +715,40 @@ const ProductEditPage = () => {
};
}, [currentPlatform, snapshot]);
const collected = sourceDraft ?? snapshot?.source_material ?? null;
// 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值;
// 首次拿到采集数据时只自动填充通用模式下的 SPU 包装规格:只补空字段,绝不覆盖已有值;
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
// sourceSpecsApplied 保证整个页面生命周期只执行一次。
useEffect(() => {
if (sourceSpecsApplied.current || !collected) return;
const specs = detectedSpecs(collected.attributes);
const packageSpec = form.getFieldValue(['common', 'default_package_spec']);
const commonWeight = form.getFieldValue(['common', 'package_weight_mode']) !== 'variant';
const commonDimensions =
form.getFieldValue(['common', 'package_dimensions_mode']) !== 'variant';
const hasAppliedValue = [
[packageSpec?.length_mm, specs.package_length_mm],
[packageSpec?.width_mm, specs.package_width_mm],
[packageSpec?.height_mm, specs.package_height_mm],
[packageSpec?.shipping_weight_g, specs.shipping_weight_g],
].some(([current, detected]) => !current && !!detected);
[commonDimensions, packageSpec?.length_mm, specs.package_length_mm],
[commonDimensions, packageSpec?.width_mm, specs.package_width_mm],
[commonDimensions, packageSpec?.height_mm, specs.package_height_mm],
[commonWeight, packageSpec?.shipping_weight_g, specs.shipping_weight_g],
].some(([enabled, current, detected]) => enabled && !current && !!detected);
if (hasAppliedValue) {
form.setFieldsValue({
common: {
default_package_spec: {
...packageSpec,
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null,
width_mm: packageSpec?.width_mm ?? specs.package_width_mm ?? null,
height_mm: packageSpec?.height_mm ?? specs.package_height_mm ?? null,
length_mm: commonDimensions
? (packageSpec?.length_mm ?? specs.package_length_mm ?? null)
: (packageSpec?.length_mm ?? null),
width_mm: commonDimensions
? (packageSpec?.width_mm ?? specs.package_width_mm ?? null)
: (packageSpec?.width_mm ?? null),
height_mm: commonDimensions
? (packageSpec?.height_mm ?? specs.package_height_mm ?? null)
: (packageSpec?.height_mm ?? null),
shipping_weight_g:
packageSpec?.shipping_weight_g ?? specs.shipping_weight_g ?? null,
commonWeight
? (packageSpec?.shipping_weight_g ?? specs.shipping_weight_g ?? null)
: (packageSpec?.shipping_weight_g ?? null),
},
},
});
@@ -750,53 +780,47 @@ const ProductEditPage = () => {
}, [blocker, modal]);
const leave = () => navigate('/collection');
/** 将 SPU 共用包装重量复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */
const useVariantPackageWeight = () => {
const defaultPackageSpec = form.getFieldValue([
'common',
'default_package_spec',
]) as EditorFormValues['common']['default_package_spec'];
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
form.setFieldsValue({
variants: variants.map((variant) => {
const packageSpec = variant.package_spec;
return {
...variant,
package_spec: {
...packageSpec,
shipping_weight_g:
packageSpec.shipping_weight_g ?? defaultPackageSpec.shipping_weight_g,
},
};
}),
});
setUsesVariantPackageWeight(true);
/** 包装重量和尺寸独立切换;退出逐 SKU 模式时清除全部 SKU 对应覆盖值。 */
const changePackageMode = (field: 'weight' | 'dimensions', mode: 'common' | 'variant') => {
const keys =
field === 'weight'
? (['shipping_weight_g'] as const)
: (['length_mm', 'width_mm', 'height_mm'] as const);
const modeKey = field === 'weight' ? 'package_weight_mode' : 'package_dimensions_mode';
if (mode === 'variant') {
keys.forEach((key) =>
form.setFieldValue(['common', 'default_package_spec', key], null)
);
form.setFieldValue(['common', 'default_package_spec', 'status'], 'missing');
} else {
const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
variants.forEach((_, index) => {
keys.forEach((key) =>
form.setFieldValue(['variants', index, 'package_spec', key], null)
);
form.setFieldValue(['variants', index, 'package_spec', 'status'], null);
});
}
form.setFieldValue(['common', modeKey], mode);
if (field === 'weight') setUsesVariantPackageWeight(mode === 'variant');
else setUsesVariantPackageDimensions(mode === 'variant');
setDirty(true);
};
/** 将 SPU 共用包装尺寸复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */
const useVariantPackageDimensions = () => {
const defaultPackageSpec = form.getFieldValue([
'common',
'default_package_spec',
]) as EditorFormValues['common']['default_package_spec'];
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
form.setFieldsValue({
variants: variants.map((variant) => {
const packageSpec = variant.package_spec;
return {
...variant,
package_spec: {
...packageSpec,
length_mm: packageSpec.length_mm ?? defaultPackageSpec.length_mm,
width_mm: packageSpec.width_mm ?? defaultPackageSpec.width_mm,
height_mm: packageSpec.height_mm ?? defaultPackageSpec.height_mm,
},
};
}),
const confirmCommonPackageMode = (field: 'weight' | 'dimensions') => {
const label = field === 'weight' ? '包装重量' : '包装尺寸';
modal.confirm({
title: `将${label}设为通用?`,
content: `确认后将清空所有 SKU 的${label},并恢复商品通用${label}的编辑。`,
okText: '确认调整',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => changePackageMode(field, 'common'),
});
setUsesVariantPackageDimensions(true);
setDirty(true);
};
const useVariantPackageWeight = () => changePackageMode('weight', 'variant');
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
const useVariantTags = () => setUsesVariantTags(true);
const useVariantDescription = () => setUsesVariantDescription(true);
@@ -953,7 +977,12 @@ const ProductEditPage = () => {
'height_mm',
'shipping_weight_g',
] as const) {
if (!spec[key]) {
if (
(key === 'shipping_weight_g' && common.package_weight_mode === 'variant') ||
(key !== 'shipping_weight_g' && common.package_dimensions_mode === 'variant')
)
continue;
if (!spec[key] && isPositivePackageInteger(result.package_spec[key])) {
spec[key] = result.package_spec[key];
packageFilled = true;
}
@@ -1410,6 +1439,8 @@ const ProductEditPage = () => {
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
onUseVariantPackageWeight={useVariantPackageWeight}
onUseVariantPackageDimensions={useVariantPackageDimensions}
onUseCommonPackageWeight={useCommonPackageWeight}
onUseCommonPackageDimensions={useCommonPackageDimensions}
/>
<ProductAttributeSection
@@ -1452,6 +1483,8 @@ const ProductEditPage = () => {
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseCommonPackageWeight={useCommonPackageWeight}
onUseCommonPackageDimensions={useCommonPackageDimensions}
usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription}
usesVariantTitle={usesVariantTitle}
@@ -44,7 +44,7 @@
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label {
width: 100%;
height: auto;
align-items: flex-start;
align-items: center;
justify-content: flex-end;
overflow: visible;
font-weight: 400;
@@ -54,6 +54,24 @@
white-space: normal;
word-break: break-word;
}
/* 横向表单的标签与输入框共用 35px 高度;长标签换行时整行仍保持垂直居中。 */
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-label > label {
display: flex;
min-height: 35px;
}
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-row {
align-items: center;
}
/* SKU 标签改为块级 flex,避免必填星号影响行盒高度,使同排输入框对齐。 */
.product-editor .editor-section .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
display: flex;
min-height: 25px;
}
@container (max-width: 479px) {
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-row {
align-items: stretch;
}
}
/* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */
.dynamic-attribute-label {
display: inline-flex;
@@ -106,10 +124,10 @@
border-color: #667085;
background: transparent;
}
/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */
/* 多选标签与全站输入控件使用 35px 基准高度;多项换行时按内容自然增高。 */
.product-color-select.ant-select-multiple .ant-select-selector {
box-sizing: border-box;
min-height: 32px;
min-height: 35px;
padding-top: 1px;
padding-bottom: 1px;
}
@@ -14,7 +14,7 @@
display: grid;
grid-template-columns:
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
22px 32px;
22px max-content;
align-items: center;
gap: 7px;
max-width: 100%;
@@ -31,7 +31,7 @@
line-height: 32px;
white-space: nowrap;
}
.package-dimensions-controls .ant-input-number {
.package-dimensions-controls .ant-input {
width: 100%;
}
.package-weight-control {
@@ -39,6 +39,15 @@
align-items: center;
gap: 4px;
}
.package-mode-button.ant-btn {
height: 35px;
padding-inline: 5px;
color: #714edf;
white-space: nowrap;
}
.package-mode-button.ant-btn:hover {
color: #6240d2;
}
/* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */
.editor-category-popup .ant-cascader-menu {
@@ -90,6 +99,9 @@
width: 100%;
grid-template-columns: 1fr;
}
.package-dimensions-controls .package-mode-button {
justify-self: start;
}
.package-dimension-separator {
display: none;
}
@@ -1,7 +1,8 @@
import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd';
import type { FormInstance } from 'antd';
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot } from '@atlas/contracts';
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
import { SectionTitle } from './SectionTitle';
import './ProductMainInfoSection.css';
@@ -20,62 +21,74 @@ function PackageFields({
usesVariantPackageDimensions,
onUseVariantPackageWeight,
onUseVariantPackageDimensions,
onUseCommonPackageWeight,
onUseCommonPackageDimensions,
}: {
name: NamePath;
usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean;
onUseVariantPackageWeight: () => void;
onUseVariantPackageDimensions: () => void;
onUseCommonPackageWeight: () => void;
onUseCommonPackageDimensions: () => void;
}) {
return (
<div className="package-fields">
{!usesVariantPackageWeight && (
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
<div className="package-weight-control">
<InputNumber<string>
min="0"
step="0.001"
stringMode
<Form.Item label="包装重量" required={!usesVariantPackageWeight}>
<div className="package-weight-control">
<Form.Item name={[...name, 'shipping_weight_g']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput
addonAfter="g"
style={{ width: 150 }}
disabled={usesVariantPackageWeight}
/>
<Tooltip title="按 SKU 分别填写包装重量">
<Button
type="text"
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写包装重量"
onClick={onUseVariantPackageWeight}
/>
</Tooltip>
</div>
</Form.Item>
)}
{!usesVariantPackageDimensions && (
<Form.Item label="包装尺寸" required className="package-dimensions">
<div className="package-dimensions-controls">
<Form.Item name={[...name, 'length_mm']} noStyle>
<InputNumber<string> min="0" step="0.001" stringMode placeholder="长" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle>
<InputNumber<string> min="0" step="0.001" stringMode placeholder="宽" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle>
<InputNumber<string> min="0" step="0.001" stringMode placeholder="高" />
</Form.Item>
<span className="package-dimension-unit">mm</span>
<Tooltip title="按 SKU 分别填写包装尺寸">
<Button
type="text"
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
aria-label="按 SKU 分别填写包装尺寸"
onClick={onUseVariantPackageDimensions}
/>
</Tooltip>
</div>
</Form.Item>
)}
</Form.Item>
<Button
type="link"
className="package-mode-button"
aria-label={`${usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}包装重量`}
onClick={
usesVariantPackageWeight
? onUseCommonPackageWeight
: onUseVariantPackageWeight
}
>
{usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}
</Button>
</div>
</Form.Item>
<Form.Item
label="包装尺寸"
required={!usesVariantPackageDimensions}
className="package-dimensions"
>
<div className="package-dimensions-controls">
<Form.Item name={[...name, 'length_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="长" disabled={usesVariantPackageDimensions} />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="宽" disabled={usesVariantPackageDimensions} />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="高" disabled={usesVariantPackageDimensions} />
</Form.Item>
<span className="package-dimension-unit">mm</span>
<Button
type="link"
className="package-mode-button"
aria-label={`${usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}包装尺寸`}
onClick={
usesVariantPackageDimensions
? onUseCommonPackageDimensions
: onUseVariantPackageDimensions
}
>
{usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}
</Button>
</div>
</Form.Item>
</div>
);
}
@@ -93,6 +106,8 @@ type Props = {
usesVariantPackageDimensions: boolean;
onUseVariantPackageWeight: () => void;
onUseVariantPackageDimensions: () => void;
onUseCommonPackageWeight: () => void;
onUseCommonPackageDimensions: () => void;
usesVariantTitle: boolean;
onUseVariantTitle: () => void;
onCompareTitle: () => void;
@@ -117,6 +132,8 @@ export function ProductMainInfoSection({
usesVariantPackageDimensions,
onUseVariantPackageWeight,
onUseVariantPackageDimensions,
onUseCommonPackageWeight,
onUseCommonPackageDimensions,
usesVariantTitle,
onUseVariantTitle,
onCompareTitle,
@@ -172,7 +189,7 @@ export function ProductMainInfoSection({
name={['profile', 'platform_product_category_id']}
label="产品类目"
className="span-full"
rules={[{ required: true, message: '请选择产品类目' }]}
required
getValueProps={(categoryId: string | null) => ({
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
})}
@@ -205,7 +222,8 @@ export function ProductMainInfoSection({
name={['common', 'source_title']}
label="中文商品名"
className="span-full"
rules={[{ required: true, message: '请填写中文商品名' }, { max: 1000 }]}
required
rules={[{ max: 1000 }]}
>
<Input placeholder="填写中文商品名" />
</Form.Item>
@@ -214,14 +232,7 @@ export function ProductMainInfoSection({
<Form.Item
name={['profile', 'title']}
noStyle
rules={[
{
required: true,
whitespace: true,
message: '请填写俄文发布标题',
},
{ max: 1000 },
]}
rules={[{ max: 1000 }]}
>
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
</Form.Item>
@@ -248,27 +259,27 @@ export function ProductMainInfoSection({
<Form.Item
name={['common', 'model']}
label="型号"
rules={[{ required: true, message: '请填写型号' }]}
required
>
<Input className="editor-short-input" placeholder="首次上架前需填写" />
</Form.Item>
<Form.Item
name={['common', 'brand']}
label="品牌"
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]}
required
>
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
</Form.Item>
</div>
{(!usesVariantPackageWeight || !usesVariantPackageDimensions) && (
<PackageFields
name={['common', 'default_package_spec']}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseVariantPackageWeight={onUseVariantPackageWeight}
onUseVariantPackageDimensions={onUseVariantPackageDimensions}
/>
)}
<PackageFields
name={['common', 'default_package_spec']}
usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseVariantPackageWeight={onUseVariantPackageWeight}
onUseVariantPackageDimensions={onUseVariantPackageDimensions}
onUseCommonPackageWeight={onUseCommonPackageWeight}
onUseCommonPackageDimensions={onUseCommonPackageDimensions}
/>
</Card>
);
}
@@ -1,366 +1,566 @@
/* 「03 变体设置」区块:SKU 卡片、展开后的图片选择与规格面板。 */
/* SKU 卡片内的输入圆角与 antd 输入框保持一致 */
.sku-section .ant-input {
border-radius: 8px;
/* SKU 卡片沿用编辑页配色,收起态仅保留识别、价格和单一试算状态。 */
.sku-section.ant-card {
container-type: inline-size;
}
.sku-section .ant-input-number {
width: 100%;
border-radius: 8px;
.sku-section-count {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 3px 8px;
border-radius: 6px;
background: #f7f5fa;
color: #77718a;
font-size: 12px;
font-weight: 500;
vertical-align: 2px;
}
.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-section .ant-input-number-group .ant-input-number {
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: 1px solid #e8e4ed;
border-radius: 12px;
box-shadow: none;
overflow: hidden;
}
/* 用 .sku-card.ant-card 提高权重:内层卡片同时命中外层
.editor-section .ant-card-body(页面级)规则,同级特异性会按加载顺序被覆盖 */
.sku-card.ant-card > .ant-card-body {
padding: 12px 14px;
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
grid-template-rows: minmax(74px, auto) minmax(70px, auto) auto;
padding: 0;
}
.sku-main-preview {
grid-column: 1;
grid-row: 1 / span 2;
width: 112px;
height: 144px;
padding: 8px;
}
/* 卡片主体:左侧主图缩略图(贴边、跨"名称 + 货号"两行),右侧内容列 */
.sku-card-main {
grid-column: 2;
grid-row: 1;
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,
.sku-card-summary .ant-input-number {
width: 100%;
}
.sku-card-field,
.sku-card-value {
gap: 12px;
min-width: 0;
}
/* SKU 名称:无标签,直接内联在序号右侧,占据顶行剩余宽度 */
.sku-card-name {
flex: 1 1 auto;
width: auto;
min-width: 0;
font-weight: 500;
}
.sku-card-field {
/* 三个可编辑字段在同一行时,输入框允许收缩至 100px 后才换行。 */
flex: 1 1 100px;
min-width: 100px;
}
.sku-price-field {
flex: 1 1 100px;
}
.sku-card-field > span,
.sku-card-value > span {
display: block;
margin-bottom: 4px;
color: #77718a;
font-size: 12px;
padding: 15px 16px 12px;
}
.sku-main-thumbnail {
position: relative;
display: flex;
flex: 0 0 auto;
align-self: flex-start;
width: 75px;
width: 96px;
height: 128px;
aspect-ratio: 3 / 4;
align-items: center;
justify-content: center;
padding: 0 4px;
overflow: hidden;
border: 1px solid #ece8f0;
border: 1px solid #e8e4ed;
border-radius: 8px;
background: #f4f2f7;
background: #f6f4f8;
color: #90889c;
font-size: 10px;
font-size: 11px;
text-align: center;
}
.sku-main-thumbnail .ant-image {
position: absolute;
inset: 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.sku-main-thumbnail .ant-image img {
display: block;
.sku-main-thumbnail img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.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 信息的顺序展示。 */
.sku-card-expanded {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0edf3;
}
.sku-media-panel,
.sku-info-panel {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.sku-media-panel {
width: 100%;
}
.sku-media-panel h5.ant-typography,
.sku-info-panel h5.ant-typography {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 10px;
text-align: center;
font-size: 16px;
line-height: 24px;
}
.sku-media-panel h5.ant-typography::before,
.sku-media-panel h5.ant-typography::after,
.sku-info-panel h5.ant-typography::before,
.sku-info-panel h5.ant-typography::after {
content: '';
flex: 1;
border-top: 1px dashed #d9d4df;
}
.sku-media-local-note {
display: block;
margin-bottom: 10px;
color: #8d789c;
font-size: 12px;
}
.sku-media-grid {
display: grid;
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-flow: row;
gap: 8px;
align-items: start;
}
.sku-media-item {
position: relative;
min-width: 0;
/* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */
aspect-ratio: 3 / 4;
align-self: start;
overflow: hidden;
border: 1px solid #ece8f0;
border-radius: 6px;
background: #f5f3f7;
}
.sku-media-item--main {
grid-column: span 2;
grid-row: span 2;
}
.sku-media-main-badge {
.sku-main-badge,
.sku-main-count {
position: absolute;
top: 4px;
left: 4px;
z-index: 1;
padding: 2px 6px;
border-radius: 4px;
background: rgb(64 57 79 / 72%);
left: 8px;
padding: 3px 6px;
border-radius: 5px;
background: rgb(41 36 51 / 82%);
color: #fff;
font-size: 11px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
}
.sku-media-item .ant-image {
position: absolute;
inset: 0;
display: flex;
.sku-main-badge {
top: 8px;
}
.sku-main-count {
bottom: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sku-media-item .ant-image img {
.sku-main-count .anticon {
font-size: 12px;
}
.sku-main-empty {
padding: 4px;
}
.sku-card-content {
width: 100%;
height: 100%;
min-width: 0;
}
.sku-card-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
}
.sku-card-index {
padding: 2px 6px;
border-radius: 5px;
background: #f7f5fa;
color: #77718a;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.sku-card-name {
min-width: 0;
color: #292433;
font-size: 15px;
font-weight: 600;
overflow-wrap: anywhere;
}
.sku-card-top > .ant-space {
margin-left: auto;
}
.sku-card-top > .ant-space .sku-card-action.ant-btn {
padding-inline: 4px;
color: #714edf;
}
.sku-card-top > .ant-space .sku-card-action.ant-btn:hover {
background: transparent;
color: #714edf;
}
.sku-card-top > .ant-space .sku-card-action--muted.ant-btn,
.sku-card-top > .ant-space .sku-card-action--muted.ant-btn:hover {
color: #77718a;
}
.sku-card-offer {
margin-top: 5px;
color: #77718a;
font-size: 12px;
overflow-wrap: anywhere;
}
.sku-pricing-status {
display: inline-flex;
align-items: center;
min-height: 21px;
padding: 2px 8px;
border-radius: 99px;
background: #f1ecff;
color: #714edf;
font-size: 11px;
font-weight: 600;
}
.sku-pricing-status--warning {
background: #fff5e6;
color: #956014;
}
.sku-pricing-status--confirmed {
background: #eef8f1;
color: #237b4c;
}
.sku-card-summary {
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-width: 0;
padding: 10px 16px 11px;
border-top: 1px solid #f0edf3;
}
.sku-pricing-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
.sku-pricing-summary > div {
min-width: 0;
}
.sku-pricing-summary span,
.sku-pricing-fields > label > span,
.sku-pricing-fields > div > span {
display: block;
object-fit: contain;
object-position: center;
margin-bottom: 4px;
color: #77718a;
font-size: 11px;
}
.sku-media-picker.ant-btn {
.sku-pricing-summary strong {
display: block;
color: #292433;
font-size: 16px;
font-variant-numeric: tabular-nums;
font-weight: 600;
white-space: nowrap;
}
.sku-pricing-summary .sku-pricing-profit,
.sku-pricing-readonly.sku-pricing-profit {
color: #237b4c;
}
.sku-pricing-summary .sku-pricing-loss,
.sku-pricing-readonly.sku-pricing-loss {
color: #c62843;
}
.sku-expand-button.ant-btn {
border-color: #e8e4ed;
border-radius: 7px;
color: #292433;
white-space: nowrap;
}
.sku-card-expanded {
grid-column: 1 / -1;
grid-row: 3;
padding: 0 18px 18px;
border-top: 1px solid #f0edf3;
}
.sku-pricing-panel,
.sku-info-panel,
.sku-media-panel {
padding-top: 18px;
}
.sku-info-panel,
.sku-media-panel {
margin-top: 18px;
border-top: 1px solid #e8e4ed;
}
.sku-panel-heading {
display: flex;
width: 100%;
height: 100%;
aspect-ratio: 3 / 4;
align-self: start;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #90889c;
font-size: 13px;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sku-media-picker--empty {
grid-column: span 2;
grid-row: span 2;
.sku-panel-heading h5 {
margin: 0;
color: #292433;
font-size: 15px;
font-weight: 600;
}
.sku-media-count {
margin-left: 6px;
color: #77718a;
font-size: 11px;
font-weight: 400;
}
.sku-panel-actions {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.sku-panel-actions .ant-btn,
.sku-media-panel .ant-btn,
.sku-expand-button.ant-btn {
border-radius: 7px;
}
.sku-pricing-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 11px;
}
.sku-pricing-fields > label,
.sku-pricing-fields > div {
min-width: 0;
}
.sku-pricing-fields .ant-input-number {
width: 100%;
min-height: 35px;
border-radius: 7px;
}
.sku-pricing-readonly {
display: flex;
align-items: center;
min-height: 35px;
padding: 6px 9px;
border: 1px solid #e8e4ed;
border-radius: 7px;
background: #faf9fc;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.sku-pricing-feedback {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 11px;
padding: 10px 12px;
border-radius: 7px;
background: #f1ecff;
color: #40394f;
font-size: 12px;
}
.sku-pricing-feedback--warning {
background: #fff5e6;
}
.sku-pricing-feedback--confirmed {
background: #eef8f1;
}
.sku-pricing-feedback-dot {
flex: 0 0 6px;
width: 6px;
height: 6px;
margin-top: 6px;
border-radius: 50%;
background: #714edf;
}
.sku-pricing-feedback--warning .sku-pricing-feedback-dot {
background: #956014;
}
.sku-pricing-feedback--confirmed .sku-pricing-feedback-dot {
background: #237b4c;
}
.sku-pricing-meta {
display: flex;
align-items: center;
gap: 4px 10px;
flex-wrap: wrap;
margin-top: 9px;
color: #77718a;
font-size: 12px;
}
.sku-info-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px 12px;
}
.sku-info-panel h5.ant-typography,
.sku-info-panel > .ant-typography {
.sku-info-panel > .sku-panel-heading,
.sku-info-group-title,
.sku-info-inherit-note,
.sku-info-panel > .dynamic-attribute-list {
grid-column: 1 / -1;
}
.sku-info-panel > .sku-panel-heading {
margin-bottom: 0;
}
.sku-info-group-title {
margin-top: 4px;
color: #77718a;
font-size: 12px;
}
.sku-info-panel .ant-form-item {
margin-bottom: 0;
min-width: 0;
}
/* SKU 信息保持标签在上、控件在下,独立于页面其他横向表单布局。 */
.sku-info-panel .ant-form-item-row {
display: block;
}
.sku-info-panel .ant-form-item-label {
display: block;
min-height: 26px;
padding: 0 0 4px;
padding: 0 0 5px;
text-align: left;
}
.sku-info-panel .ant-form-item-label > label {
align-items: center;
.sku-info-panel .ant-form-item-label > label,
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
justify-content: flex-start;
font-weight: 600;
color: #77718a;
font-size: 12px;
font-weight: 500;
}
.sku-info-panel .ant-form-item-control {
display: block;
min-width: 0;
.sku-info-panel .ant-input,
.sku-info-panel .ant-input-number,
.sku-info-panel .ant-select-selector {
width: 100%;
min-height: 35px;
border-radius: 7px;
}
.sku-info-panel .ant-input-number,
.sku-info-panel .ant-select {
width: 100%;
}
.sku-info-panel .ant-input-textarea textarea {
min-height: 54px;
resize: vertical;
}
.sku-info-panel .ant-input-group-addon {
border-radius: 0 7px 7px 0;
}
.sku-info-panel .ant-space-compact .ant-btn {
min-height: 35px;
}
.sku-info-package-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px 12px;
}
.sku-info-package-row .sku-info-package-dimensions {
grid-column: span 2;
.sku-info-package-row > :only-child {
grid-column: 1 / -1;
}
.sku-info-variant-description {
grid-column: span 2;
.sku-info-panel .sku-info-package-row .ant-form-item-label > label {
width: 100%;
}
.sku-package-label {
display: inline-flex;
flex: 1;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.sku-package-label .ant-btn {
height: auto;
min-height: 0;
padding: 0 2px;
color: #714edf;
font-size: 12px;
line-height: 1.5;
}
.sku-package-label .ant-btn:hover {
color: #6240d2;
}
.sku-info-variant-description,
.sku-info-variant-title,
.sku-info-variant-tags {
grid-column: 1 / -1;
}
/* 所有动态 SKU 属性与包装、文案字段共用“SKU 信息”网格。 */
.sku-info-panel .dynamic-attribute-list {
display: contents;
}
.sku-info-panel .dynamic-attribute-item {
margin-bottom: 0;
min-width: 0;
}
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label {
display: block;
padding: 0 0 4px;
text-align: left;
}
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
align-items: center;
justify-content: flex-start;
padding: 0 0 5px;
text-align: left;
}
.sku-inline-dimensions {
display: grid;
grid-template-columns:
minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr)
28px;
gap: 6px;
grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 12px minmax(0, 1fr) 25px;
align-items: center;
gap: 4px;
}
.sku-dimension-separator {
color: #98a2b3;
font-size: 16px;
font-weight: 600;
text-align: center;
.sku-inline-dimensions .ant-input,
.sku-inline-dimensions .ant-input-number {
min-width: 0;
}
.sku-dimension-separator,
.sku-dimension-unit {
color: #77718a;
font-size: 13px;
text-align: right;
text-align: center;
}
.sku-info-inherit-note {
color: #77718a;
font-size: 12px;
}
/* 图片顺序与主图选择共用现有选图数据,不新增单独的主图字段。 */
.sku-media-layout {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.sku-media-main {
position: relative;
flex: 0 0 133px;
width: 133px;
height: 160px;
overflow: hidden;
border: 2px solid #714edf;
border-radius: 9px;
background: #f6f4f8;
}
.sku-media-main--empty {
display: flex;
align-items: center;
justify-content: center;
border-style: dashed;
color: #90889c;
font-size: 12px;
}
.sku-media-main .ant-image {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
.sku-media-main .ant-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sku-media-main-badge {
position: absolute;
z-index: 2;
top: 7px;
left: 7px;
padding: 3px 7px;
border-radius: 5px;
background: #714edf;
color: #fff;
font-size: 11px;
font-weight: 600;
}
.sku-media-main-caption {
position: absolute;
z-index: 2;
right: 0;
bottom: 0;
left: 0;
padding: 8px 4px 5px;
background: linear-gradient(transparent, rgb(30 20 45 / 70%));
color: #fff;
font-size: 11px;
text-align: center;
pointer-events: none;
}
.sku-media-other {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
flex: 1;
}
.sku-media-other-item {
display: flex;
width: 64px;
flex-direction: column;
align-items: center;
gap: 5px;
}
.sku-media-other-item .ant-image {
display: flex;
width: 64px;
height: 78px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid #e8e4ed;
border-radius: 7px;
background: #f6f4f8;
}
.sku-media-other-item img {
width: 100%;
height: 100%;
object-fit: contain;
}
.sku-media-other-item .ant-btn {
height: auto;
padding: 0;
font-size: 11px;
}
.sku-media-add.ant-btn {
width: 64px;
height: 78px;
border: 1px solid #e8e4ed;
border-radius: 7px;
background: #f6f4f8;
}
.sku-media-other-item:hover .ant-image {
border-color: #714edf;
}
.sku-media-add.ant-btn {
color: #77718a;
font-size: 22px;
}
.sku-media-note {
margin: 10px 0 0;
color: #77718a;
font-size: 12px;
}
.add-sku-row {
display: flex;
@@ -370,24 +570,59 @@
.add-sku-button {
min-width: 180px;
}
@media (max-width: 560px) {
.sku-inline-dimensions {
@container (max-width: 620px) {
.sku-card.ant-card > .ant-card-body {
grid-template-columns: 96px minmax(0, 1fr);
grid-template-rows: minmax(128px, auto) auto auto;
}
.sku-main-preview {
grid-row: 1;
width: 96px;
height: 128px;
padding: 8px 6px;
}
.sku-main-thumbnail {
width: 84px;
height: 112px;
}
.sku-card-main {
flex-wrap: wrap;
}
.sku-card-top > .ant-space {
width: 100%;
justify-content: flex-end;
}
.sku-card-summary {
grid-column: 1 / -1;
grid-row: 2;
grid-template-columns: 1fr;
}
.sku-inline-dimensions > span {
display: none;
.sku-expand-button.ant-btn {
justify-self: start;
}
.sku-info-panel {
grid-template-columns: 1fr;
.sku-pricing-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sku-pricing-fields > div:last-child {
grid-column: 1 / -1;
}
}
@container (max-width: 430px) {
.sku-pricing-summary strong {
font-size: 14px;
}
.sku-pricing-fields,
.sku-info-panel,
.sku-info-package-row {
grid-template-columns: 1fr;
}
.sku-info-package-row .sku-info-package-dimensions {
grid-column: span 1;
.sku-pricing-fields > div:last-child,
.sku-info-variant-description,
.sku-info-variant-title,
.sku-info-variant-tags {
grid-column: auto;
}
.sku-info-variant-description {
grid-column: span 1;
.sku-panel-heading {
flex-wrap: wrap;
}
}
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import './SectionTitle.css';
type Props = {
number: string;
title: string;
title: ReactNode;
description?: string;
extra?: ReactNode;
};
@@ -0,0 +1,34 @@
import { PictureOutlined } from '@ant-design/icons';
type Props = {
imageUrl?: string;
imageCount: number;
sourcePreview: boolean;
};
/** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
const imageLabel = sourcePreview ? '来源图' : '主图';
return (
<div className="sku-main-preview">
<div
className="sku-main-thumbnail"
role="img"
aria-label={`SKU ${sourcePreview ? '来源图片' : '主图'},已选 ${imageCount} 张图片`}
>
{imageUrl ? (
<>
<img src={imageUrl} alt="" />
<span className="sku-main-badge">{imageLabel}</span>
</>
) : (
<span className="sku-main-empty">暂无主图</span>
)}
<span className="sku-main-count" aria-hidden="true">
<PictureOutlined /> {sourcePreview ? '已选 0 张' : `${imageCount} 张图`}
</span>
</div>
</div>
);
}
@@ -213,7 +213,7 @@
height: 24px;
min-width: 24px;
padding: 0;
border-radius: 5px;
border-radius: 7px;
background: rgb(255 255 255 / 94%);
color: #26232d;
}
@@ -0,0 +1,256 @@
import { Button, Form, Input, Select, Space, Typography } from 'antd';
import type { FormInstance } from 'antd';
import { RobotOutlined, TranslationOutlined } from '@ant-design/icons';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
import { DynamicAttributeFields } from './DynamicAttributeFields';
type EditorFormValues = SaveProductEditorDraftRequest;
type NamePath = Array<string | number>;
type Props = {
form: FormInstance<EditorFormValues>;
fieldName: number;
sourceId: string | null;
usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean;
onUseCommonPackageWeight: () => void;
onUseCommonPackageDimensions: () => void;
usesVariantTags: boolean;
usesVariantDescription: boolean;
usesVariantTitle: boolean;
skuInfoAspectDefinitions: ProductAttributeSchema[];
colorNameDefinition: ProductAttributeSchema | undefined;
catalogSyncing: string | null;
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
onOpenDictionaryValues: (dictionaryId: string) => void;
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
};
/** 长×宽×高行内输入组(单位 mm)。 */
function InlineDimensions({ name }: { name: NamePath }) {
return (
<div className="sku-inline-dimensions">
<Form.Item name={[...name, 'length_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="长" />
</Form.Item>
<span className="sku-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="宽" />
</Form.Item>
<span className="sku-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle rules={[packageIntegerRule]}>
<PackageIntegerInput placeholder="高" />
</Form.Item>
<span className="sku-dimension-unit">mm</span>
</div>
);
}
/** SKU 自身的规格、文案与类目属性;空白覆盖值由服务端继承商品默认值。 */
export function SkuInfoPanel({
form,
fieldName,
sourceId,
usesVariantPackageWeight,
usesVariantPackageDimensions,
onUseCommonPackageWeight,
onUseCommonPackageDimensions,
usesVariantTags,
usesVariantDescription,
usesVariantTitle,
skuInfoAspectDefinitions,
colorNameDefinition,
catalogSyncing,
dictionaryValues,
onLoadDictionaryValues,
onSyncDictionaryValues,
onOpenDictionaryValues,
onLoadMoreDictionaryValues,
onAiCopy,
onCompareCopy,
}: Props) {
return (
<section className="sku-info-panel" aria-label="规格与发布信息">
<div className="sku-panel-heading">
<h5>规格与发布信息</h5>
</div>
<div className="sku-info-group-title">SKU 基础资料</div>
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
<Input placeholder="填写 SKU 名称" />
</Form.Item>
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical">
<Input placeholder="首次上架前填写" />
</Form.Item>
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
<>
<div className="sku-info-group-title">包装规格</div>
<div className="sku-info-package-row">
{usesVariantPackageWeight && (
<Form.Item
name={[fieldName, 'package_spec', 'shipping_weight_g']}
label={
<span className="sku-package-label">
<span>包装重量</span>
<Button
type="link"
size="small"
onClick={onUseCommonPackageWeight}
>
设为通用
</Button>
</span>
}
required
layout="vertical"
className="sku-info-package-weight"
rules={[packageIntegerRule]}
>
<PackageIntegerInput addonAfter="g" style={{ width: '100%' }} />
</Form.Item>
)}
{usesVariantPackageDimensions && (
<Form.Item
label={
<span className="sku-package-label">
<span>包装尺寸</span>
<Button
type="link"
size="small"
onClick={onUseCommonPackageDimensions}
>
设为通用
</Button>
</span>
}
required
layout="vertical"
className="sku-info-package-dimensions"
>
<InlineDimensions name={[fieldName, 'package_spec']} />
</Form.Item>
)}
</div>
<Typography.Text type="secondary" className="sku-info-inherit-note">
按 SKU 设置的包装规格需在各 SKU 中填写。
</Typography.Text>
</>
)}
<div className="sku-info-group-title">发布文案与类目属性</div>
<Form.Item
label="俄文发布标题"
layout="vertical"
className="sku-info-variant-title"
required={usesVariantTitle}
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name={[fieldName, 'title']} noStyle>
<Input placeholder="留空则继承 SPU 标题" />
</Form.Item>
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 文案"
disabled={!sourceId}
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
/>
<Button
icon={<TranslationOutlined />}
aria-label="查看当前 SKU 标题中文对照"
disabled={!sourceId}
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
/>
</Space.Compact>
</Form.Item>
{usesVariantTags && (
<Form.Item label="主题标签" layout="vertical" className="sku-info-variant-tags">
<Space.Compact
style={{
width: '100%',
}}
>
<Form.Item name={[fieldName, 'tags']} noStyle>
<Select
style={{
flex: 1,
}}
mode="tags"
tokenSeparators={[',']}
placeholder="留空则继承 SPU 标签"
/>
</Form.Item>
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 标签"
disabled={!sourceId}
onClick={() => sourceId && onAiCopy(sourceId, 'tags')}
/>
<Button
icon={<TranslationOutlined />}
aria-label="查看当前 SKU 标签中文对照"
disabled={!sourceId}
onClick={() => sourceId && onCompareCopy(sourceId, 'tags')}
/>
</Space.Compact>
</Form.Item>
)}
{usesVariantDescription && (
<Form.Item
label="产品描述"
layout="vertical"
className="sku-info-variant-description"
>
<Space.Compact
style={{
width: '100%',
}}
>
<Form.Item name={[fieldName, 'description']} noStyle>
<Input.TextArea rows={3} placeholder="留空则继承 SPU 描述" />
</Form.Item>
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 描述"
disabled={!sourceId}
onClick={() => sourceId && onAiCopy(sourceId, 'description')}
/>
<Button
icon={<TranslationOutlined />}
aria-label="查看当前 SKU 描述中文对照"
disabled={!sourceId}
onClick={() => sourceId && onCompareCopy(sourceId, 'description')}
/>
</Space.Compact>
</Form.Item>
)}
{skuInfoAspectDefinitions.length > 0 && sourceId && (
<DynamicAttributeFields
form={form}
definitions={skuInfoAspectDefinitions}
scope="variant"
variantId={sourceId}
dictionaryValues={dictionaryValues}
catalogSyncing={catalogSyncing}
onLoadDictionaryValues={onLoadDictionaryValues}
onSyncDictionaryValues={onSyncDictionaryValues}
onOpenDictionaryValues={onOpenDictionaryValues}
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
itemLayout="vertical"
forceRequiredNames={['商品颜色']}
colorNameDefinition={colorNameDefinition}
/>
)}
{skuInfoAspectDefinitions.length > 0 && !sourceId && (
<Typography.Text type="secondary" className="sku-info-inherit-note">
新 SKU 保存后可填写平台类目属性。
</Typography.Text>
)}
</section>
);
}
@@ -0,0 +1,78 @@
import { Button, Image } from 'antd';
import type { SkuEditorImage } from './SkuImageEditorModal';
type Props = {
images: SkuEditorImage[];
imageCount: number;
sourcePreview: boolean;
localPreview: boolean;
onPick: () => void;
onMakeMain: (index: number) => void;
};
/** 主图位与其他图片分层展示;图片顺序仍由表单中的 media_asset_ids 决定。 */
export function SkuMediaPanel({
images,
imageCount,
sourcePreview,
localPreview,
onPick,
onMakeMain,
}: Props) {
const [main, ...others] = images;
return (
<section className="sku-media-panel" aria-label="SKU 图片">
<div className="sku-panel-heading">
<h5>
SKU 图片{' '}
<small className="sku-media-count">
{sourcePreview ? '已选' : '共'} {imageCount} 张
</small>
</h5>
<Button onClick={onPick}>选择图片</Button>
</div>
<div className="sku-media-layout">
<div className={`sku-media-main${main ? '' : ' sku-media-main--empty'}`}>
<span className="sku-media-main-badge">
{sourcePreview ? '来源主图' : '主图'}
</span>
{main ? <Image src={main.url} alt="SKU 主图" /> : <span>暂无主图</span>}
{main && (
<span className="sku-media-main-caption">
{sourcePreview ? '选择后可用作商品主图' : '第 1 张用于商品主图'}
</span>
)}
</div>
<div className="sku-media-other">
{others.map((image, offset) => (
<div className="sku-media-other-item" key={image.id}>
<Image src={image.url} alt={`SKU 图片 ${offset + 2}`} />
<Button
type="link"
size="small"
onClick={() => onMakeMain(offset + 1)}
aria-label={`将第 ${offset + 2} 张图片设为主图`}
>
设为主图
</Button>
</div>
))}
<Button
className="sku-media-add"
type="dashed"
onClick={onPick}
aria-label="添加 SKU 图片"
>
+
</Button>
</div>
</div>
{sourcePreview && (
<p className="sku-media-note">来源图片仅供预览,选择后才成为正式主图。</p>
)}
{localPreview && (
<p className="sku-media-note">当前图片仅在本页预览,保存前请确认顺序。</p>
)}
</section>
);
}
@@ -20,7 +20,7 @@
.sku-single-ai-empty strong { color: #494556; }
.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; }
.sku-single-ai-styles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.sku-single-ai-styles button { min-height: 34px; border: 1px solid #e1deeb; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-styles button { min-height: 35px; border: 1px solid #e1deeb; border-radius: 7px; background: #fff; cursor: pointer; }
.sku-single-ai-styles button.selected { border-color: #7062e9; background: #f2f0ff; color: #6254dc; }
.sku-single-ai-styles + .ant-btn, .sku-single-ai-section > .ant-btn-link { padding-left: 0; }
.sku-single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 78px; gap: 6px; margin-bottom: 8px; }
@@ -29,7 +29,7 @@
.sku-single-ai-footer .ant-typography { font-size: 12px; }
.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.sku-single-ai-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; max-height: 450px; overflow-y: auto; }
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 6px; background: #fff; cursor: pointer; }
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 7px; background: #fff; cursor: pointer; }
.sku-single-ai-picker img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; }
.sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 760px) {
@@ -37,7 +37,7 @@
gap: 12px;
padding: 12px;
border: 1px solid #e5e3eb;
border-radius: 8px;
border-radius: 7px;
background: #fff;
color: inherit;
cursor: pointer;
@@ -129,9 +129,13 @@
}
@media (max-width: 720px) {
.sku-watermark-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sku-watermark-picker-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.sku-watermark-picker-grid { grid-template-columns: 1fr; }
.sku-watermark-picker-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,477 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
App,
Button,
Card,
Checkbox,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type {
CommissionImportCategory,
CommissionImportDetail,
CommissionImportPreview,
CommissionImportRequest,
CommissionImportVersion,
} from '@atlas/contracts';
import { commissionImportRequestSchema } from '@atlas/contracts';
import { platformCatalogAdminApi, type AdminPlatform } from '@/services/platformCatalogAdmin';
import { categoryCommissionsApi } from '@/services/categoryCommissions';
import { apiErrorMessage } from '@/services/api';
const percent = (rate: string) => `${(Number(rate) * 100).toFixed(2)}%`;
const columns = [
{
title: '类目',
key: 'category',
render: (_: unknown, c: CommissionImportCategory) => (
<>
<div>
{c.parent_name} / {c.name}
</div>
<Typography.Text type="secondary">{c.source_category_id}</Typography.Text>
</>
),
},
...['≤ 1500₽', '1500–5000₽(不含 1500)', '> 5000₽'].map((title, i) => ({
title,
key: String(i),
render: (_: unknown, c: CommissionImportCategory) => percent(c.bands[i].commission_rate),
})),
{ title: '商品类型', dataIndex: 'mapped_types', key: 'types' },
{
title: '核对结果',
key: 'status',
render: (_: unknown, c: CommissionImportCategory) =>
c.status === 'excluded' ? (
<>
<Tag color="warning">未映射</Tag>
{c.issues.join(';')}
</>
) : (
<Tag color={c.change === 'unchanged' ? 'default' : 'blue'}>
{{ added: '新增', changed: '变更', unchanged: '未变' }[c.change]}
</Tag>
),
},
];
export default function SystemCommissionsPage() {
const { message } = App.useApp();
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]),
[platform, setPlatform] = useState('');
const [versions, setVersions] = useState<CommissionImportVersion[]>([]),
[selected, setSelected] = useState(''),
[detail, setDetail] = useState<CommissionImportDetail | null>(null);
const [error, setError] = useState(''),
[loading, setLoading] = useState(false),
[open, setOpen] = useState(false),
[busy, setBusy] = useState(false),
[saving, setSaving] = useState(false);
const [version, setVersion] = useState(''),
[source, setSource] = useState(''),
[date, setDate] = useState(''),
[json, setJson] = useState('');
const [preview, setPreview] = useState<CommissionImportPreview | null>(null),
[previewInput, setPreviewInput] = useState<CommissionImportRequest | null>(null),
[modalError, setModalError] = useState('');
const [scope, setScope] = useState(false),
[search, setSearch] = useState('');
const [previewSearch, setPreviewSearch] = useState('');
const [onlyExcluded, setOnlyExcluded] = useState(false);
const sequence = useRef(0),
publishKey = useRef('');
useEffect(() => {
void platformCatalogAdminApi
.platforms()
.then((items) => {
setPlatforms(items.filter((p) => p.code.toUpperCase() === 'OZON'));
setPlatform(items.find((p) => p.code.toUpperCase() === 'OZON')?.id ?? '');
})
.catch((e) => setError(apiErrorMessage(e)));
}, []);
useEffect(() => {
let active = true;
if (!platform) return;
setLoading(true);
setVersions([]);
setDetail(null);
setSelected('');
void categoryCommissionsApi
.list(platform)
.then((items) => {
if (active) {
setVersions(items);
setSelected(items[0]?.id ?? '');
setError('');
}
})
.catch((e) => {
if (active) setError(apiErrorMessage(e));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [platform]);
useEffect(() => {
let active = true;
setDetail(null);
if (selected)
void categoryCommissionsApi
.detail(selected)
.then((d) => {
if (active) setDetail(d);
})
.catch((e) => {
if (active) setError(apiErrorMessage(e));
});
return () => {
active = false;
};
}, [selected]);
const invalidate = () => {
sequence.current++;
setPreview(null);
setPreviewInput(null);
setScope(false);
setBusy(false);
setModalError('');
setPreviewSearch('');
setOnlyExcluded(false);
};
const start = () => {
invalidate();
setVersion('');
setSource('');
setDate(new Date().toISOString().slice(0, 10));
setJson('');
setOpen(true);
};
const check = async () => {
const seq = ++sequence.current;
setBusy(true);
setModalError('');
setPreview(null);
setScope(false);
try {
if (new TextEncoder().encode(json).length > 2 * 1024 * 1024)
throw new Error('JSON 不能超过 2 MiB');
const input = commissionImportRequestSchema.safeParse({
platform_id: platform,
version,
source_reference: source,
captured_on: date,
document: JSON.parse(json),
});
if (!input.success)
throw new Error('请填写版本名称、至少 3 字来源说明、有效采集日期,并上传 JSON');
const p = await categoryCommissionsApi.preview(input.data);
if (seq === sequence.current) {
setPreview(p);
setPreviewInput(input.data);
publishKey.current = crypto.randomUUID();
}
} catch (e) {
if (seq === sequence.current)
setModalError(
e instanceof SyntaxError
? 'JSON 格式不正确,请检查文件内容'
: apiErrorMessage(e)
);
} finally {
if (seq === sequence.current) setBusy(false);
}
};
const publish = async () => {
if (!preview || !previewInput) return;
setSaving(true);
setModalError('');
try {
const v = await categoryCommissionsApi.publish(
{
...previewInput,
preview_hash: preview.preview_hash,
confirm_scope: true,
},
publishKey.current
);
setVersions((old) => [v, ...old.map((x) => ({ ...x, is_current: false }))]);
setSelected(v.id);
setOpen(false);
message.success('佣金规则已启用,相关类目可直接试算');
} catch (e) {
setModalError(apiErrorMessage(e));
} finally {
setSaving(false);
}
};
return (
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3}>类目佣金</Typography.Title>
<Typography.Paragraph type="secondary">
导入类目分档费率,核对后用于商品定价。范围:Ozon · RU ·
rFBS;价格区间币种:RUB。
</Typography.Paragraph>
</div>
{error && <Alert type="error" title={error} />}
<Space>
<Select
aria-label="佣金平台"
style={{ width: 220 }}
value={platform || undefined}
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
onChange={setPlatform}
/>
<Button type="primary" disabled={!platform} onClick={start}>
导入 JSON
</Button>
</Space>
<Card title="佣金版本">
<Table
rowKey="id"
loading={loading}
dataSource={versions}
pagination={{ pageSize: 8 }}
locale={{ emptyText: '尚未导入佣金规则,点击“导入 JSON”开始' }}
columns={[
{ title: '版本', dataIndex: 'version' },
{
title: '状态',
render: (_, v) => (
<Tag color={v.is_current ? 'green' : 'default'}>
{v.is_current ? '当前启用' : '历史版本'}
</Tag>
),
},
{ title: '来源', dataIndex: 'source_reference' },
{ title: '采集日期', dataIndex: 'captured_on' },
{
title: '启用时间',
render: (_, v) => new Date(v.activated_at).toLocaleString(),
},
{
title: '覆盖',
render: (_, v) =>
`${v.summary.eligible} 类 / ${v.summary.mapped_types} 类型`,
},
{
title: '操作',
render: (_, v) => (
<Button type="link" onClick={() => setSelected(v.id)}>
{selected === v.id ? '正在查看' : '查看规则'}
</Button>
),
},
]}
/>
</Card>
{detail && (
<Card
title={`${detail.version} · 分档规则`}
extra={
<Input
aria-label="搜索佣金类目"
placeholder="搜索类目名称或 ID"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
}
>
<Table
rowKey="source_category_id"
columns={columns}
dataSource={detail.categories.filter((c) =>
`${c.name} ${c.parent_name} ${c.source_category_id}`.includes(search)
)}
pagination={{ pageSize: 15, showSizeChanger: true }}
scroll={{ x: 900 }}
/>
</Card>
)}
<Modal
title="导入类目佣金"
open={open}
width={1150}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
maskClosable={!saving}
closable={!saving}
onCancel={() => {
if (!saving) {
invalidate();
setOpen(false);
}
}}
footer={
<Space>
<Button
disabled={saving}
onClick={() => {
invalidate();
setOpen(false);
}}
>
取消
</Button>
<Button loading={busy} disabled={saving} onClick={() => void check()}>
解析并预览
</Button>
<Button
type="primary"
loading={saving}
disabled={!preview || !scope || !preview.summary.eligible}
onClick={() => void publish()}
>
确认并启用
</Button>
</Space>
}
>
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="info"
title="核对来源后启用"
description="导入会完整替换本入口上次启用的规则。历史报价保留原快照;新的试算使用新版本。0% 佣金正常导入;无法映射的类目在预览中提示,暂不参与算价。"
/>
<Space wrap>
<Input
aria-label="佣金版本名称"
placeholder="版本名称,例如 2026-09-29"
value={version}
disabled={saving}
onChange={(e) => {
invalidate();
setVersion(e.target.value);
}}
/>
<Input
aria-label="佣金来源说明"
placeholder="来源说明 / 资费页面"
style={{ width: 350 }}
value={source}
disabled={saving}
onChange={(e) => {
invalidate();
setSource(e.target.value);
}}
/>
<label>
采集日期{' '}
<Input
aria-label="佣金采集日期"
type="date"
value={date}
disabled={saving}
onChange={(e) => {
invalidate();
setDate(e.target.value);
}}
/>
</label>
</Space>
<label>
选择 JSON 文件(最多 2 MiB){' '}
<input
aria-label="佣金 JSON 文件"
type="file"
accept=".json,.txt,application/json"
disabled={saving}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
invalidate();
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setModalError('文件不能超过 2 MiB');
return;
}
const seq = sequence.current;
try {
const text = await file.text();
if (seq === sequence.current) setJson(text);
} catch {
if (seq === sequence.current)
setModalError('无法读取文件,请重新选择');
}
}}
/>
</label>
<Input.TextArea
aria-label="佣金 JSON 内容"
rows={5}
value={json}
disabled={saving}
placeholder="也可以在这里粘贴原始 JSON"
onChange={(e) => {
invalidate();
setJson(e.target.value);
}}
/>
{modalError && <Alert type="error" title={modalError} />}
{preview && (
<>
<Alert
type={
preview.summary.excluded || preview.summary.removed
? 'warning'
: 'success'
}
title={`共 ${preview.summary.categories} 类 / ${preview.summary.rules} 条规则;可用 ${preview.summary.eligible} 类,覆盖 ${preview.summary.mapped_types} 个商品类型${preview.summary.excluded ? `;${preview.summary.excluded} 类目录异常,暂未映射` : ''}`}
description={
preview.summary.removed
? `相对上一版本,有 ${preview.summary.removed} 个原已覆盖类目将不再参与算价;请查看下表。`
: preview.summary.excluded
? '目录异常仅作提示,可继续启用其他已映射类目。'
: '全部类目已映射;可搜索类目核对各档费率。'
}
/>
<Space wrap>
<Input
aria-label="搜索预览类目"
placeholder="搜索类目名称或 ID"
value={previewSearch}
onChange={(e) => setPreviewSearch(e.target.value)}
/>
<Checkbox
checked={onlyExcluded}
onChange={(e) => setOnlyExcluded(e.target.checked)}
>
只看异常类目
</Checkbox>
</Space>
<Table
size="small"
rowKey="source_category_id"
columns={columns}
dataSource={preview.categories.filter(
(c) =>
(!onlyExcluded || c.status === 'excluded') &&
`${c.name} ${c.parent_name} ${c.source_category_id}`.includes(
previewSearch
)
)}
pagination={{ pageSize: 8, showSizeChanger: true }}
scroll={{ x: 900 }}
/>
<Checkbox
checked={scope}
disabled={saving}
onChange={(e) => setScope(e.target.checked)}
>
我已核对来源,确认适用于 Ozon RU / rFBS,售价区间单位为卢布(RUB)
</Checkbox>
</>
)}
</Space>
</Modal>
</Space>
);
}
+2
View File
@@ -1,3 +1,4 @@
import SystemCommissionsPage from '@/pages/system/SystemCommissionsPage';
import type { ReactNode } from 'react';
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
import MainLayout from '@/layouts/MainLayout';
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> },
{ path: 'platform-catalog', element: <SystemCatalogPage /> },
{ path: 'pricing-rates', element: <SystemPricingRatesPage /> },
{ path: 'commissions', element: <SystemCommissionsPage /> },
],
},
{
@@ -0,0 +1,21 @@
import type {
CommissionImportRequest,
CommissionImportPublish,
CommissionImportPreview,
CommissionImportVersion,
CommissionImportDetail,
} from '@atlas/contracts';
import { api } from './api';
export const categoryCommissionsApi = {
list: (platform: string) =>
api.get<CommissionImportVersion[]>('/system/pricing/commissions', {
params: { platform_id: platform },
}),
detail: (id: string) => api.get<CommissionImportDetail>(`/system/pricing/commissions/${id}`),
preview: (input: CommissionImportRequest) =>
api.post<CommissionImportPreview>('/system/pricing/commissions/preview', input),
publish: (input: CommissionImportPublish, key: string) =>
api.post<CommissionImportVersion>('/system/pricing/commissions/imports', input, {
headers: { 'Idempotency-Key': key },
}),
};