Skip to content
On this page

阶梯报价、成本报价

TIP

阶梯报价或成本报价本质上是特定格式拼接成的 JSON 字符串数据,实现模板选择、增删改查、实时动态计算等需求的业务组件。

询价管理模块阶梯报价、成本报价

vue
<template>
  <div class="edit-page">
    <q-edit-page-layout ref="layoutRef" v-bind="options"></q-edit-page-layout>

    <!-- 模板选择 -->
    <q-select-template
      v-model:visible="templateVisible"
      businessType="costForm"
      :elsAccount="currentRow.elsAccount"
      :autoVisible="false"
      @ok="handleSetTemplate"
    ></q-select-template>

    <!-- 阶梯报价 -->
    <ladder-price-modal
      v-model="ladderPriceVisible"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      @success="handleLadderPriceSuccess"
    ></ladder-price-modal>

    <!-- 成本模板 编辑 -->
    <cost-price-edit
      v-model="priceEditShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
      @success="handleCostSuccess"
    ></cost-price-edit>
  </div>
</template>

<script setup lang="tsx">
import { ref, reactive } from "vue";
import type { ComponentPublicInstance } from "vue";
// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";
// 国际化
import { srmI18n } from "@/utils/srmI18n";

// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";
import { useRefInstanceHook } from "@qqt-product/ui";
import { loadJS, getRemoteJsFilePath } from "@qqt-product/ui";

import { Decimal } from "decimal.js";

// 工具函数及types
import qqtUtils from "@qqt-product/utils";
import CostPriceEdit from "./components/costPriceEdit.vue";
import LadderPriceDisplay from "./components/ladderPriceDisplay.vue";
import LadderPriceModal from "./components/ladderPriceModal.vue";

import type { SuccessPayload } from "./components/costPriceEdit.vue";

import { QIcon } from "@qqt-product/icons";

defineOptions({
  name: "srm-enquiry-enquiryHead-edit",
});

const DIGIT = 4;

const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

// 全局数据缓存
const { getCurrentRow, userInfo, token } = useGlobalStoreWithDefaultValue();
const currentRow = getCurrentRow();

const { cloneDeep } = qqtUtils;

const templateVisible = ref<boolean>(false);
const cachePageData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceEditShow = ref<boolean>(false);
const priceRow = ref<GlobalPageLayoutTypes.CurrentRow>({
  templateNumber: "",
  templateVersion: 0,
  templateAccount: "",
  busAccount: "",
  elsAccount: "",
});
const priceGroups = ref<GlobalPageLayoutTypes.Group[]>([]);
const priceData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceMap = ref<GlobalPageLayoutTypes.RecordString>({});

// 缓存行信息
const cachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);

const { pageData } = useRefInstanceHook(layoutRef);

const chooseTemplate = (
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
) => {
  cachePayload.value = payload;
  templateVisible.value = true;
};

// 模板选择后赋值成本报价 costFormJson
const handleSetTemplate = async ({ row }) => {
  const {
    templateName = "",
    templateAccount = "",
    templateNumber = "",
    templateVersion = 0,
    elsAccount = "",
  } = row;

  priceRow.value = {
    templateName,
    templateAccount,
    templateNumber,
    templateVersion,
    elsAccount,
    busAccount: elsAccount,
  };

  if (cachePayload.value) {
    priceRow.value.id = cachePayload.value.row.id || "";
    const costFormJson: GlobalPageLayoutTypes.RecordString = {
      ...priceRow.value,
      data: {},
      groups: [],
    };

    const filePath: string = getRemoteJsFilePath({
      currentRow: priceRow.value,
      userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
      businessType: "costForm",
      role: "purchase",
      token: token.value,
    });

    const config = (await loadJS(
      `${filePath}?t=${+new Date()}`
    )) as GlobalPageLayoutTypes.RemoteConfig;
    const groups = (config.groups as GlobalPageLayoutTypes.Group[]) || [];
    const itemColumns = config.itemColumns || [];
    if (groups && groups.length) {
      priceGroups.value = cloneDeep(groups);
      // 获取 group 分组的 sum 合计列的 field
      priceMap.value = groups.reduce((acc, group) => {
        if (acc[group.groupCode]) {
          acc[group.groupCode] = "";
        }
        const { field = "" } = itemColumns.find(
          (n) => n.groupCode === group.groupCode && n.sum === "1"
        ) || { field: "" };
        acc[group.groupCode] = field;
        return acc;
      }, {});

      costFormJson.groups = groups.map((group) => {
        return {
          groupName: group.groupName || "",
          groupNameI18nKey: group.groupNameI18nKey || "",
          groupCode: group.groupCode || "",
          groupType: group.groupType || "",
          totalValue: 0,
          price: 0,
          netPrice: 0,
        };
      });
    }

    cachePayload.value.row.costFormJson = JSON.stringify(costFormJson);
  }

  templateVisible.value = false;
};

const openCostPrice = async (
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
) => {
  const row = payload.row;
  cachePayload.value = payload;
  const costFormJson = row.costFormJson || "";

  if (costFormJson) {
    try {
      const obj = JSON.parse(costFormJson);

      const {
        templateName = "",
        templateAccount = "",
        templateNumber = "",
        templateVersion = 0,
        busAccount = "",
        elsAccount = "",
        id = "",
      } = obj;

      priceRow.value = {
        templateName,
        templateAccount,
        templateNumber,
        templateVersion,
        busAccount,
        elsAccount,
        id,
      };

      priceData.value = obj.data
        ? (obj.data as GlobalPageLayoutTypes.RecordString)
        : {};

      const filePath: string = getRemoteJsFilePath({
        currentRow: priceRow.value,
        userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
        businessType: "costForm",
        role: "purchase",
        token: token.value,
      });

      const config = (await loadJS(
        `${filePath}?t=${+new Date()}`
      )) as GlobalPageLayoutTypes.RemoteConfig;
      const groups = (config.groups as GlobalPageLayoutTypes.Group[]) || [];
      const itemColumns = config.itemColumns || [];
      if (groups && groups.length) {
        priceGroups.value = cloneDeep(groups);
        // 获取 group 分组的 sum 合计列的 field
        priceMap.value = groups.reduce((acc, group) => {
          if (acc[group.groupCode]) {
            acc[group.groupCode] = "";
          }
          const { field = "" } = itemColumns.find(
            (n) => n.groupCode === group.groupCode && n.sum === "1"
          ) || { field: "" };
          acc[group.groupCode] = field;
          return acc;
        }, {});
      }

      cachePageData.value = cloneDeep(pageData.value);
      priceEditShow.value = true;
    } catch (err) {
      console.log(err);
    }
  }
};

// 行赋值 赋值成本报价模型 及 计算 含税价、未税单价、含税总额、未税总额
const handleCostSuccess = ({ costFormJson, pageTotalVal }: SuccessPayload) => {
  const purchaseEnquiryItemList = pageData.value[
    "purchaseEnquiryItemList"
  ] as GlobalPageLayoutTypes.RecordString[];

  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (pageData.value.quoteType as string) || "0";
  const { row } = cachePayload.value || { row: { taxRate: 0 } };
  const taxRate = row["taxRate"] || 0;
  const formula = Decimal.add(1, Decimal.div(taxRate, 100));

  if (cachePayload.value) {
    const { rowIndex } = cachePayload.value;
    if (purchaseEnquiryItemList[rowIndex]) {
      purchaseEnquiryItemList[rowIndex]["costFormJson"] = costFormJson;

      let requireQuantity =
        purchaseEnquiryItemList[rowIndex]["requireQuantity"];

      // 根据含税价 price, 实时计算未税单价
      if (quoteType === "0") {
        purchaseEnquiryItemList[rowIndex]["price"] = pageTotalVal;
        let price = pageTotalVal;

        if (price && taxRate) {
          let netPrice = Decimal.div(price, formula);
          // 4位小数
          purchaseEnquiryItemList[rowIndex]["netPrice"] = new Decimal(
            netPrice
          ).toFixed(DIGIT);
          if (requireQuantity) {
            let taxAmount = Decimal.mul(price, requireQuantity);
            let netAmount = Decimal.div(taxAmount, formula);
            purchaseEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
            purchaseEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
          }
        }
      } else {
        // 根据未税单价价 netPrice, 实时计算含税价
        purchaseEnquiryItemList[rowIndex]["netPrice"] = pageTotalVal;
        let netPrice = pageTotalVal;

        if (netPrice && taxRate) {
          let price = Decimal.mul(netPrice, formula);
          // 4位小数
          purchaseEnquiryItemList[rowIndex]["price"] = new Decimal(
            price
          ).toFixed(DIGIT);
          if (requireQuantity) {
            let netAmount = Decimal.mul(netPrice, requireQuantity);
            let taxAmount = Decimal.mul(netAmount, formula);
            purchaseEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
            purchaseEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
          }
        }
      }
    }
  }
};

const handleLadderPriceSuccess = (str: string) => {
  if (ladderPriceCachePayload.value) {
    const { row, column } = ladderPriceCachePayload.value;
    row[column.field] = str;
  }
};

const handleAfterRemoteConfig = (config) => {
  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    if (n.field == "costFormJson") {
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const row = payload.row;
          let name = srmI18n("i18n_title_pleaseSelect", "请选择");
          let flag = false;
          try {
            const costFormJson = row.costFormJson || "";
            if (costFormJson) {
              const obj = JSON.parse(costFormJson);
              if (obj.templateName) {
                name = obj.templateName;
                flag = true;
              }
            }
          } catch (err) {
            flag = false;
            console.log(err);
          }

          const elem = flag ? (
            <div style="display: flex; justify-content: center;">
              <a style="color: #1677ff;" onClick={() => openCostPrice(payload)}>
                {name}
              </a>
              <span onClick={() => (row.costFormJson = "")}>
                <QIcon
                  type="icon-Q-remove"
                  size="14"
                  style="margin-left: 4px; cursor: pointer; color: #ee1d1d;"
                ></QIcon>
              </span>
            </div>
          ) : (
            <a style="color: #1677ff;" onClick={() => chooseTemplate(payload)}>
              {name}
            </a>
          );

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)

          return [row.quotePriceWay === "2" ? elem : null];
        },
      };
    }

    // 阶梯报价JSON
    if (n.field === "ladderPriceJson") {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const handleLadderPriceClick = () => {
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            ladderPriceVisible.value = true;
          };
          const handleLadderPriceClear = () => {
            row[column.field] = "";
          };

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)
          const elem =
            row.quotePriceWay === "1" ? (
              <LadderPriceDisplay
                value={val}
                onClick={handleLadderPriceClick}
                onClear={handleLadderPriceClear}
              ></LadderPriceDisplay>
            ) : null;

          return [elem];
        },
      };
    }
  });
  return config;
};

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div class="detail-page">
    <q-detail-page-layout
      ref="layoutRef"
      v-bind="options"
    ></q-detail-page-layout>

    <!-- 阶梯报价 -->
    <ladder-price-modal
      v-model="ladderPriceVisible"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      disabled
    ></ladder-price-modal>

    <!-- 成本模板 详情 -->
    <cost-price-detail
      v-model="priceDetailShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
    ></cost-price-detail>
  </div>
</template>

<script setup lang="tsx">
import { ref, reactive } from "vue";
import type { ComponentPublicInstance } from "vue";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";

import { useRefInstanceHook } from "@qqt-product/ui";
import { loadJS, getRemoteJsFilePath } from "@qqt-product/ui";

// 全局数据缓存
// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import CostPriceDetail from "./components/costPriceDetail.vue";
import LadderPriceDisplay from "./components/ladderPriceDisplay.vue";
import LadderPriceModal from "./components/ladderPriceModal.vue";
// 工具函数及types
import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const cachePageData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceDetailShow = ref<boolean>(false);
const priceRow = ref<GlobalPageLayoutTypes.CurrentRow>({
  templateNumber: "",
  templateVersion: 0,
  templateAccount: "",
  busAccount: "",
  elsAccount: "",
});
const priceGroups = ref<GlobalPageLayoutTypes.Group[]>([]);
const priceData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceMap = ref<GlobalPageLayoutTypes.RecordString>({});

// 缓存行信息
const cachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();
const { token, userInfo } = useGlobalStoreWithDefaultValue();

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);

const { pageData } = useRefInstanceHook(layoutRef);

const openCostPrice = async (
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
) => {
  const row = payload.row;
  cachePayload.value = payload;
  const costFormJson = row.costFormJson || "";

  if (costFormJson) {
    try {
      const obj = JSON.parse(costFormJson);

      const {
        templateName = "",
        templateAccount = "",
        templateNumber = "",
        templateVersion = 0,
        busAccount = "",
        elsAccount = "",
        id = "",
      } = obj;

      priceRow.value = {
        templateName,
        templateAccount,
        templateNumber,
        templateVersion,
        busAccount,
        elsAccount,
        id,
      };

      priceData.value = obj.data
        ? (obj.data as GlobalPageLayoutTypes.RecordString)
        : {};

      const filePath: string = getRemoteJsFilePath({
        currentRow: priceRow.value,
        userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
        businessType: "costForm",
        role: "purchase",
        token: token.value,
      });

      const config = (await loadJS(
        `${filePath}?t=${+new Date()}`
      )) as GlobalPageLayoutTypes.RemoteConfig;
      const groups = (config.groups as GlobalPageLayoutTypes.Group[]) || [];
      const itemColumns = config.itemColumns || [];
      if (groups && groups.length) {
        priceGroups.value = cloneDeep(groups);
        // 获取 group 分组的 sum 合计列的 field
        priceMap.value = groups.reduce((acc, group) => {
          if (acc[group.groupCode]) {
            acc[group.groupCode] = "";
          }
          const { field = "" } = itemColumns.find(
            (n) => n.groupCode === group.groupCode && n.sum === "1"
          ) || { field: "" };
          acc[group.groupCode] = field;
          return acc;
        }, {});
      }

      cachePageData.value = cloneDeep(pageData.value);
      priceDetailShow.value = true;
    } catch (err) {
      console.log(err);
    }
  }
};

const handleAfterRemoteConfig = (config) => {
  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    if (n.field == "costFormJson") {
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const row = payload.row;
          let name = "";
          let flag = false;
          try {
            const costFormJson = row.costFormJson || "";
            if (costFormJson) {
              const obj = JSON.parse(costFormJson);
              if (obj.templateName) {
                name = obj.templateName;
                flag = true;
              }
            }
          } catch (err) {
            flag = false;
            console.log(err);
          }

          const elem = flag ? (
            <a style="color: #1677ff;" onClick={() => openCostPrice(payload)}>
              {name}
            </a>
          ) : null;

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)

          return [row.quotePriceWay === "2" ? elem : null];
        },
      };
    }

    // 阶梯报价JSON
    if (n.field === "ladderPriceJson") {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const handleLadderPriceClick = () => {
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            ladderPriceVisible.value = true;
          };

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)
          const elem =
            row.quotePriceWay === "1" ? (
              <LadderPriceDisplay
                value={val}
                onClick={handleLadderPriceClick}
                disabled={true}
              ></LadderPriceDisplay>
            ) : null;

          return [elem];
        },
      };
    }
  });
  return config;
};

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div :class="['ladder-price-display', str && !props.disabled ? 'set' : '']">
    <a-tooltip overlayClassName="tip-overlay-class">
      <template #title>
        <div class="grid">
          <vxe-table
            ref="xGrid"
            auto-resize
            border
            row-id="id"
            size="mini"
            :data="tableData"
          >
            <vxe-table-column
              type="seq"
              :title="srmI18n('i18n_alert_seq', '序号')"
              width="80"
            ></vxe-table-column>
            <vxe-table-column
              field="ladder"
              :title="srmI18n('i18n_title_ladderLeve', '阶梯级')"
              width="140"
            ></vxe-table-column>
            <vxe-table-column
              field="price"
              :title="srmI18n('i18n_title_price', '含税价')"
              width="140"
            ></vxe-table-column>
            <vxe-table-column
              field="netPrice"
              :title="srmI18n('i18n_title_netPrice', '不含税价')"
              width="140"
            ></vxe-table-column>
          </vxe-table>
        </div>
      </template>
      {{ str }}
    </a-tooltip>
    <a class="icons">
      <StockOutlined @click="handleClick" />
      <CloseCircleOutlined
        v-show="str && !props.disabled"
        class="clear"
        @click="handleClear"
      />
    </a>
  </div>
</template>

<script lang="ts" setup>
import { computed } from "vue";
import { StockOutlined, CloseCircleOutlined } from "@ant-design/icons-vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";

type Props = {
  value: string;
  disabled?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
  value: "",
  disabled: false,
});

const emit = defineEmits<{
  (e: "click"): void;
  (e: "clear"): void;
}>();

const str = computed<string>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr.map((n) => `${n.ladder}:${n.price || ""}`).join(";");
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return "";
});

const tableData = computed<GlobalPageLayoutTypes.RecordString[]>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return [];
});

const handleClick = () => emit("click");
const handleClear = () => emit("clear");
</script>

<style lang="less" scoped>
.ladder-price-display {
  position: relative;
  height: 36px;
  line-height: 36px;
  padding-right: 20px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  a:hover {
    color: #1677ff;
  }
  &.set {
    padding-right: 42px;
  }
  .icons {
    position: absolute;
    // display: inline-block
    font-size: 16px;
    right: 0px;
    top: 3px;
    z-index: 10;
  }
  .clear {
    color: red;
    margin-left: 8px;
    &.unDelete {
      color: rgba(0, 0, 0, 0.25);
      cursor: not-allowed;
    }
  }
}
</style>
vue
<template>
  <div class="checkPrice">
    <q-basic-modal
      :visible="modelValue"
      :width="1000"
      :title="srmI18n('i18n_title_ladderSetting', '阶梯设置')"
      :maskClosable="false"
      :keyboard="false"
      :bodyStyle="{ padding: '0 12px' }"
      @cancel="handleCancel"
      @ok="handleOk"
    >
      <div class="form" v-if="!disabled">
        <a-form
          style="display: flex; flex-flow: row wrap; margin: 4px"
          layout="inline"
        >
          <a-form-item
            style="margin-right: 12px"
            :label="srmI18n('i18n_title_ladderCount', '阶梯数量')"
          >
            <a-input-number
              style="width: 200px"
              placeholder="请输入大于0的阶梯数量"
              v-model:value="form.ladderQuantity"
              :min="0"
              @pressEnter="setData"
            />
          </a-form-item>
          <a-form-item style="margin-right: 12px">
            <a-button type="primary" @click="setData">{{
              srmI18n("i18n_title_add", "添加")
            }}</a-button>
          </a-form-item>
        </a-form>
      </div>
      <div class="grid" style="margin-top: 12px">
        <vxe-grid
          ref="xGrid"
          v-bind="gridConfig"
          :columns="columns"
          :data="tableData"
        >
          <template #empty>
            <q-empty></q-empty>
          </template>
        </vxe-grid>
      </div>
    </q-basic-modal>
  </div>
</template>

<script lang="tsx" setup>
import { ref, toRefs, watch } from "vue";
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type {
  VxeGridInstance,
  VxeGridProps,
  VxeGridPropTypes,
  VxeColumnSlotTypes,
  VxeTableDataRow,
} from "vxe-table";

import { editVxeTableConfig } from "@qqt-product/ui";
import { message as Message } from "ant-design-vue";
import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

interface Props {
  modelValue: boolean;
  value: string;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
  disabled?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  value: "",
  disabled: false,
});
const { modelValue, disabled } = toRefs(props);

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", val: string): void;
}>();

const LT = "< x <";
const ELT = "<= x <";
const GT = "<= x";

const xGrid = ref<VxeGridInstance>();

const rawState = {
  ladderQuantity: "",
};

const form = ref<GlobalPageLayoutTypes.RecordString>(rawState);

const tableData = ref<GlobalPageLayoutTypes.RecordString[]>([]);

const gridConfig: VxeGridProps = {
  ...editVxeTableConfig,
  rowConfig: {
    keyField: "id",
    isHover: true,
    isCurrent: true,
  },
  radioConfig: {
    trigger: "row",
  },
  height: 340,
  keyboardConfig: {},
  menuConfig: {},
  mouseConfig: {}, // 鼠标配置项
  areaConfig: {}, // 按键配置项
};

const columns: VxeGridPropTypes.Columns = [
  { type: "seq", width: 60, fixed: "left" },
  {
    field: "ladder",
    title: srmI18n("i18n_title_ladderLeve", "阶梯级"),
    width: 150,
  },
  {
    field: "ladderQuantity",
    title: srmI18n("i18n_title_ladderCount", "阶梯数量"),
    minWidth: 150,
  },
  {
    field: "price",
    title: srmI18n("i18n_title_price", "含税价"),
    minWidth: 150,
  },
  {
    field: "netPrice",
    title: srmI18n("i18n_title_netPrice", "不含税价"),
    minWidth: 150,
  },
  {
    field: "grid_operation",
    // fixed: 'right',
    title: "操作",
    width: 140,
    slots: {
      default({
        $rowIndex,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        const handleDelete = () => {
          tableData.value.splice($rowIndex, 1);
        };

        return [
          <a-button
            type="text"
            danger
            disabled={
              disabled.value || $rowIndex !== tableData.value.length - 1
            }
            onClick={handleDelete}
          >
            {srmI18n(`i18n_title_delete`, "删除")}
          </a-button>,
        ];
      },
    },
  },
];

const setNltRow = () => {
  if (tableData.value.length <= 1) return;
  if (tableData.value.length > 2) {
    let last = tableData.value[tableData.value.length - 1];
    let prev = tableData.value[tableData.value.length - 2];
    prev.ladder = `${prev.ladderQuantity} ${ELT} ${last.ladderQuantity}`;
  } else {
    let last = tableData.value[tableData.value.length - 1];
    last.ladder = `${last.ladderQuantity} ${GT}`;
  }
};

const setData = () => {
  const val = form.value.ladderQuantity;
  if (!val) {
    Message.error(srmI18n("title_pleaseEnterladderCount", "请输入阶梯数量"));
    return;
  }
  if (val <= 1) {
    Message.error(srmI18n("i18n_title_ladderMostOne", "梯数量必须大于1"));
    return;
  }
  let taxCode = "";
  let taxRate = "";
  if (props.cachePayload) {
    taxCode = props.cachePayload.row.taxCode || "";
    taxRate = props.cachePayload.row.taxRate || "";
  }

  if (tableData.value.length === 0) {
    let row = {
      ladderQuantity: 0,
      ladder: `0 ${LT} ${val}`,
      price: "",
      netPrice: "",
      taxCode,
      taxRate,
    };
    tableData.value.push(row);
  } else {
    let flag = tableData.value.every((n) => n.ladderQuantity < val);
    if (!flag) {
      Message.error(
        srmI18n(
          "i18n_title_ladderMostLastLadderCount",
          "阶梯数量必须大于上一阶梯数量"
        )
      );
      return;
    }
  }
  let row = {
    ladderQuantity: val,
    ladder: `${val} ${GT}`,
    price: "",
    netPrice: "",
    taxCode,
    taxRate,
  };
  tableData.value.push(row);
  setNltRow();

  form.value.ladderQuantity = "";
};

const resetData = () => {
  tableData.value = [];
  form.value = { ...rawState };
};

watch(modelValue, (bool) => {
  if (bool) {
    init();
  } else {
    resetData();
  }
});

const handleCancel = () => {
  emit("update:modelValue", false);
};

const handleOk = () => {
  const arr = cloneDeep(tableData.value);
  emit("success", JSON.stringify(arr));
  handleCancel();
};

const init = () => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        tableData.value = arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
};
</script>
vue
<template>
  <div class="cost-price">
    <a-modal
      :open="modelValue"
      width="100%"
      wrap-class-name="full-modal"
      :title="srmI18n(`i18n_title_costTemplate`, '成本模板')"
      @cancel="handleCancel"
      @ok="setCostOk"
    >
      <a-spin :spinning="loading">
        <div class="page-container">
          <q-edit-page-layout
            v-if="show"
            class="cost-price-layout"
            ref="layoutRef"
            v-bind="options"
          >
            <template
              v-for="el in namedSlots"
              :key="el.append"
              #[el.append]="{ groupCode }"
            >
              <div class="desc" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_quoteType`, "报价项")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{
                      isPriceQuoteType
                        ? srmI18n("i18n_title_price", "含税价")
                        : srmI18n("i18n_title_netPrice", "不含税价")
                    }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_taxRate`, "税率")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ curTaxRate }}</strong>
                  </span>
                </span>
              </div>
              <div class="total" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_price", "含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalPrice(groupCode) }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_netPrice", "不含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalNetPrice(groupCode) }}</strong>
                  </span>
                </span>
              </div>
            </template>
          </q-edit-page-layout>
        </div>
      </a-spin>
    </a-modal>
  </div>
</template>

<script lang="ts" setup>
import { ref, reactive, computed, toRefs, watch, nextTick } from "vue";
import type { ComponentPublicInstance } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

import { BUTTON_ADD_ONE_ROW, BUTTON_DELETE_ROW } from "@qqt-product/ui";

import { useRefInstanceHook } from "@qqt-product/ui";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import REGEXP from "@/utils/regexp";
import { Decimal } from "decimal.js";

import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

const DIGIT = 4;

const loading = ref<boolean>(false);

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);
const { pageData } = useRefInstanceHook(layoutRef);

interface Props {
  modelValue: boolean;
  currentRow: GlobalPageLayoutTypes.CurrentRow;
  priceData: GlobalPageLayoutTypes.RecordString;
  groups: GlobalPageLayoutTypes.Group[];
  priceMap: GlobalPageLayoutTypes.RecordString;
  cachePageData: GlobalPageLayoutTypes.RecordString;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
}

export interface SuccessPayload {
  costFormJson: string;
  pageTotalVal: string;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  currentRow: () => ({
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  }),
  priceData: () => ({}),
  groups: () => [],
  priceMap: () => ({}),
});
const { modelValue, priceMap } = toRefs(props);

const namedSlots = computed(() => {
  return props.groups.map((group) => ({
    append: `vertical_${group.groupCode}_append`,
  }));
});

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", payload: SuccessPayload): void;
}>();

// 全局数据缓存
const { userInfo, token, manuallyExit } = useGlobalStoreWithDefaultValue();

const show = ref<boolean>(false);

// 是否为“含税价”报价项
const isPriceQuoteType = computed<boolean>(() => {
  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (props.cachePageData.quoteType as string) || "0";
  return quoteType === "0";
});

const curTaxRate = computed(() => {
  if (props.cachePayload) {
    return props.cachePayload.row.taxRate || 0;
  }
  return 0;
});

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  showPageHeader: false,
  showLayoutAnchor: false,
  /**
   * 模块业务类型 string
   * 取值根据各模块配置
   * 当定义为"custom"时,使用本地配置
   */
  businessType: "costForm",
  // 布局模式: 平铺布局 vertical (默认), Tab布局 tab, 主从布局 master
  pattern: "vertical",
  // 仅使用本地布局模式
  isUseLocalPattern: true,
  // 当前已选行数据
  currentRow: {
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  },
  refreshMethods(row?: GlobalPageLayoutTypes.CurrentRow) {
    if (options.currentRow) {
      options.currentRow =
        row ||
        Object.assign({}, options.currentRow, {
          _t: +new Date(),
        });
    }
  },
  // 当前登录租户信息
  userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
  // 有导入excel 操作的必填
  token: token.value,
  // 本地页面数据配置
  localConfig: {
    groups: [],
  },
  isUseLocalData: true,
  localData: {},
});

watch(modelValue, (bool) => {
  if (bool) {
    if (options.localConfig) {
      options.localConfig.groups = props.groups.map((n) => {
        return {
          ...n,
          buttons: [BUTTON_ADD_ONE_ROW, BUTTON_DELETE_ROW],
        };
      });
    }
    const copyData = cloneDeep(props.priceData);
    options.currentRow = { ...props.currentRow, ...copyData };

    nextTick(() => {
      show.value = true;
    });
  }
});

const getTotal = (groupCode: string) => {
  if (!groupCode || !Decimal) {
    return 0;
  }
  const tableData = pageData.value[
    groupCode
  ] as GlobalPageLayoutTypes.RecordString[];
  if (!tableData || !tableData.length) {
    return 0;
  }
  // 合计列配置 property
  // 没有配置, 默认取 total
  const key = priceMap.value[groupCode] || "total";
  const flag = tableData.some((n) => !!n[key]);
  if (flag) {
    let totalAmount = tableData.reduce((acc: Decimal, row) => {
      let count = REGEXP.interger.test(row[key]) ? row[key] : 0;
      return Decimal.add(acc, count);
    }, new Decimal(0));

    const num = new Decimal(totalAmount).toFixed(DIGIT);
    return Number(num) > 0 ? num : 0;
  }

  return 0;
};

const getTotalPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      return getTotal(groupCode);
    } else {
      let netPrice = getTotal(groupCode);
      let price = Decimal.mul(netPrice, formula);
      return new Decimal(price).toFixed(DIGIT);
    }
  }
  return 0;
};

const getTotalNetPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      let price = getTotal(groupCode);
      let netPrice = Decimal.div(price, formula);
      return new Decimal(netPrice).toFixed(DIGIT);
    } else {
      return getTotal(groupCode);
    }
  }
  return 0;
};

const cancelFunc = () => {
  show.value = false;
  emit("update:modelValue", false);
};

// 生成成本报价JSON 及 页面价格汇总
const generateCostFormJsonData = () => {
  let copyCachePageData = cloneDeep(props.cachePageData);
  // 赋值表行成本报价JSON
  let copyPageData = cloneDeep(pageData.value);
  const costFormJson: GlobalPageLayoutTypes.RecordString = {
    ...props.currentRow,
    data: { ...copyPageData },
    groups: [],
  };

  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (copyCachePageData.quoteType as string) || "0";

  const taxRate = curTaxRate.value;
  const formula = Decimal.add(1, Decimal.div(taxRate, 100));

  // 获取页面所有价格汇总
  const pageTotal = props.groups.reduce((acc: Decimal, group) => {
    // 返回 groups 分组数据供给后端比价接口使用
    const obj: GlobalPageLayoutTypes.RecordString = {
      groupName: group.groupName || "",
      groupNameI18nKey: group.groupNameI18nKey || "",
      groupCode: group.groupCode || "",
      groupType: group.groupType || "",
      totalValue: "",
      price: 0,
      netPrice: 0,
    };

    // 分组合计
    let groupTotal = getTotal(group.groupCode);
    obj.totalValue = groupTotal;
    if (quoteType === "0") {
      obj.price = groupTotal;
      let netPrice = Decimal.div(groupTotal, formula);
      obj.netPrice = new Decimal(netPrice).toFixed(DIGIT);
    } else {
      obj.netPrice = groupTotal;
      let price = Decimal.mul(groupTotal, formula);
      obj.price = new Decimal(price).toFixed(DIGIT);
    }
    costFormJson.groups.push(obj);

    return Decimal.add(acc, groupTotal);
  }, new Decimal(0));
  const pageTotalVal = new Decimal(pageTotal).toFixed(DIGIT);

  emit("success", {
    costFormJson: JSON.stringify(costFormJson),
    pageTotalVal,
  });
};

const handleCancel = () => {
  cancelFunc();
};

const setCostOk = () => {
  generateCostFormJsonData();
  cancelFunc();
};
</script>

<style lang="less">
.cost-price {
  height: 100%;
}
.full-modal {
  .ant-modal {
    max-width: 100%;
    top: 0;
    padding-bottom: 0;
    margin: 0;
  }
  .ant-modal-content {
    display: flex;
    flex-direction: column;
    height: calc(100vh);
    max-height: calc(100vh);
    overflow-x: hidden;

    .page-container {
      min-height: calc(100vh - 55px - 53px);
    }
  }
  .ant-modal-body {
    flex: 1;
    padding: 0;
  }
}

.total,
.desc {
  margin-top: 6px;
  .tit {
    &::after {
      margin-right: 6px;
      color: #171832;
      content: ":";
    }
  }
  .item {
    & + .item {
      margin-left: 10px;
    }
  }
}
</style>
vue
<template>
  <div class="cost-price">
    <a-modal
      :open="modelValue"
      width="100%"
      wrap-class-name="full-modal"
      :title="srmI18n(`i18n_title_costTemplate`, '成本模板')"
      :footer="null"
      @cancel="handleCancel"
    >
      <a-spin :spinning="loading">
        <div class="page-container">
          <q-detail-page-layout
            v-if="show"
            class="cost-price-layout"
            ref="layoutRef"
            v-bind="options"
          >
            <template
              v-for="el in namedSlots"
              :key="el.append"
              #[el.append]="{ groupCode }"
            >
              <div class="desc" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_quoteType`, "报价项")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{
                      isPriceQuoteType
                        ? srmI18n("i18n_title_price", "含税价")
                        : srmI18n("i18n_title_netPrice", "不含税价")
                    }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_taxRate`, "税率")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ curTaxRate }}</strong>
                  </span>
                </span>
              </div>
              <div class="total" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_price", "含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalPrice(groupCode) }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_netPrice", "不含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalNetPrice(groupCode) }}</strong>
                  </span>
                </span>
              </div>
            </template>
          </q-detail-page-layout>
        </div>
      </a-spin>
    </a-modal>
  </div>
</template>

<script lang="ts" setup>
import { ref, reactive, computed, toRefs, watch, nextTick } from "vue";
import type { ComponentPublicInstance } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

import { useRefInstanceHook } from "@qqt-product/ui";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import REGEXP from "@/utils/regexp";
import { Decimal } from "decimal.js";

import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

const DIGIT = 4;

const loading = ref<boolean>(false);

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);
const { pageData } = useRefInstanceHook(layoutRef);

interface Props {
  modelValue: boolean;
  currentRow: GlobalPageLayoutTypes.CurrentRow;
  priceData: GlobalPageLayoutTypes.RecordString;
  groups: GlobalPageLayoutTypes.Group[];
  priceMap: GlobalPageLayoutTypes.RecordString;
  cachePageData: GlobalPageLayoutTypes.RecordString;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  currentRow: () => ({
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  }),
  priceData: () => ({}),
  groups: () => [],
  priceMap: () => ({}),
});
const { modelValue, priceMap } = toRefs(props);

const namedSlots = computed(() => {
  return props.groups.map((group) => ({
    append: `vertical_${group.groupCode}_append`,
  }));
});

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
}>();

// 全局数据缓存
const { userInfo, token, manuallyExit } = useGlobalStoreWithDefaultValue();

const show = ref<boolean>(false);

// 是否为“含税价”报价项
const isPriceQuoteType = computed<boolean>(() => {
  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (props.cachePageData.quoteType as string) || "0";
  return quoteType === "0";
});

const curTaxRate = computed(() => {
  if (props.cachePayload) {
    return props.cachePayload.row.taxRate || 0;
  }
  return 0;
});

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  showPageHeader: false,
  showLayoutAnchor: false,
  /**
   * 模块业务类型 string
   * 取值根据各模块配置
   * 当定义为"custom"时,使用本地配置
   */
  businessType: "costForm",
  // 布局模式: 平铺布局 vertical (默认), Tab布局 tab, 主从布局 master
  pattern: "vertical",
  // 仅使用本地布局模式
  isUseLocalPattern: true,
  // 当前已选行数据
  currentRow: {
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  },
  refreshMethods(row?: GlobalPageLayoutTypes.CurrentRow) {
    if (options.currentRow) {
      options.currentRow =
        row ||
        Object.assign({}, options.currentRow, {
          _t: +new Date(),
        });
    }
  },
  // 当前登录租户信息
  userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
  // 有导入excel 操作的必填
  token: token.value,
  // 本地页面数据配置
  localConfig: {
    groups: [],
  },
  isUseLocalData: true,
  localData: {},
});

watch(modelValue, (bool) => {
  if (bool) {
    if (options.localConfig) {
      options.localConfig.groups = cloneDeep(props.groups);
    }
    const copyData = cloneDeep(props.priceData);
    options.currentRow = { ...props.currentRow, ...copyData };

    nextTick(() => {
      show.value = true;
    });
  }
});

const getTotal = (groupCode: string) => {
  if (!groupCode || !Decimal) {
    return 0;
  }
  const tableData = pageData.value[
    groupCode
  ] as GlobalPageLayoutTypes.RecordString[];
  if (!tableData || !tableData.length) {
    return 0;
  }
  // 合计列配置 property
  // 没有配置, 默认取 total
  const key = priceMap.value[groupCode] || "total";
  const flag = tableData.some((n) => !!n[key]);
  if (flag) {
    let totalAmount = tableData.reduce((acc: Decimal, row) => {
      let count = REGEXP.interger.test(row[key]) ? row[key] : 0;
      return Decimal.add(acc, count);
    }, new Decimal(0));

    const num = new Decimal(totalAmount).toFixed(DIGIT);
    return Number(num) > 0 ? num : 0;
  }

  return 0;
};

const getTotalPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      return getTotal(groupCode);
    } else {
      let netPrice = getTotal(groupCode);
      let price = Decimal.mul(netPrice, formula);
      return new Decimal(price).toFixed(DIGIT);
    }
  }
  return 0;
};

const getTotalNetPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      let price = getTotal(groupCode);
      let netPrice = Decimal.div(price, formula);
      return new Decimal(netPrice).toFixed(DIGIT);
    } else {
      return getTotal(groupCode);
    }
  }
  return 0;
};

const cancelFunc = () => {
  show.value = false;
  emit("update:modelValue", false);
};

const handleCancel = () => {
  cancelFunc();
};
</script>

<style lang="less">
.cost-price {
  height: 100%;
}
.full-modal {
  .ant-modal {
    max-width: 100%;
    top: 0;
    padding-bottom: 0;
    margin: 0;
  }
  .ant-modal-content {
    display: flex;
    flex-direction: column;
    height: calc(100vh);
    max-height: calc(100vh);
    overflow-x: hidden;

    .page-container {
      min-height: calc(100vh - 55px);
    }
  }
  .ant-modal-body {
    flex: 1;
    padding: 0;
  }
}

.total,
.desc {
  margin-top: 6px;
  .tit {
    &::after {
      margin-right: 6px;
      color: #171832;
      content: ":";
    }
  }
  .item {
    & + .item {
      margin-left: 10px;
    }
  }
}
</style>

示例

cost_price.jpg

purchase_ladder_price.jpg

报价管理模块阶梯报价、成本报价

vue
<template>
  <div class="edit-page">
    <q-edit-page-layout ref="layoutRef" v-bind="options"></q-edit-page-layout>

    <!-- 阶梯报价 -->
    <sale-ladder-price-modal
      v-model="ladderPriceVisible"
      :quoteType="quoteType"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      @success="handleLadderPriceSuccess"
    ></sale-ladder-price-modal>

    <!-- 成本模板 编辑 -->
    <cost-price-edit
      v-model="priceEditShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
      @success="handleCostSuccess"
    ></cost-price-edit>

    <!-- 成本模板 详情 -->
    <cost-price-detail
      v-model="priceDetailShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
    ></cost-price-detail>
  </div>
</template>

<script setup lang="tsx">
import { ref, reactive } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";

import type { ComponentPublicInstance } from "vue";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import { useRefInstanceHook } from "@qqt-product/ui";
import { loadJS, getRemoteJsFilePath } from "@qqt-product/ui";

import qqtApi from "@qqt-product/api";
import { message as Message } from "ant-design-vue";

// 工具函数及types
import qqtUtils from "@qqt-product/utils";
import SaleLadderPriceDisplay from "./components/saleLadderPriceDisplay.vue";
import SaleLadderPriceModal from "./components/saleLadderPriceModal.vue";
import CostPriceEdit from "./components/costPriceEdit.vue";
import CostPriceDetail from "./components/costPriceDetail.vue";
import type { SuccessPayload } from "./components/costPriceEdit.vue";

import { Decimal } from "decimal.js";

const quoteType = ref<string>("0");
const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const DIGIT = 4;
const { cloneDeep } = qqtUtils;

const URL = "/base/heartBeat/noToken/check";
const getTimeStamp = () => Request.get({ url: URL });

const cachePageData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceEditShow = ref<boolean>(false);
const priceDetailShow = ref<boolean>(false);
const priceRow = ref<GlobalPageLayoutTypes.CurrentRow>({
  templateNumber: "",
  templateVersion: 0,
  templateAccount: "",
  busAccount: "",
  elsAccount: "",
});
const priceGroups = ref<GlobalPageLayoutTypes.Group[]>([]);
const priceData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceMap = ref<GlobalPageLayoutTypes.RecordString>({});

const Request: qqtApi.Request = qqtApi.useHttp();

// 全局数据缓存
const { userInfo, token } = useGlobalStoreWithDefaultValue();

// 缓存行信息
const cachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const openCostPrice = async (
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
) => {
  const row = payload.row;
  if (!row.taxCode) {
    Message.error(srmI18n(`i18n_title_pleaseEnterTaxCode`, "请先选择税码"));
    return;
  }

  cachePayload.value = payload;
  const costFormJson = row.costFormJson || "";

  if (costFormJson) {
    try {
      const obj = JSON.parse(costFormJson);

      const {
        templateName = "",
        templateAccount = "",
        templateNumber = "",
        templateVersion = 0,
        busAccount = "",
        elsAccount = "",
      } = obj;

      priceRow.value = {
        templateName,
        templateAccount,
        templateNumber,
        templateVersion,
        busAccount,
        elsAccount,
        id: row.id,
      };

      priceData.value = obj.data
        ? (obj.data as GlobalPageLayoutTypes.RecordString)
        : {};

      const filePath: string = getRemoteJsFilePath({
        currentRow: priceRow.value,
        userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
        businessType: "costForm",
        role: "sale",
        token: token.value,
      });

      const config = (await loadJS(
        `${filePath}?t=${+new Date()}`
      )) as GlobalPageLayoutTypes.RemoteConfig;
      const groups = (config.groups as GlobalPageLayoutTypes.Group[]) || [];
      const itemColumns = config.itemColumns || [];
      if (groups && groups.length) {
        priceGroups.value = cloneDeep(groups);
        // 获取 group 分组的 sum 合计列的 field
        priceMap.value = groups.reduce((acc, group) => {
          if (acc[group.groupCode]) {
            acc[group.groupCode] = "";
          }
          const { field = "" } = itemColumns.find(
            (n) => n.groupCode === group.groupCode && n.sum === "1"
          ) || { field: "" };
          acc[group.groupCode] = field;
          return acc;
        }, {});
      }

      // 询价单状态 enquiryStatus_dictText
      // 数据字典 srmEnquiryStatus (0: 新建, 1: 报价中, 2: 已报价, 3: 未报价, 4: 接受, 5: 拒绝, 6: 不能报价, 7: 议价中, 8: 重报价, 9: 已定价, 10: 已作废, 11: 已悔标, 12: 发布中, 13: 发布失败, 14: 已转单)
      const props = ["1", "2", "8"];
      let status =
        props.includes(pageData.value.enquiryStatus) ||
        row.itemStatus === "1" ||
        row.itemStatus === "8"
          ? "edit"
          : "detail";

      const res = await getTimeStamp();
      const timestamp = (res?.timestamp as number) || Date.now();
      // 报价截止日期 expiryDate
      const expiryDate_timestamp = row.expiryDate_timestamp as number;
      if (timestamp >= expiryDate_timestamp) {
        status = "detail";
      }

      cachePageData.value = cloneDeep(pageData.value);

      status === "edit"
        ? (priceEditShow.value = true)
        : (priceDetailShow.value = true);
    } catch (err) {
      console.log(err);
    }
  }
};

// 行赋值 赋值成本报价模型 及 计算 含税价、未税单价、含税总额、未税总额
const handleCostSuccess = ({ costFormJson, pageTotalVal }: SuccessPayload) => {
  const saleEnquiryItemList = pageData.value[
    "saleEnquiryItemList"
  ] as GlobalPageLayoutTypes.RecordString[];

  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (pageData.value.quoteType as string) || "0";
  const { row } = cachePayload.value || { row: { taxRate: 0 } };
  const taxRate = row["taxRate"] || 0;
  const formula = Decimal.add(1, Decimal.div(taxRate, 100));

  if (cachePayload.value) {
    const { rowIndex } = cachePayload.value;
    if (saleEnquiryItemList[rowIndex]) {
      saleEnquiryItemList[rowIndex]["costFormJson"] = costFormJson;

      let requireQuantity = saleEnquiryItemList[rowIndex]["requireQuantity"];

      // 根据含税价 price, 实时计算未税单价
      if (quoteType === "0") {
        saleEnquiryItemList[rowIndex]["price"] = pageTotalVal;
        let price = pageTotalVal;

        if (price && taxRate) {
          let netPrice = Decimal.div(price, formula);
          // 4位小数
          saleEnquiryItemList[rowIndex]["netPrice"] = new Decimal(
            netPrice
          ).toFixed(DIGIT);
          if (requireQuantity) {
            let taxAmount = Decimal.mul(price, requireQuantity);
            let netAmount = Decimal.div(taxAmount, formula);
            saleEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
            saleEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
          }
        }
      } else {
        // 根据未税单价价 netPrice, 实时计算含税价
        saleEnquiryItemList[rowIndex]["netPrice"] = pageTotalVal;
        let netPrice = pageTotalVal;

        if (netPrice && taxRate) {
          let price = Decimal.mul(netPrice, formula);
          // 4位小数
          saleEnquiryItemList[rowIndex]["price"] = new Decimal(price).toFixed(
            DIGIT
          );
          if (requireQuantity) {
            let netAmount = Decimal.mul(netPrice, requireQuantity);
            let taxAmount = Decimal.mul(netAmount, formula);
            saleEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
            saleEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
          }
        }
      }
    }
  }
};

// 获取行对应阶梯价格
const handleLadderPriceSuccess = (
  arr: GlobalPageLayoutTypes.RecordString[]
) => {
  if (!arr.length || !ladderPriceCachePayload.value) {
    return;
  }
  const { row, column } = ladderPriceCachePayload.value;
  row[column.field] = JSON.stringify(arr);

  let requireQuantity = row.requireQuantity || 1;
  let idx = arr.findIndex((n) => {
    return Number(requireQuantity) < n.ladderQuantity;
  });
  // 取最后一个阶梯的报价
  if (idx === -1) {
    idx = arr.length - 1;
  } else {
    idx = idx - 1;
  }
  const { price, netPrice } = arr[idx];
  row.price = price; // 含税价
  row.netPrice = netPrice; // 未税价
  row.taxAmount = Decimal.mul(price, requireQuantity).toFixed(DIGIT); // 含税总额
  row.netAmount = Decimal.mul(netPrice, requireQuantity).toFixed(DIGIT); // 未税总额
};

// 自定义业务模板数据配置
// 兼容方法设置供应方分组groupCode
const handleAfterRemoteConfig = (config) => {
  // 过滤采购方表行编辑规则
  const groups = config.groups || [];
  config.groups = groups.filter(
    (n) => n.groupCode !== "purchaseEnquiryItemList"
  );

  const formFields = config.formFields || [];
  // 去除采购方callback
  formFields.forEach((n) => {
    if (n.fieldName === "enquiryScope") {
      if (n.callback) {
        delete n.callback;
      }
    }
  });

  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    if (n.groupCode === "purchaseEnquiryItemList") {
      n.groupCode = "saleEnquiryItemList";
    }
    // 成本组成JSON
    if (n.field === "costFormJson") {
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const row = payload.row;
          const price = row.price;
          const netPrice = row.netPrice;
          const flag =
            !!(price && Number(price) > 0) ||
            !!(netPrice && Number(netPrice) > 0);

          // debugger
          const elem = (
            <a onClick={() => openCostPrice(payload)}>
              {flag
                ? srmI18n("i18n_title_quotationPrice", "已报价")
                : srmI18n("i18n_title_noQuotationPrice", "未报价")}
            </a>
          );

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)

          return [
            row.quotePriceWay === "2" && row.quotePrice == "1" ? elem : null,
          ];
        },
      };
    }

    // 是否报价
    if (n.field === "quotePrice") {
      n.fieldType = "";
      n.slots = {
        default({
          row,
          column,
        }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
          function quotePriceChange(val) {
            if (!val) {
              column.disabled = false;
            } else {
              column.disabled = true;
            }
          }
          return [
            <q-switch
              v-model:checked={row[column.field]}
              onChange={quotePriceChange}
            ></q-switch>,
          ];
        },
      };
    }

    // 阶梯报价JSON
    if (n.field === "ladderPriceJson") {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const handleLadderPriceClick = () => {
            if (!payload.row.taxCode) {
              Message.error(
                srmI18n(`i18n_title_pleaseEnterTaxCode`, "请先选择税码")
              );
              return;
            }
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            quoteType.value = pageData.value.quoteType || "0";
            ladderPriceVisible.value = true;
          };
          const handleLadderPriceClear = () => {
            row[column.field] = "";
          };

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)
          const elem =
            row.quotePriceWay === "1" ? (
              <SaleLadderPriceDisplay
                value={val}
                onClick={handleLadderPriceClick}
                onClear={handleLadderPriceClear}
              ></SaleLadderPriceDisplay>
            ) : null;

          return [elem];
        },
      };
    }

    //供应商税率为是时,税码可弹框
    // if (n.field === 'taxCode' && currentRow['supplierTaxRate'] == '1') {
    //   n.disabled = false
    // }
    // if (n.field === 'taxCode' && currentRow['supplierTaxRate'] != '1') {
    //   n.disabled = true
    // }
  });

  return config; // 返回处理后的配置数据, 可以是 promise 对象
};

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);

const { pageData } = useRefInstanceHook(layoutRef);

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  // 角色: 供应方
  role: "sale",
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div class="detail-page">
    <q-detail-page-layout
      ref="layoutRef"
      v-bind="options"
    ></q-detail-page-layout>

    <!-- 阶梯报价 -->
    <sale-ladder-price-modal
      v-model="ladderPriceVisible"
      :quoteType="quoteType"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      disabled
    ></sale-ladder-price-modal>

    <!-- 成本模板 编辑 -->
    <cost-price-edit
      v-model="priceEditShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
      @success="handleCostSuccess"
    ></cost-price-edit>

    <!-- 成本模板 详情 -->
    <cost-price-detail
      v-model="priceDetailShow"
      :currentRow="priceRow"
      :priceData="priceData"
      :groups="priceGroups"
      :priceMap="priceMap"
      :cachePageData="cachePageData"
      :cachePayload="cachePayload"
    ></cost-price-detail>
  </div>
</template>

<script setup lang="tsx">
import { ref, reactive } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";

import type { ComponentPublicInstance } from "vue";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import { useRefInstanceHook } from "@qqt-product/ui";
import { loadJS, getRemoteJsFilePath } from "@qqt-product/ui";

import { message as Message } from "ant-design-vue";

// 工具函数及types
import qqtUtils from "@qqt-product/utils";
import SaleLadderPriceDisplay from "./components/saleLadderPriceDisplay.vue";
import SaleLadderPriceModal from "./components/saleLadderPriceModal.vue";
import CostPriceEdit from "./components/costPriceEdit.vue";
import CostPriceDetail from "./components/costPriceDetail.vue";
import type { SuccessPayload } from "./components/costPriceEdit.vue";

import { Decimal } from "decimal.js";

const quoteType = ref<string>("0");
const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const DIGIT = 4;
const { cloneDeep } = qqtUtils;

const cachePageData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceEditShow = ref<boolean>(false);
const priceDetailShow = ref<boolean>(false);
const priceRow = ref<GlobalPageLayoutTypes.CurrentRow>({
  templateNumber: "",
  templateVersion: 0,
  templateAccount: "",
  busAccount: "",
  elsAccount: "",
});
const priceGroups = ref<GlobalPageLayoutTypes.Group[]>([]);
const priceData = ref<GlobalPageLayoutTypes.RecordString>({});
const priceMap = ref<GlobalPageLayoutTypes.RecordString>({});

// 全局数据缓存
const { userInfo, token } = useGlobalStoreWithDefaultValue();

// 缓存行信息
const cachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const openCostPrice = async (
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
) => {
  const row = payload.row;
  if (!row.taxCode) {
    Message.error(srmI18n(`i18n_title_pleaseEnterTaxCode`, "请先选择税码"));
    return;
  }

  cachePayload.value = payload;
  const costFormJson = row.costFormJson || "";

  if (costFormJson) {
    try {
      const obj = JSON.parse(costFormJson);

      const {
        templateName = "",
        templateAccount = "",
        templateNumber = "",
        templateVersion = 0,
        busAccount = "",
        elsAccount = "",
      } = obj;

      priceRow.value = {
        templateName,
        templateAccount,
        templateNumber,
        templateVersion,
        busAccount,
        elsAccount,
        id: row.id,
      };

      priceData.value = obj.data
        ? (obj.data as GlobalPageLayoutTypes.RecordString)
        : {};

      const filePath: string = getRemoteJsFilePath({
        currentRow: priceRow.value,
        userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
        businessType: "costForm",
        role: "sale",
        token: token.value,
      });

      const config = (await loadJS(
        `${filePath}?t=${+new Date()}`
      )) as GlobalPageLayoutTypes.RemoteConfig;
      const groups = (config.groups as GlobalPageLayoutTypes.Group[]) || [];
      const itemColumns = config.itemColumns || [];
      if (groups && groups.length) {
        priceGroups.value = cloneDeep(groups);
        // 获取 group 分组的 sum 合计列的 field
        priceMap.value = groups.reduce((acc, group) => {
          if (acc[group.groupCode]) {
            acc[group.groupCode] = "";
          }
          const { field = "" } = itemColumns.find(
            (n) => n.groupCode === group.groupCode && n.sum === "1"
          ) || { field: "" };
          acc[group.groupCode] = field;
          return acc;
        }, {});
      }

      // 询价单状态 enquiryStatus_dictText
      // 数据字典 srmEnquiryStatus (0: 新建, 1: 报价中, 2: 已报价, 3: 未报价, 4: 接受, 5: 拒绝, 6: 不能报价, 7: 议价中, 8: 重报价, 9: 已定价, 10: 已作废, 11: 已悔标, 12: 发布中, 13: 发布失败, 14: 已转单)
      // const props = ['1', '2', '8']
      // let status = props.includes(pageData.value.enquiryStatus) || row.itemStatus === '1' || row.itemStatus === '8' ? 'edit' : 'detail'

      // const res = await getTimeStamp()
      // const timestamp = (res?.timestamp as number) || Date.now()
      // // 报价截止日期 expiryDate
      // const expiryDate_timestamp = row.expiryDate_timestamp as number
      // if (timestamp >= expiryDate_timestamp) {
      //   status = 'detail'
      // }

      // cachePageData.value = cloneDeep(pageData.value)

      // status === 'edit' ? (priceEditShow.value = true) : (priceDetailShow.value = true)
      priceDetailShow.value = true;
    } catch (err) {
      console.log(err);
    }
  }
};

// 行赋值 赋值成本报价模型 及 计算 含税价、未税单价、含税总额、未税总额
const handleCostSuccess = ({ costFormJson, pageTotalVal }: SuccessPayload) => {
  const saleEnquiryItemList = pageData.value[
    "saleEnquiryItemList"
  ] as GlobalPageLayoutTypes.RecordString[];

  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (pageData.value.quoteType as string) || "0";
  const { row } = cachePayload.value || { row: { taxRate: 0 } };
  const taxRate = row["taxRate"] || 0;
  const formula = Decimal.add(1, Decimal.div(taxRate, 100));

  if (cachePayload.value) {
    const { rowIndex } = cachePayload.value;
    if (saleEnquiryItemList[rowIndex]) {
      saleEnquiryItemList[rowIndex]["costFormJson"] = costFormJson;

      let requireQuantity = saleEnquiryItemList[rowIndex]["requireQuantity"];

      // 根据含税价 price, 实时计算未税单价
      if (quoteType === "0") {
        saleEnquiryItemList[rowIndex]["price"] = pageTotalVal;
        let price = pageTotalVal;

        if (price && taxRate) {
          let netPrice = Decimal.div(price, formula);
          // 4位小数
          saleEnquiryItemList[rowIndex]["netPrice"] = new Decimal(
            netPrice
          ).toFixed(DIGIT);
          if (requireQuantity) {
            let taxAmount = Decimal.mul(price, requireQuantity);
            let netAmount = Decimal.div(taxAmount, formula);
            saleEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
            saleEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
          }
        }
      } else {
        // 根据未税单价价 netPrice, 实时计算含税价
        saleEnquiryItemList[rowIndex]["netPrice"] = pageTotalVal;
        let netPrice = pageTotalVal;

        if (netPrice && taxRate) {
          let price = Decimal.mul(netPrice, formula);
          // 4位小数
          saleEnquiryItemList[rowIndex]["price"] = new Decimal(price).toFixed(
            DIGIT
          );
          if (requireQuantity) {
            let netAmount = Decimal.mul(netPrice, requireQuantity);
            let taxAmount = Decimal.mul(netAmount, formula);
            saleEnquiryItemList[rowIndex]["netAmount"] = new Decimal(
              netAmount
            ).toFixed(DIGIT);
            saleEnquiryItemList[rowIndex]["taxAmount"] = new Decimal(
              taxAmount
            ).toFixed(DIGIT);
          }
        }
      }
    }
  }
};

// 自定义业务模板数据配置
// 兼容方法设置供应方分组groupCode
const handleAfterRemoteConfig = (config) => {
  // 过滤采购方表行编辑规则
  const groups = config.groups || [];
  config.groups = groups.filter(
    (n) => n.groupCode !== "purchaseEnquiryItemList"
  );

  const formFields = config.formFields || [];
  // 去除采购方callback
  formFields.forEach((n) => {
    if (n.fieldName === "enquiryScope") {
      if (n.callback) {
        delete n.callback;
      }
    }
  });

  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    if (n.groupCode === "purchaseEnquiryItemList") {
      n.groupCode = "saleEnquiryItemList";
    }
    // 成本组成JSON
    if (n.field === "costFormJson") {
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const row = payload.row;
          const price = row.price;
          const netPrice = row.netPrice;
          const flag =
            !!(price && Number(price) > 0) ||
            !!(netPrice && Number(netPrice) > 0);

          // debugger
          const elem = (
            <a onClick={() => openCostPrice(payload)}>
              {flag
                ? srmI18n("i18n_title_quotationPrice", "已报价")
                : srmI18n("i18n_title_noQuotationPrice", "未报价")}
            </a>
          );

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)

          return [row.quotePriceWay === "2" ? elem : null];
        },
      };
    }
    // 是否报价
    if (n.field === "quotePrice") {
      n.fieldType = "";
      n.slots = {
        default({
          row,
          column,
        }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
          return [<q-switch v-model:checked={row[column.field]}></q-switch>];
        },
      };
    }

    // 阶梯报价JSON
    if (n.field === "ladderPriceJson") {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const handleLadderPriceClick = () => {
            if (!payload.row.taxCode) {
              Message.error(
                srmI18n(`i18n_title_pleaseEnterTaxCode`, "请先选择税码")
              );
              return;
            }
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            quoteType.value = pageData.value.quoteType || "0";
            ladderPriceVisible.value = true;
          };

          // 报价方式 quotePriceWay
          // 数据字典 srmQuotePriceWay (0: 常规报价, 1: 阶梯报价, 2: 成本报价)
          const elem =
            row.quotePriceWay === "1" ? (
              <SaleLadderPriceDisplay
                value={val}
                onClick={handleLadderPriceClick}
                disabled={true}
              ></SaleLadderPriceDisplay>
            ) : null;

          return [elem];
        },
      };
    }
  });

  return config; // 返回处理后的配置数据, 可以是 promise 对象
};

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);
const { pageData } = useRefInstanceHook(layoutRef);

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  // 角色: 供应方
  role: "sale",
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div :class="['ladder-price-display', str && !props.disabled ? 'set' : '']">
    <a-tooltip overlayClassName="tip-overlay-class">
      <template #title>
        <div class="grid">
          <vxe-table
            ref="xGrid"
            auto-resize
            border
            row-id="id"
            size="mini"
            :data="tableData"
          >
            <vxe-table-column
              type="seq"
              :title="srmI18n('i18n_alert_seq', '序号')"
              width="80"
            ></vxe-table-column>
            <vxe-table-column
              field="ladder"
              :title="srmI18n('i18n_title_ladderLeve', '阶梯级')"
              width="140"
            ></vxe-table-column>
            <vxe-table-column
              field="price"
              :title="srmI18n('i18n_title_price', '含税价')"
              width="140"
            ></vxe-table-column>
            <vxe-table-column
              field="netPrice"
              :title="srmI18n('i18n_title_netPrice', '不含税价')"
              width="140"
            ></vxe-table-column>
          </vxe-table>
        </div>
      </template>
      {{ str }}
    </a-tooltip>
    <a class="icons">
      <StockOutlined @click="handleClick" />
      <CloseCircleOutlined
        v-show="str && !props.disabled"
        class="clear"
        @click="handleClear"
      />
    </a>
  </div>
</template>

<script lang="ts" setup>
import { computed } from "vue";
import { StockOutlined, CloseCircleOutlined } from "@ant-design/icons-vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";

type Props = {
  value: string;
  disabled?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
  value: "",
  disabled: false,
});

const emit = defineEmits<{
  (e: "click"): void;
  (e: "clear"): void;
}>();

const str = computed<string>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr.map((n) => `${n.ladder}:${n.price || ""}`).join(";");
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return "";
});

const tableData = computed<GlobalPageLayoutTypes.RecordString[]>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return [];
});

const handleClick = () => emit("click");
const handleClear = () => emit("clear");
</script>

<style lang="less" scoped>
.ladder-price-display {
  position: relative;
  height: 36px;
  line-height: 36px;
  padding-right: 20px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  a:hover {
    color: #1677ff;
  }
  &.set {
    padding-right: 42px;
  }
  .icons {
    position: absolute;
    // display: inline-block
    font-size: 16px;
    right: 0px;
    top: 3px;
    z-index: 10;
  }
  .clear {
    color: red;
    margin-left: 8px;
    &.unDelete {
      color: rgba(0, 0, 0, 0.25);
      cursor: not-allowed;
    }
  }
}
</style>
vue
<template>
  <div class="checkPrice">
    <q-basic-modal
      :visible="modelValue"
      :width="1000"
      :title="srmI18n('i18n_title_ladderSetting', '阶梯设置')"
      :maskClosable="false"
      :keyboard="false"
      :bodyStyle="{ padding: '0 12px' }"
      @cancel="handleCancel"
      @ok="handleOk"
    >
      <div class="form">
        <a-descriptions :title="descTitle">
          <a-descriptions-item
            :label="srmI18n('i18n_field_currency', '币别')"
            >{{
              cachePayload ? cachePayload.row.currency : ""
            }}</a-descriptions-item
          >
          <a-descriptions-item
            :label="srmI18n('i18n_title_enterTaxCode', '税码')"
            >{{
              cachePayload ? cachePayload.row.taxCode : ""
            }}</a-descriptions-item
          >
          <a-descriptions-item :label="srmI18n('i18n_title_taxRate', '税率')">{{
            cachePayload ? cachePayload.row.taxRate : ""
          }}</a-descriptions-item>
        </a-descriptions>
      </div>
      <div class="grid" style="margin-top: 12px">
        <vxe-grid
          ref="xGrid"
          v-bind="gridConfig"
          :columns="columns"
          :data="tableData"
        >
          <template #empty>
            <q-empty></q-empty>
          </template>
        </vxe-grid>
      </div>
    </q-basic-modal>
  </div>
</template>

<script lang="tsx" setup>
import { ref, computed, toRefs, watch } from "vue";
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type {
  VxeGridInstance,
  VxeGridProps,
  VxeGridPropTypes,
  VxeColumnSlotTypes,
  VxeTableDataRow,
} from "vxe-table";

import { editVxeTableConfig } from "@qqt-product/ui";
import qqtUtils from "@qqt-product/utils";

import { Decimal } from "decimal.js";

const { cloneDeep } = qqtUtils;

interface Props {
  modelValue: boolean;
  value: string;
  quoteType: string;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
  disabled?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  value: "",
  quoteType: "0",
  disabled: false,
});
const { modelValue, quoteType, cachePayload, disabled } = toRefs(props);

// 小数位数
const DIGIT = 6;

const descTitle = computed<string>(() => {
  if (cachePayload.value) {
    return `${srmI18n("i18n_title_material", "物料")}: ${
      cachePayload.value.row.materialDesc
    }`;
  }
  return srmI18n("i18n_title_material", "物料");
});

// 报价项 quoteType;
// 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
const isPrice = computed<boolean>(() => {
  return quoteType.value === "0";
});

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", arr: GlobalPageLayoutTypes.RecordString[]): void;
}>();

const xGrid = ref<VxeGridInstance>();

const tableData = ref<GlobalPageLayoutTypes.RecordString[]>([]);

const gridConfig: VxeGridProps = {
  ...editVxeTableConfig,
  rowConfig: {
    keyField: "id",
    isHover: true,
    isCurrent: true,
  },
  radioConfig: {
    trigger: "row",
  },
  height: 340,
  keyboardConfig: {},
  menuConfig: {},
  mouseConfig: {}, // 鼠标配置项
  areaConfig: {}, // 按键配置项
};

const columns: VxeGridPropTypes.Columns = [
  { type: "seq", width: 60, fixed: "left" },
  {
    field: "ladder",
    title: srmI18n("i18n_title_ladderLeve", "阶梯级"),
    width: 150,
  },
  {
    field: "ladderQuantity",
    title: srmI18n("i18n_title_ladderCount", "阶梯数量"),
    minWidth: 150,
  },
  {
    field: "price",
    title: srmI18n("i18n_title_price", "含税价"),
    minWidth: 150,
    editRender: {
      enabled: !disabled.value && isPrice.value,
      autofocus: ".vxe-input--inner",
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        return [<span>{row[column.field]}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          type: "number",
          transfer: true,
          clearable: true,
        };

        function onVxeInputBlur({ value }: { value: string }) {
          console.log("value :>> ", value);

          // 税率 taxRate;
          let taxRate = cachePayload.value
            ? cachePayload.value.row.taxRate
            : "0";
          // 含税单价 price;
          let price = value || "";

          // 计算未税单价 netPrice
          if (taxRate && price) {
            if (Decimal) {
              let formula = Decimal.add(1, Decimal.div(taxRate, 100));
              let netPrice = Decimal.div(price, formula);
              // 4位小数
              row.netPrice = new Decimal(netPrice).toFixed(DIGIT);
            }
          }
        }

        return [
          <vxe-input
            v-model={row[column.field]}
            {...props}
            onChange={onVxeInputBlur}
          ></vxe-input>,
        ];
      },
    },
  },
  {
    field: "netPrice",
    title: srmI18n("i18n_title_netPrice", "不含税价"),
    minWidth: 150,
    editRender: {
      enabled: !disabled.value && !isPrice.value,
      autofocus: ".vxe-input--inner",
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        return [<span>{row[column.field]}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          type: "number",
          transfer: true,
          clearable: true,
        };

        function onVxeInputBlur({ value }: { value: string }) {
          console.log("value :>> ", value);

          // 税率 taxRate;
          let taxRate = cachePayload.value
            ? cachePayload.value.row.taxRate
            : "0";
          // 未税单价 netPrice
          let netPrice = value || "";

          // 计算未税单价 netPrice
          if (taxRate && netPrice) {
            if (Decimal) {
              let formula = Decimal.add(1, Decimal.div(taxRate, 100));
              let price = Decimal.mul(netPrice, formula);
              // 4位小数
              row.price = new Decimal(price).toFixed(DIGIT);
            }
          }
        }

        return [
          <vxe-input
            v-model={row[column.field]}
            {...props}
            onChange={onVxeInputBlur}
          ></vxe-input>,
        ];
      },
    },
  },
];

const resetData = () => {
  tableData.value = [];
};

watch(modelValue, (bool) => {
  if (bool) {
    init();
  } else {
    resetData();
  }
});

const handleCancel = () => {
  emit("update:modelValue", false);
};

const handleOk = () => {
  const arr = cloneDeep(tableData.value);
  emit("success", arr);
  handleCancel();
};

const init = () => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        tableData.value = arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
};
</script>
vue
<template>
  <div class="cost-price">
    <a-modal
      :open="modelValue"
      width="100%"
      wrap-class-name="full-modal"
      :title="srmI18n(`i18n_title_costTemplate`, '成本模板')"
      @cancel="handleCancel"
      @ok="setCostOk"
    >
      <a-spin :spinning="loading">
        <div class="page-container">
          <q-edit-page-layout
            v-if="show"
            class="cost-price-layout"
            ref="layoutRef"
            v-bind="options"
          >
            <template
              v-for="el in namedSlots"
              :key="el.append"
              #[el.append]="{ groupCode }"
            >
              <div class="desc" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_quoteType`, "报价项")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{
                      isPriceQuoteType
                        ? srmI18n("i18n_title_price", "含税价")
                        : srmI18n("i18n_title_netPrice", "不含税价")
                    }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_taxRate`, "税率")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ curTaxRate }}</strong>
                  </span>
                </span>
              </div>
              <div class="total" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_price", "含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalPrice(groupCode) }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_netPrice", "不含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalNetPrice(groupCode) }}</strong>
                  </span>
                </span>
              </div>
            </template>
          </q-edit-page-layout>
        </div>
      </a-spin>
    </a-modal>
  </div>
</template>

<script lang="ts" setup>
import { ref, reactive, computed, toRefs, watch, nextTick } from "vue";
import type { ComponentPublicInstance } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

import { BUTTON_ADD_ONE_ROW, BUTTON_DELETE_ROW } from "@qqt-product/ui";

import { useRefInstanceHook } from "@qqt-product/ui";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import REGEXP from "@/utils/regexp";
import { Decimal } from "decimal.js";

import qqtApi from "@qqt-product/api";
import { message as Message } from "ant-design-vue";

import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

const DIGIT = 4;

const Request: qqtApi.Request = qqtApi.useHttp();

const loading = ref<boolean>(false);

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);
const { pageData } = useRefInstanceHook(layoutRef);

interface Props {
  modelValue: boolean;
  currentRow: GlobalPageLayoutTypes.CurrentRow;
  priceData: GlobalPageLayoutTypes.RecordString;
  groups: GlobalPageLayoutTypes.Group[];
  priceMap: GlobalPageLayoutTypes.RecordString;
  cachePageData: GlobalPageLayoutTypes.RecordString;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
}

export interface SuccessPayload {
  costFormJson: string;
  pageTotalVal: string;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  currentRow: () => ({
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  }),
  priceData: () => ({}),
  groups: () => [],
  priceMap: () => ({}),
});
const { modelValue, priceMap } = toRefs(props);

const namedSlots = computed(() => {
  return props.groups.map((group) => ({
    append: `vertical_${group.groupCode}_append`,
  }));
});

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", payload: SuccessPayload): void;
}>();

// 全局数据缓存
const { userInfo, token, manuallyExit } = useGlobalStoreWithDefaultValue();

const show = ref<boolean>(false);

// 是否为“含税价”报价项
const isPriceQuoteType = computed<boolean>(() => {
  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (props.cachePageData.quoteType as string) || "0";
  return quoteType === "0";
});

const curTaxRate = computed(() => {
  if (props.cachePayload) {
    return props.cachePayload.row.taxRate || 0;
  }
  return 0;
});

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  showPageHeader: false,
  showLayoutAnchor: false,
  /**
   * 模块业务类型 string
   * 取值根据各模块配置
   * 当定义为"custom"时,使用本地配置
   */
  businessType: "costForm",
  // 角色: 供应方
  role: "sale",
  // 布局模式: 平铺布局 vertical (默认), Tab布局 tab, 主从布局 master
  pattern: "vertical",
  // 仅使用本地布局模式
  isUseLocalPattern: true,
  // 当前已选行数据
  currentRow: {
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  },
  refreshMethods(row?: GlobalPageLayoutTypes.CurrentRow) {
    if (options.currentRow) {
      options.currentRow =
        row ||
        Object.assign({}, options.currentRow, {
          _t: +new Date(),
        });
    }
  },
  // 当前登录租户信息
  userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
  // 有导入excel 操作的必填
  token: token.value,
  // 本地页面数据配置
  localConfig: {
    groups: [],
  },
  isUseLocalData: true,
  localData: {},
});

watch(modelValue, (bool) => {
  if (bool) {
    if (options.localConfig) {
      options.localConfig.groups = props.groups.map((n) => {
        return {
          ...n,
          buttons: [BUTTON_ADD_ONE_ROW, BUTTON_DELETE_ROW],
        };
      });
    }
    const copyData = cloneDeep(props.priceData);
    options.currentRow = { ...props.currentRow, ...copyData };

    nextTick(() => {
      show.value = true;
    });
  }
});

const getTotal = (groupCode: string) => {
  if (!groupCode || !Decimal) {
    return 0;
  }
  const tableData = pageData.value[
    groupCode
  ] as GlobalPageLayoutTypes.RecordString[];
  if (!tableData || !tableData.length) {
    return 0;
  }
  // 合计列配置 property
  // 没有配置, 默认取 total
  const key = priceMap.value[groupCode] || "total";
  const flag = tableData.some((n) => !!n[key]);
  if (flag) {
    let totalAmount = tableData.reduce((acc: Decimal, row) => {
      let count = REGEXP.interger.test(row[key]) ? row[key] : 0;
      return Decimal.add(acc, count);
    }, new Decimal(0));

    const num = new Decimal(totalAmount).toFixed(DIGIT);
    return Number(num) > 0 ? num : 0;
  }

  return 0;
};

const getTotalPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      return getTotal(groupCode);
    } else {
      let netPrice = getTotal(groupCode);
      let price = Decimal.mul(netPrice, formula);
      return new Decimal(price).toFixed(DIGIT);
    }
  }
  return 0;
};

const getTotalNetPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      let price = getTotal(groupCode);
      let netPrice = Decimal.div(price, formula);
      return new Decimal(netPrice).toFixed(DIGIT);
    } else {
      return getTotal(groupCode);
    }
  }
  return 0;
};

const cancelFunc = () => {
  show.value = false;
  emit("update:modelValue", false);
};

// 生成成本报价JSON 及 页面价格汇总
const generateCostFormJsonData = () => {
  let copyCachePageData = cloneDeep(props.cachePageData);
  // 赋值表行成本报价JSON
  let copyPageData = cloneDeep(pageData.value);
  const costFormJson: GlobalPageLayoutTypes.RecordString = {
    ...props.currentRow,
    data: { ...copyPageData },
    groups: [],
  };

  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (copyCachePageData.quoteType as string) || "0";

  const { row } = props.cachePayload || { row: { taxRate: 0 } };
  const taxRate = row["taxRate"] || 0;
  const formula = Decimal.add(1, Decimal.div(taxRate, 100));

  // 获取页面所有价格汇总
  const pageTotal = props.groups.reduce((acc: Decimal, group) => {
    // 返回 groups 分组数据供给后端比价接口使用
    const obj: GlobalPageLayoutTypes.RecordString = {
      groupName: group.groupName || "",
      groupNameI18nKey: group.groupNameI18nKey || "",
      groupCode: group.groupCode || "",
      groupType: group.groupType || "",
      totalValue: "",
      price: 0,
      netPrice: 0,
    };

    // 分组合计
    let groupTotal = getTotal(group.groupCode);
    obj.totalValue = groupTotal;
    if (quoteType === "0") {
      obj.price = groupTotal;
      let netPrice = Decimal.div(groupTotal, formula);
      obj.netPrice = new Decimal(netPrice).toFixed(DIGIT);
    } else {
      obj.netPrice = groupTotal;
      let price = Decimal.mul(groupTotal, formula);
      obj.price = new Decimal(price).toFixed(DIGIT);
    }
    costFormJson.groups.push(obj);

    return Decimal.add(acc, groupTotal);
  }, new Decimal(0));
  const pageTotalVal = new Decimal(pageTotal).toFixed(DIGIT);

  emit("success", {
    costFormJson: JSON.stringify(costFormJson),
    pageTotalVal,
  });
};

const handleCancel = () => {
  cancelFunc();
};

const setCostOk = () => {
  generateCostFormJsonData();
  cancelFunc();
};
</script>

<style lang="less">
.cost-price {
  height: 100%;
}
.full-modal {
  .ant-modal {
    max-width: 100%;
    top: 0;
    padding-bottom: 0;
    margin: 0;
  }
  .ant-modal-content {
    display: flex;
    flex-direction: column;
    height: calc(100vh);
    max-height: calc(100vh);
    overflow-x: hidden;

    .page-container {
      min-height: calc(100vh - 55px - 53px);
    }
  }
  .ant-modal-body {
    flex: 1;
    padding: 0;
  }
}

.total,
.desc {
  margin-top: 6px;
  .tit {
    &::after {
      margin-right: 6px;
      color: #171832;
      content: ":";
    }
  }
  .item {
    & + .item {
      margin-left: 10px;
    }
  }
}
</style>
vue
<template>
  <div class="cost-price">
    <a-modal
      :open="modelValue"
      width="100%"
      wrap-class-name="full-modal"
      :title="srmI18n(`i18n_title_costTemplate`, '成本模板')"
      :footer="null"
      @cancel="handleCancel"
    >
      <a-spin :spinning="loading">
        <div class="page-container">
          <q-detail-page-layout
            v-if="show"
            class="cost-price-layout"
            ref="layoutRef"
            v-bind="options"
          >
            <template
              v-for="el in namedSlots"
              :key="el.append"
              #[el.append]="{ groupCode }"
            >
              <div class="desc" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_quoteType`, "报价项")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{
                      isPriceQuoteType
                        ? srmI18n("i18n_title_price", "含税价")
                        : srmI18n("i18n_title_netPrice", "不含税价")
                    }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    srmI18n(`i18n_field_taxRate`, "税率")
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ curTaxRate }}</strong>
                  </span>
                </span>
              </div>
              <div class="total" v-show="getTotal(groupCode)">
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_price", "含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalPrice(groupCode) }}</strong>
                  </span>
                </span>
                <span class="item">
                  <span class="tit">{{
                    `${srmI18n("i18n_title_netPrice", "不含税价")}${srmI18n(
                      `i18n_field_kMk_176ba4f`,
                      "总汇总"
                    )}`
                  }}</span>
                  <span class="ant-typography">
                    <strong>{{ getTotalNetPrice(groupCode) }}</strong>
                  </span>
                </span>
              </div>
            </template>
          </q-detail-page-layout>
        </div>
      </a-spin>
    </a-modal>
  </div>
</template>

<script lang="ts" setup>
import { ref, reactive, computed, toRefs, watch, nextTick } from "vue";
import type { ComponentPublicInstance } from "vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";

import { useRefInstanceHook } from "@qqt-product/ui";

// 当前行 currentRow, 当前用户信息 userInfo
import { useGlobalStoreWithDefaultValue } from "@/use/useGlobalStore";

import REGEXP from "@/utils/regexp";
import { Decimal } from "decimal.js";

import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

const DIGIT = 4;

const loading = ref<boolean>(false);

// 组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);
const { pageData } = useRefInstanceHook(layoutRef);

interface Props {
  modelValue: boolean;
  currentRow: GlobalPageLayoutTypes.CurrentRow;
  priceData: GlobalPageLayoutTypes.RecordString;
  groups: GlobalPageLayoutTypes.Group[];
  priceMap: GlobalPageLayoutTypes.RecordString;
  cachePageData: GlobalPageLayoutTypes.RecordString;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  currentRow: () => ({
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  }),
  priceData: () => ({}),
  groups: () => [],
  priceMap: () => ({}),
});
const { modelValue, priceMap } = toRefs(props);

const namedSlots = computed(() => {
  return props.groups.map((group) => ({
    append: `vertical_${group.groupCode}_append`,
  }));
});

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
}>();

// 全局数据缓存
const { userInfo, token, manuallyExit } = useGlobalStoreWithDefaultValue();

const show = ref<boolean>(false);

// 是否为“含税价”报价项
const isPriceQuoteType = computed<boolean>(() => {
  // 报价项 quoteType;
  // 数据字典 srmQuoteType (0: 含税价, 1: 不含税价);
  const quoteType = (props.cachePageData.quoteType as string) || "0";
  return quoteType === "0";
});

const curTaxRate = computed(() => {
  if (props.cachePayload) {
    return props.cachePayload.row.taxRate || 0;
  }
  return 0;
});

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  showPageHeader: false,
  showLayoutAnchor: false,
  /**
   * 模块业务类型 string
   * 取值根据各模块配置
   * 当定义为"custom"时,使用本地配置
   */
  businessType: "costForm",
  // 角色: 供应方
  role: "sale",
  // 布局模式: 平铺布局 vertical (默认), Tab布局 tab, 主从布局 master
  pattern: "vertical",
  // 仅使用本地布局模式
  isUseLocalPattern: true,
  // 当前已选行数据
  currentRow: {
    templateNumber: "",
    templateVersion: 0,
    templateAccount: "",
    busAccount: "",
    elsAccount: "",
  },
  refreshMethods(row?: GlobalPageLayoutTypes.CurrentRow) {
    if (options.currentRow) {
      options.currentRow =
        row ||
        Object.assign({}, options.currentRow, {
          _t: +new Date(),
        });
    }
  },
  // 当前登录租户信息
  userInfo: userInfo.value as GlobalPageLayoutTypes.UserInfo,
  // 有导入excel 操作的必填
  token: token.value,
  // 本地页面数据配置
  localConfig: {
    groups: [],
  },
  isUseLocalData: true,
  localData: {},
});

watch(modelValue, (bool) => {
  if (bool) {
    if (options.localConfig) {
      options.localConfig.groups = cloneDeep(props.groups);
    }

    const copyData = cloneDeep(props.priceData);
    options.currentRow = { ...props.currentRow, ...copyData };

    nextTick(() => {
      show.value = true;
    });
  }
});

const getTotal = (groupCode: string) => {
  if (!groupCode || !Decimal) {
    return 0;
  }
  const tableData = pageData.value[
    groupCode
  ] as GlobalPageLayoutTypes.RecordString[];
  if (!tableData || !tableData.length) {
    return 0;
  }
  // 合计列配置 property
  // 没有配置, 默认取 total
  const key = priceMap.value[groupCode] || "total";
  const flag = tableData.some((n) => !!n[key]);
  if (flag) {
    let totalAmount = tableData.reduce((acc: Decimal, row) => {
      let count = REGEXP.interger.test(row[key]) ? row[key] : 0;
      return Decimal.add(acc, count);
    }, new Decimal(0));

    const num = new Decimal(totalAmount).toFixed(DIGIT);
    return Number(num) > 0 ? num : 0;
  }

  return 0;
};

const getTotalPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      return getTotal(groupCode);
    } else {
      let netPrice = getTotal(groupCode);
      let price = Decimal.mul(netPrice, formula);
      return new Decimal(price).toFixed(DIGIT);
    }
  }
  return 0;
};

const getTotalNetPrice = (groupCode: string) => {
  if (Decimal && getTotal(groupCode)) {
    const taxRate = curTaxRate.value;
    const formula = Decimal.add(1, Decimal.div(taxRate, 100));

    if (isPriceQuoteType.value) {
      let price = getTotal(groupCode);
      let netPrice = Decimal.div(price, formula);
      return new Decimal(netPrice).toFixed(DIGIT);
    } else {
      return getTotal(groupCode);
    }
  }
  return 0;
};

const handleCancel = () => {
  show.value = false;
  emit("update:modelValue", false);
};
</script>

<style lang="less">
.cost-price {
  height: 100%;
}
.full-modal {
  .ant-modal {
    max-width: 100%;
    top: 0;
    padding-bottom: 0;
    margin: 0;
  }
  .ant-modal-content {
    display: flex;
    flex-direction: column;
    height: calc(100vh);
    max-height: calc(100vh);
    overflow-x: hidden;

    .page-container {
      min-height: calc(100vh - 55px - 53px);
    }
  }
  .ant-modal-body {
    flex: 1;
    padding: 0;
  }
}

.total,
.desc {
  margin-top: 6px;
  .tit {
    &::after {
      margin-right: 6px;
      color: #171832;
      content: ":";
    }
  }
  .item {
    & + .item {
      margin-left: 10px;
    }
  }
}
</style>

示例

sale_ladder_price.jpg

返利规则管理模块 父子级联动、可拖拽穿梭选择阶梯报价

vue
<template>
  <div class="edit-page">
    <q-edit-page-layout ref="layoutRef" v-bind="options"></q-edit-page-layout>

    <!-- 阶梯报价 -->
    <ladder-price-modal
      v-model="ladderPriceVisible"
      :unControl="unControl"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      @success="handleLadderPriceSuccess"
    ></ladder-price-modal>

    <!-- 返利商品子级阶梯 -->
    <RebateModal
      v-model="rebateVisible"
      :payload="rebatePayload"
      :config="rebateConfig"
      @success="handleRebateSuccess"
    ></RebateModal>
  </div>
</template>

<script lang="tsx" setup>
import { ref, reactive } from "vue";
import type { ComponentPublicInstance } from "vue";

import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";
import { message as Message, Modal } from "ant-design-vue";

import LadderPriceDisplay from "./components/ladderPriceDisplay.vue";
import LadderPriceModal from "./components/ladderPriceModal.vue";

import RebateProduct from "./components/rebateProduct.vue";
import RebateModal from "./components/rebateModal.vue";

interface Payload {
  rebateExtends: string;
  ids: string;
  names: string;
}

// 编辑组件 ref
const layoutRef = ref<ComponentPublicInstance | null>(null);

const rebateVisible = ref<boolean>(false);
// 缓存行信息
const rebatePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();
const rebateConfig = ref<GlobalPageLayoutTypes.ColumnItem>({
  groupCode: "rebateRuleItems",
  title: "返利商品",
  fieldLabelI18nKey: "i18n_field_vvXN_42a083d0",
  fieldType: "selectModal",
  field: "rebateProductText",
  defaultValue: "",
});

const ladderPriceVisible = ref<boolean>(false);
const unControl = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const handleLadderPriceSuccess = (
  arr: GlobalPageLayoutTypes.RecordString[]
) => {
  if (ladderPriceCachePayload.value) {
    const { row, column } = ladderPriceCachePayload.value;
    row[column.field] = JSON.stringify(arr);
  }
};

const handleRebateSuccess = ({ rebateExtends, ids, names }: Payload) => {
  if (rebatePayload.value) {
    const { row } = rebatePayload.value;
    row.rebateProductText = names;
    row.rebateProduct = ids;
    row.rebateProductExtends = rebateExtends;
  }
};

const handleAfterRemoteConfig = (config) => {
  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    // 阶梯报价JSON
    // 返利阶梯 rebateLadder
    // 追加返利阶梯 rebateLadder
    if (
      (n.groupCode === "rebateRuleItems" ||
        n.groupCode === "rebateRuleSupplements") &&
      n.field === "rebateLadder"
    ) {
      n.fieldType = "";
      n.width = 250;
      n.helpText = srmI18n(
        "i18n_alert_NRAPcvvXNIGRJtyDumFxiTcr_fe9d41a5",
        "如果当前行返利商品已设置子级阶梯价格则不允许修改"
      );
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const unDelete = !!row.rebateProductExtends;

          const handleLadderPriceClick = () => {
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            unControl.value = unDelete;
            ladderPriceVisible.value = true;
          };
          const handleLadderPriceClear = () => {
            row[column.field] = "";
          };

          return [
            <LadderPriceDisplay
              value={val}
              onClick={handleLadderPriceClick}
              unDelete={unDelete}
              onClear={handleLadderPriceClear}
            ></LadderPriceDisplay>,
          ];
        },
      };
    }
    // 基本返利规则 表行 返利商品
    // 追加返利规则 表行 追加返利商品
    if (
      (n.groupCode === "rebateRuleItems" && n.field === "rebateProductText") ||
      (n.groupCode === "rebateRuleSupplements" &&
        n.field === "rebateProductText")
    ) {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";

          const handleRebateClick = () => {
            if (!row.rebateLadder) {
              Message.error(
                srmI18n(
                  "i18n_alert_VWGRAPcvvyD_1cfc1b7f",
                  "请先设置当前行返利阶梯"
                )
              );
              return;
            }
            rebatePayload.value = payload;
            rebateConfig.value = n;
            rebateVisible.value = true;
          };

          const handleRebateClear = () => {
            Modal.confirm({
              title: srmI18n(`i18n_title_tip`, "提示"),
              content: srmI18n(
                "i18n_alert_KQVVvvXNtJtyDum_1ed2bcc3",
                "是否清空返利商品及子级阶梯价格"
              ),
              onOk() {
                row.rebateProduct = "";
                row.rebateProductText = "";
                row.rebateProductExtends = null;
              },
            });
          };

          return [
            <RebateProduct
              value={val}
              onClick={handleRebateClick}
              onClear={handleRebateClear}
            ></RebateProduct>,
          ];
        },
      };
    }
  });
  return config;
};

// 配置
const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div class="detail-page">
    <q-detail-page-layout
      ref="layoutRef"
      v-bind="options"
    ></q-detail-page-layout>

    <!-- 阶梯报价 -->
    <ladder-price-modal
      v-model="ladderPriceVisible"
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      disabled
    ></ladder-price-modal>

    <!-- 返利商品子级阶梯 -->
    <RebateModal
      v-model="rebateVisible"
      :payload="rebatePayload"
      :config="rebateConfig"
      disabled
    ></RebateModal>
  </div>
</template>

<script setup lang="tsx">
import { reactive, ref } from "vue";
import type { ComponentPublicInstance } from "vue";
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type { VxeColumnSlotTypes, VxeTableDataRow } from "vxe-table";
// 按钮常量引用
import { srmI18n } from "@/utils/srmI18n";
import { message as Message } from "ant-design-vue";

import LadderPriceDisplay from "./components/ladderPriceDisplay.vue";
import LadderPriceModal from "./components/ladderPriceModal.vue";
import RebateProduct from "./components/rebateProduct.vue";
import RebateModal from "./components/rebateModal.vue";

const layoutRef = ref<ComponentPublicInstance | null>(null);

const rebateVisible = ref<boolean>(false);
// 缓存行信息
const rebatePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();
const rebateConfig = ref<GlobalPageLayoutTypes.ColumnItem>({
  groupCode: "rebateRuleItems",
  title: "返利商品",
  fieldLabelI18nKey: "i18n_field_vvXN_42a083d0",
  fieldType: "selectModal",
  field: "rebateProductText",
  defaultValue: "",
});

const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();

const handleAfterRemoteConfig = (config) => {
  const itemColumns = config.itemColumns || [];
  itemColumns.forEach((n) => {
    // 阶梯报价JSON
    // 返利阶梯 rebateLadder
    // 追加返利阶梯 rebateLadder
    if (
      (n.groupCode === "rebateRuleItems" ||
        n.groupCode === "rebateRuleSupplements") &&
      n.field === "rebateLadder"
    ) {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";
          const handleLadderPriceClick = () => {
            ladderPriceCachePayload.value = payload;
            ladderPriceValue.value = val;
            ladderPriceVisible.value = true;
          };

          return [
            <LadderPriceDisplay
              value={val}
              disabled={true}
              onClick={handleLadderPriceClick}
            ></LadderPriceDisplay>,
          ];
        },
      };
    }
    // 基本返利规则 表行 返利商品
    // 追加返利规则 表行 追加返利商品
    if (
      (n.groupCode === "rebateRuleItems" && n.field === "rebateProductText") ||
      (n.groupCode === "rebateRuleSupplements" &&
        n.field === "rebateProductText")
    ) {
      n.fieldType = "";
      n.width = 250;
      n.slots = {
        default(
          payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
        ) {
          const { row, column } = payload;
          const val = row[column.field] || "";

          const handleRebateClick = () => {
            if (!row.rebateLadder) {
              Message.error(
                srmI18n(
                  "i18n_alert_VWGRAPcvvyD_1cfc1b7f",
                  "请先设置当前行返利阶梯"
                )
              );
              return;
            }
            rebatePayload.value = payload;
            rebateConfig.value = n;
            rebateVisible.value = true;
          };

          return [
            <RebateProduct
              value={val}
              onClick={handleRebateClick}
              disabled={true}
            ></RebateProduct>,
          ];
        },
      };
    }
  });
  return config;
};

const options = reactive<Partial<GlobalPageLayoutTypes.EditPageLayoutProps>>({
  // ...
  handleAfterRemoteConfig,
});
</script>
vue
<template>
  <div :class="['ladder-price-display', str && !props.disabled ? 'set' : '']">
    <a-tooltip overlayClassName="tip-overlay-class">
      <template #title>
        <div class="grid">
          <vxe-table
            ref="xGrid"
            auto-resize
            border
            row-id="id"
            size="mini"
            :data="tableData"
          >
            <vxe-table-column
              type="seq"
              :title="srmI18n('i18n_alert_seq', '序号')"
              width="60"
            ></vxe-table-column>
            <vxe-table-column
              field="ladder"
              :title="srmI18n('i18n_title_ladderLeve', '阶梯级')"
              width="150"
            ></vxe-table-column>
            <vxe-table-column
              field="ladderQuantity"
              :title="srmI18n('i18n_title_ladderCount', '阶梯数量')"
              width="150"
            ></vxe-table-column>
            <vxe-table-column
              field="type_dictText"
              :title="srmI18n('i18n_field_tdCK_4155670c', '计算方式')"
              width="150"
            ></vxe-table-column>
            <vxe-table-column
              field="fix"
              :title="srmI18n('i18n_dict_CIR_151e85c', '固定值')"
              width="100"
            ></vxe-table-column>
            <vxe-table-column
              field="rate"
              :title="srmI18n('i18n_alert_lvR_19eb8e5', '比例值') + '%'"
              width="100"
            ></vxe-table-column>
            <vxe-table-column
              field="union"
              :title="srmI18n('i18n_alert_BtLv_3244ca0d', '每单位返')"
              width="100"
            ></vxe-table-column>
          </vxe-table>
        </div>
      </template>
      {{ str }}
    </a-tooltip>
    <a class="icons">
      <StockOutlined @click="handleClick" />
      <CloseCircleOutlined
        v-show="str && !props.disabled"
        :class="['clear', unDelete ? 'unDelete' : '']"
        @click="handleClear"
      />
    </a>
  </div>
</template>

<script lang="ts" setup>
import { computed } from "vue";
import { StockOutlined, CloseCircleOutlined } from "@ant-design/icons-vue";
// 国际化
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";

type Props = {
  value: string;
  disabled?: boolean;
  unDelete?: boolean;
};

const props = withDefaults(defineProps<Props>(), {
  value: "",
  disabled: false,
  unDelete: false,
});
const emit = defineEmits<{
  (e: "click"): void;
  (e: "clear"): void;
}>();

const str = computed<string>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr
          .map((n) => `${n.ladder}:${n.fix || n.rate || n.union || ""}`)
          .join(";");
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return "";
});

const tableData = computed<GlobalPageLayoutTypes.RecordString[]>(() => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        return arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
  return [];
});

const handleClick = () => emit("click");
const handleClear = () => !props.unDelete && emit("clear");
</script>

<style lang="less" scoped>
.ladder-price-display {
  position: relative;
  height: 36px;
  line-height: 36px;
  padding-right: 20px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  a:hover {
    color: #1677ff;
  }
  &.set {
    padding-right: 42px;
  }
  .icons {
    position: absolute;
    // display: inline-block
    font-size: 16px;
    right: 0px;
    top: 3px;
    z-index: 10;
  }
  .clear {
    color: red;
    margin-left: 8px;
    &.unDelete {
      color: rgba(0, 0, 0, 0.25);
      cursor: not-allowed;
    }
  }
}
</style>
vue
<template>
  <div class="checkPrice">
    <q-basic-modal
      :visible="modelValue"
      :width="1000"
      :title="srmI18n('i18n_title_ladderSetting', '阶梯设置')"
      :maskClosable="false"
      :keyboard="false"
      :bodyStyle="{ padding: '0 12px' }"
      destroyOnClose
      @cancel="handleCancel"
      @ok="handleOk"
    >
      <div class="form" v-if="!disabled && !props.unControl">
        <a-form
          style="display: flex; flex-flow: row wrap; margin: 4px"
          layout="inline"
        >
          <a-form-item
            style="margin-right: 12px"
            :label="srmI18n('i18n_title_ladderCount', '阶梯数量')"
          >
            <a-input-number
              style="width: 200px"
              :placeholder="
                srmI18n(
                  'i18n_alert_VWNfU0jyDWR_ffbcea1c',
                  '请输入大于0的阶梯数量'
                )
              "
              v-model:value="form.ladderQuantity"
              :min="0"
              @pressEnter="setData"
            />
          </a-form-item>
          <a-form-item style="margin-right: 12px">
            <a-button type="primary" @click="setData">{{
              srmI18n("i18n_title_add", "添加")
            }}</a-button>
          </a-form-item>
        </a-form>
      </div>
      <div class="grid" style="margin-top: 12px">
        <vxe-grid
          ref="xGrid"
          v-bind="gridConfig"
          :columns="columns"
          :data="tableData"
        >
          <template #empty>
            <q-empty></q-empty>
          </template>
        </vxe-grid>
      </div>
    </q-basic-modal>
  </div>
</template>

<script lang="tsx" setup>
import { ref, toRefs, watch } from "vue";
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type {
  VxeGridInstance,
  VxeGridProps,
  VxeGridPropTypes,
  VxeColumnSlotTypes,
  VxeTableDataRow,
} from "vxe-table";

import { editVxeTableConfig } from "@qqt-product/ui";
import { message as Message } from "ant-design-vue";
import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;

interface Props {
  modelValue: boolean;
  value: string;
  cachePayload:
    | VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>
    | undefined;
  disabled?: boolean;
  unControl?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  value: "",
  disabled: false,
  unControl: false, // 状态为不允许操作
});
const { modelValue, disabled } = toRefs(props);

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", arr: GlobalPageLayoutTypes.RecordString[]): void;
}>();

const LT = "< x <";
const ELT = "<= x <";
const GT = "<= x";

const xGrid = ref<VxeGridInstance>();

const rawState = {
  ladderQuantity: "",
};

const form = ref<GlobalPageLayoutTypes.RecordString>(rawState);

const tableData = ref<GlobalPageLayoutTypes.RecordString[]>([]);

const gridConfig: VxeGridProps = {
  ...editVxeTableConfig,
  rowConfig: {
    keyField: "id",
    isHover: true,
    isCurrent: true,
  },
  radioConfig: {
    trigger: "row",
  },
  height: 340,
  keyboardConfig: {},
  menuConfig: {},
  mouseConfig: {}, // 鼠标配置项
  areaConfig: {}, // 按键配置项
  editConfig: {
    trigger: "click", // 双击触发编辑
    mode: "cell", // 单元格编辑模式
    beforeEditMethod({ row, rowIndex, column, columnIndex }) {
      if (disabled.value) {
        return false;
      }

      const props = ["fix", "rate", "union"];
      // 计算方式
      if (column.field === "type") {
        const val = row.fix || row.rate || row.union || "";
        if (val) {
          Message.warning(
            srmI18n(
              "i18n_alert_VGcCIRlvRBtLvWRSniTcr_e53af709",
              "清除行固定值/比例值%/每单位返数值后才允许修改"
            )
          );
          return false;
        }
      }
      if (props.includes(column.field)) {
        if (row.type !== column.field) {
          Message.warning(
            srmI18n(
              "i18n_alert_RiTWNtdCKdiAcWF_39fa99f6",
              "只允许输入计算方式所选类型数据"
            )
          );
          return false;
        }
      }

      return true;
    },
  },
};

const options: GlobalPageLayoutTypes.RecordString = [
  { value: "fix", label: srmI18n("i18n_dict_CIR_151e85c", "固定值") },
  { value: "rate", label: srmI18n("i18n_alert_lvR_19eb8e5", "比例值") + "%" },
  { value: "union", label: srmI18n("i18n_alert_BtLv_3244ca0d", "每单位返") },
];

const columns: VxeGridPropTypes.Columns = [
  { type: "seq", width: 60, fixed: "left" },
  {
    field: "ladder",
    title: srmI18n("i18n_title_ladderLeve", "阶梯级"),
    width: 150,
  },
  {
    field: "ladderQuantity",
    title: srmI18n("i18n_title_ladderCount", "阶梯数量"),
    minWidth: 150,
  },
  {
    field: "type",
    title: srmI18n("i18n_field_tdCK_4155670c", "计算方式"),
    minWidth: 150,
    editRender: {
      enabled: true,
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        const val = row[`${column.field}_dictText`] || row[column.field];
        return [<span>{val}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          options,
          filterable: true,
          clearable: true,
        };
        const onVxeSelectChange = ({ value }: { value: string }) => {
          let obj = options.find((n) => n.value === value);
          row[`${column.field}_dictText`] = obj.label || "";
        };

        return [
          <vxe-select
            v-model={row[column.field]}
            {...props}
            onChange={onVxeSelectChange}
          ></vxe-select>,
        ];
      },
    },
  },
  {
    field: "fix",
    title: srmI18n("i18n_dict_CIR_151e85c", "固定值"),
    minWidth: 100,
    editRender: {
      enabled: true,
      autofocus: ".vxe-input--inner",
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        return [<span>{row[column.field]}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          type: "number",
          transfer: true,
          clearable: true,
        };

        return [<vxe-input v-model={row[column.field]} {...props}></vxe-input>];
      },
    },
  },
  {
    field: "rate",
    title: srmI18n("i18n_alert_lvR_19eb8e5", "比例值") + "%",
    minWidth: 100,
    editRender: {
      enabled: true,
      autofocus: ".vxe-input--inner",
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        return [<span>{row[column.field]}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          type: "number",
          transfer: true,
          clearable: true,
        };

        return [<vxe-input v-model={row[column.field]} {...props}></vxe-input>];
      },
    },
  },
  {
    field: "union",
    title: srmI18n("i18n_alert_BtLv_3244ca0d", "每单位返"),
    minWidth: 100,
    editRender: {
      enabled: true,
      autofocus: ".vxe-input--inner",
    },
    slots: {
      default({
        row,
        column,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        return [<span>{row[column.field]}</span>];
      },
      edit({
        row,
        column,
      }: VxeColumnSlotTypes.EditSlotParams<VxeTableDataRow>) {
        const props = {
          type: "number",
          transfer: true,
          clearable: true,
        };

        return [<vxe-input v-model={row[column.field]} {...props}></vxe-input>];
      },
    },
  },
];

if (!props.unControl) {
  columns.push({
    field: "grid_operation",
    title: srmI18n(`i18n_title_operation`, "操作"),
    minWidth: 150,
    slots: {
      default({
        $rowIndex,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        const handleDelete = () => {
          tableData.value.splice($rowIndex, 1);
          setNltRow();
        };

        return [
          <a-button
            type="text"
            danger
            disabled={
              disabled.value || $rowIndex !== tableData.value.length - 1
            }
            onClick={handleDelete}
          >
            {srmI18n(`i18n_title_delete`, "删除")}
          </a-button>,
        ];
      },
    },
  });
}

// 设置 阶梯级 格式
const setNltRow = () => {
  if (!tableData.value.length) return;
  let last = tableData.value[tableData.value.length - 1];
  last.ladder = `${last.ladderQuantity} ${GT}`;
  last.min = last.ladderQuantity;
  last.max = "";
  if (tableData.value.length > 1) {
    let prev = tableData.value[tableData.value.length - 2];
    prev.ladder = `${prev.ladderQuantity} ${ELT} ${last.ladderQuantity}`;
    prev.min = prev.ladderQuantity;
    prev.max = last.ladderQuantity;
  }
};

const setData = () => {
  const val = form.value.ladderQuantity;
  if (!val) {
    Message.error(srmI18n("i18n_alert_VWNyDWR_cd05cb41", "请输入阶梯数量"));
    return;
  }

  let flag = tableData.value.every((n) => n.ladderQuantity < val);
  if (!flag) {
    Message.error(
      srmI18n(
        "i18n_title_ladderMostLastLadderCount",
        "阶梯数量必须大于上一阶梯数量"
      )
    );
    return;
  }
  let row = {
    ladderQuantity: val,
    ladder: `${val} ${GT}`,
    rate: "",
    fix: "",
    union: "",
    min: "",
    max: "",
    type: "",
    type_dictText: "",
  };
  tableData.value.push(row);
  setNltRow();

  form.value.ladderQuantity = "";
};

const resetData = () => {
  tableData.value = [];
  form.value = { ...rawState };
};

watch(modelValue, (bool) => {
  if (bool) {
    init();
  } else {
    resetData();
  }
});

const handleCancel = () => {
  emit("update:modelValue", false);
};

const handleOk = () => {
  emit("success", cloneDeep(tableData.value));
  handleCancel();
};

const init = () => {
  if (props.value) {
    try {
      const arr = JSON.parse(props.value);
      if (Array.isArray(arr)) {
        tableData.value = arr;
      }
    } catch (err) {
      console.log("parse ladderPriceJSON error :>> ", err);
    }
  }
};
</script>
vue
<template>
  <div :class="['rebateProduct', !props.disabled ? 'set' : '']">
    <span @click="handleClick">{{ value }}</span>
    <a class="icons">
      <StockOutlined @click="handleClick" />
      <CloseCircleOutlined
        v-show="!props.disabled"
        :class="['clear', unDelete ? 'unDelete' : '']"
        @click="handleClear"
      />
    </a>
  </div>
</template>

<script lang="ts" setup>
import { StockOutlined, CloseCircleOutlined } from "@ant-design/icons-vue";

type Props = {
  value: string;
  disabled?: boolean;
  unDelete?: boolean;
};

const props = withDefaults(defineProps<Props>(), {
  value: "",
  disabled: false,
  unDelete: false,
});
const emit = defineEmits<{
  (e: "click"): void;
  (e: "clear"): void;
}>();

const handleClick = () => emit("click");
const handleClear = () => !props.unDelete && emit("clear");
</script>

<style lang="less" scoped>
.rebateProduct {
  position: relative;
  height: 36px;
  line-height: 36px;
  padding-right: 20px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  a:hover {
    color: #1677ff;
  }
  &.set {
    padding-right: 42px;
  }
  .icons {
    position: absolute;
    // display: inline-block
    font-size: 16px;
    right: 0px;
    top: 3px;
    z-index: 10;
  }
  .clear {
    color: red;
    margin-left: 8px;
    &.unDelete {
      color: rgba(0, 0, 0, 0.25);
      cursor: not-allowed;
    }
  }
}
</style>
vue
<template>
  <div class="rebateModal">
    <q-basic-modal
      :visible="modelValue"
      :title="computedTitle"
      :helpMessage="srmI18n('i18n_alert_ERJtyDum_cb99b3f6', '配置子级阶梯价格')"
      :width="1200"
      :bodyStyle="{ padding: '0 12px' }"
      destroyOnClose
      centered
      @cancel="handleCancel"
      @ok="handleOk"
    >
      <div class="form">
        <a-form
          layout="inline"
          style="display: flex; flex-flow: row wrap; margin: 12px 4px"
        >
          <a-form-item
            style="margin-right: 12px"
            :label="srmI18n('i18n_title_keyword', '关键字')"
          >
            <a-input
              style="width: 200px"
              :placeholder="
                srmI18n('i18n_title_pleaseInputThekeyword', '请输入关键字')
              "
              v-model:value="form.keyWord"
              allowClear
              @change="loadData"
            />
          </a-form-item>
          <a-form-item style="margin-right: 12px">
            <a-checkbox
              v-model:checked="form.inverseSelection"
              :disabled="disabled"
              >{{ srmI18n("i18n_alert_vi_ab5dc", "反选") }}</a-checkbox
            >
          </a-form-item>
          <a-form-item>
            <a-button type="primary" @click="loadData">{{
              srmI18n("i18n_title_Query", "查询")
            }}</a-button>
          </a-form-item>
        </a-form>
      </div>
      <div class="container">
        <div class="grid_left">
          <vxe-grid
            ref="xGrid_left"
            v-bind="gridConfig"
            :pager-config="pagerConfig"
            :loading="loading"
            :columns="columns_left"
            :data="tableData"
            @page-change="handlePageChange"
            v-on="gridEvents"
          >
            <template #empty>
              <q-empty></q-empty>
            </template>
          </vxe-grid>
        </div>
        <div class="tools">
          <span
            class="right"
            :class="['right', disabled ? 'disabled' : '']"
            @click="toRight"
          >
            <i class="vxe-icon-arrow-right"></i>
          </span>
        </div>
        <div
          class="grid_right"
          :class="{
            'drag-over': isRightOver,
          }"
        >
          <vxe-grid
            ref="xGrid_right"
            v-bind="gridConfig"
            :loading="loading_right"
            :columns="columns_right"
            :data="tableData_right"
          >
            <template #empty>
              <q-empty></q-empty>
            </template>
          </vxe-grid>
        </div>
      </div>
    </q-basic-modal>

    <!-- 阶梯报价 -->
    <ladder-price-modal
      v-model="ladderPriceVisible"
      unControl
      :value="ladderPriceValue"
      :cachePayload="ladderPriceCachePayload"
      @success="handleLadderPriceSuccess"
    ></ladder-price-modal>
  </div>
</template>

<script lang="tsx" setup>
import { ref, toRefs, computed, watch, nextTick, onUnmounted } from "vue";
import { srmI18n } from "@/utils/srmI18n";
// ui组件库 types
import type { GlobalPageLayoutTypes } from "@qqt-product/ui";
import type {
  VxeColumnSlotTypes,
  VxeTableDataRow,
  VxeGridInstance,
  VxeGridProps,
  VxeGridPropTypes,
  VxePagerEvents,
  VxeGridListeners,
} from "vxe-table";

import LadderPriceDisplay from "./ladderPriceDisplay.vue";
import LadderPriceModal from "./ladderPriceModal.vue";

import { editVxeTableConfig } from "@qqt-product/ui";

import Sortable from "sortablejs";

import qqtApi from "@qqt-product/api";
import qqtUtils from "@qqt-product/utils";
const { cloneDeep } = qqtUtils;
import { message as Message } from "ant-design-vue";
const Request: qqtApi.Request = qqtApi.useHttp();

const deleteDelay = 2000; // 超出区域删除延迟时间
const DOM_TBODY = ".body--wrapper>.vxe-table--body tbody";

interface Props {
  modelValue: boolean;
  payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow> | undefined;
  config: GlobalPageLayoutTypes.ColumnItem;
  disabled?: boolean;
}

interface Payload {
  rebateExtends: string;
  ids: string;
  names: string;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  disabled: false,
});
const { modelValue, payload, config, disabled } = toRefs(props);

const emit = defineEmits<{
  (e: "update:modelValue", modelValue: boolean): void;
  (e: "success", payload: Payload): void;
}>();

const computedTitle = computed<string>(() => {
  return `${config.value.title || ""}${srmI18n(
    "i18n_title_popupChoose",
    "弹窗选择"
  )}`;
});

const xGrid_left = ref<VxeGridInstance>();
const xGrid_right = ref<VxeGridInstance>();
let sortable_left: Sortable | null = null;
let sortable_right: Sortable | null = null;
let initTime_left: any;
let initTime_right: any;

const isLeftOver = ref(false); // 监听左侧覆盖状态
const isRightOver = ref(false); // 监听右侧覆盖状态

const rawState = {
  inverseSelection: false, // 反选
  keyWord: "",
};

const ladderPriceVisible = ref<boolean>(false);
const ladderPriceValue = ref<string>("");
// 缓存行信息
const ladderPriceCachePayload =
  ref<VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>>();
const form = ref<GlobalPageLayoutTypes.RecordString>(rawState);

const ids = ref<string>("");
const rebateLadder = ref<string>("");
const rebateProductExtends = ref<GlobalPageLayoutTypes.RecordString[]>([]);

const loading = ref<boolean>(false);
const loading_right = ref<boolean>(false);
const tableData = ref<GlobalPageLayoutTypes.RecordString[]>([]);
const tableData_right = ref<GlobalPageLayoutTypes.RecordString[]>([]);
const pagerConfig = ref({
  total: 0,
  currentPage: 1,
  pageSize: 20,
});

const gridConfig: VxeGridProps = {
  ...editVxeTableConfig,
  rowConfig: {
    keyField: "id",
    isHover: true,
    isCurrent: true,
    useKey: true,
  },
  checkboxConfig: {
    highlight: true,
    reserve: true,
    // trigger: 'cell',
  },
  height: "auto",
  keyboardConfig: {},
  menuConfig: {},
  mouseConfig: {}, // 鼠标配置项
  areaConfig: {}, // 按键配置项
  seqConfig: {
    startIndex:
      ((pagerConfig.value.currentPage as number) - 1) *
      (pagerConfig.value.pageSize as number),
  },
};

const columns: VxeGridPropTypes.Columns = [
  { type: "seq", width: 50, fixed: "left" },
  {
    title: "",
    field: "",
    width: 40,
    slots: {
      default() {
        return [
          <span class="drag-btn" style="cursor: grab;">
            <i class="vxe-icon--menu"></i>
          </span>,
        ];
      },
      header() {
        return [
          <vxe-tooltip
            content={srmI18n(
              "i18n_title_pressHoldDragUpDown",
              "按住后可以上下拖动排序!"
            )}
            enterable
          >
            <i class="vxe-icon--question"></i>
          </vxe-tooltip>,
        ];
      },
    },
  },
  { field: "materialNumber", title: "物料编码", width: 150 },
  { field: "materialName", title: "物料名称", width: 150 },
  { field: "materialSpec", title: "物料规格", width: 150 },
  { field: "cateLevelCode", title: "物料分类级别编码", width: 150 },
  { field: "cateName", title: "物料分类名称", width: 150 },
  { field: "materialDesc", title: "物料描述", width: 150 },
  { field: "MaterialGroup", title: "物料组", width: 150 },
  { field: "brand", title: "物料品牌", width: 150 },
];

const columns_left: VxeGridPropTypes.Columns = [
  { type: "checkbox", width: 50, fixed: "left" },
  ...columns,
];

let operationColumn: VxeGridPropTypes.Columns = [
  {
    field: "grid_operation",
    fixed: "right",
    align: "center",
    title: "操作",
    width: 80,
    slots: {
      default({
        $rowIndex,
      }: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        const handleDelete = () => {
          tableData_right.value.splice($rowIndex, 1);
        };

        return [
          <span style="color: #ff4d4f; font-size: 14px; cursor: pointer;">
            <i class="vxe-icon-delete-fill" onClick={handleDelete}></i>
          </span>,
        ];
      },
    },
  },
];

let columns_right: VxeGridPropTypes.Columns = [
  ...columns,
  {
    field: "ladderPriceJson",
    fixed: "right",
    title: "阶梯价格",
    width: 180,
    slots: {
      default(payload: VxeColumnSlotTypes.DefaultSlotParams<VxeTableDataRow>) {
        const { row, column } = payload;
        const val = row[column.field] || "";
        const handleLadderPriceClick = () => {
          ladderPriceCachePayload.value = payload;
          ladderPriceValue.value = val;
          ladderPriceVisible.value = true;
        };

        return [
          <LadderPriceDisplay
            value={val}
            unDelete={true}
            onClick={handleLadderPriceClick}
          ></LadderPriceDisplay>,
        ];
      },
    },
  },
];

if (!disabled.value) {
  columns_right = [...columns_right, ...operationColumn];
}

const resetData = () => {
  pagerConfig.value = {
    total: 0,
    currentPage: 1,
    pageSize: 20,
  };
  tableData.value = [];
  tableData_right.value = [];
  form.value = { ...rawState };

  xGrid_left.value = undefined;
  xGrid_right.value = undefined;

  sortable_left = null;
  sortable_right = null;

  initTime_left = null;
  initTime_right = null;
};

const destroySortable = () => {
  clearTimeout(initTime_left);
  clearTimeout(initTime_right);
  sortable_left?.destroy();
  sortable_right?.destroy();
};

watch(modelValue, (bool) => {
  if (bool) {
    getCacheData();
    loadData();
    loadOtherData();

    nextTick(() => {
      // 加载完成之后在绑定拖动事件
      initTime_left = setTimeout(() => {
        rowDrop_left();
      }, 500);
      initTime_right = setTimeout(() => {
        rowDrop_right();
      }, 500);
    });
  } else {
    resetData();
    destroySortable();
  }
});

const handleCancel = () => {
  emit("update:modelValue", false);
};

const handleOk = () => {
  if (!tableData_right.value.length) {
    Message.error(srmI18n(`i18n_title_selectDataMsg`, "请选择数据"));
    return;
  }

  const jsonArr = tableData_right.value.map((item) => ({
    id: item.id,
    inverseSelection: form.value.inverseSelection,
    childRebateLadder: JSON.parse(item.ladderPriceJson),
  }));

  const ids = tableData_right.value.map((item) => item.id).join(",");
  const names = tableData_right.value
    .map((item) => item.materialName)
    .join(",");

  const payload: Payload = {
    rebateExtends: JSON.stringify(jsonArr),
    ids,
    names,
  };

  emit("success", payload);
  handleCancel();
};

const getCacheData = () => {
  if (!payload.value) {
    return;
  }
  const { row } = payload.value;
  ids.value = (row.rebateProduct as string) || "";
  if (row.rebateLadder) {
    try {
      let jsonArr = JSON.parse(row.rebateLadder);
      let props = ["fix", "rate", "union"];
      // 重置子阶梯中的固定值、比例值、每单位返
      jsonArr.forEach((item) => {
        props.forEach((prop) => {
          item[prop] = "";
        });
      });
      rebateLadder.value = JSON.stringify(jsonArr);
    } catch (err) {
      console.log("parse rebateProductExtends error :>> ", err);
    }
  }

  if (row.rebateProductExtends) {
    try {
      rebateProductExtends.value = JSON.parse(row.rebateProductExtends);
      if (Array.isArray(rebateProductExtends.value)) {
        form.value.inverseSelection =
          rebateProductExtends.value[0].inverseSelection || false;
      }
    } catch (err) {
      console.log("parse rebateProductExtends error :>> ", err);
    }
  }
};

const handleLadderPriceSuccess = (
  arr: GlobalPageLayoutTypes.RecordString[]
) => {
  if (ladderPriceCachePayload.value) {
    const { row } = ladderPriceCachePayload.value;
    row.ladderPriceJson = JSON.stringify(arr);
  }
};

const loadData = () => {
  const url = "/material/purchaseMaterialHead/queryPageListByCalculation";
  const params = {
    pageSize: pagerConfig.value.pageSize,
    pageNo: pagerConfig.value.currentPage,
    column: "id",
    order: "asc",
    frozenFunctionValue: "1",
    keyWord: form.value.keyWord || "",
  };
  loading.value = true;
  Request.get({ url, params })
    .then((res) => {
      if (!res.success) {
        Message.error(res.message);
        return;
      }
      const records = res.result.records || [];
      tableData.value = records;
      pagerConfig.value.total = res.result.total;
    })
    .finally(() => {
      loading.value = false;
    });
};

const loadOtherData = () => {
  if (!ids.value) {
    return;
  }

  const url = "/material/purchaseMaterialHead/queryPageListByCalculation";
  const params = {
    frozenFunctionValue: "1",
    ids: ids.value,
    column: "id",
    order: "asc",
  };
  loading_right.value = true;
  Request.get({ url, params })
    .then((res) => {
      if (!res.success) {
        Message.error(res.message);
        return;
      }
      const records = res.result.records || [];
      tableData_right.value = records.map((item) => {
        let obj = rebateProductExtends.value.find((n) => n.id === item.id);
        let json = obj
          ? JSON.stringify(obj.childRebateLadder)
          : rebateLadder.value;
        return {
          ...item,
          ladderPriceJson: json,
        };
      });
    })
    .finally(() => {
      loading_right.value = false;
    });
};

const handlePageChange: VxePagerEvents.PageChange = ({
  currentPage,
  pageSize,
}) => {
  pagerConfig.value.currentPage = currentPage;
  pagerConfig.value.pageSize = pageSize;
  loadData();
};

const getJSON = (row) => {
  let obj = rebateProductExtends.value.find((n) => n.id === row.id);
  return obj ? JSON.stringify(obj.childRebateLadder) : rebateLadder.value;
};

const copyRow = (row) => {
  let copyRow = cloneDeep(row);
  let json = getJSON(row);
  tableData_right.value.push({ ...copyRow, ladderPriceJson: json });
};

const toRight = () => {
  if (disabled.value) {
    return;
  }
  if (xGrid_left.value) {
    let records = xGrid_left.value.getCheckboxRecords() || [];
    if (!records.length) {
      Message.error(srmI18n(`i18n_title_pleaseSelectRowData`, "请选择行数据"));
      return;
    }
    let ids = tableData_right.value.map((n) => n.id);
    let filterRecords = records.filter((n) => !ids.includes(n.id));
    if (filterRecords.length !== records.length) {
      Message.warning(srmI18n(`i18n_alert_IRIVBtF_5c05f588`, "已过滤重复单据"));
    }
    filterRecords.forEach((item) => copyRow(item));
    xGrid_left.value.clearCheckboxRow();
  }
};

const gridEvents: VxeGridListeners<VxeTableDataRow> = {
  // 单元格被双击时会触发该事件
  cellDblclick({ row }) {
    if (disabled.value) {
      return;
    }
    const flag = tableData_right.value.some((n) => n.id === row.id);
    if (flag) {
      Message.warning(srmI18n(`i18n_alert_rtFIMK_7d4f8a24`, "该单据已存在"));
    } else {
      copyRow(row);
    }
  },
};

const rowDrop_left = () => {
  const $table = xGrid_left.value;
  if ($table) {
    sortable_left = new Sortable($table.$el.querySelector(DOM_TBODY), {
      group: {
        name: "shared_left",
        pull: "clone", // true 允许拖出, clone: 拖出时复制数据(原数据保留)
        put: false, // 禁止接收
      },
      sort: false,
      animation: 150,
      handle: ".drag-btn",
      ghostClass: "ghost", // 拖拽幽灵元素样式
      chosenClass: "chosen", // 选中项样式
      onStart: () => {
        isRightOver.value = false; // 重置状态
      },
      onMove: (evt) => {
        if (xGrid_right.value) {
          isRightOver.value =
            evt.to === xGrid_right.value.$el.querySelector(DOM_TBODY);
        }
      },
      onEnd: () => {
        isRightOver.value = false; // 重置状态
      },
    });
  }
};

const rowDrop_right = () => {
  const $table = xGrid_right.value;
  if ($table) {
    sortable_right = Sortable.create($table.$el.querySelector(DOM_TBODY), {
      group: {
        name: "shared_right",
        pull: false,
        put: true,
      },
      animation: 150,
      ghostClass: "ghost",
      chosenClass: "chosen",
      onAdd: (evt) => {
        evt.item.remove(); // 移除Sortable自动添加的DOM元素

        if (disabled.value) {
          return;
        }
        const newIndex = evt.newIndex as number;
        const oldIndex = evt.oldIndex as number;
        const row = tableData.value[oldIndex];

        if (tableData_right.value.some((n) => n.id === row.id)) {
          Message.warning(
            srmI18n(`i18n_alert_rtFIMK_7d4f8a24`, "该单据已存在")
          );
          return;
        }

        let copyRow = cloneDeep(row);
        let json = getJSON(copyRow);
        let cloneData = cloneDeep(tableData_right.value);
        cloneData.splice(newIndex, 0, { ...copyRow, ladderPriceJson: json });
        tableData_right.value = [];
        nextTick(() => {
          tableData_right.value = cloneData;
        });
      },
      onEnd: (evt) => {
        const newIndex = evt.newIndex as number;
        const oldIndex = evt.oldIndex as number;
        let cloneData = cloneDeep(tableData_right.value);

        const currRow = cloneData.splice(oldIndex, 1)[0];
        cloneData.splice(newIndex, 0, currRow);
        tableData_right.value = [];
        nextTick(() => {
          tableData_right.value = cloneData;
        });
      },
    });
  }
};

onUnmounted(() => {
  destroySortable();
});
</script>

<style lang="less" scoped>
.container {
  display: flex;
  width: 100%;
  gap: 16px;
  height: calc(100vh - 300px);
  .grid_left {
    flex: 0 0 50%;
    max-width: 50%;
    background-color: #f8f9fa;
    border: 1.5px dashed transparent;
  }
  .grid_right {
    /* Make it scrollable */
    overflow: auto;
    /* Take the remaining width */
    flex: 1;
    background-color: #fff;
    border: 1.5px dashed transparent;
  }
  /* 拖拽覆盖样式(仅右侧) */
  .drag-over {
    border-color: #409eff;
    box-shadow: 15px 15px 30px #409eff, -15px -15px 30px #ffffff;
  }

  /* 拖拽选中样式 */
  :deep(.chosen) {
    background-color: #409eff;
    color: white;
  }

  /* 拖拽幽灵元素样式 */
  :deep(.ghost) {
    opacity: 0.6;
    transform: scale(1.02);
  }

  .tools {
    display: flex;
    align-items: center;
    .right {
      color: #5570f1;
      font-size: 32px;
      cursor: pointer;
      &.disabled {
        color: rgba(0, 0, 0, 0.25);
        cursor: not-allowed;
      }
    }
  }
}
</style>

示例

rabate_ladderPrice.jpg