张诺
2 天以前 ccd67e291e00a2ad9c29ad8df43de6fab5a4afed
src/views/salesManagement/salesQuotation/index.vue
@@ -51,7 +51,7 @@
        height="calc(100vh - 22em)"
      >
            <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column prop="quotationNo" label="报价单号" width="150" />
        <el-table-column prop="quotationNo" label="报价单号" />
        <el-table-column prop="customer" label="客户名称" />
        <el-table-column prop="salesperson" label="业务员" width="100" />
        <el-table-column prop="quotationDate" label="报价日期" width="120" />
@@ -231,43 +231,52 @@
            <el-table :data="form.products" border style="width: 100%" class="product-table" v-if="form.products.length > 0">
            <el-table-column prop="product" label="产品名称" width="200">
              <template #default="scope">
                        <el-tree-select
                           v-model="scope.row.productId"
                           placeholder="请选择"
                           clearable
                           check-strictly
                           @change="getModels($event, scope.row)"
                           :data="productOptions"
                           :render-after-expand="false"
                           style="width: 100%"
                        />
                <el-form-item :prop="`products.${scope.$index}.productId`" class="product-table-form-item">
                  <el-tree-select
                    v-model="scope.row.productId"
                    placeholder="请选择"
                    clearable
                    check-strictly
                    @change="getModels($event, scope.row)"
                    :data="productOptions"
                    :render-after-expand="false"
                    style="width: 100%"
                  />
                </el-form-item>
              </template>
            </el-table-column>
            <el-table-column prop="specification" label="规格型号" width="150">
            <el-table-column prop="specification" label="规格型号" width="200">
              <template #default="scope">
                        <el-select
                           v-model="scope.row.specificationId"
                           placeholder="请选择"
                           clearable
                           @change="getProductModel($event, scope.row)"
                        >
                           <el-option
                              v-for="item in modelOptions"
                              :key="item.id"
                              :label="item.model"
                              :value="item.id"
                           />
                        </el-select>
                <el-form-item :prop="`products.${scope.$index}.specificationId`" class="product-table-form-item">
                  <el-select
                    v-model="scope.row.specificationId"
                    placeholder="请选择"
                    clearable
                    @change="getProductModel($event, scope.row)"
                    style="width: 100%"
                  >
                    <el-option
                      v-for="item in scope.row.modelOptions || []"
                      :key="item.id"
                      :label="item.model"
                      :value="item.id"
                    />
                  </el-select>
                </el-form-item>
              </template>
            </el-table-column>
            <el-table-column prop="unit" label="单位">
              <template #default="scope">
                <el-input v-model="scope.row.unit" placeholder="单位" />
                <el-form-item :prop="`products.${scope.$index}.unit`" class="product-table-form-item">
                  <el-input v-model="scope.row.unit" placeholder="单位" clearable/>
                </el-form-item>
              </template>
            </el-table-column>
            <el-table-column prop="unitPrice" label="单价">
              <template #default="scope">
                <el-input-number v-model="scope.row.unitPrice" :min="0" :precision="2" style="width: 100%" />
                <el-form-item :prop="`products.${scope.$index}.unitPrice`" class="product-table-form-item">
                  <el-input-number v-model="scope.row.unitPrice" :min="0" :precision="2" style="width: 100%" />
                </el-form-item>
              </template>
            </el-table-column>
            <el-table-column label="操作" width="80" align="center">
@@ -301,6 +310,52 @@
            </el-form-item>
          </div>
        </el-card>
        <el-card class="form-card" shadow="hover">
          <template #header>
            <div class="card-header-wrapper">
              <el-icon class="card-icon"><Paperclip /></el-icon>
              <span class="card-title">附件材料</span>
            </div>
          </template>
          <div class="form-content">
            <el-form-item label="附件">
              <el-upload
                v-model:file-list="fileList"
                :action="upload.url"
                multiple
                ref="fileUpload"
                auto-upload
                :headers="upload.headers"
                :before-upload="handleBeforeUpload"
                :on-error="handleUploadError"
                :on-success="handleUploadSuccess"
                :on-remove="handleRemove"
                :on-preview="handlePreview"
              >
                <el-button type="primary">上传</el-button>
                <template #file="{ file }">
                  <div style="display:flex; align-items:center; gap: 10px; width: 100%;">
                    <span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
                      {{ file.name }}
                    </span>
                    <div style="display:flex; align-items:center; gap: 6px;">
                      <el-button link type="success" :icon="Download" @click="handleDownload(file)" />
                      <el-button link type="primary" :icon="View" @click="handlePreview(file)" />
                      <el-button link type="danger" :icon="Delete" @click="triggerRemoveFile(file)" />
                    </div>
                  </div>
                </template>
                <template #tip>
                  <div class="el-upload__tip">
                    文件格式支持 doc,docx,xls,xlsx,ppt,pptx,pdf,txt,xml,jpg,jpeg,png,gif,bmp,rar,zip,7z
                  </div>
                </template>
              </el-upload>
            </el-form-item>
          </div>
        </el-card>
      </el-form>
      </div>
    </FormDialog>
@@ -322,7 +377,7 @@
        </el-descriptions-item>
      </el-descriptions>
      
      <div style="margin-top: 20px;">
      <div style="margin: 20px 0;">
        <h4>产品明细</h4>
        <el-table :data="currentQuotation.products" border style="width: 100%">
          <el-table-column prop="product" label="产品名称" />
@@ -341,19 +396,25 @@
        <p>{{ currentQuotation.remark }}</p>
      </div>
    </el-dialog>
    <filePreview ref="filePreviewRef" />
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, markRaw, shallowRef } from 'vue'
import { ref, reactive, computed, onMounted, markRaw, shallowRef, getCurrentInstance } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Document, UserFilled, Box, EditPen, Plus, ArrowRight, Delete } from '@element-plus/icons-vue'
import { Search, Document, UserFilled, Box, EditPen, Plus, ArrowRight, Delete, Paperclip, View, Download } from '@element-plus/icons-vue'
import Pagination from '@/components/PIMTable/Pagination.vue'
import FormDialog from '@/components/Dialog/FormDialog.vue'
import {getQuotationList,addQuotation,updateQuotation,deleteQuotation} from '@/api/salesManagement/salesQuotation.js'
import {userListNoPage} from "@/api/system/user.js";
import {customerList} from "@/api/salesManagement/salesLedger.js";
import { customerList, delLedgerFile } from "@/api/salesManagement/salesLedger.js";
import {modelList, productTreeList} from "@/api/basicData/product.js";
import { getToken } from "@/utils/auth";
import filePreview from "@/components/filePreview/index.vue";
const { proxy } = getCurrentInstance()
// 响应式数据
const loading = ref(false)
@@ -375,6 +436,13 @@
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const dialogTitle = ref('新增报价')
const fileList = ref([])
const fileUpload = ref()
const filePreviewRef = ref()
const upload = reactive({
  url: import.meta.env.VITE_APP_BASE_API + "/file/upload",
  headers: { Authorization: "Bearer " + getToken() },
})
const form = reactive({
  quotationNo: '',
  customer: '',
@@ -390,16 +458,34 @@
  otherFee: 0,
  discountRate: 0,
  discountAmount: 0,
  totalAmount: 0
  totalAmount: 0,
  tempFileIds: []
})
const rules = {
const baseRules = {
  customer: [{ required: true, message: '请选择客户', trigger: 'change' }],
  salesperson: [{ required: true, message: '请选择业务员', trigger: 'change' }],
  quotationDate: [{ required: true, message: '请选择报价日期', trigger: 'change' }],
  validDate: [{ required: true, message: '请选择有效期', trigger: 'change' }],
  paymentMethod: [{ required: true, message: '请输入付款方式', trigger: 'blur' }]
}
const productRowRules = {
  productId: [{ required: true, message: '请选择产品名称', trigger: 'change' }],
  specificationId: [{ required: true, message: '请选择规格型号', trigger: 'change' }],
  unit: [{ required: true, message: '请填写单位', trigger: 'blur' }],
  unitPrice: [{ required: true, message: '请填写单价', trigger: 'change' }]
}
const rules = computed(() => {
  const r = { ...baseRules }
  ;(form.products || []).forEach((_, i) => {
    r[`products.${i}.productId`] = productRowRules.productId
    r[`products.${i}.specificationId`] = productRowRules.specificationId
    r[`products.${i}.unit`] = productRowRules.unit
    r[`products.${i}.unitPrice`] = productRowRules.unitPrice
  })
  return r
})
const userList = ref([]);
const customerOption = ref([]);
@@ -427,15 +513,6 @@
// 计算属性
const filteredList = computed(() => {
  let list = quotationList.value
  if (searchForm.quotationNo) {
    list = list.filter(item => item.quotationNo.includes(searchForm.quotationNo))
  }
  if (searchForm.customer) {
    list = list.filter(item => item.customer === searchForm.customer)
  }
  if (searchForm.status) {
    list = list.filter(item => item.status === searchForm.status)
  }
  return list
})
@@ -454,12 +531,33 @@
  searchForm.quotationNo = ''
  searchForm.customer = ''
  searchForm.status = ''
  // 重置到第一页并重新查询
  pagination.currentPage = 1
  handleSearch()
}
const normalizeQuotationFiles = (raw) => {
  const list =
    (raw && Array.isArray(raw.salesLedgerFiles) && raw.salesLedgerFiles) ||
    (raw && Array.isArray(raw.quotationFiles) && raw.quotationFiles) ||
    (raw && Array.isArray(raw.fileList) && raw.fileList) ||
    (raw && Array.isArray(raw.files) && raw.files) ||
    []
  return list
    .map((item) => ({
      id: item?.id,
      name: item?.fileName || item?.name || item?.originalName || item?.filename || "附件",
      url: item?.fileUrl || item?.url || item?.path || item?.tempPath,
      tempId: item?.tempId,
    }))
    .filter((i) => i.url)
}
const handleAdd = async () => {
  dialogTitle.value = '新增报价'
  isEdit.value = false
  resetForm()
  fileList.value = []
  // 重置审批人节点
  approverNodes.value = [{ id: 1, userId: null }]
  nextApproverId = 2
@@ -521,7 +619,7 @@
   if (!value) {
      row.productId = '';
      row.product = '';
      modelOptions.value = [];
      row.modelOptions = [];
      row.specificationId = '';
      row.specification = '';
      row.unit = '';
@@ -534,9 +632,9 @@
   if (label) {
      row.product = label;
   }
   // 获取规格型号列表
   // 获取规格型号列表,设置到当前行的 modelOptions
   modelList({ id: value }).then((res) => {
      modelOptions.value = res || [];
      row.modelOptions = res || [];
   });
};
const getProductModel = (value, row) => {
@@ -550,10 +648,11 @@
   }
   // 更新 specificationId(v-model 已经自动更新,这里确保一致性)
   row.specificationId = value;
   const index = modelOptions.value.findIndex((item) => item.id === value);
   const modelOptions = row.modelOptions || [];
   const index = modelOptions.findIndex((item) => item.id === value);
   if (index !== -1) {
      row.specification = modelOptions.value[index].model;
      row.unit = modelOptions.value[index].unit;
      row.specification = modelOptions[index].model;
      row.unit = modelOptions[index].unit;
   } else {
      row.specification = '';
      row.unit = '';
@@ -616,23 +715,46 @@
  form.paymentMethod = row.paymentMethod || ''
  form.status = row.status || '草稿'
  form.remark = row.remark || ''
  form.products = row.products ? row.products.map(product => {
  form.products = row.products ? await Promise.all(row.products.map(async (product) => {
    const productName = product.product || product.productName || ''
    // 优先用 productId;如果只有名称,尝试反查 id 以便树选择器反显
    const resolvedId = product.productId
    const resolvedProductId = product.productId
      ? Number(product.productId)
      : findNodeIdByLabel(productOptions.value, productName) || ''
    // 如果有产品ID,加载对应的规格型号列表
    let modelOptions = [];
    let resolvedSpecificationId = product.specificationId || '';
    if (resolvedProductId) {
      try {
        const res = await modelList({ id: resolvedProductId });
        modelOptions = res || [];
        // 如果返回的数据没有 specificationId,但有 specification 名称,根据名称查找 ID
        if (!resolvedSpecificationId && product.specification) {
          const foundModel = modelOptions.find(item => item.model === product.specification);
          if (foundModel) {
            resolvedSpecificationId = foundModel.id;
          }
        }
      } catch (error) {
        console.error('加载规格型号失败:', error);
      }
    }
    return {
      productId: resolvedId,
      productId: resolvedProductId,
      product: productName,
      specificationId: product.specificationId || '',
      specificationId: resolvedSpecificationId,
      specification: product.specification || '',
      quantity: product.quantity || 0,
      unit: product.unit || '',
      unitPrice: product.unitPrice || 0,
      amount: product.amount || 0
      amount: product.amount || 0,
      modelOptions: modelOptions // 为每行添加独立的规格型号列表
    }
  }) : []
  })) : []
  form.subtotal = row.subtotal || 0
  form.freight = row.freight || 0
  form.otherFee = row.otherFee || 0
@@ -661,6 +783,7 @@
    userName: item.userName || ''
  }));
  
  fileList.value = normalizeQuotationFiles(row)
  dialogVisible.value = true
}
@@ -702,6 +825,9 @@
  form.discountRate = 0
  form.discountAmount = 0
  form.totalAmount = 0
  form.tempFileIds = []
  form.files = []
  fileList.value = []
}
const addProduct = () => {
@@ -714,7 +840,8 @@
    quantity: 1,
    unit: '',
    unitPrice: 0,
    amount: 0
    amount: 0,
    modelOptions: [] // 为每行添加独立的规格型号列表
  })
}
@@ -742,6 +869,57 @@
  // 可以根据客户信息自动填充一些默认值
}
function handleBeforeUpload() {
  proxy?.$modal?.loading?.("正在上传文件,请稍候...")
  return true
}
function handleUploadError() {
  proxy?.$modal?.closeLoading?.()
  ElMessage.error("上传文件失败")
}
function handleUploadSuccess(res, file) {
  proxy?.$modal?.closeLoading?.()
  if (res?.code === 200) {
    file.tempId = res?.data?.tempId
    const url = res?.data?.tempPath || res?.data?.url
    if (url) file.url = url
    file.name = res?.data?.originalName || file?.name
    ElMessage.success("上传成功")
  } else {
    ElMessage.error(res?.msg || "上传失败")
    fileUpload.value?.handleRemove?.(file)
  }
}
function handleRemove(file) {
  if (!isEdit.value) return
  if (!file?.id) return
  delLedgerFile([file.id]).then((res) => {
    if (res?.code === 200) {
      ElMessage.success("删除成功")
    } else {
      ElMessage.error(res?.msg || "删除失败")
    }
  }).catch(() => {
    ElMessage.error("删除失败")
  })
}
const handleDownload = (file) => {
  if (!file?.url) return
  proxy?.$modal?.loading?.("正在下载文件,请稍候...")
  proxy.$download.name(file.url);
  proxy?.$modal?.closeLoading?.()
}
function handlePreview(file) {
  const url = file?.url || file?.response?.data?.tempPath || file?.response?.data?.url
  if (!url) return
  filePreviewRef.value?.open?.(url)
}
function triggerRemoveFile(file) {
  fileUpload.value?.handleRemove?.(file)
}
const handleSubmit = () => {
  formRef.value.validate((valid) => {
    if (valid) {
@@ -749,7 +927,7 @@
        ElMessage.warning('请至少添加一个产品')
        return
      }
      // 审批人必填校验
      const hasEmptyApprover = approverNodes.value.some(node => !node.userId)
      if (hasEmptyApprover) {
@@ -759,6 +937,13 @@
      
      // 收集所有节点的审批人id
      form.approveUserIds = approverNodes.value.map(node => node.userId).join(',')
      form.files = fileList.value.map(f => ({
        tempId: f.tempId,
        name: f.name,
        url: f.url,
        uid: f.uid,
      })) || []
      
      // 计算所有产品的单价总和
      form.totalAmount = form.products.reduce((sum, product) => {
@@ -797,10 +982,14 @@
const handleCurrentChange = (val) => {
  pagination.currentPage = val.page
  pagination.pageSize = val.limit
  // 分页变化时重新查询列表
  handleSearch()
}
const handleSearch = ()=>{
  const params = {
    ...pagination,
    // 后端分页参数:current / size
    current: pagination.currentPage,
    size: pagination.pageSize,
    ...searchForm
  }
  getQuotationList(params).then(res=>{
@@ -819,6 +1008,7 @@
        // 审批人(用于编辑时反显)
        approveUserIds: item.approveUserIds || '',
        remark: item.remark || '',
        salesLedgerFiles: normalizeQuotationFiles(item),
        products: item.products ? item.products.map(product => ({
          productId: product.productId || '',
          product: product.product || product.productName || '',
@@ -931,6 +1121,17 @@
  padding: 8px 0;
}
.product-table-form-item {
  margin-bottom: 0;
  :deep(.el-form-item__content) {
    margin-left: 0 !important;
  }
  :deep(.el-form-item__label) {
    width: auto;
    min-width: auto;
  }
}
.approver-nodes-container {
  display: flex;
  flex-wrap: wrap;