front/src/views/channel/salesProducts/index.vue
zzh d02e580d91 feat: unify channel and sales product save/publish UX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:25:35 +08:00

5515 lines
176 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { ref, reactive, computed, nextTick, onMounted, watch, onBeforeUnmount } from 'vue';
import {
createSalesProduct,
createSalesProductPrice,
deleteSalesProduct,
getSalesProductDetail,
getSalesProductList,
getSalesProductAllocation,
getSalesProductPriceList,
getSalesProductPublishPriceList,
getSalesProductPublishRulesList,
getSalesProductPublishPreview,
publishSalesProduct,
rollbackSalesProductPublish,
saveSalesProductAllocation,
saveSalesProductCustomerGuide,
toggleSalesProductPriceStatus,
updateSalesProduct,
updateSalesProductPrice,
} from '@/api/channel/salesProduct';
import { getLabelTemplateList } from '@/api/channel/labelTemplate';
import { postForewarningServiceKpiAreaGetKpiAreas } from '@/api/channel/kpiArea';
import { getWarehouseList } from '@/api/system/warehouse';
import { postWeightRuleList } from '@/api/channel/weightRule';
import { postWeightRoundingList } from '@/api/channel/weightRounding';
import { getDictList, getDictListAll } from '@/api/system/dict';
import { getServiceChannelList } from '@/api/channel/serviceChannel';
import {
canonicalWeightFactorCellForApi,
isWeightPricingFactor,
weightValueToIntervalCell,
} from '@/utils/priceWeightInterval';
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill } from '@arco-design/web-vue/es/icon';
import FormLabelWithPreview from './components/FormLabelWithPreview.vue';
import ChannelReferencePreviewModal, {
type ReferencePreviewType,
} from './components/ChannelReferencePreviewModal.vue';
import { Message, Modal, Input } from '@arco-design/web-vue';
import ModalPro from '@/components/ModalPro/index.vue';
import useModal from '@/components/ModalPro/useModal';
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
import { useI18n } from 'vue-i18n';
import {
computeSnapshotDiff,
filterDiffRows,
formatSnapshotJson,
type SnapshotDiffRow,
} from '@/utils/publishSnapshotDiff';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import Sortable from 'sortablejs';
import MarkdownDocPanel from '@/components/MarkdownDocPanel/index.vue';
import customerGuideTemplate from './customerGuideTemplate.md?raw';
import { isEnableStatusEnabled, toEnableStatus, ENABLE_STATUS } from '@/utils/enableStatus';
defineOptions({
name: 'SalesProducts',
});
const { t } = useI18n();
// 搜索关键词
const searchKeyword = ref('');
// 当前选中的产品(初始不选中)
const selectedProduct = ref<number | null>(null);
// 当前激活的标签页
const activeTab = ref('price');
const productList = ref<any[]>([]);
async function loadProductList() {
try {
const { items } = await getSalesProductList({
page: 1,
pageSize: 500,
keyword: searchKeyword.value?.trim() || undefined,
});
productList.value = (items || []).map((it: any) => ({
id: it.id ?? it.Id,
name: it.productName ?? it.ProductName ?? it.name,
isActive: isEnableStatusEnabled(it.isActive ?? it.IsActive),
createTime: it.createTime ?? it.CreateTime,
creator: it.createBy || it.updateBy || it.CreateBy || it.UpdateBy || '',
raw: it,
}));
// 有数据但未选中时自动选中首条,以便「产品配置」等 Tab 能展示详情(表单来自 detail 接口,非 list)
if (productList.value.length > 0 && selectedProduct.value == null) {
await handleSelectProduct(productList.value[0]);
}
} catch (e: any) {
Message.error(e?.message || '加载销售产品失败');
productList.value = [];
}
}
// 标签页配置
const tabs = ref([
{ key: 'config', labelKey: 'channel.salesProducts.tabs.config' },
{ key: 'price', labelKey: 'channel.salesProducts.tabs.price' },
{ key: 'rules', labelKey: 'channel.salesProducts.tabs.rules' },
{ key: 'records', labelKey: 'channel.salesProducts.tabs.records' },
{ key: 'customerGuide', labelKey: 'channel.salesProducts.tabs.customerGuide' },
]);
// 表单数据
const formData = reactive({
productCode: '',
productName: '',
group: '',
minTimeliness: '',
maxTimeliness: '',
customerWaybill: '系统面单',
labelTemplateId: undefined as number | undefined,
blacklistArea: '',
remoteAreaDivision: '',
priceArea: '',
weightCalculationRule: '',
weightRounding: '',
productDescription: '',
preStorageScript: '',
});
const customerGuideMarkdown = ref('');
const customerGuideSaving = ref(false);
const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null);
const savingConfig = ref(false);
type ConfigFormSnapshot = {
productCode: string;
productName: string;
group: string;
minTimeliness: string;
maxTimeliness: string;
customerWaybill: string;
labelTemplateId: number | undefined;
blacklistArea: string;
remoteAreaDivision: string;
priceArea: string;
weightCalculationRule: string;
weightRounding: string;
productDescription: string;
preStorageScript: string;
};
const configFormBaseline = ref<ConfigFormSnapshot | null>(null);
function snapshotConfigForm(): ConfigFormSnapshot {
return {
productCode: formData.productCode,
productName: formData.productName,
group: formData.group,
minTimeliness: formData.minTimeliness,
maxTimeliness: formData.maxTimeliness,
customerWaybill: formData.customerWaybill,
labelTemplateId: formData.labelTemplateId,
blacklistArea: formData.blacklistArea,
remoteAreaDivision: formData.remoteAreaDivision,
priceArea: formData.priceArea,
weightCalculationRule: formData.weightCalculationRule,
weightRounding: formData.weightRounding,
productDescription: formData.productDescription,
preStorageScript: formData.preStorageScript,
};
}
function resetConfigFormBaseline() {
configFormBaseline.value = snapshotConfigForm();
}
const configFormDirty = computed(() => {
const baseline = configFormBaseline.value;
if (!baseline) return false;
const cur = snapshotConfigForm();
return (Object.keys(baseline) as Array<keyof ConfigFormSnapshot>).some(
(key) => cur[key] !== baseline[key]
);
});
const selectedProductContext = computed(() => {
const pid = selectedProduct.value;
if (pid == null) return null;
const product = productList.value.find((p: any) => p.id === pid);
const groupLabel =
groupOptions.value.find((o) => o.value === formData.group)?.label ?? formData.group;
return {
productName: formData.productName || product?.name || '-',
productCode: formData.productCode || '-',
groupName: groupLabel || '-',
};
});
function confirmDiscardUnsavedChanges(): Promise<boolean> {
const dirty =
(activeTab.value === 'config' && configFormDirty.value)
|| (activeTab.value === 'rules' && rulesFormDirty.value);
if (!dirty) return Promise.resolve(true);
return new Promise((resolve) => {
Modal.confirm({
title: t('channel.salesProducts.unsaved.title'),
content: t('channel.salesProducts.unsaved.message'),
okText: t('channel.salesProducts.unsaved.discard'),
cancelText: t('channel.salesProducts.unsaved.stay'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
}
type IdSelectOption = { label: string; value: string };
// 分组选项:字典 sales_product_group
const SALES_PRODUCT_GROUP_DICT = 'sales_product_group';
const groupOptions = ref<IdSelectOption[]>([]);
function defaultProductGroupValue(): string {
return groupOptions.value[0]?.value ?? '';
}
function resolveProductGroupValue(raw: unknown): string {
const s = String(raw ?? '').trim();
if (!s) return defaultProductGroupValue();
const hit = groupOptions.value.find((o) => o.value === s || o.label === s);
return hit?.value ?? s;
}
async function loadGroupSelectOptions() {
try {
const items = await getDictListAll({ dictType: SALES_PRODUCT_GROUP_DICT });
groupOptions.value = (items || [])
.map((raw: any) => {
const dictCode = String(raw?.dictCode ?? raw?.DictCode ?? '').trim();
const dictName = String(raw?.dictName ?? raw?.DictName ?? '').trim();
if (!dictCode && !dictName) return null;
const dictSort = Number(raw?.dictSort ?? raw?.DictSort ?? 0) || 0;
return {
dictCode: dictCode || dictName,
dictName: dictName || dictCode,
dictSort,
};
})
.filter((row): row is { dictCode: string; dictName: string; dictSort: number } => Boolean(row))
.sort((a, b) => a.dictSort - b.dictSort || String(a.dictCode).localeCompare(String(b.dictCode)))
.map((row) => ({
label: row.dictName || row.dictCode,
value: row.dictCode,
}));
} catch (e: any) {
Message.error(e?.message || '加载产品分组失败');
groupOptions.value = [];
}
}
// 客户面单:真实标签模板下拉
const labelTemplateOptions = ref<{ label: string; value: number }[]>([]);
async function loadLabelTemplateOptions() {
try {
const items = await getLabelTemplateList();
labelTemplateOptions.value = (items || []).map((it) => ({
label: it.name,
value: it.id,
}));
} catch {
labelTemplateOptions.value = [];
}
}
const weightFactorOptions: IdSelectOption[] = Array.from({ length: 20 }, (_, index) => {
const v = ((index + 1) * 0.5).toFixed(1).replace(/\.0$/, '');
return { label: v, value: v };
});
function filterKpiAreasByType(items: any[], areaType: string) {
const want = areaType.toLowerCase();
return (items || []).filter((item) => {
const t = String(item?.areaType ?? item?.AreaType ?? '').toLowerCase();
return !t || t === want;
});
}
function mapKpiAreasToSelectOptions(items: any[]): IdSelectOption[] {
return (items || []).map((it: any) => {
const id = it.id ?? it.Id;
const name = it.name ?? it.Name ?? String(id ?? '');
return { label: String(name), value: String(id ?? '') };
});
}
/** 保存时把分区下拉的值转为 KPI 分区表主键 id(正整数) */
function toOptionalPositiveId(v: unknown): number | undefined {
if (v === '' || v == null) return undefined;
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return undefined;
return n;
}
// 黑名单区 / 偏远分区:来自 KPI 分区列表接口(areaType 区分)
const blacklistAreaOptions = ref<IdSelectOption[]>([]);
const remoteAreaDivisionOptions = ref<IdSelectOption[]>([]);
const priceAreaFactorOptions = ref<IdSelectOption[]>([]);
const warehouseFactorOptions = ref<IdSelectOption[]>([]);
async function loadKpiAreaSelectOptions() {
try {
const [blRes, rmRes] = await Promise.all([
postForewarningServiceKpiAreaGetKpiAreas({
pageIndex: 1,
pageSize: 500,
areaType: 'blacklist',
}),
postForewarningServiceKpiAreaGetKpiAreas({
pageIndex: 1,
pageSize: 500,
areaType: 'remote',
}),
]);
const blItems =
blRes?.items ?? (blRes as any)?.data?.items ?? [];
const rmItems =
rmRes?.items ?? (rmRes as any)?.data?.items ?? [];
blacklistAreaOptions.value = mapKpiAreasToSelectOptions(
filterKpiAreasByType(blItems, 'blacklist')
);
remoteAreaDivisionOptions.value = mapKpiAreasToSelectOptions(
filterKpiAreasByType(rmItems, 'remote')
);
} catch (e: any) {
Message.error(e?.message || '加载分区列表失败');
blacklistAreaOptions.value = [];
remoteAreaDivisionOptions.value = [];
}
}
// 计重规则 / 计重进位:来自对应列表接口
const weightCalculationRuleOptions = ref<IdSelectOption[]>([]);
const weightRoundingOptions = ref<IdSelectOption[]>([]);
function mapRuleSelectOptions(items: any[]): IdSelectOption[] {
return (items || []).map((it: any) => {
const id = it.id ?? it.Id;
const name = it.name ?? it.Name ?? String(id ?? '');
return { label: String(name), value: String(id ?? '') };
});
}
const referencePreviewRef = ref<InstanceType<typeof ChannelReferencePreviewModal> | null>(null);
function openReferencePreview(
type: ReferencePreviewType,
rawId: string | number | undefined | null,
options: IdSelectOption[]
) {
const id = toOptionalPositiveId(rawId);
if (!id) {
Message.warning(t('channel.salesProducts.referencePreview.noSelection'));
return;
}
void ensureSalesPriceDictLoaded().then(() => {
const hit = options.find((o) => String(o.value) === String(rawId));
const name = hit?.label || String(id);
referencePreviewRef.value?.open({ type, id, name });
});
}
async function loadWeightSelectOptions() {
try {
const [ruleRes, roundingRes] = await Promise.all([
postWeightRuleList({
page: 1,
pageSize: 500,
}),
postWeightRoundingList({
page: 1,
pageSize: 500,
}),
]);
weightCalculationRuleOptions.value = mapRuleSelectOptions(ruleRes?.items ?? []);
weightRoundingOptions.value = mapRuleSelectOptions(roundingRes?.items ?? []);
} catch (e: any) {
Message.error(e?.message || '加载计重规则/进位失败');
weightCalculationRuleOptions.value = [];
weightRoundingOptions.value = [];
}
}
async function loadBillingFactorSelectOptions() {
try {
const [areaRes, warehouseRes] = await Promise.all([
postForewarningServiceKpiAreaGetKpiAreas({
page: 1,
pageSize: 500,
areaType: 'price',
}),
getWarehouseList({
page: 1,
pageSize: 500,
}),
]);
const areaItems = areaRes?.items ?? (areaRes as any)?.data?.items ?? [];
const warehouseItems = warehouseRes?.items ?? [];
priceAreaFactorOptions.value = filterKpiAreasByType(areaItems, 'price').map((it: any) => {
const id = it?.id ?? it?.Id;
const name = String(it?.name ?? it?.Name ?? id ?? '');
return { label: name, value: String(id ?? '') };
});
warehouseFactorOptions.value = warehouseItems.map((it: any) => {
const id = it?.id ?? it?.Id;
const name = String(it?.warehouseName ?? it?.WarehouseName ?? id ?? '');
return { label: name, value: String(id ?? '') };
});
} catch (e: any) {
Message.error(e?.message || '加载计费因素下拉失败');
priceAreaFactorOptions.value = [];
warehouseFactorOptions.value = [];
}
}
type NormalizedDictRow = {
dictType: string;
dictCode: string;
dictName: string;
dictSort: number;
};
const salesPriceDictRows = ref<NormalizedDictRow[]>([]);
const salesPriceDictLoading = ref(false);
const salesPriceDictFetched = ref(false);
let salesPriceDictLoadPromise: Promise<void> | null = null;
function normalizeAnyDictRow(raw: any): NormalizedDictRow | null {
const dictType = String(raw?.dictType ?? raw?.DictType ?? '').trim();
if (!dictType) return null;
const dictCode = String(raw?.dictCode ?? raw?.DictCode ?? '').trim();
const dictName = String(raw?.dictName ?? raw?.DictName ?? '').trim();
if (!dictCode && !dictName) return null;
const dictSort = Number(raw?.dictSort ?? raw?.DictSort ?? 0) || 0;
return {
dictType,
dictCode: dictCode || dictName,
dictName: dictName || dictCode,
dictSort,
};
}
function dictRowsOfType(dictType: string): NormalizedDictRow[] {
const want = dictType.toLowerCase();
return salesPriceDictRows.value
.filter((r) => r.dictType.toLowerCase() === want)
.sort((a, b) => a.dictSort - b.dictSort || String(a.dictCode).localeCompare(String(b.dictCode)));
}
function mapDictToSelectOptions(dictType: string): IdSelectOption[] {
return dictRowsOfType(dictType).map((r) => ({
label: r.dictName || r.dictCode,
value: r.dictCode,
}));
}
/** 列表/展示:字典编码 → 字典名称(无匹配则回显原编码) */
function dictLabelByType(dictType: string, code: string | undefined | null): string {
if (code == null || code === '') return '';
const c = String(code).trim();
const want = dictType.toLowerCase();
const hit = salesPriceDictRows.value.find(
(r) => r.dictType.toLowerCase() === want && r.dictCode === c
);
if (hit?.dictName) return hit.dictName;
const byName = salesPriceDictRows.value.find(
(r) => r.dictType.toLowerCase() === want && r.dictName === c
);
return byName?.dictName || c;
}
/** 卖价表格:计费因素编码列表展示 */
function formatPriceTableBillingFactors(row: any): string {
const list = row?.factors;
if (Array.isArray(list) && list.length > 0) {
return list
.map((f: any) => pricingFactorColumnLabel(String(f?.factor ?? f?.name ?? '')))
.filter(Boolean)
.join('+');
}
const legacy = row?.billingFactors;
return typeof legacy === 'string' ? legacy : '';
}
async function ensureSalesPriceDictLoaded() {
if (salesPriceDictFetched.value) return;
if (salesPriceDictLoadPromise) {
await salesPriceDictLoadPromise;
return;
}
salesPriceDictLoading.value = true;
salesPriceDictLoadPromise = (async () => {
try {
const { items } = await getDictList({ page: 1, pageSize: 10000 });
salesPriceDictRows.value = (items || [])
.map(normalizeAnyDictRow)
.filter(Boolean) as NormalizedDictRow[];
} catch (e: any) {
Message.error(e?.message || '加载字典失败');
salesPriceDictRows.value = [];
} finally {
salesPriceDictFetched.value = true;
salesPriceDictLoading.value = false;
salesPriceDictLoadPromise = null;
}
})();
await salesPriceDictLoadPromise;
}
function pricingFactorColumnLabel(factorValue: string): string {
if (!factorValue) return '';
const hit = dictRowsOfType('pricing_factor').find(
(r) => r.dictCode === factorValue || r.dictName === factorValue
);
return hit?.dictName || factorValue;
}
function getPricingFactorMeta(factorKey: string): NormalizedDictRow | undefined {
if (!factorKey) return undefined;
return dictRowsOfType('pricing_factor').find(
(r) => r.dictCode === factorKey || r.dictName === factorKey
);
}
/** 因素值来自字典 cargo_attribute,存/传 dictCode */
function isCargoTypeValueFactor(factorKey: string): boolean {
if (factorKey === '货物类型') return true;
const m = getPricingFactorMeta(factorKey);
return m?.dictCode === 'cargo';
}
/** 因素值来自字典 customer_group,存/传 dictCode(因素下拉存的是 pricing_factor 的 dictCode,如 customer) */
function isCustomerGroupValueFactor(factorKey: string): boolean {
if (!factorKey) return false;
const k = String(factorKey).trim();
if (k === '客户' || k === '客户组' || k === 'customer') return true;
const m = getPricingFactorMeta(k);
return m?.dictCode === 'customer' || m?.dictName === '客户' || m?.dictName === '客户组';
}
/** 计费因素里「客户组」行当前勾选的因素值(编码),用于价格表 customer_* 列与表头 */
function getSelectedCustomerGroupCodesFromBilling(): string[] {
for (const item of billingFactorsData.value) {
if (!item.factor || !Array.isArray(item.factorValues) || item.factorValues.length === 0) {
continue;
}
if (!isCustomerGroupValueFactor(item.factor)) continue;
return item.factorValues.map((v) => String(v).trim()).filter(Boolean);
}
return [];
}
/** 因素值存分区 KPI 主键 id */
function isPartitionValueFactor(factorKey: string): boolean {
if (factorKey === '分区') return true;
const m = getPricingFactorMeta(factorKey);
return m?.dictCode === 'zone';
}
/** 因素值存仓库主键 id */
function isWarehouseValueFactor(factorKey: string): boolean {
if (factorKey === '仓库') return true;
const m = getPricingFactorMeta(factorKey);
return m?.dictCode === 'warehouse';
}
function isWeightValueFactor(factorKey: string): boolean {
if (isWeightPricingFactor(factorKey)) return true;
const m = getPricingFactorMeta(factorKey);
return m?.dictCode === 'weight';
}
function isCustomerFactor(factorValue: string): boolean {
return isCustomerGroupValueFactor(factorValue);
}
/** 提交/回填统一:分区、仓库传 id;货物类型、客户组传字典编码 */
function toCanonicalFactorValues(factor: string, values: string[] | undefined): string[] {
const vs = Array.isArray(values) ? values : [];
if (isPartitionValueFactor(factor)) {
return vs.map((v) => {
const s = String(v).trim();
if (priceAreaFactorOptions.value.some((o) => o.value === s)) return s;
const byLabel = priceAreaFactorOptions.value.find((o) => o.label === s);
return byLabel?.value ?? s;
});
}
if (isWarehouseValueFactor(factor)) {
return vs.map((v) => {
const s = String(v).trim();
if (warehouseFactorOptions.value.some((o) => o.value === s)) return s;
const byLabel = warehouseFactorOptions.value.find((o) => o.label === s);
return byLabel?.value ?? s;
});
}
if (isCargoTypeValueFactor(factor)) {
return vs.map((v) => {
const s = String(v).trim();
const hit = dictRowsOfType('cargo_attribute').find(
(r) => r.dictCode === s || r.dictName === s
);
return hit?.dictCode ?? s;
});
}
if (isCustomerGroupValueFactor(factor)) {
return vs.map((v) => {
const s = String(v).trim();
const hit = dictRowsOfType('customer_group').find(
(r) => r.dictCode === s || r.dictName === s
);
return hit?.dictCode ?? s;
});
}
return vs.map((v) => String(v));
}
/** 价格表客户组列表头:与写死全量字典列一致,用 customer_group 的 dictName;入参为勾选的因素编码 */
function customerGroupPriceTableHeaderLabel(code: string, index: number): string {
const c = String(code ?? '').trim();
if (c) {
const hit = dictRowsOfType('customer_group').find(
(r) => r.dictCode === c || r.dictName === c
);
const name = hit?.dictName?.trim();
if (name) return name;
}
return `客户组${index + 1}`;
}
/** 字典因素值仅展示名称,不展示 dictCode(解析不到则空串,避免把编码露给用户) */
function dictFactorDisplayNameOnly(dictType: string, stored: string): string {
const s = String(stored ?? '').trim();
if (!s) return '';
const rows = dictRowsOfType(dictType);
const byCode = rows.find((r) => r.dictCode === s);
if (byCode?.dictName) return byCode.dictName;
const byName = rows.find((r) => r.dictName === s);
return byName?.dictName || '';
}
/** 因素值界面展示:分区/仓库只显示名称;货物类型/客户组只显示字典名称;重量等仍显示原值 */
function formatFactorValueTagDisplay(factorKey: string, stored: string): string {
const s = String(stored ?? '').trim();
if (!s) return '';
if (isCargoTypeValueFactor(factorKey)) {
return dictFactorDisplayNameOnly('cargo_attribute', s) || '';
}
if (isCustomerGroupValueFactor(factorKey)) {
return dictFactorDisplayNameOnly('customer_group', s) || '';
}
if (isPartitionValueFactor(factorKey)) {
const opt = priceAreaFactorOptions.value.find((o) => o.value === s || o.label === s);
return opt?.label || '';
}
if (isWarehouseValueFactor(factorKey)) {
const opt = warehouseFactorOptions.value.find((o) => o.value === s || o.label === s);
return opt?.label || '';
}
if (isWeightValueFactor(factorKey)) {
const bf = billingFactorsData.value.find((item) => item.factor === factorKey);
return weightValueToIntervalCell(s, bf?.factorValues ?? []);
}
return s;
}
function formatPriceTableFactorCell(factorIndex: number | undefined, raw: string): string {
if (factorIndex == null || factorIndex < 0) return '';
const fac = billingFactorsData.value[factorIndex]?.factor;
if (!fac) return '';
return formatFactorValueTagDisplay(fac, raw);
}
const cargoAttributeValueOptions = computed<IdSelectOption[]>(() =>
dictRowsOfType('cargo_attribute').map((r) => ({
label: r.dictName || '(未命名)',
value: r.dictCode,
}))
);
const customerGroupValueOptions = computed<IdSelectOption[]>(() =>
dictRowsOfType('customer_group').map((r) => ({
label: r.dictName || '(未命名)',
value: r.dictCode,
}))
);
function defaultCalculationFormulaCode(): string {
const rows = dictRowsOfType('calculation_formula');
const hit = rows.find((r) => r.dictName === '总价');
return hit?.dictCode || rows[0]?.dictCode || '总价';
}
const factorValuesOptionsByFactor = computed<Record<string, IdSelectOption[]>>(() => {
const cargoOpts = cargoAttributeValueOptions.value;
const cgOpts = customerGroupValueOptions.value;
const base: Record<string, IdSelectOption[]> = {
货物类型: cargoOpts,
分区: priceAreaFactorOptions.value,
重量: weightFactorOptions,
仓库: warehouseFactorOptions.value,
客户: cgOpts,
客户组: cgOpts,
};
const out: Record<string, IdSelectOption[]> = { ...base };
for (const r of dictRowsOfType('pricing_factor')) {
const code = r.dictCode;
const name = r.dictName;
if (!code) continue;
const src = (name && base[name]) || base[code];
if (src) {
out[code] = src;
if (name && base[name]) out[name] = base[name];
}
}
return out;
});
const handleSearch = () => {
loadProductList();
};
// 新增产品弹窗相关
const addProductModalVisible = ref(false);
const addProductFormData = reactive({
productCode: '',
productName: '',
group: '',
minTimeliness: '',
maxTimeliness: '',
customerWaybill: '系统面单',
labelTemplateId: undefined as number | undefined,
blacklistArea: '',
remoteAreaDivision: '',
priceArea: '',
weightCalculationRule: '',
weightRounding: '',
productDescription: '',
preStorageScript: '',
});
// 新增产品
const handleAddProduct = () => {
// 重置表单数据
Object.assign(addProductFormData, {
productCode: '',
productName: '',
group: defaultProductGroupValue(),
minTimeliness: '',
maxTimeliness: '',
customerWaybill: '系统面单',
labelTemplateId: undefined,
blacklistArea: '',
remoteAreaDivision: '',
priceArea: '',
weightCalculationRule: '',
weightRounding: '',
productDescription: '',
preStorageScript: '',
});
addProductModalVisible.value = true;
};
const handleSaveAddProduct = async () => {
if (!addProductFormData.productCode?.trim() || !addProductFormData.productName?.trim()) {
Message.warning('请填写产品代码和名称');
return;
}
try {
const blId = toOptionalPositiveId(addProductFormData.blacklistArea);
const rmId = toOptionalPositiveId(addProductFormData.remoteAreaDivision);
const paId = toOptionalPositiveId(addProductFormData.priceArea);
const wrId = toOptionalPositiveId(addProductFormData.weightCalculationRule);
const roundingId = toOptionalPositiveId(addProductFormData.weightRounding);
await createSalesProduct({
productCode: addProductFormData.productCode.trim(),
productName: addProductFormData.productName.trim(),
productGroup: addProductFormData.group || defaultProductGroupValue(),
minTimeliness: Number(addProductFormData.minTimeliness) || 0,
maxTimeliness: Number(addProductFormData.maxTimeliness) || 0,
customerWaybill: addProductFormData.customerWaybill || '系统面单',
labelTemplateId: addProductFormData.labelTemplateId || undefined,
productDescription: addProductFormData.productDescription || '',
preStorageScript: addProductFormData.preStorageScript || '',
isActive: ENABLE_STATUS.enabled,
scheme: 'costLowest',
...(blId != null ? { blacklistAreaId: blId } : {}),
...(rmId != null ? { remoteAreaId: rmId } : {}),
...(paId != null ? { priceAreaId: paId } : {}),
...(wrId != null ? { weightRuleId: wrId } : {}),
...(roundingId != null ? { weightRoundingId: roundingId } : {}),
});
Message.success('保存成功');
addProductModalVisible.value = false;
await loadProductList();
} catch (e: any) {
Message.error(e?.message || '保存失败');
}
};
// 取消新增产品
const handleCancelAddProduct = () => {
addProductModalVisible.value = false;
};
// 左侧菜单:筛选后的产品列表(按名称搜索)
const filteredProductList = computed(() => {
if (!searchKeyword.value) return productList.value;
return productList.value.filter((p: any) =>
p.name?.toLowerCase().includes(searchKeyword.value.toLowerCase())
);
});
// 左侧菜单:产品树数据(单分类 + 子节点,供 CustomLeftMenu 使用)
const productTreeData = computed<CustomLeftMenuTreeNode[]>(() => {
const list = filteredProductList.value || [];
if (list.length === 0) return [];
return [
{
key: 'category-products',
title: '产品',
children: list.map((p: any) => ({
key: `item-${p.id}`,
title: p.name,
isLeaf: true,
id: p.id,
name: p.name,
...p,
})),
},
];
});
const productSelectedKeys = computed(() =>
selectedProduct.value !== null ? [`item-${selectedProduct.value}`] : []
);
const productExpandedKeys = ref(['category-products']);
function handleProductSelectFromTree(keys: string[], info: any) {
void handleProductSelectFromTreeAsync(keys, info);
}
async function handleProductSelectFromTreeAsync(keys: string[], info: any) {
const node = info?.node;
if (!node || !node.isLeaf) return;
const key = node.key;
if (!key.startsWith('item-')) return;
const id = Number(key.slice(5));
const product = filteredProductList.value.find((p: any) => p.id === id);
if (!product) return;
const ok = await confirmDiscardUnsavedChanges();
if (!ok) return;
await handleSelectProduct(product);
}
async function refreshSalesProductPriceEffectiveInfo(productId: number) {
try {
const res = await getSalesProductPublishPriceList({ salesProductId: productId, page: 1, pageSize: 50 });
const items = res.items || [];
const effectiveRow = items.find((r: any) => isPublishRecordEffectiveRow(r));
const pick = effectiveRow || items[0];
if (pick) {
effectiveVersion.value = String(pick.version ?? pick.Version ?? '--');
effectiveTime.value = String(pick.publishTime ?? pick.PublishTime ?? pick.createTime ?? pick.CreateTime ?? '--');
} else {
effectiveVersion.value = '--';
effectiveTime.value = '--';
}
} catch {
effectiveVersion.value = '--';
effectiveTime.value = '--';
}
}
async function loadPriceTabData(productId: number) {
try {
const [prices] = await Promise.all([
getSalesProductPriceList(productId),
refreshSalesProductPriceEffectiveInfo(productId),
]);
priceTableData.value = (prices || []).map((it: any) => ({
id: it.id,
serialNumber: it.serialNumber,
status: isEnableStatusEnabled(it.status ?? it.Status),
priceName: it.priceName,
costItem: it.costItem,
costUnit: it.costUnit,
currency: it.currency,
chargingCondition: it.chargingCondition || '',
billingFactors: (it.factors || []).map((f: any) => f.factor).join('+'),
factors: it.factors || [],
details: it.details || [],
modifyTime: it.updateTime || it.createTime,
modifier: it.updateBy ?? it.UpdateBy ?? it.createBy ?? it.CreateBy ?? '',
}));
void refreshPricePublishSyncStatus(productId);
} catch (e) {
console.error(e);
effectiveVersion.value = '--';
effectiveTime.value = '--';
}
}
/** 排货规则:服务渠道下拉(GET /channel/service-channel/list) */
const serviceChannelListRaw = ref<any[]>([]);
/** 兼容 allocation/get:camelCase / PascalCase、以及多包一层 data */
function normalizeSalesProductAllocationPayload(raw: unknown): { scheme: string; providers: any[] } {
const empty = { scheme: 'costLowest', providers: [] as any[] };
if (raw == null || typeof raw !== 'object') return empty;
let cur: any = raw;
for (let depth = 0; depth < 4; depth++) {
const scheme = cur.scheme ?? cur.Scheme;
const providersRaw = cur.providers ?? cur.Providers;
if (Array.isArray(providersRaw) || scheme != null) {
return {
scheme: String(scheme ?? 'costLowest'),
providers: Array.isArray(providersRaw) ? providersRaw : [],
};
}
const next = cur.data ?? cur.Data;
if (next == null || typeof next !== 'object') break;
cur = next;
}
return empty;
}
const serviceChannelSelectOptions = computed(() => {
const fromApi = serviceChannelListRaw.value
.map((c: any) => {
const id = Number(c.id ?? c.Id);
const gn = String(c.groupName ?? c.GroupName ?? '').trim();
const cn = String(c.channelName ?? c.ChannelName ?? '').trim();
const label = `${gn}${cn}`.trim() || `渠道 #${id}`;
return { value: id, label };
})
.filter((o) => Number.isFinite(o.value));
const byVal = new Map<number, { value: number; label: string }>();
for (const o of fromApi) {
byVal.set(o.value, o);
}
for (const row of shippingProviderData.value) {
const rid = row.serviceChannelId;
const id = rid != null && rid !== '' ? Number(rid) : NaN;
if (!Number.isFinite(id) || byVal.has(id)) continue;
const label =
String(row.serviceChannelName || row.providerName || '').trim() || `渠道 #${id}`;
byVal.set(id, { value: id, label });
}
return Array.from(byVal.values());
});
async function loadServiceChannelOptionsForRules() {
try {
// 不限制 isActive:已配置的渠道可能已停用,仍需在下拉中可展示/回显
const { items } = await getServiceChannelList({ page: 1, pageSize: 5000 });
serviceChannelListRaw.value = items ?? [];
} catch (e: any) {
Message.warning(e?.message || '加载服务渠道列表失败');
serviceChannelListRaw.value = [];
}
}
function syncShippingRowProviderNameFromChannel(row: any) {
const rawId = row.serviceChannelId;
const id = rawId != null && rawId !== '' ? Number(rawId) : NaN;
if (!Number.isFinite(id)) {
row.providerName = '';
return;
}
const raw = serviceChannelListRaw.value.find((c: any) => Number(c.id ?? c.Id) === id);
if (raw) {
const gn = String(raw.groupName ?? raw.GroupName ?? '').trim();
const cn = String(raw.channelName ?? raw.ChannelName ?? '').trim();
row.providerName = `${gn}${cn}`.trim() || row.providerName || '';
}
}
async function loadRulesTabData(productId: number) {
try {
await loadServiceChannelOptionsForRules();
const allocRaw = await getSalesProductAllocation(productId);
const alloc = normalizeSalesProductAllocationPayload(allocRaw);
shippingScheme.value = alloc.scheme || 'costLowest';
shippingProviderData.value = (alloc.providers || []).map((p: any) => ({
id: p.id ?? p.Id,
serialNumber: p.serialNumber ?? p.SerialNumber ?? 0,
providerName: String(p.providerName ?? p.ProviderName ?? '').trim(),
serviceChannelName: String(p.serviceChannelName ?? p.ServiceChannelName ?? '').trim() || undefined,
serviceChannelId:
p.serviceChannelId != null && p.serviceChannelId !== ''
? Number(p.serviceChannelId ?? p.ServiceChannelId)
: undefined,
weight: p.weight ?? p.Weight ?? 0,
customers: p.customerIds ?? p.CustomerIds ?? p.customers ?? p.Customers ?? [],
}));
resetRulesFormBaseline();
} catch (e) {
console.error(e);
}
}
async function refreshPricePublishSyncStatus(productId?: number) {
const pid = productId ?? selectedProduct.value;
if (pid == null || !priceTableData.value.length) {
pricePublishPending.value = null;
return;
}
try {
const preview = await getSalesProductPublishPreview({
salesProductId: pid,
operatorType: 'price',
});
const pub = await getSalesProductPublishPriceList({ salesProductId: pid, page: 1, pageSize: 1 });
const latest = pub.items?.[0] as Record<string, any> | undefined;
const online = latest?.content ?? latest?.Content;
if (online == null) {
pricePublishPending.value = true;
return;
}
const { length } = computeSnapshotDiff(online, preview);
pricePublishPending.value = length > 0;
} catch {
pricePublishPending.value = null;
}
}
async function loadRecordsTabData(productId: number) {
try {
const [pubPrice, pubRules] = await Promise.all([
getSalesProductPublishPriceList({ salesProductId: productId, page: 1, pageSize: 50 }),
getSalesProductPublishRulesList({ salesProductId: productId, page: 1, pageSize: 50 }),
]);
priceData.value = (pubPrice.items || []).map((r: any) => ({
id: r.id ?? r.Id,
version: r.version ?? r.Version,
operatorType: r.operatorType ?? r.OperatorType,
status: r.status ?? r.Status,
publishUser: r.publishUser ?? r.PublishUser,
publishTime: r.publishTime ?? r.PublishTime,
description: r.description ?? r.Description,
content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content || {}),
}));
rulesData.value = (pubRules.items || []).map((r: any) => ({
id: r.id ?? r.Id,
version: r.version ?? r.Version,
operatorType: r.operatorType ?? r.OperatorType,
status: r.status ?? r.Status,
publishUser: r.publishUser ?? r.PublishUser,
publishTime: r.publishTime ?? r.PublishTime,
description: r.description ?? r.Description,
content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content || {}),
}));
} catch (e) {
console.error(e);
}
}
async function loadSalesProductRelatedByTab(productId: number, tabKey: string) {
if (tabKey === 'price') {
await ensureSalesPriceDictLoaded();
await loadPriceTabData(productId);
return;
}
if (tabKey === 'rules') {
await loadRulesTabData(productId);
return;
}
if (tabKey === 'records') {
await loadRecordsTabData(productId);
return;
}
}
const handleSelectProduct = async (product: any) => {
selectedProduct.value = product.id;
if (!product?.id) return;
try {
const d = await getSalesProductDetail(product.id);
if (d == null || typeof d !== 'object') {
Message.error('产品详情为空,请检查接口返回');
return;
}
const p = d as Record<string, any>;
const numStr = (v: unknown) => (v != null && v !== '' ? String(v) : '');
Object.assign(formData, {
productCode: p.productCode ?? p.ProductCode ?? '',
productName: p.productName ?? p.ProductName ?? '',
group: resolveProductGroupValue(p.productGroup ?? p.ProductGroup),
minTimeliness: numStr(p.minTimeliness ?? p.MinTimeliness),
maxTimeliness: numStr(p.maxTimeliness ?? p.MaxTimeliness),
customerWaybill: p.customerWaybill ?? p.CustomerWaybill ?? '系统面单',
labelTemplateId: (() => {
const raw = p.labelTemplateId ?? p.LabelTemplateId;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : undefined;
})(),
blacklistArea: numStr(p.blacklistAreaId ?? p.BlacklistAreaId),
remoteAreaDivision: numStr(p.remoteAreaId ?? p.RemoteAreaId),
priceArea: numStr(p.priceAreaId ?? p.PriceAreaId),
weightCalculationRule: numStr(p.weightRuleId ?? p.WeightRuleId),
weightRounding: numStr(p.weightRoundingId ?? p.WeightRoundingId),
productDescription: p.productDescription ?? p.ProductDescription ?? '',
preStorageScript: p.preStorageScript ?? p.PreStorageScript ?? '',
});
customerGuideMarkdown.value = p.customerGuideMarkdown ?? p.CustomerGuideMarkdown ?? '';
resetConfigFormBaseline();
await loadSalesProductRelatedByTab(product.id, activeTab.value);
} catch (e: any) {
console.error(e);
Message.error(e?.message || '加载产品详情失败');
}
};
onMounted(() => {
void loadGroupSelectOptions();
void loadKpiAreaSelectOptions();
void loadWeightSelectOptions();
void loadBillingFactorSelectOptions();
void loadLabelTemplateOptions();
loadProductList();
});
const handleToggleProduct = async (product: any) => {
if (!product?.id) return;
const targetEnabled = !!product.isActive;
const previousActive = !targetEnabled;
try {
if (!targetEnabled) {
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: t('channel.salesProducts.messages.toggleDisableTitle'),
content: t('channel.salesProducts.messages.toggleDisableConfirm', {
name: product.name || product.id,
}),
okText: t('channel.salesProducts.modal.actions.ok'),
cancelText: t('channel.salesProducts.modal.actions.cancel'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) {
product.isActive = previousActive;
return;
}
}
const d = await getSalesProductDetail(product.id);
if (d == null || typeof d !== 'object') {
Message.error('无法加载产品详情');
product.isActive = previousActive;
return;
}
const next = toEnableStatus(targetEnabled);
await updateSalesProduct({ ...(d as Record<string, any>), isActive: next });
product.isActive = isEnableStatusEnabled(next);
Message.success('已更新状态');
await loadProductList();
} catch (e: any) {
product.isActive = previousActive;
Message.error(e?.message || '更新失败');
}
};
// 删除产品确认弹窗
const deleteProductModalVisible = ref(false);
const deleteProductInput = ref('');
const deletingProduct = ref<any>(null);
// 删除产品
const handleDeleteProduct = (product: any) => {
deletingProduct.value = product;
deleteProductInput.value = '';
deleteProductModalVisible.value = true;
};
const handleConfirmDeleteProduct = async () => {
if (!deletingProduct.value) return;
if (deleteProductInput.value !== deletingProduct.value.name) {
Message.error('输入的产品名称不正确,请重新输入');
return;
}
try {
await deleteSalesProduct(deletingProduct.value.id);
if (selectedProduct.value === deletingProduct.value.id) {
selectedProduct.value = null;
configFormBaseline.value = null;
rulesFormBaseline.value = null;
}
Message.success('删除成功');
deleteProductModalVisible.value = false;
deletingProduct.value = null;
deleteProductInput.value = '';
await loadProductList();
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
};
// 取消删除产品
const handleCancelDeleteProduct = () => {
deleteProductModalVisible.value = false;
deletingProduct.value = null;
deleteProductInput.value = '';
};
// 标签页切换
const handleTabChange = async (key: string) => {
if (key === activeTab.value) return;
const ok = await confirmDiscardUnsavedChanges();
if (!ok) return;
activeTab.value = key;
if (['price', 'rules', 'records'].includes(key) && selectedProduct.value != null) {
await loadSalesProductRelatedByTab(selectedProduct.value, key);
}
};
async function handleSaveCustomerGuide(content: string) {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
customerGuideSaving.value = true;
try {
await saveSalesProductCustomerGuide({
salesProductId: pid,
customerGuideMarkdown: content,
});
customerGuideMarkdown.value = content;
customerGuidePanelRef.value?.stopEditing?.();
Message.success('对客说明已保存');
} catch (e: any) {
Message.error(e?.message || '保存对客说明失败');
} finally {
customerGuideSaving.value = false;
}
}
// 状态开关滑动切换:记录触摸起点
const statusTouchStartX = ref(0)
const STATUS_SWIPE_THRESHOLD = 40
function onStatusSwipeStart(e: TouchEvent) {
statusTouchStartX.value = e.touches[0].clientX
}
function onStatusSwipeEnd(record: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
record.status = !record.status
void handleTogglePriceStatus(record)
}
}
function onProductSwitchSwipeEnd(nodeData: any, e: TouchEvent) {
const endX = e.changedTouches[0].clientX
if (Math.abs(endX - statusTouchStartX.value) >= STATUS_SWIPE_THRESHOLD) {
const product = productList.value.find((p: any) => p.id === nodeData?.id)
if (product) {
product.isActive = !product.isActive
handleToggleProduct(product)
}
}
}
async function persistSalesProductConfig(pid: number) {
const d = await getSalesProductDetail(pid);
if (d == null || typeof d !== 'object') {
throw new Error('无法加载产品详情,保存已取消');
}
const prev = d as Record<string, any>;
const id = Number(prev.id ?? prev.Id ?? pid);
const blAreaId = toOptionalPositiveId(formData.blacklistArea);
const rmAreaId = toOptionalPositiveId(formData.remoteAreaDivision);
const paAreaId = toOptionalPositiveId(formData.priceArea);
const wrId = toOptionalPositiveId(formData.weightCalculationRule);
const roundingId = toOptionalPositiveId(formData.weightRounding);
await updateSalesProduct({
id,
productCode: formData.productCode,
productName: formData.productName,
productGroup: formData.group,
minTimeliness: Number(formData.minTimeliness) || 0,
maxTimeliness: Number(formData.maxTimeliness) || 0,
customerWaybill: formData.customerWaybill,
labelTemplateId: formData.labelTemplateId || undefined,
blacklistAreaId: blAreaId ?? null,
remoteAreaId: rmAreaId ?? null,
priceAreaId: paAreaId ?? null,
weightRuleId: wrId ?? null,
weightRoundingId: roundingId ?? null,
productDescription: formData.productDescription,
preStorageScript: formData.preStorageScript,
isActive: toEnableStatus(isEnableStatusEnabled(prev.isActive ?? prev.IsActive)),
scheme: String(prev.scheme ?? prev.Scheme ?? 'costLowest'),
});
resetConfigFormBaseline();
}
async function persistSalesProductAllocationDraft(pid: number) {
await saveSalesProductAllocation({
salesProductId: Number(pid),
publish: false,
scheme: shippingScheme.value,
providers: shippingProviderData.value.map((row: any) => ({
id: row.id || 0,
serialNumber: row.serialNumber,
providerName: row.providerName ?? '',
serviceChannelId:
row.serviceChannelId != null && row.serviceChannelId !== ''
? Number(row.serviceChannelId)
: 0,
weight: row.weight,
customerIds: row.customers || [],
})),
});
resetRulesFormBaseline();
}
const publishOperatorType = ref<'price' | 'rules'>('price');
const publishHasOnlineSnapshot = ref(false);
const publishDiffLength = ref(0);
const publishDiffRows = ref<SnapshotDiffRow[]>([]);
const pricePublishPending = ref<boolean | null>(null);
const publishDiffStatus = computed(() => {
if (!publishHasOnlineSnapshot.value) return 'first';
if (publishDiffLength.value === 0) return 'synced';
return 'changed';
});
const visiblePublishDiffRows = computed(() =>
filterDiffRows(publishDiffRows.value, showOnlyDiff.value)
);
function applyPublishDiffState(originalRaw: unknown, modifiedRaw: unknown, hasOnline: boolean) {
publishHasOnlineSnapshot.value = hasOnline;
const { length, rows } = computeSnapshotDiff(originalRaw, modifiedRaw);
publishDiffLength.value = length;
publishDiffRows.value = rows;
}
/** 用后端 publish/preview 接口展示真实快照(含黑名单/计重等内嵌明细) */
async function openPublishDiffModal(operatorType: 'rules' | 'price') {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
publishOperatorType.value = operatorType;
const preview = await getSalesProductPublishPreview({
salesProductId: pid,
operatorType,
});
modifiedModel.value = formatSnapshotJson(preview);
const pub =
operatorType === 'price'
? await getSalesProductPublishPriceList({ salesProductId: pid, page: 1, pageSize: 1 })
: await getSalesProductPublishRulesList({ salesProductId: pid, page: 1, pageSize: 1 });
const latest = pub.items?.[0] as Record<string, any> | undefined;
const online = latest?.content ?? latest?.Content;
const hasOnline = online != null;
originalValue.value = hasOnline ? formatSnapshotJson(online) : formatSnapshotJson({});
applyPublishDiffState(online ?? {}, preview, hasOnline);
showOnlyDiff.value = publishDiffLength.value > 0;
await nextTick();
publishModalMethods.openModal();
}
const handlePublishPrice = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (priceTableData.value.length === 0) {
Message.warning('暂无可发布的费用项');
return;
}
try {
await openPublishDiffModal('price');
} catch (e: any) {
Message.error(e?.message || '加载发布预览失败');
}
};
const handleSaveConfigDraft = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!configFormDirty.value) return;
savingConfig.value = true;
try {
await persistSalesProductConfig(pid);
Message.success(t('channel.salesProducts.messages.saveDraftSuccess'));
} catch (e: any) {
Message.error(e?.message || t('channel.salesProducts.messages.saveFailed'));
} finally {
savingConfig.value = false;
}
};
const handleSaveConfigAndPublish = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!configFormDirty.value) return;
Modal.confirm({
title: t('channel.salesProducts.saveConfirm.title'),
content: t('channel.salesProducts.saveConfirm.message'),
okText: t('channel.salesProducts.actions.saveAndPublish'),
cancelText: t('channel.salesProducts.saveConfirm.cancel'),
onOk: async () => {
savingConfig.value = true;
try {
await persistSalesProductConfig(pid);
await openPublishDiffModal('rules');
} catch (e: any) {
Message.error(e?.message || t('channel.salesProducts.messages.saveFailed'));
} finally {
savingConfig.value = false;
}
},
});
};
const handleSaveRulesDraft = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!rulesFormDirty.value) return;
savingConfig.value = true;
try {
await persistSalesProductAllocationDraft(pid);
Message.success(t('channel.salesProducts.messages.saveDraftSuccess'));
} catch (e: any) {
Message.error(e?.message || t('channel.salesProducts.messages.saveFailed'));
} finally {
savingConfig.value = false;
}
};
const handleSaveRulesAndPublish = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!rulesFormDirty.value) return;
Modal.confirm({
title: t('channel.salesProducts.saveConfirm.title'),
content: t('channel.salesProducts.saveConfirm.message'),
okText: t('channel.salesProducts.actions.saveAndPublish'),
cancelText: t('channel.salesProducts.saveConfirm.cancel'),
onOk: async () => {
savingConfig.value = true;
try {
await persistSalesProductAllocationDraft(pid);
await openPublishDiffModal('rules');
} catch (e: any) {
Message.error(e?.message || t('channel.salesProducts.messages.saveFailed'));
} finally {
savingConfig.value = false;
}
},
});
};
// 发布弹窗相关
const [registerPublishModal, publishModalMethods] = useModal() as [any, any];
const modifiedModel = ref<string>('');
const originalValue = ref<string | undefined>('');
const showOnlyDiff = ref(true);
const publishConfirmVisible = ref(false);
const publishDescription = ref('');
const publishConfirmDate = ref(dayjs());
const getOriginalValue = computed(() => formatSnapshotJson(originalValue.value || {}));
const getModifiedModel = computed(() => formatSnapshotJson(modifiedModel.value || {}));
const handlePublishConfirm = () => {
if (!publishDiffLength.value) {
Message.info(t('channel.salesProducts.publish.status.synced'));
return;
}
publishConfirmDate.value = dayjs();
publishConfirmVisible.value = true;
};
const handleConfirmPublish = async () => {
const pid = selectedProduct.value;
if (pid == null) return;
const operatorType = publishOperatorType.value;
try {
await publishSalesProduct({
salesProductId: Number(pid),
operatorType,
description: publishDescription.value || undefined,
});
Message.success('发布成功');
publishConfirmVisible.value = false;
publishDescription.value = '';
publishConfirmDate.value = dayjs();
publishModalMethods.closeModal();
await loadSalesProductRelatedByTab(pid, 'records');
if (operatorType === 'price') {
await loadPriceTabData(pid);
}
} catch (e: any) {
Message.error(e?.message || '发布失败');
}
};
const handleCancelPublishConfirm = () => {
publishConfirmVisible.value = false;
publishDescription.value = '';
publishConfirmDate.value = dayjs();
};
// 禁用小于当前时间的日期
const disabledDate = (current: any) => {
try {
if (!current) return false;
// 确保 current 是 dayjs 对象
const currentDate = dayjs.isDayjs(current) ? current : dayjs(current);
if (!currentDate.isValid()) return false;
const now = dayjs();
if (!now.isValid()) return false;
return currentDate.isBefore(now, 'day');
} catch (error) {
return false;
}
};
// 禁用小于当前时间的时间(如果选择的是今天)
const disabledTime = (current: any) => {
try {
if (!current || !dayjs.isDayjs(current)) return {};
const now = dayjs();
if (!now.isValid()) return {};
if (current.isSame(now, 'day')) {
// 如果是今天,禁用当前时间之前的时间
const currentHour = now.hour();
const currentMinute = now.minute();
const currentSecond = now.second();
return {
disabledHours: () => {
const hours: number[] = [];
for (let i = 0; i < currentHour; i++) {
hours.push(i);
}
return hours;
},
disabledMinutes: (selectedHour: number) => {
if (selectedHour === currentHour) {
const minutes: number[] = [];
for (let i = 0; i <= currentMinute; i++) {
minutes.push(i);
}
return minutes;
}
return [];
},
disabledSeconds: (selectedHour: number, selectedMinute: number) => {
if (selectedHour === currentHour && selectedMinute === currentMinute) {
const seconds: number[] = [];
for (let i = 0; i <= currentSecond; i++) {
seconds.push(i);
}
return seconds;
}
return [];
},
};
}
return {};
} catch (error) {
return {};
}
};
// 排货规则相关数据
const shippingScheme = ref('costLowest'); // 成本最低、时效最快、综合最优
const shippingSchemeOptions = [
{ labelKey: 'channel.salesProducts.rules.scheme.costLowest', value: 'costLowest' },
{ labelKey: 'channel.salesProducts.rules.scheme.timeFastest', value: 'timeFastest' },
{ labelKey: 'channel.salesProducts.rules.scheme.overallBest', value: 'overallBest' },
];
const shippingProviderData = ref<any[]>([]);
type RulesFormSnapshot = {
scheme: string;
providers: Array<{
serialNumber: number;
serviceChannelId: number | undefined;
weight: number;
customers: string[];
}>;
};
const rulesFormBaseline = ref<RulesFormSnapshot | null>(null);
function snapshotRulesForm(): RulesFormSnapshot {
return {
scheme: shippingScheme.value,
providers: shippingProviderData.value.map((row: any) => ({
serialNumber: Number(row.serialNumber ?? 0),
serviceChannelId:
row.serviceChannelId != null && row.serviceChannelId !== ''
? Number(row.serviceChannelId)
: undefined,
weight: Number(row.weight ?? 0),
customers: [...(row.customers || [])].map(String).sort(),
})),
};
}
function resetRulesFormBaseline() {
rulesFormBaseline.value = snapshotRulesForm();
}
const rulesFormDirty = computed(() => {
const baseline = rulesFormBaseline.value;
if (!baseline) return false;
return JSON.stringify(snapshotRulesForm()) !== JSON.stringify(baseline);
});
// 新增服务商
const handleAddShippingProvider = () => {
const newId = Math.max(...shippingProviderData.value.map((item) => item.id), 0) + 1;
shippingProviderData.value.push({
id: newId,
serialNumber: shippingProviderData.value.length + 1,
providerName: '',
serviceChannelId: undefined,
weight: shippingProviderData.value.length,
customers: [],
});
};
// 删除服务商
const handleDeleteShippingProvider = (index: number) => {
shippingProviderData.value.splice(index, 1);
// 重新编号
shippingProviderData.value.forEach((item, idx) => {
item.serialNumber = idx + 1;
});
};
// 删除客户标签
const handleDeleteCustomer = (row: any, customerIndex: number) => {
row.customers.splice(customerIndex, 1);
};
// 添加客户
const handleAddCustomer = (row: any) => {
// 这里可以弹出一个选择框来选择客户
Message.info('添加客户');
};
// 发布记录相关数据
const publishRecordActiveIndex = ref(0);
const publishRecordTab = ref('price'); // price 或 rules
const publishDateVisible = ref(false);
const publishDate = ref();
const priceData = ref<any[]>([]);
const rulesData = ref<any[]>([]);
// 当前显示的列表数据
const currentListData = computed(() => {
return publishRecordTab.value === 'price' ? priceData.value : rulesData.value;
});
// 当前选中的发布记录
const currentPublishRecord = computed(() => {
const list = currentListData.value;
return list[publishRecordActiveIndex.value] || {};
});
// 切换发布记录
const handleChangePublishRecordIndex = (index: number) => {
publishRecordActiveIndex.value = index;
};
// 切换标签页
const handleChangePublishRecordTab = (tab: string) => {
publishRecordTab.value = tab;
publishRecordActiveIndex.value = 0;
};
// 发布操作 - 弹出日期选择器
const handlePublish = () => {
publishDateVisible.value = true;
};
// 确认发布日期
const handleConfirmPublishDate = (date: any) => {
if (date) {
Message.success(`发布成功,日期:${date}`);
publishDateVisible.value = false;
publishDate.value = undefined;
} else {
Message.warning('请选择发布日期');
}
};
/** 是否为「线上生效」的发布记录(兼容后端 PascalCase / 枚举) */
function isPublishRecordEffectiveRow(row: Record<string, any> | undefined): boolean {
if (!row) return false;
const s = row.status ?? row.Status;
if (s === '生效') return true;
if (typeof s === 'string' && ['effective', 'active', 'published'].includes(s.toLowerCase())) return true;
if (s === 1 || s === '1') return true;
return false;
}
// 回滚到此版本(POST /channel/sales-product/publish/rollback)
const handleRollbackToVersion = () => {
const pid = selectedProduct.value;
const rec = currentPublishRecord.value as Record<string, any>;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!rec || rec.id == null) {
Message.warning('请先选择一个版本');
return;
}
// 仅当选中行就是「当前生效」那条时禁止(避免后端给每条都标生效时误伤全部)
const list = currentListData.value as Record<string, any>[];
const effectiveRow = list.find((item) => isPublishRecordEffectiveRow(item));
if (effectiveRow != null && Number(effectiveRow.id) === Number(rec.id)) {
Message.warning('当前选中版本为线上生效版本,无需回滚');
return;
}
Modal.confirm({
title: '确认回滚',
content: `确定要回滚到版本 "${rec.version}" 吗?`,
onOk: async () => {
try {
const operatorType = publishRecordTab.value === 'rules' ? 'rules' : 'price';
await rollbackSalesProductPublish({
salesProductId: Number(pid),
recordId: rec.id,
operatorType,
});
Message.success('回滚成功');
await loadRecordsTabData(Number(pid));
publishRecordActiveIndex.value = 0;
} catch (e: any) {
Message.error(e?.message || '回滚失败');
}
},
});
};
// 格式化JSON内容用于显示
const formattedJsonContent = computed(() => {
const content = currentPublishRecord.value.content || '';
try {
// 如果内容是JSON字符串,尝试格式化
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch {
// 如果不是有效的JSON,直接返回原内容
return content;
}
});
const effectiveVersion = ref('--');
const effectiveTime = ref('--');
const priceTableData = ref<any[]>([]);
// 价格配置弹窗相关
const priceModalVisible = ref(false);
const priceDrawerActiveTab = ref('basic');
const priceDrawerTabList = [
{ key: 'basic', labelKey: 'channel.salesProducts.drawer.tabs.basic' },
{ key: 'priceList', labelKey: 'channel.salesProducts.drawer.tabs.priceList' },
];
// 编辑模式
const isEditMode = ref(false);
const editingPriceRecord = ref<any>(null);
// 基础信息表单数据
const basicInfoForm = reactive({
costItem: '',
costUnit: '',
currency: '',
chargingCondition: '无',
});
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'));
const costUnitOptions = computed(() => mapDictToSelectOptions('cost_unit'));
const currencyOptions = computed(() => mapDictToSelectOptions('currency'));
const factorOptions = computed(() => mapDictToSelectOptions('pricing_factor'));
const calculationFormulaOptions = computed(() => mapDictToSelectOptions('calculation_formula'));
const getDefaultBillingFactorsData = () => {
const legacyNames = ['分区', '重量', '客户'];
const rows = dictRowsOfType('pricing_factor');
const resolveFactor = (displayName: string): string => {
const hit = rows.find((r) => r.dictName === displayName || r.dictCode === displayName);
return hit?.dictCode || displayName;
};
return legacyNames.map((name, index) => ({
id: index + 1,
serialNumber: index + 1,
factor: resolveFactor(name),
factorValues: [] as string[],
}));
};
// 计费因素数据
const billingFactorsData = ref(getDefaultBillingFactorsData().map(item => ({ ...item })));
function normalizeBillingFactorsFromApi(factors: any): Array<{ id: number; serialNumber: number; factor: string; factorValues: string[] }> {
if (!Array.isArray(factors) || factors.length === 0) {
return getDefaultBillingFactorsData().map((item) => ({ ...item }));
}
return factors.map((item: any, index: number) => ({
id: item?.id ?? index + 1,
serialNumber: item?.serialNumber ?? index + 1,
factor: item?.factor ?? item?.name ?? '',
factorValues: item?.factorValues ?? item?.values ?? [],
}));
}
// 新增价格配置
const handleAddPrice = async () => {
await ensureSalesPriceDictLoaded();
await loadBillingFactorSelectOptions();
// 重置编辑模式
isEditMode.value = false;
editingPriceRecord.value = null;
// 重置表单
Object.assign(basicInfoForm, {
costItem: '',
costUnit: '',
currency: '',
chargingCondition: '无',
});
// 重置计费因素数据为默认值
billingFactorsData.value = getDefaultBillingFactorsData().map(item => ({ ...item }));
// 重置价格表数据
priceListData.value = [];
priceDrawerActiveTab.value = 'basic';
priceModalVisible.value = true;
// 初始化拖拽排序
nextTick(() => {
initSortable();
});
};
// 新增计费因素行
const handleAddBillingFactor = () => {
const newId = Math.max(...billingFactorsData.value.map((item) => item.id), 0) + 1;
billingFactorsData.value.push({
id: newId,
serialNumber: billingFactorsData.value.length + 1,
factor: '',
factorValues: [],
});
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
nextTick(() => {
initSortable();
});
}
};
// 删除计费因素行
const handleDeleteBillingFactor = (index: number) => {
billingFactorsData.value.splice(index, 1);
// 重新编号
billingFactorsData.value.forEach((item, idx) => {
item.serialNumber = idx + 1;
});
if (priceModalVisible.value && priceDrawerActiveTab.value === 'basic') {
nextTick(() => {
initSortable();
});
}
};
// 添加因素值弹窗
const addFactorValueModalVisible = ref(false);
const addFactorValueSelected = ref<string[]>([]);
const currentFactorRow = ref<any>(null);
const currentFactorValueOptions = computed(() => {
const factor = currentFactorRow.value?.factor;
if (!factor) return [];
return factorValuesOptionsByFactor.value[factor] || [];
});
const isAllFactorValuesSelected = computed(() => {
const options = currentFactorValueOptions.value;
if (options.length === 0) return false;
return options.every((option) => addFactorValueSelected.value.includes(option.value));
});
// 添加因素值
const handleAddFactorValue = (row: any) => {
if (!row?.factor) {
Message.warning('请先选择因素');
return;
}
currentFactorRow.value = row;
const factor = row.factor as string;
const opts = factorValuesOptionsByFactor.value[factor] || [];
const allowed = new Set(opts.map((o) => String(o.value)));
const canonical = toCanonicalFactorValues(factor, row.factorValues || []);
addFactorValueSelected.value = canonical
.map((v) => String(v).trim())
.filter((v) => v && allowed.has(v));
addFactorValueModalVisible.value = true;
};
function resetAddFactorValueModal() {
addFactorValueModalVisible.value = false;
addFactorValueSelected.value = [];
currentFactorRow.value = null;
}
/** 将弹窗内勾选的因素值写入当前行;forbidEmpty 为 true 且无勾选时提示并返回 false */
function commitAddFactorSelections(opts: { forbidEmpty: boolean }): boolean {
const selectedValues = addFactorValueSelected.value || [];
if (selectedValues.length === 0) {
if (opts.forbidEmpty) {
Message.warning('请至少选择一个因素值');
}
return false;
}
if (!currentFactorRow.value) return false;
const existing = new Set(currentFactorRow.value.factorValues || []);
const toAdd = selectedValues.filter((value) => !existing.has(value));
if (toAdd.length === 0) {
Message.warning('所选因素值均已存在');
return false;
}
currentFactorRow.value.factorValues.push(...toAdd);
Message.success('添加成功');
return true;
}
// 确认添加因素值
const handleConfirmAddFactorValue = () => {
if (!commitAddFactorSelections({ forbidEmpty: true })) return;
resetAddFactorValueModal();
};
/** 点击遮罩关闭时:按当前勾选提交(无勾选则仅关闭);点取消/X/ESC 仍丢弃 */
function handleAddFactorValueModalCancel(e?: Event) {
const el = e && 'target' in e ? ((e as MouseEvent).target as HTMLElement | null) : null;
const fromMask = !!el?.classList?.contains('arco-modal-wrapper');
if (fromMask) {
commitAddFactorSelections({ forbidEmpty: false });
resetAddFactorValueModal();
return;
}
resetAddFactorValueModal();
}
const handleSelectAllFactorValues = () => {
if (isAllFactorValuesSelected.value) {
addFactorValueSelected.value = [];
return;
}
addFactorValueSelected.value = currentFactorValueOptions.value.map((option) => option.value);
};
// 拖拽排序相关
let sortableInstance: any = null;
// 初始化拖拽排序
const initSortable = () => {
// 销毁旧的实例
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
nextTick(() => {
const tbody = document.querySelector('.billing-factors-table .billing-factors-tbody');
if (!tbody) return;
sortableInstance = Sortable.create(tbody as HTMLElement, {
handle: '.drag-icon',
animation: 150,
onEnd: (evt: any) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex || oldIndex === undefined || newIndex === undefined) return;
// 更新数据顺序
const movedItem = billingFactorsData.value.splice(oldIndex, 1)[0];
billingFactorsData.value.splice(newIndex, 0, movedItem);
// 重新编号
billingFactorsData.value.forEach((item, idx) => {
item.serialNumber = idx + 1;
});
}
});
});
};
// 删除因素值
const handleDeleteFactorValue = (row: any, valueIndex: number) => {
row.factorValues.splice(valueIndex, 1);
};
// 价格表数据
const priceListData = ref<any[]>([]);
// 价格表列顺序(表头):[ 非客户计费因素列… ] → [ 计算公式 ] → [ 客户组列… ]
// 客户组列表头为 customer_group.dictName,且整段客户组列紧跟在「计算公式」列之后(右侧)
const priceTableHeaders = computed(() => {
type H = { key: string; label: string; isCustomer: boolean; factorIndex?: number };
const rows = priceListData.value;
const factorHeaders: H[] = [];
for (let i = 0; i < billingFactorsData.value.length; i++) {
const factor = billingFactorsData.value[i];
if (!factor.factor || isCustomerFactor(factor.factor)) continue;
const colKey = `factor_${i}`;
const hasMappedValues = factor.factorValues.length > 0;
const hasRowValues = rows.some((row) => {
const v = (row as any)?.[colKey];
return v != null && String(v).trim() !== '';
});
if (hasMappedValues || hasRowValues) {
factorHeaders.push({
key: colKey,
label: pricingFactorColumnLabel(factor.factor),
isCustomer: false,
factorIndex: i,
});
}
}
const formulaHeader: H = {
key: 'calculationFormula',
label: '计算公式',
isCustomer: false,
};
const customerGroupHeaders: H[] = [];
const selectedCg = getSelectedCustomerGroupCodesFromBilling();
if (selectedCg.length > 0) {
selectedCg.forEach((code, index) => {
customerGroupHeaders.push({
key: `customer_${index}`,
label: customerGroupPriceTableHeaderLabel(code, index),
isCustomer: true,
});
});
} else {
let maxIdx = -1;
for (const row of rows) {
if (!row || typeof row !== 'object') continue;
for (const k of Object.keys(row as object)) {
const m = /^customer_(\d+)$/.exec(k);
if (m) maxIdx = Math.max(maxIdx, Number(m[1]));
}
}
for (let index = 0; index <= maxIdx; index++) {
customerGroupHeaders.push({
key: `customer_${index}`,
label: `客户组${index + 1}`,
isCustomer: true,
});
}
}
return [...factorHeaders, formulaHeader, ...customerGroupHeaders];
});
// 生成价格表数据(根据计费因素和因素值)
const generatePriceListData = () => {
const newData: any[] = [];
// 获取非客户因素
const nonCustomerFactors = billingFactorsData.value.filter(
(item) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
);
if (nonCustomerFactors.length === 0) {
Message.warning('请至少配置一个非客户因素');
return;
}
// 生成所有因素值的组合(笛卡尔积)
const generateCombinations = (arrays: string[][]): string[][] => {
if (arrays.length === 0) return [[]];
if (arrays.length === 1) return arrays[0].map((item) => [item]);
const [first, ...rest] = arrays;
const restCombinations = generateCombinations(rest);
const result: string[][] = [];
for (const firstItem of first) {
for (const restCombination of restCombinations) {
result.push([firstItem, ...restCombination]);
}
}
return result;
};
const factorValueArrays = nonCustomerFactors.map((factor) => factor.factorValues);
const combinations = generateCombinations(factorValueArrays);
// 为每个组合创建一行数据
combinations.forEach((combination, index) => {
const row: any = {
id: index + 1,
calculationFormula: defaultCalculationFormulaCode(),
};
// 设置因素值(重量转为左开右闭区间,如 (0-0.5])
nonCustomerFactors.forEach((factor, factorIndex) => {
const colIdx = billingFactorsData.value.findIndex((item) => item.id === factor.id);
const raw = combination[factorIndex];
row[`factor_${colIdx}`] = isWeightValueFactor(factor.factor)
? weightValueToIntervalCell(raw, factor.factorValues)
: raw;
});
const selectedCg = getSelectedCustomerGroupCodesFromBilling();
selectedCg.forEach((_, customerIndex) => {
row[`customer_${customerIndex}`] = 0;
});
newData.push(row);
});
priceListData.value = newData;
};
// 生成价格表
const handleGeneratePriceList = async () => {
await ensureSalesPriceDictLoaded();
generatePriceListData();
// 切换到价格表tab
priceDrawerActiveTab.value = 'priceList';
Message.success('价格表生成成功');
};
/** 与文档 /channel/sales-product/price/create 的 factors 一致:serialNumber、factor、factorValues */
function buildSalesProductFactorsForApi(): Array<{
serialNumber: number;
factor: string;
factorValues: string[];
}> {
return billingFactorsData.value
.filter((item) => item.factor)
.map((item, idx) => ({
serialNumber: item.serialNumber ?? idx + 1,
factor: item.factor,
factorValues: toCanonicalFactorValues(item.factor, item.factorValues),
}));
}
/**
* 与文档 details 一致:factorValuesJson、calculationFormula、calculatedValue、customerGroupValues
* (与服务渠道买价 buildServiceChannelDetailsPayload 同构)
*/
function buildSalesProductPriceDetailsForApi(): any[] {
if (!priceListData.value.length) return [];
return priceListData.value.map((row: any, index: number) => {
const factorValuesJson: Record<string, string> = {};
for (let i = 0; i < billingFactorsData.value.length; i++) {
const bf = billingFactorsData.value[i];
if (!bf.factor || isCustomerFactor(bf.factor)) continue;
const colKey = `factor_${i}`;
const v = row[colKey];
const nameKey = pricingFactorColumnLabel(bf.factor);
let cellVal = v != null && String(v).trim() !== '' ? String(v) : '';
if (cellVal && isWeightValueFactor(bf.factor)) {
cellVal = canonicalWeightFactorCellForApi(cellVal, bf.factorValues ?? []);
}
factorValuesJson[nameKey] = cellVal;
}
const cgCodes = getSelectedCustomerGroupCodesFromBilling();
const customerGroupValues: Record<string, number> = {};
cgCodes.forEach((code, idx) => {
const label = customerGroupPriceTableHeaderLabel(code, idx);
customerGroupValues[label] = Number(row[`customer_${idx}`]) || 0;
});
return {
id: row.id ?? index + 1,
factorValuesJson,
calculationFormula: row.calculationFormula || defaultCalculationFormulaCode(),
calculatedValue: Number(row.calculatedValue ?? 0) || 0,
customerGroupValues,
};
});
}
/** 将 list 接口返回的 details(含 factorValuesJson)还原为弹窗内价格表行 */
function mapSalesProductDetailsToPriceListRows(details: any[]): any[] {
if (!Array.isArray(details)) return [];
return details.map((item: any, index: number) => {
if (item?.factorValuesJson && typeof item.factorValuesJson === 'object') {
const row: any = {
id: item.id ?? index + 1,
calculationFormula: item.calculationFormula || defaultCalculationFormulaCode(),
calculatedValue: item.calculatedValue,
};
const fj = item.factorValuesJson as Record<string, string>;
for (let i = 0; i < billingFactorsData.value.length; i++) {
const bf = billingFactorsData.value[i];
if (!bf.factor || isCustomerFactor(bf.factor)) continue;
const label = pricingFactorColumnLabel(bf.factor);
const raw = fj[label] ?? fj[bf.factor] ?? '';
row[`factor_${i}`] = isWeightValueFactor(bf.factor)
? weightValueToIntervalCell(String(raw), bf.factorValues ?? [])
: raw;
}
const cgs = item.customerGroupValues;
if (cgs && typeof cgs === 'object') {
const codes = getSelectedCustomerGroupCodesFromBilling();
codes.forEach((code, idx) => {
const lab = customerGroupPriceTableHeaderLabel(code, idx);
row[`customer_${idx}`] = Number(cgs[lab]) || 0;
});
}
return row;
}
return { id: item?.id ?? index + 1, ...item };
});
}
function buildPricePayload(pid: number) {
const priceName =
dictLabelByType('fee_item', basicInfoForm.costItem) || basicInfoForm.costItem || '';
return {
salesProductId: pid,
serialNumber: isEditMode.value && editingPriceRecord.value?.serialNumber
? editingPriceRecord.value.serialNumber
: priceTableData.value.length + 1,
status: isEditMode.value && editingPriceRecord.value
? toEnableStatus(!!editingPriceRecord.value.status)
: toEnableStatus(true),
priceName,
costItem: basicInfoForm.costItem,
costUnit: basicInfoForm.costUnit,
currency: basicInfoForm.currency,
chargingCondition: basicInfoForm.chargingCondition || '',
factors: buildSalesProductFactorsForApi(),
details: buildSalesProductPriceDetailsForApi(),
};
}
async function submitPriceConfig(pid: number) {
const payload = buildPricePayload(pid);
if (isEditMode.value && editingPriceRecord.value?.id) {
await updateSalesProductPrice({
...payload,
id: editingPriceRecord.value.id,
});
return '价格配置更新成功';
}
const created = await createSalesProductPrice(payload);
const createdId = created?.id ?? created?.Id;
if (createdId != null) {
isEditMode.value = true;
editingPriceRecord.value = {
...(editingPriceRecord.value || {}),
id: createdId,
serialNumber: payload.serialNumber,
status: isEnableStatusEnabled(payload.status),
};
} else {
// 兼容后端不返回 id:用唯一组合定位刚新增记录,切成编辑态避免下次重复 create
await loadPriceTabData(pid);
const latest = [...priceTableData.value]
.reverse()
.find((row: any) => row.serialNumber === payload.serialNumber && row.costItem === payload.costItem);
if (latest?.id != null) {
isEditMode.value = true;
editingPriceRecord.value = latest;
}
}
return '价格配置添加成功';
}
// 保存价格表
const handleSavePriceList = async () => {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
if (!basicInfoForm.costItem?.trim()) {
Message.warning('请先填写费用项');
return;
}
try {
const msg = await submitPriceConfig(pid);
Message.success(msg);
await loadPriceTabData(pid);
priceModalVisible.value = false;
} catch (e: any) {
Message.error(e?.message || '保存失败');
}
};
// 导出价格表到Excel
const handleExportPriceList = () => {
try {
// 准备表头数据
const headers = priceTableHeaders.value.map((h) => h.label);
// 准备表格数据(所有行都导出,合并的单元格只在第一行显示值)
const tableData: any[] = [];
const merges: Array<{ s: { r: number; c: number }; e: { r: number; c: number } }> = [];
priceListData.value.forEach((row: any, rowIndex: number) => {
const rowData: any = {};
priceTableHeaders.value.forEach((header, colIndex) => {
if (header.key.startsWith('factor_')) {
// 因素列:只在需要合并的第一行显示值(与表格展示一致:名称而非编码/id)
const rowSpan = getFactorRowSpan(header.key, rowIndex);
if (rowSpan > 0) {
// 这是合并的第一行,显示值
rowData[header.label] = formatPriceTableFactorCell(
header.factorIndex,
String((row as any)[header.key] ?? '')
);
if (rowSpan > 1) {
// 需要合并:从当前行(rowIndex+1,因为第0行是表头)到rowIndex+rowSpan-1
merges.push({
s: { r: rowIndex + 1, c: colIndex }, // +1 因为第0行是表头
e: { r: rowIndex + rowSpan, c: colIndex } // rowIndex + rowSpan 是最后一行(包含)
});
}
} else {
// 这是合并的其他行,留空(Excel会自动处理合并)
rowData[header.label] = '';
}
} else if (header.key === 'calculationFormula') {
rowData[header.label] = row.calculationFormula || '';
} else if (header.isCustomer) {
rowData[header.label] = (row as any)[header.key] || 0;
}
});
tableData.push(rowData);
});
// 创建工作簿
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(tableData);
// 设置合并单元格
if (merges.length > 0) {
ws['!merges'] = merges;
}
// 设置列宽
const colWidths = headers.map(() => ({ wch: 15 }));
ws['!cols'] = colWidths;
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(wb, ws, '价格表');
// 导出文件
const fileName = `${basicInfoForm.costItem || '价格表'}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`;
XLSX.writeFile(wb, fileName);
Message.success('导出成功');
} catch (error) {
Message.error('导出失败,请重试');
}
};
// 导入价格表
const handleImportPriceList = () => {
// 创建文件输入元素
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xlsx,.xls';
input.onchange = (e: any) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event: any) => {
try {
const data = new Uint8Array(event.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// 转换为JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet);
if (jsonData.length === 0) {
Message.warning('Excel文件为空');
return;
}
// 转换数据格式
const importedData = jsonData.map((row: any, index) => {
const newRow: any = {
id: index + 1,
calculationFormula: row[priceTableHeaders.value.find((h) => h.key === 'calculationFormula')?.label || ''] || defaultCalculationFormulaCode(),
};
// 设置因素值和客户组
priceTableHeaders.value.forEach((header) => {
if (header.key !== 'calculationFormula') {
if (header.isCustomer) {
newRow[header.key] = row[header.label] || 0;
} else {
newRow[header.key] = row[header.label] || '';
}
}
});
return newRow;
});
priceListData.value = importedData;
coercePriceListRowsFactorCells();
Message.success('导入成功');
} catch (error) {
Message.error('导入失败,请检查文件格式');
}
};
reader.readAsArrayBuffer(file);
};
input.click();
};
// 取消
const handleCancelPriceDrawer = () => {
priceModalVisible.value = false;
// 重置编辑模式
isEditMode.value = false;
editingPriceRecord.value = null;
// 清理拖拽实例
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
};
// 检查是否可以切换到价格表tab(已有明细行时允许进入,便于编辑时查看接口返回的 priceList)
const canSwitchToPriceList = computed(() => {
if (priceListData.value.length > 0) return true;
return billingFactorsData.value.some(
(item) => item.factor && !isCustomerFactor(item.factor) && item.factorValues.length > 0
);
});
// 处理tab切换
const handlePriceDrawerTabSwitch = async (tabKey: string) => {
if (tabKey === 'priceList' && !canSwitchToPriceList.value) {
Message.warning('请配置计费因素和映射值,点击生成价格表');
return;
}
if (tabKey === 'priceList') {
await ensureSalesPriceDictLoaded();
}
priceDrawerActiveTab.value = tabKey;
if (tabKey === 'basic' && priceModalVisible.value) {
nextTick(() => {
initSortable();
});
}
};
watch(
() => [priceModalVisible.value, priceDrawerActiveTab.value] as const,
([visible, tab]) => {
if (visible && tab === 'basic') {
nextTick(() => {
initSortable();
});
return;
}
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
}
);
onBeforeUnmount(() => {
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
});
// 计算因素列的合并行数(rowspan)- 根据前置因素值是否相同来决定合并
const getFactorRowSpan = (headerKey: string, rowIndex: number) => {
if (!headerKey.startsWith('factor_')) {
return 0; // 非因素列不合并
}
const currentRow = priceListData.value[rowIndex];
const currentValue = (currentRow as any)[headerKey];
if (currentValue === undefined || currentValue === '') {
return 0;
}
// 找到该因素在表头中的位置
const headerIndex = priceTableHeaders.value.findIndex((h) => h.key === headerKey);
if (headerIndex < 0) return 0;
// 找到该因素在billingFactorsData中的原始索引
const factorIndex = parseInt(headerKey.replace('factor_', ''));
// 检查前面的因素是否相同
let startIndex = rowIndex;
for (let i = rowIndex - 1; i >= 0; i--) {
const prevRow = priceListData.value[i];
// 检查所有前面的因素是否都相同
let allFactorsMatch = true;
for (let j = 0; j < headerIndex; j++) {
const prevHeader = priceTableHeaders.value[j];
if (prevHeader.key.startsWith('factor_')) {
if ((prevRow as any)[prevHeader.key] !== (currentRow as any)[prevHeader.key]) {
allFactorsMatch = false;
break;
}
}
}
if (!allFactorsMatch) break;
// 检查当前因素值是否相同
if ((prevRow as any)[headerKey] === currentValue) {
startIndex = i;
} else {
break;
}
}
// 找到结束位置
let endIndex = rowIndex;
for (let i = rowIndex + 1; i < priceListData.value.length; i++) {
const nextRow = priceListData.value[i];
// 检查所有前面的因素是否都相同
let allFactorsMatch = true;
for (let j = 0; j < headerIndex; j++) {
const prevHeader = priceTableHeaders.value[j];
if (prevHeader.key.startsWith('factor_')) {
if ((nextRow as any)[prevHeader.key] !== (currentRow as any)[prevHeader.key]) {
allFactorsMatch = false;
break;
}
}
}
if (!allFactorsMatch) break;
// 检查当前因素值是否相同
if ((nextRow as any)[headerKey] === currentValue) {
endIndex = i;
} else {
break;
}
}
// 如果是第一个出现的行,返回合并行数
if (startIndex === rowIndex) {
return endIndex - startIndex + 1;
}
return 0;
};
/** 明细行里 factor_i 与计费因素对齐,将名称/旧值统一为 id 或字典编码 */
function coercePriceListRowsFactorCells() {
const rows = billingFactorsData.value;
for (const prow of priceListData.value) {
if (!prow || typeof prow !== 'object') continue;
rows.forEach((bf, i) => {
const k = `factor_${i}`;
if (!Object.prototype.hasOwnProperty.call(prow, k)) return;
const cur = (prow as any)[k];
if (cur == null || String(cur).trim() === '') return;
if (isWeightValueFactor(bf.factor)) {
const next = weightValueToIntervalCell(String(cur), bf.factorValues ?? []);
if (next) (prow as any)[k] = next;
return;
}
const [next] = toCanonicalFactorValues(bf.factor, [String(cur)]);
if (next != null && String(next).trim() !== '') (prow as any)[k] = next;
});
}
}
// 编辑价格配置
const handleEditPrice = async (record: any) => {
await ensureSalesPriceDictLoaded();
await loadBillingFactorSelectOptions();
// 设置编辑模式
isEditMode.value = true;
editingPriceRecord.value = record;
// 加载基础信息
Object.assign(basicInfoForm, {
costItem: record.costItem || '',
costUnit: record.costUnit || '',
currency: record.currency || '',
chargingCondition: record.chargingCondition || '无',
});
const pid = selectedProduct.value;
let sourceRecord = record;
if (pid != null) {
try {
const list = await getSalesProductPriceList(pid);
const matched = (list || []).find((item: any) => String(item?.id ?? item?.Id) === String(record?.id ?? record?.Id));
if (matched) sourceRecord = matched;
} catch (e: any) {
Message.warning(e?.message || '加载最新价格配置失败,已使用列表数据回填');
}
}
// 编辑时回填 /channel/sales-product/price/list 返回的 factors/details(因素值统一为编码/id)
billingFactorsData.value = normalizeBillingFactorsFromApi(sourceRecord?.factors);
for (const row of billingFactorsData.value) {
row.factorValues = toCanonicalFactorValues(row.factor, row.factorValues);
}
priceListData.value = mapSalesProductDetailsToPriceListRows(sourceRecord?.details || []);
coercePriceListRowsFactorCells();
// 重置到基础信息tab
priceDrawerActiveTab.value = 'basic';
priceModalVisible.value = true;
};
// 删除价格配置确认弹窗
const deletePriceModalVisible = ref(false);
const deletePriceInput = ref('');
const deletingPriceRecord = ref<any>(null);
// 删除价格配置
const handleDeletePrice = (record: any) => {
deletingPriceRecord.value = record;
deletePriceInput.value = '';
deletePriceModalVisible.value = true;
};
// 确认删除价格配置
const handleConfirmDeletePrice = () => {
if (!deletingPriceRecord.value) return;
const correctName = deletingPriceRecord.value.priceName || deletingPriceRecord.value.costItem || '';
if (deletePriceInput.value !== correctName) {
Message.error('输入的费用项名称不正确,请重新输入');
return;
}
// 从列表中删除
const index = priceTableData.value.findIndex((item) => item.id === deletingPriceRecord.value.id);
if (index > -1) {
priceTableData.value.splice(index, 1);
// 重新编号
priceTableData.value.forEach((item, idx) => {
item.serialNumber = idx + 1;
});
Message.success('删除成功');
} else {
Message.error('删除失败,未找到该记录');
}
deletePriceModalVisible.value = false;
deletingPriceRecord.value = null;
deletePriceInput.value = '';
};
// 取消删除价格配置
const handleCancelDeletePrice = () => {
deletePriceModalVisible.value = false;
deletingPriceRecord.value = null;
deletePriceInput.value = '';
};
// 切换卖价费用项状态(落库 POST /channel/sales-product/price/toggleStatus)
const handleTogglePriceStatus = async (record: any) => {
const pid = selectedProduct.value
if (pid == null || record?.id == null) return
try {
await toggleSalesProductPriceStatus(record.id, toEnableStatus(!!record.status))
await loadPriceTabData(pid)
} catch (e: any) {
record.status = !record.status
Message.error(e?.message || '更新失败')
}
}
// 产品卖价表格列配置(接口字段多为字典编码,展示用 dictLabelByType / 因素用 pricingFactorColumnLabel)
const priceColumns = ref([
{ title: t('channel.salesProducts.table.providers.serialNo'), dataIndex: 'serialNumber', width: 80, align: 'center' },
{
title: t('channel.salesProducts.table.price.status'),
dataIndex: 'status',
width: 100,
align: 'center',
slotName: 'status'
},
{
title: t('channel.salesProducts.drawer.form.costItem'),
dataIndex: 'costItem',
width: 140,
slotName: 'costItemCell',
},
{
title: t('channel.salesProducts.drawer.form.costUnit'),
dataIndex: 'costUnit',
width: 100,
align: 'center',
slotName: 'costUnitCell',
},
{
title: t('channel.salesProducts.drawer.form.currency'),
dataIndex: 'currency',
width: 100,
align: 'center',
slotName: 'currencyCell',
},
{
title: t('channel.salesProducts.drawer.section.billingFactors'),
dataIndex: 'billingFactors',
width: 200,
ellipsis: true,
tooltip: true,
slotName: 'billingFactorsCell',
},
{ title: '修改时间', dataIndex: 'modifyTime', width: 180 },
{ title: '修改人', dataIndex: 'modifier', width: 100 },
{
title: t('channel.salesProducts.table.providers.actions'),
width: 150,
align: 'center',
fixed: 'right',
slotName: 'operations'
},
]);
</script>
<template>
<div class="sales-product-layout">
<!-- 左侧产品列表:使用公共组件 CustomLeftMenu -->
<div class="left-sidebar">
<CustomLeftMenu
:tree-data="productTreeData"
:selected-keys="productSelectedKeys"
v-model:expanded-keys="productExpandedKeys"
v-model:search-keyword="searchKeyword"
:search-placeholder="t('channel.salesProducts.search.placeholder')"
:refresh-title="t('channel.salesProducts.search.refresh')"
:add-title="t('channel.salesProducts.search.addProduct')"
:show-node-index="true"
:show-method="false"
:show-map-count="false"
:show-node-code="false"
:show-node-actions="true"
:delete-label="t('channel.salesProducts.actions.delete')"
@search="handleSearch"
@refresh="handleSearch"
@add="handleAddProduct"
@select="handleProductSelectFromTree"
@delete-node="(nodeData) => {
const product = productList.find((p: any) => p.id === nodeData?.id);
if (product) handleDeleteProduct(product);
}"
>
<template #node-second-row-extra="{ nodeData }">
<table class="product-second-row-table">
<tr>
<td class="product-row-left">
<span class="create-time">创建: {{ nodeData?.createTime }}</span>
</td>
<td class="product-row-right">
<span
class="switch-swipe-wrapper"
@click.capture.stop="() => {
const product = productList.find((p: any) => p.id === nodeData?.id);
if (product) { product.isActive = !product.isActive; handleToggleProduct(product); }
}"
@touchstart.passive="onStatusSwipeStart"
@touchend="(e) => onProductSwitchSwipeEnd(nodeData, e)"
>
<a-switch
:model-value="nodeData?.isActive"
size="small"
type="circle"
class="product-active-switch"
checked-color="#00b42a"
unchecked-color="#f53f3f"
/>
</span>
</td>
</tr>
</table>
</template>
</CustomLeftMenu>
</div>
<!-- 右侧产品配置 -->
<div class="right-content content-panel">
<!-- 标签页 -->
<div class="tabs-section">
<div class="tabs-container">
<div
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', { active: activeTab === tab.key }]"
@click="handleTabChange(tab.key)"
>
{{ t(tab.labelKey) }}
</div>
</div>
<div v-if="selectedProduct != null && activeTab === 'config'" class="tabs-toolbar">
<a-space :size="8">
<a-button
size="small"
:loading="savingConfig"
:disabled="!configFormDirty"
@click="handleSaveConfigDraft"
>
{{ t('channel.salesProducts.actions.saveDraft') }}
</a-button>
<a-button
type="primary"
size="small"
:loading="savingConfig"
:disabled="!configFormDirty"
@click="handleSaveConfigAndPublish"
>
{{ t('channel.salesProducts.actions.saveAndPublish') }}
</a-button>
</a-space>
</div>
<div v-if="selectedProduct != null && activeTab === 'rules'" class="tabs-toolbar">
<a-space :size="8">
<a-button
size="small"
:loading="savingConfig"
:disabled="!rulesFormDirty"
@click="handleSaveRulesDraft"
>
{{ t('channel.salesProducts.actions.saveDraft') }}
</a-button>
<a-button
type="primary"
size="small"
:loading="savingConfig"
:disabled="!rulesFormDirty"
@click="handleSaveRulesAndPublish"
>
{{ t('channel.salesProducts.actions.saveAndPublish') }}
</a-button>
</a-space>
</div>
</div>
<div v-if="selectedProduct != null && selectedProductContext" class="product-context-bar">
<span class="context-item">
<span class="context-label">{{ t('channel.salesProducts.context.product') }}:</span>
<span class="context-value">{{ selectedProductContext.productName }}</span>
<span v-if="selectedProductContext.productCode" class="context-code">({{ selectedProductContext.productCode }})</span>
</span>
<span class="context-divider">|</span>
<span class="context-item">
<span class="context-label">{{ t('channel.salesProducts.context.group') }}:</span>
<span class="context-value">{{ selectedProductContext.groupName }}</span>
</span>
<a-tag
v-if="(activeTab === 'config' && configFormDirty) || (activeTab === 'rules' && rulesFormDirty)"
color="orangered"
size="small"
class="context-dirty-tag"
>
{{ t('channel.salesProducts.context.unsaved') }}
</a-tag>
</div>
<div v-if="selectedProduct == null" class="empty-state-panel">
<div class="empty-state">
<p>{{ t('channel.salesProducts.empty.selectProduct') }}</p>
</div>
</div>
<!-- 产品卖价tab内容 -->
<div v-else-if="activeTab === 'price'" class="price-section">
<div class="price-header">
<div class="version-info">
{{ t('channel.salesProducts.price.effectiveVersion', { version: effectiveVersion }) }}
<span style="margin-left: 16px; color: #8c8c8c;">生效时间:{{ effectiveTime }}</span>
<a-tag
v-if="pricePublishPending === true"
color="orangered"
size="small"
class="price-sync-tag"
>
{{ t('channel.salesProducts.publish.badge.pending') }}
</a-tag>
<a-tag
v-else-if="pricePublishPending === false"
color="green"
size="small"
class="price-sync-tag"
>
{{ t('channel.salesProducts.publish.badge.synced') }}
</a-tag>
</div>
<div class="price-header-actions">
<a-button
type="secondary"
class="add-price-btn"
@click="handleAddPrice"
>
<icon-plus class="btn-icon" />
{{ t('channel.salesProducts.actions.add') }}
</a-button>
<a-button
type="primary"
class="save-btn"
@click="handlePublishPrice"
>
价格发布
</a-button>
</div>
</div>
<div class="price-table-container">
<a-table
:data="priceTableData"
:columns="priceColumns"
:pagination="false"
:bordered="false"
class="price-table"
>
<template #status="{ record }">
<div
class="switch-swipe-wrapper"
@touchstart.passive="onStatusSwipeStart"
@touchend="(e) => onStatusSwipeEnd(record, e)"
>
<a-switch
v-model="record.status"
type="circle"
size="small"
class="product-active-switch"
checked-color="#00b42a"
unchecked-color="#f53f3f"
@change="() => handleTogglePriceStatus(record)"
/>
</div>
</template>
<template #costItemCell="{ record }">
{{ dictLabelByType('fee_item', record.costItem) }}
</template>
<template #costUnitCell="{ record }">
{{ dictLabelByType('cost_unit', record.costUnit) }}
</template>
<template #currencyCell="{ record }">
{{ dictLabelByType('currency', record.currency) }}
</template>
<template #billingFactorsCell="{ record }">
{{ formatPriceTableBillingFactors(record) }}
</template>
<template #operations="{ record }">
<a-space>
<a-button
type="text"
size="small"
class="action-btn"
@click="handleEditPrice(record)"
:title="t('channel.salesProducts.actions.edit')"
>
<icon-edit />
</a-button>
<a-button
type="text"
size="small"
class="action-btn delete-btn"
@click="handleDeletePrice(record)"
:title="t('channel.salesProducts.actions.delete')"
>
<icon-delete />
</a-button>
</a-space>
</template>
</a-table>
</div>
</div>
<!-- 排货规则tab内容 -->
<div v-else-if="activeTab === 'rules'" class="shipping-rules-section">
<div class="shipping-rules-header">
<div class="scheme-selection">
<div class="scheme-title">{{ t('channel.salesProducts.rules.schemeTitle') }}</div>
<a-radio-group v-model="shippingScheme" class="scheme-radio-group">
<a-radio
v-for="option in shippingSchemeOptions"
:key="option.value"
:value="option.value"
>
{{ t(option.labelKey) }}
</a-radio>
</a-radio-group>
<span class="scheme-explanation">
{{ t('channel.salesProducts.rules.scheme.explanation') }}
</span>
</div>
<div class="shipping-rules-header-actions">
<a-button
type="secondary"
class="add-provider-btn"
@click="handleAddShippingProvider"
>
<icon-plus />
{{ t('channel.salesProducts.actions.add') }}
</a-button>
</div>
</div>
<div class="shipping-rules-content">
<div class="shipping-provider-table-container">
<table class="shipping-provider-table">
<colgroup>
<col class="shipping-col-serial" />
<col class="shipping-col-channel" />
<col class="shipping-col-weight" />
<col class="shipping-col-customers" />
<col class="shipping-col-actions" />
</colgroup>
<thead>
<tr>
<th>{{ t('channel.salesProducts.table.providers.serialNo') }}</th>
<th>{{ t('channel.salesProducts.table.providers.serviceChannel') }}</th>
<th>{{ t('channel.salesProducts.table.providers.weight') }}</th>
<th>{{ t('channel.salesProducts.table.providers.customers') }}</th>
<th>{{ t('channel.salesProducts.table.providers.actions') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in shippingProviderData"
:key="row.id"
>
<td class="shipping-col-serial-cell">{{ row.serialNumber }}</td>
<td class="shipping-service-channel-cell shipping-col-channel-cell">
<a-select
v-model="row.serviceChannelId"
:options="serviceChannelSelectOptions"
allow-search
allow-clear
size="small"
class="shipping-service-channel-select"
:placeholder="t('channel.serviceChannels.form.placeholder.select')"
@change="() => syncShippingRowProviderNameFromChannel(row)"
/>
</td>
<td class="shipping-col-weight-cell">
<a-input-number
v-model="row.weight"
size="small"
style="width: 100%"
:min="0"
/>
</td>
<td class="shipping-col-customers-cell">
<div class="customers-cell">
<a-tag
v-for="(customer, customerIndex) in row.customers"
:key="customerIndex"
closable
@close="handleDeleteCustomer(row, customerIndex)"
style="margin-right: 8px; margin-bottom: 4px"
>
{{ customer }}
</a-tag>
<a-button
type="text"
size="small"
@click="handleAddCustomer(row)"
class="add-customer-btn"
>
<icon-plus />
</a-button>
</div>
</td>
<td class="shipping-col-actions-cell">
<a-button
type="text"
size="small"
class="action-btn delete-btn"
@click="handleDeleteShippingProvider(index)"
:title="t('channel.salesProducts.actions.delete')"
>
<icon-delete />
</a-button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 对客说明 tab -->
<div v-else-if="activeTab === 'customerGuide'" class="customer-guide-section">
<MarkdownDocPanel
v-if="selectedProduct != null"
ref="customerGuidePanelRef"
:content="customerGuideMarkdown"
:saving="customerGuideSaving"
:template-content="customerGuideTemplate"
:empty-hint="t('channel.salesProducts.customerGuide.empty')"
@save="handleSaveCustomerGuide"
/>
<a-empty v-else :description="t('channel.salesProducts.customerGuide.selectProduct')" />
</div>
<!-- 发布记录tab内容 -->
<div v-else-if="activeTab === 'records'" class="publish-records-section">
<div class="publish-records-container">
<!-- 左侧面板 -->
<div class="publish-records-left">
<div class="publish-tabs-container">
<div class="publish-tabs">
<div
:class="['publish-tab-item', { active: publishRecordTab === 'price' }]"
@click="handleChangePublishRecordTab('price')"
>
{{ t('channel.salesProducts.publish.tabs.price') }}
</div>
<div
:class="['publish-tab-item', { active: publishRecordTab === 'rules' }]"
@click="handleChangePublishRecordTab('rules')"
>
{{ t('channel.salesProducts.publish.tabs.rules') }}
</div>
</div>
</div>
<div class="user-info">
</div>
<div class="version-list">
<div
v-for="(item, index) in currentListData"
:key="item.id"
:class="['version-item', { active: publishRecordActiveIndex === index }]"
@click="handleChangePublishRecordIndex(index)"
>
<div class="version-item-header">
<span class="version-item-number">版本号:{{ item.version }}</span>
<span
:class="[
'version-item-type',
item.status === '生效' ? 'type-effective' : item.status === '回滚' ? 'type-rollback' : 'type-invalid'
]"
>
{{ item.status === '生效' ? '发布' : item.status === '回滚' ? '回滚' : item.status }}
</span>
</div>
<div class="version-item-footer">
<span>{{ item.publishUser }}</span>
<span>生效:{{ item.publishTime }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧面板 -->
<div class="publish-records-right">
<div class="right-header">
<a-button
type="text"
@click="handleRollbackToVersion"
class="rollback-btn"
>
{{ t('channel.salesProducts.actions.rollback') }}
</a-button>
<div class="version-notes">
<span>{{ t('channel.salesProducts.publish.versionRemark') }}</span>
<a-popover
:content="currentPublishRecord.description || t('channel.salesProducts.publish.noRemark')"
placement="top-start"
>
<template #content>
{{ currentPublishRecord.description || t('channel.salesProducts.publish.noRemark') }}
</template>
<template #default>
<icon-question-circle-fill
class="notes-icon"
style="color: #faad14; font-size: 18px"
/>
</template>
</a-popover>
</div>
</div>
<div class="code-content">
<a-textarea
:model-value="formattedJsonContent"
:readonly="true"
:auto-size="{ minRows: 20, maxRows: 30 }"
class="w-100%"
style="font-family: Menlo, Consolas, monospace; font-size: 12px;"
/>
</div>
</div>
</div>
</div>
<!-- 表单内容 -->
<div v-else-if="activeTab === 'config'" class="form-section">
<div class="form-container">
<!-- 第一行 -->
<div class="form-row">
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.productCode') }}</label>
<a-input
v-model="formData.productCode"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.productName') }}</label>
<a-input
v-model="formData.productName"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.group') }}</label>
<a-select
v-model="formData.group"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in groupOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第二行 -->
<div class="form-row">
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.efficiency') }}</label>
<div class="timeliness-inputs">
<a-input
v-model="formData.minTimeliness"
:placeholder="t('channel.salesProducts.form.placeholder.min')"
class="timeliness-input"
/>
<span class="timeliness-separator">-</span>
<a-input
v-model="formData.maxTimeliness"
:placeholder="t('channel.salesProducts.form.placeholder.max')"
class="timeliness-input"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.customerWaybill') }}</label>
<a-select
v-model="formData.labelTemplateId"
class="form-select"
allow-clear
allow-search
:placeholder="t('channel.salesProducts.form.placeholder.labelTemplate')"
>
<a-option
v-for="option in labelTemplateOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.blacklistArea')"
:has-value="!!formData.blacklistArea"
@preview="openReferencePreview('kpi-blacklist', formData.blacklistArea, blacklistAreaOptions)"
/>
<a-select
v-model="formData.blacklistArea"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in blacklistAreaOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第三行 -->
<div class="form-row">
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.remoteAreaDivision')"
:has-value="!!formData.remoteAreaDivision"
@preview="openReferencePreview('kpi-remote', formData.remoteAreaDivision, remoteAreaDivisionOptions)"
/>
<a-select
v-model="formData.remoteAreaDivision"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in remoteAreaDivisionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.priceArea')"
:has-value="!!formData.priceArea"
@preview="openReferencePreview('kpi-price', formData.priceArea, priceAreaFactorOptions)"
/>
<a-select
v-model="formData.priceArea"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
allow-clear
>
<a-option
v-for="option in priceAreaFactorOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.weightCalculationRule')"
:has-value="!!formData.weightCalculationRule"
@preview="openReferencePreview('weight-rule', formData.weightCalculationRule, weightCalculationRuleOptions)"
/>
<a-select
v-model="formData.weightCalculationRule"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in weightCalculationRuleOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.weightRounding')"
:has-value="!!formData.weightRounding"
@preview="openReferencePreview('weight-rounding', formData.weightRounding, weightRoundingOptions)"
/>
<a-select
v-model="formData.weightRounding"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in weightRoundingOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第四行 -->
<div class="form-row">
<div class="form-item full-width">
<label class="form-label">{{ t('channel.salesProducts.form.productDescription') }}</label>
<a-textarea
v-model="formData.productDescription"
:placeholder="t('channel.salesProducts.form.productDescription')"
:rows="4"
class="form-textarea"
/>
</div>
</div>
<!-- 第五行 -->
<div class="form-row">
<div class="form-item full-width">
<label class="form-label">{{ t('channel.salesProducts.form.preStorageScript') }}</label>
<a-textarea
v-model="formData.preStorageScript"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
:rows="4"
class="form-textarea"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 发布日期选择弹窗 -->
<a-modal
v-model:visible="publishDateVisible"
:title="t('channel.salesProducts.modal.publishDateTitle')"
:footer="false"
width="400px"
>
<div class="publish-date-modal">
<a-date-picker
v-model="publishDate"
:placeholder="t('channel.salesProducts.modal.publishDate.placeholder')"
style="width: 100%"
show-time
@ok="handleConfirmPublishDate"
/>
<div class="publish-date-actions">
<a-button @click="publishDateVisible = false">
{{ t('channel.salesProducts.modal.actions.cancel') }}
</a-button>
<a-button type="primary" @click="handleConfirmPublishDate(publishDate)">
{{ t('channel.salesProducts.modal.actions.ok') }}
</a-button>
</div>
</div>
</a-modal>
<!-- 新增/编辑价格配置弹窗 -->
<a-modal
v-model:visible="priceModalVisible"
:title="isEditMode ? t('channel.salesProducts.actions.edit') : t('channel.salesProducts.actions.add')"
width="1400px"
:footer="false"
@cancel="handleCancelPriceDrawer"
>
<div class="price-modal-content">
<!-- Tab切换 -->
<div class="drawer-title">
<div class="drawer-tabs">
<div
v-for="tab in priceDrawerTabList"
:key="tab.key"
:class="['drawer-tab-item', { active: priceDrawerActiveTab === tab.key }]"
@click="handlePriceDrawerTabSwitch(tab.key)"
>
{{ t(tab.labelKey) }}
</div>
</div>
</div>
<!-- 基础信息tab -->
<div v-if="priceDrawerActiveTab === 'basic'" class="basic-info-content">
<!-- 表单部分 -->
<div class="form-section-drawer">
<a-form :model="basicInfoForm" layout="vertical" class="price-form">
<a-row :gutter="24">
<a-col :span="8">
<a-form-item :label="t('channel.salesProducts.drawer.form.costItem')" required>
<a-select
v-model="basicInfoForm.costItem"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
:loading="salesPriceDictLoading"
>
<a-option
v-for="option in costItemOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :label="t('channel.salesProducts.drawer.form.costUnit')" required>
<a-select
v-model="basicInfoForm.costUnit"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
:loading="salesPriceDictLoading"
>
<a-option
v-for="option in costUnitOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :label="t('channel.salesProducts.drawer.form.currency')" required>
<a-select
v-model="basicInfoForm.currency"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
:loading="salesPriceDictLoading"
>
<a-option
v-for="option in currencyOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item :label="t('channel.salesProducts.drawer.form.chargingCondition')">
<a-input
v-model="basicInfoForm.chargingCondition"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 计费因素表格 -->
<div class="billing-factors-section">
<div class="billing-factors-header">
<div class="section-title">{{ t('channel.salesProducts.drawer.section.billingFactors') }}</div>
<a-space>
<a-button
type="secondary"
size="small"
@click="handleAddBillingFactor"
>
<icon-plus />
新增
</a-button>
<a-button
type="primary"
size="small"
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
</a-button>
</a-space>
</div>
<div class="billing-factors-table">
<table class="factors-table">
<thead>
<tr>
<th style="width: 80px">{{ t('channel.salesProducts.drawer.table.billingFactors.serialNo') }}</th>
<th style="width: 200px">{{ t('channel.salesProducts.drawer.table.billingFactors.factor') }}</th>
<th>{{ t('channel.salesProducts.drawer.table.billingFactors.factorValues') }}</th>
<th style="width: 100px">{{ t('channel.salesProducts.drawer.table.billingFactors.actions') }}</th>
</tr>
</thead>
<tbody class="billing-factors-tbody">
<tr
v-for="(row, index) in billingFactorsData"
:key="row.id"
>
<td>
<div class="serial-number-cell">
<icon-drag-arrow class="drag-icon" />
<span>{{ row.serialNumber }}</span>
</div>
</td>
<td>
<a-select
v-model="row.factor"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
:loading="salesPriceDictLoading"
size="small"
style="width: 100%"
>
<a-option
v-for="option in factorOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</td>
<td>
<div class="factor-values-cell">
<a-tag
v-for="(value, valueIndex) in row.factorValues"
:key="valueIndex"
closable
@close="handleDeleteFactorValue(row, valueIndex)"
style="margin-right: 8px; margin-bottom: 4px"
>
{{ formatFactorValueTagDisplay(row.factor, value) }}
</a-tag>
<a-button
type="text"
size="small"
@click="handleAddFactorValue(row)"
class="add-value-btn"
>
<icon-plus />
</a-button>
</div>
</td>
<td>
<a-button
type="text"
size="small"
status="danger"
@click="handleDeleteBillingFactor(index)"
>
<icon-delete />
</a-button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 价格表tab -->
<div
v-if="priceDrawerActiveTab === 'priceList'"
:key="`price-list-${priceListData.length}-${priceTableHeaders.length}`"
class="price-list-content"
>
<div class="price-list-header">
<div class="price-list-title">
{{
dictLabelByType('fee_item', basicInfoForm.costItem) ||
t('channel.salesProducts.drawer.section.priceListTitle')
}}
</div>
<div class="price-list-actions">
<a-button
type="secondary"
size="small"
@click="handleExportPriceList"
>
导出价格
</a-button>
<a-button
type="secondary"
size="small"
@click="handleImportPriceList"
>
导入价格
</a-button>
<a-button
type="primary"
size="small"
@click="handleSavePriceList"
>
{{ t('channel.salesProducts.actions.save') }}
</a-button>
</div>
</div>
<div class="price-list-table-container">
<table class="price-list-table">
<thead>
<tr>
<th
v-for="header in priceTableHeaders"
:key="header.key"
style="width: 120px"
>
{{ header.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in priceListData"
:key="row.id"
>
<template v-for="header in priceTableHeaders" :key="header.key">
<td
v-if="header.key.startsWith('factor_')"
:rowspan="getFactorRowSpan(header.key, index) > 0 ? getFactorRowSpan(header.key, index) : undefined"
:style="getFactorRowSpan(header.key, index) > 0 ? {} : { display: 'none' }"
class="factor-cell"
>
{{
formatPriceTableFactorCell(
header.factorIndex,
String((row as any)[header.key] ?? '')
)
}}
</td>
<td
v-else-if="header.key === 'calculationFormula'"
>
<a-select
v-model="row.calculationFormula"
placeholder="请选择计算公式"
:loading="salesPriceDictLoading"
size="small"
style="width: 100%"
>
<a-option
v-for="option in calculationFormulaOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</td>
<td
v-else-if="header.isCustomer"
>
<a-input-number
v-model="(row as any)[header.key]"
size="small"
style="width: 100%"
:min="0"
:precision="2"
/>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 自定义底部按钮 -->
<template #footer>
<div class="price-modal-footer">
<a-button @click="handleCancelPriceDrawer">
{{ t('channel.salesProducts.drawer.footer.cancel') }}
</a-button>
<a-button
v-if="priceDrawerActiveTab === 'basic'"
type="primary"
@click="handleGeneratePriceList"
>
<icon-plus />
{{ t('channel.salesProducts.drawer.footer.generatePriceList') }}
</a-button>
<a-button
v-if="priceDrawerActiveTab === 'priceList'"
type="primary"
@click="handleSavePriceList"
>
<icon-plus />
{{ t('channel.salesProducts.drawer.footer.save') }}
</a-button>
</div>
</template>
</a-modal>
<!-- 新增产品配置弹窗 -->
<a-modal
v-model:visible="addProductModalVisible"
:title="t('channel.salesProducts.actions.add')"
width="900px"
@cancel="handleCancelAddProduct"
>
<div class="add-product-modal-content">
<div class="form-container">
<!-- 第一行 -->
<div class="form-row">
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.productCode') }}</label>
<a-input
v-model="addProductFormData.productCode"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.productName') }}</label>
<a-input
v-model="addProductFormData.productName"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
class="form-input"
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.group') }}</label>
<a-select
v-model="addProductFormData.group"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in groupOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第二行 -->
<div class="form-row">
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.efficiency') }}</label>
<div class="timeliness-inputs">
<a-input
v-model="addProductFormData.minTimeliness"
:placeholder="t('channel.salesProducts.form.placeholder.min')"
class="timeliness-input"
/>
<span class="timeliness-separator">-</span>
<a-input
v-model="addProductFormData.maxTimeliness"
:placeholder="t('channel.salesProducts.form.placeholder.max')"
class="timeliness-input"
/>
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.customerWaybill') }}</label>
<a-select
v-model="addProductFormData.labelTemplateId"
class="form-select"
allow-clear
allow-search
:placeholder="t('channel.salesProducts.form.placeholder.labelTemplate')"
>
<a-option
v-for="option in labelTemplateOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.blacklistArea')"
:has-value="!!addProductFormData.blacklistArea"
@preview="openReferencePreview('kpi-blacklist', addProductFormData.blacklistArea, blacklistAreaOptions)"
/>
<a-select
v-model="addProductFormData.blacklistArea"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in blacklistAreaOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第三行 -->
<div class="form-row">
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.remoteAreaDivision')"
:has-value="!!addProductFormData.remoteAreaDivision"
@preview="openReferencePreview('kpi-remote', addProductFormData.remoteAreaDivision, remoteAreaDivisionOptions)"
/>
<a-select
v-model="addProductFormData.remoteAreaDivision"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in remoteAreaDivisionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.priceArea')"
:has-value="!!addProductFormData.priceArea"
@preview="openReferencePreview('kpi-price', addProductFormData.priceArea, priceAreaFactorOptions)"
/>
<a-select
v-model="addProductFormData.priceArea"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
allow-clear
>
<a-option
v-for="option in priceAreaFactorOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.weightCalculationRule')"
:has-value="!!addProductFormData.weightCalculationRule"
@preview="openReferencePreview('weight-rule', addProductFormData.weightCalculationRule, weightCalculationRuleOptions)"
/>
<a-select
v-model="addProductFormData.weightCalculationRule"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in weightCalculationRuleOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<FormLabelWithPreview
:label="t('channel.salesProducts.form.weightRounding')"
:has-value="!!addProductFormData.weightRounding"
@preview="openReferencePreview('weight-rounding', addProductFormData.weightRounding, weightRoundingOptions)"
/>
<a-select
v-model="addProductFormData.weightRounding"
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option
v-for="option in weightRoundingOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<!-- 第四行 -->
<div class="form-row">
<div class="form-item full-width">
<label class="form-label">{{ t('channel.salesProducts.form.productDescription') }}</label>
<a-textarea
v-model="addProductFormData.productDescription"
:placeholder="t('channel.salesProducts.form.productDescription')"
:rows="4"
class="form-textarea"
/>
</div>
</div>
<!-- 第五行 -->
<div class="form-row">
<div class="form-item full-width">
<label class="form-label">{{ t('channel.salesProducts.form.preStorageScript') }}</label>
<a-textarea
v-model="addProductFormData.preStorageScript"
:placeholder="t('channel.salesProducts.form.placeholder.input')"
:rows="4"
class="form-textarea"
/>
</div>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<a-button @click="handleCancelAddProduct">
{{ t('channel.salesProducts.modal.actions.cancel') }}
</a-button>
<a-button type="primary" @click="handleSaveAddProduct">
{{ t('channel.salesProducts.actions.save') }}
</a-button>
</div>
</template>
</a-modal>
<!-- 发布弹窗 -->
<ModalPro
:title="t('channel.salesProducts.publish.title')"
:width="1280"
:footer="false"
@register="registerPublishModal"
>
<div class="release-content slim">
<div
class="publish-diff-status"
:class="{
'is-synced': publishDiffStatus === 'synced',
'is-changed': publishDiffStatus === 'changed',
'is-first': publishDiffStatus === 'first',
}"
>
<div class="status-title">
{{
publishDiffStatus === 'synced'
? t('channel.salesProducts.publish.status.synced')
: publishDiffStatus === 'first'
? t('channel.salesProducts.publish.status.first')
: t('channel.salesProducts.publish.status.changed', { count: publishDiffLength })
}}
</div>
<div v-if="publishDiffStatus === 'synced'" class="status-desc">
{{ t('channel.salesProducts.publish.noDiffDetail') }}
</div>
</div>
<div class="diff-header">
<div class="diff-actions">
<a-button
type="primary"
size="small"
:disabled="publishDiffLength === 0"
@click="handlePublishConfirm"
>
{{
publishDiffLength === 0
? t('channel.salesProducts.publish.noNeed')
: t('channel.salesProducts.publish.confirm')
}}
</a-button>
<a-checkbox v-if="publishDiffLength > 0" v-model="showOnlyDiff" size="small">
{{ t('channel.salesProducts.publish.showOnlyDiff') }}
</a-checkbox>
</div>
</div>
<div v-if="publishDiffStatus === 'synced'" class="publish-diff-synced-panel">
<pre class="diff-pre synced-preview">{{ getModifiedModel }}</pre>
</div>
<template v-else>
<div class="diff-boxes">
<div class="diff-box">
<div class="diff-box-title">{{ t('channel.salesProducts.publish.onlineCache') }}</div>
<pre class="diff-pre">{{ getOriginalValue }}</pre>
</div>
<div class="diff-box">
<div class="diff-box-title">
{{ t('channel.salesProducts.publish.pendingCache') }}
({{ t('channel.salesProducts.publish.diffCount', { count: publishDiffLength }) }})
</div>
<pre class="diff-pre">{{ getModifiedModel }}</pre>
</div>
</div>
<div class="legend">
<span class="legend-item add">{{ t('channel.salesProducts.publish.legend.add') }}</span>
<span class="legend-item remove">{{ t('channel.salesProducts.publish.legend.remove') }}</span>
<span class="legend-item change">{{ t('channel.salesProducts.publish.legend.change') }}</span>
<span class="legend-item same">{{ t('channel.salesProducts.publish.legend.same') }}</span>
</div>
<div class="merged-diff">
<div class="diff-box-title">{{ t('channel.salesProducts.publish.pendingCache') }}</div>
<div class="merged-diff-body">
<template v-for="(row, idx) in visiblePublishDiffRows" :key="idx">
<template v-if="row.type === 'change'">
<div class="merged-line change">
<span class="line-mark">~</span>
<span class="line-text">
{{ row.oldText }} → {{ row.newText }}
</span>
</div>
</template>
<template v-else>
<div
class="merged-line"
:class="{
added: row.type === 'add',
removed: row.type === 'remove',
same: row.type === 'same'
}"
>
<span class="line-mark">
{{ row.type === 'add' ? '+' : row.type === 'remove' ? '-' : ' ' }}
</span>
<span class="line-text">{{ row.text }}</span>
</div>
</template>
</template>
<div v-if="!visiblePublishDiffRows.length" class="diff-empty">
{{ t('channel.salesProducts.publish.noDiffDetail') }}
</div>
</div>
</div>
</template>
</div>
</ModalPro>
<!-- 发布确认弹窗 -->
<a-modal
v-model:visible="publishConfirmVisible"
title="发布确认"
@cancel="handleCancelPublishConfirm"
@ok="handleConfirmPublish"
>
<div class="mb-15px">请选择生效的日期时间,不选默认立即生效</div>
<div class="mb-15px">
<a-input
v-model="publishDescription"
placeholder="请输入版本备注"
/>
</div>
<div>
<a-date-picker
v-model="publishConfirmDate"
placeholder="请选择发布日期"
style="width: 100%"
show-time
format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
/>
</div>
</a-modal>
<!-- 删除产品确认弹窗 -->
<a-modal
v-model:visible="deleteProductModalVisible"
title="确认删除产品"
width="500px"
@cancel="handleCancelDeleteProduct"
@ok="handleConfirmDeleteProduct"
>
<div class="delete-confirm-modal">
<div class="delete-confirm-message">
<p>删除产品后无法恢复,请谨慎操作。</p>
<p style="margin-top: 12px; font-weight: 600; color: #262626;">
请输入产品名称 <span style="color: #f53f3f;">"{{ deletingProduct?.name }}"</span> 以确认删除:
</p>
</div>
<div style="margin-top: 16px;">
<a-input
v-model="deleteProductInput"
placeholder="请输入产品名称"
@press-enter="handleConfirmDeleteProduct"
/>
</div>
</div>
</a-modal>
<!-- 删除价格配置确认弹窗 -->
<a-modal
v-model:visible="deletePriceModalVisible"
title="确认删除价格配置"
width="500px"
@cancel="handleCancelDeletePrice"
@ok="handleConfirmDeletePrice"
>
<div class="delete-confirm-modal">
<div class="delete-confirm-message">
<p>删除价格配置后无法恢复,请谨慎操作。</p>
<p style="margin-top: 12px; font-weight: 600; color: #262626;">
请输入费用项名称 <span style="color: #f53f3f;">"{{ deletingPriceRecord?.priceName || deletingPriceRecord?.costItem || '' }}"</span> 以确认删除:
</p>
</div>
<div style="margin-top: 16px;">
<a-input
v-model="deletePriceInput"
placeholder="请输入费用项名称"
@press-enter="handleConfirmDeletePrice"
/>
</div>
</div>
</a-modal>
<!-- 添加因素值弹窗 -->
<a-modal
v-model:visible="addFactorValueModalVisible"
title="添加因素值"
width="400px"
@cancel="handleAddFactorValueModalCancel"
@ok="handleConfirmAddFactorValue"
>
<div class="add-factor-value-modal">
<div class="add-factor-value-actions">
<a-button
type="text"
size="small"
:disabled="currentFactorValueOptions.length === 0"
@click="handleSelectAllFactorValues"
>
{{ isAllFactorValuesSelected ? '清空' : '全选' }}
</a-button>
</div>
<a-select
v-model="addFactorValueSelected"
:options="currentFactorValueOptions"
:placeholder="currentFactorValueOptions.length ? '请选择因素值' : '该因素暂无可选值'"
multiple
allow-clear
allow-search
style="width: 100%"
@press-enter="handleConfirmAddFactorValue"
/>
</div>
</a-modal>
<ChannelReferencePreviewModal ref="referencePreviewRef" :dict-label-fn="dictLabelByType" />
</div>
</template>
<style lang="less" scoped>
.sales-product-layout {
display: flex;
height: 100%;
padding: 16px;
gap: 16px;
background-color: var(--color-fill-2);
overflow: hidden;
}
/* 左侧使用 CustomLeftMenu,仅保留布局(参考应用列表) */
.left-sidebar {
width: 280px;
flex-shrink: 0;
height: 100%;
display: flex;
flex-direction: column;
}
/* 第二行表格:创建时间对齐 node-title,开关对齐 node-actions-cell */
.product-second-row-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.product-row-left {
text-align: left;
padding: 0;
padding-left: 24px; /* 与 node-title 左侧对齐:node-number(min-width 16px + margin-right 8px) */
vertical-align: middle;
width: auto;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
.create-time {
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.product-row-right {
text-align: right;
padding: 0;
width: 30px; /* 与 CustomLeftMenu 的 node-actions-cell 同宽 */
vertical-align: middle;
}
/* 开关:启用-绿色,停用-红色(与应用列表一致) */
.left-sidebar :deep(.product-active-switch.arco-switch-checked) {
background-color: #00b42a !important;
}
.left-sidebar :deep(.product-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
}
:deep(.node-second-row-extra-slot) {
display: block;
width: 100%;
}
:deep(.node-second-row-extra-cell) {
text-align: left;
}
/* 选中时创建时间保持深色,不变白 */
:deep(.tree-node.selected .create-time) {
color: #888 !important;
}
.right-content.content-panel {
flex: 1;
background-color: var(--color-bg-1);
border-radius: 24px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.switch-swipe-wrapper {
display: inline-block;
}
.tabs-section {
border-bottom: 1px solid #e8e8e8;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs-container {
display: flex;
align-items: center;
gap: 0;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
position: relative;
display: flex;
align-items: center;
gap: 6px;
&:hover {
background-color: #f8f9fa;
}
&.active {
border-bottom-color: var(--app-primary-color);
color: var(--app-primary-color);
font-weight: 600;
}
.tab-icon {
font-size: 14px;
}
}
.tabs-toolbar {
display: flex;
align-items: center;
padding: 8px 0;
}
.product-context-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 10px 24px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
font-size: 13px;
color: #595959;
.context-label {
color: #8c8c8c;
}
.context-value {
color: #262626;
font-weight: 500;
}
.context-code {
margin-left: 4px;
color: #8c8c8c;
}
.context-divider {
color: #d9d9d9;
}
.context-dirty-tag {
margin-left: auto;
}
}
.empty-state-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
.empty-state {
text-align: center;
color: #8c8c8c;
font-size: 14px;
p {
margin: 0;
}
}
.form-section {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.form-container {
max-width: 800px;
}
.form-row {
display: flex;
gap: 24px;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
&.full-width {
flex: 1;
}
}
.form-label {
font-size: 14px;
font-weight: 500;
color: #262626;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
}
.timeliness-inputs {
display: flex;
align-items: center;
gap: 8px;
.timeliness-input {
flex: 1;
}
.timeliness-separator {
color: #8c8c8c;
font-weight: 500;
}
}
.price-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
overflow: hidden;
}
.customer-guide-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.price-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.version-info {
font-size: 14px;
color: #262626;
.price-sync-tag {
margin-left: 12px;
vertical-align: middle;
}
}
.price-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.add-price-btn {
display: flex;
align-items: center;
gap: 6px;
.btn-icon {
font-size: 14px;
}
}
}
.price-table-container {
flex: 1;
overflow: auto;
.price-table {
:deep(.arco-table-th) {
background-color: #fafafa;
font-weight: 500;
}
:deep(.arco-table-td) {
padding: 12px 16px;
}
.action-btn {
color: var(--app-primary-color);
&:hover {
color: #1a6b6b;
background-color: #f0f9f9;
}
&.delete-btn {
color: #f53f3f;
&:hover {
color: #d32f2f;
background-color: #fff1f0;
}
}
}
:deep(.product-active-switch.arco-switch-checked) {
background-color: #00b42a !important;
}
:deep(.product-active-switch:not(.arco-switch-checked)) {
background-color: #f53f3f !important;
}
}
}
// 抽屉相关样式
.drawer-title {
width: 100%;
.drawer-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #e8e8e8;
.drawer-tab-item {
padding: 8px 24px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
color: #8c8c8c;
&:hover {
color: var(--app-primary-color);
}
&.active {
border-bottom-color: var(--app-primary-color);
color: var(--app-primary-color);
font-weight: 600;
}
}
}
}
.basic-info-content {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px 0;
}
.form-section-drawer {
.price-form {
:deep(.arco-form-item-label) {
font-weight: 500;
}
}
}
.billing-factors-section {
.billing-factors-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.billing-factors-table {
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow-x: scroll;
overflow-y: scroll;
max-height: 400px;
height: 400px;
.factors-table {
width: 100%;
min-width: 800px;
border-collapse: collapse;
thead {
background-color: #fafafa;
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #262626;
border-bottom: 1px solid #e8e8e8;
}
}
tbody {
tr {
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #fafafa;
}
td {
padding: 12px 16px;
vertical-align: middle;
}
}
}
.serial-number-cell {
display: flex;
align-items: center;
gap: 8px;
.drag-icon {
font-size: 18px;
color: #8c8c8c;
cursor: move;
&:hover {
color: var(--app-primary-color);
}
}
}
.factor-values-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
.add-value-btn {
color: var(--app-primary-color);
&:hover {
color: #1a6b6b;
background-color: #f0f9f9;
}
}
}
}
}
}
.price-list-content {
padding: 24px 0;
.price-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.price-list-title {
display: inline-block;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: #fff;
background-color: #00b42a;
padding: 4px 12px;
border-radius: 4px;
}
.price-list-actions {
display: flex;
gap: 8px;
}
}
.price-list-table-container {
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow-x: scroll;
overflow-y: scroll;
max-height: 500px;
height: 500px;
.price-list-table {
width: 100%;
min-width: 1000px;
border-collapse: collapse;
thead {
background-color: #fafafa;
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #262626;
border-bottom: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #fafafa;
}
td {
padding: 12px 16px;
vertical-align: middle;
border-right: 1px solid #f0f0f0;
&:last-child {
border-right: none;
}
&.factor-cell {
font-weight: 500;
background-color: #fafafa;
vertical-align: middle;
}
}
}
}
}
}
}
.price-modal-content {
padding: 8px 0;
overflow: visible;
}
.price-modal-steps {
margin-bottom: 24px;
:deep(.arco-steps-item) {
flex: 0 0 auto;
padding-right: 20px;
cursor: pointer;
&:last-child {
padding-right: 0;
}
}
:deep(.arco-steps-item-title) {
font-size: 14px;
color: #262626 !important;
font-weight: 400;
}
:deep(.arco-steps-item-process .arco-steps-item-title) {
color: var(--app-primary-color) !important;
font-weight: 600;
}
:deep(.arco-steps-item-finish .arco-steps-item-title) {
color: var(--app-primary-color) !important;
}
.step-title {
color: #262626;
font-weight: 400;
cursor: pointer;
user-select: none;
&:hover {
color: var(--app-primary-color);
}
&.step-title-active {
color: var(--app-primary-color) !important;
font-weight: 600 !important;
}
}
}
.price-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
// 排货规则样式
.shipping-rules-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
overflow: hidden;
}
.shipping-rules-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
.scheme-selection {
display: flex;
align-items: center;
gap: 16px;
.scheme-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.scheme-radio-group {
display: flex;
gap: 24px;
}
.scheme-explanation {
font-size: 14px;
color: #8c8c8c;
margin-left: 8px;
}
}
.shipping-rules-header-actions {
display: flex;
align-items: center;
gap: 12px;
.add-provider-btn {
display: flex;
align-items: center;
gap: 6px;
}
}
}
.shipping-rules-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.shipping-provider-table-container {
flex: 1;
overflow: auto;
border: 1px solid #e8e8e8;
border-radius: 6px;
.shipping-provider-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
/* 权重固定 100px;服务渠道 : 客户 = 3 : 7(占除序号 80px、权重、操作 100px 外的宽度) */
--shipping-fixed-cols: 280px;
col.shipping-col-serial {
width: 80px;
}
col.shipping-col-weight {
width: 100px;
}
col.shipping-col-actions {
width: 100px;
}
col.shipping-col-channel {
width: calc((100% - var(--shipping-fixed-cols)) * 3 / 10);
}
col.shipping-col-customers {
width: calc((100% - var(--shipping-fixed-cols)) * 7 / 10);
}
.shipping-col-channel-cell,
.shipping-col-weight-cell,
.shipping-col-customers-cell {
min-width: 0;
}
thead {
background-color: #fafafa;
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: #262626;
border-bottom: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #fafafa;
}
td {
padding: 12px 16px;
vertical-align: middle;
border-right: 1px solid #f0f0f0;
&:last-child {
border-right: none;
}
&.shipping-service-channel-cell {
text-align: left;
.shipping-service-channel-select {
width: 100%;
}
}
}
}
}
.customers-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
.add-customer-btn {
color: var(--app-primary-color);
:deep(svg) {
color: var(--app-primary-color);
fill: var(--app-primary-color);
}
&:hover {
color: #1a6b6b;
background-color: #f0f9f9;
:deep(svg) {
color: #1a6b6b;
fill: #1a6b6b;
}
}
}
}
.action-btn {
color: var(--app-primary-color);
&:hover {
color: #1a6b6b;
background-color: #f0f9f9;
}
&.delete-btn {
color: #f53f3f;
&:hover {
color: #d32f2f;
background-color: #fff1f0;
}
}
}
}
}
}
// 发布记录样式
.publish-records-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
overflow: hidden;
}
.publish-records-container {
display: flex;
height: 100%;
gap: 0;
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow: hidden;
}
.publish-records-left {
width: 370px;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
padding: 24px;
overflow-y: auto;
background-color: #fafafa;
.version-header {
margin-bottom: 16px;
.version-number {
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
.publish-tabs-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.publish-tabs {
display: flex;
gap: 0;
flex: 1;
border: 1px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
.publish-tab-item {
flex: 1;
padding: 8px 16px;
text-align: center;
cursor: pointer;
background-color: #fff;
border-right: 1px solid #e8e8e8;
transition: all 0.3s;
font-size: 14px;
color: #8c8c8c;
&:last-child {
border-right: none;
}
&:hover {
background-color: #f6f8f8;
}
&.active {
background-color: var(--app-primary-color);
color: #fff;
font-weight: 600;
}
}
}
}
.user-info {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 16px;
}
.version-list {
flex: 1;
overflow-y: auto;
.version-item {
padding: 12px;
margin-bottom: 8px;
background-color: #fff;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background-color: #f6f8f8;
}
&.active {
background-color: #EDF6FF;
}
.version-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.version-item-number {
font-size: 12px;
color: #202122;
line-height: 14px;
}
.version-item-type {
font-size: 14px;
line-height: 18px;
&.type-publish {
color: #287EDC;
}
&.type-effective {
color: #508A24;
}
&.type-rollback {
color: #faad14;
}
&.type-invalid {
color: #f53f3f;
}
}
}
.version-item-footer {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #737373;
line-height: 18px;
}
}
}
}
.publish-records-right {
flex: 1;
display: flex;
flex-direction: column;
padding: 24px;
background-color: #fff;
overflow: hidden;
.right-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.rollback-btn {
color: var(--app-primary-color);
&:hover {
color: #1a6b6b;
}
}
.version-notes {
display: flex;
align-items: center;
gap: 8px;
color: #262626;
font-size: 12px;
.notes-icon {
cursor: pointer;
}
}
}
.code-content {
flex: 1;
overflow: auto;
}
}
.publish-date-modal {
padding: 8px 0;
.publish-date-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
}
// 新增产品弹窗样式
.add-product-modal-content {
padding: 8px 0;
max-height: 70vh;
overflow-y: auto;
.form-container {
max-width: 100%;
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 保存确认弹窗样式
.save-confirm-modal {
padding: 8px 0;
.save-confirm-message {
font-size: 14px;
color: #262626;
line-height: 1.6;
p {
margin: 0;
}
}
}
// 发布弹窗样式
.release-content.slim {
display: flex;
flex-direction: column;
gap: 12px;
}
.publish-diff-status {
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #e8e8e8;
background: #fafafa;
.status-title {
font-size: 14px;
font-weight: 600;
color: #262626;
}
.status-desc {
margin-top: 4px;
font-size: 13px;
color: #8c8c8c;
}
&.is-synced {
border-color: #b7eb8f;
background: #f6ffed;
.status-title {
color: #389e0d;
}
}
&.is-changed {
border-color: #ffd591;
background: #fff7e6;
.status-title {
color: #d46b08;
}
}
&.is-first {
border-color: #91d5ff;
background: #e6f7ff;
.status-title {
color: #096dd9;
}
}
}
.publish-diff-synced-panel {
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fafafa;
max-height: 420px;
overflow: auto;
.synced-preview {
margin: 0;
padding: 12px;
}
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.diff-title {
font-size: 13px;
color: #232A33;
}
.diff-actions {
display: flex;
gap: 8px;
}
.diff-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.diff-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-box-title {
font-size: 12px;
color: #666;
}
.diff-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8e8e8;
border-radius: 6px;
min-height: 160px;
max-height: 280px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-all;
}
.diff-actions :deep(.arco-btn) {
padding: 0 8px;
min-width: 32px;
}
.merged-diff {
display: flex;
flex-direction: column;
gap: 8px;
}
.merged-diff-body {
border: 1px solid #e8e8e8;
border-radius: 6px;
background: #fff;
max-height: 600px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-all;
}
.merged-line {
display: flex;
gap: 8px;
padding: 4px 0;
white-space: pre-wrap;
word-break: break-all;
}
.merged-line.added {
background: #e6ffed;
}
.merged-line.removed {
background: #ffecec;
text-decoration: line-through;
}
.merged-line.change {
background: #fffbe6;
}
.merged-line.same {
color: #666;
}
.line-mark {
flex-shrink: 0;
}
.line-text {
flex: 1;
}
.legend {
display: flex;
gap: 12px;
font-size: 12px;
}
.legend-item.add { color: #16a34a; }
.legend-item.remove { color: #dc2626; }
.legend-item.change { color: #ca8a04; }
.legend-item.same { color: #666; }
.diff-empty {
text-align: center;
color: #8c8c8c;
padding: 20px;
}
.mb-15px {
margin-bottom: 15px;
}
.add-factor-value-modal {
.add-factor-value-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
}
// 响应式设计
@media (max-width: 768px) {
.sales-product-layout {
flex-direction: column;
}
.left-sidebar {
width: 100%;
height: 300px;
}
.form-row {
flex-direction: column;
gap: 16px;
}
.timeliness-inputs {
flex-direction: column;
gap: 8px;
}
}
</style>