5515 lines
176 KiB
Vue
5515 lines
176 KiB
Vue
<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>
|