Kaynağa Gözat

feat(customer): 添加客户合同管理功能

- 将供应商等级选择框的值转换为字符串类型以确保正确绑定
- 更新合同类型选项的值从中文文本改为数字编码(0/1/2)
- 在客户详情页面添加合同搜索和重置按钮
- 实现合同表格中显示合同类型的文本映射
- 将地址表单项的验证字段从addressRegion改为shippingProvincial
- 添加完整的合同管理对话框组件,包含合同表单的所有字段
- 集成文件上传组件用于合同附件管理
- 实现合同的增删改查功能和表单验证
- 优化异步数据加载顺序,确保下拉选项正确显示
- 修复地址和付款信息表单中的供应商ID绑定问题
- 将供应商地址API接口路径从/system/address调整为/customer/supplieraddress
- 添加供应商ID字段到地址表单数据结构中
肖路 2 ay önce
ebeveyn
işleme
001bb69aaa

+ 63 - 0
src/api/customer/contact/index.ts

@@ -0,0 +1,63 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { ContactVO, ContactForm, ContactQuery } from '@/api/customer/contact/types';
+
+/**
+ * 查询联系人列表
+ * @param query
+ * @returns {*}
+ */
+
+export const listContact = (query?: ContactQuery): AxiosPromise<ContactVO[]> => {
+  return request({
+    url: '/customer/contact/list',
+    method: 'get',
+    params: query
+  });
+};
+
+/**
+ * 查询联系人详细
+ * @param id
+ */
+export const getContact = (id: string | number): AxiosPromise<ContactVO> => {
+  return request({
+    url: '/customer/contact/' + id,
+    method: 'get'
+  });
+};
+
+/**
+ * 新增联系人
+ * @param data
+ */
+export const addContact = (data: ContactForm) => {
+  return request({
+    url: '/customer/contact',
+    method: 'post',
+    data: data
+  });
+};
+
+/**
+ * 修改联系人
+ * @param data
+ */
+export const updateContact = (data: ContactForm) => {
+  return request({
+    url: '/customer/contact',
+    method: 'put',
+    data: data
+  });
+};
+
+/**
+ * 删除联系人
+ * @param id
+ */
+export const delContact = (id: string | number | Array<string | number>) => {
+  return request({
+    url: '/customer/contact/' + id,
+    method: 'delete'
+  });
+};

+ 201 - 0
src/api/customer/contact/types.ts

@@ -0,0 +1,201 @@
+export interface ContactVO {
+  /**
+   * ID
+   */
+  id?: string | number;
+
+  /**
+   * 供应商编号
+   */
+  supplierNo?: string;
+
+  /**
+   * 供应商ID
+   */
+  supplierId?: string | number;
+
+  /**
+   * 供应商名称
+   */
+  supplierName?: string;
+
+  /**
+   * 用户ID
+   */
+  userNo?: string;
+
+  /**
+   * A10标识号
+   */
+  abutment_no?: string;
+
+  /**
+   * 员工姓名
+   */
+  userName?: string;
+
+  /**
+   * 手机号
+   */
+  phone?: string;
+
+  /**
+   * 角色
+   */
+  roleNo?: string;
+
+  /**
+   * 部门
+   */
+  departmentNo?: string;
+
+  /**
+   * 职位
+   */
+  position?: string;
+
+  /**
+   * 主要联系人(0-否,1-是)
+   */
+  isPrimaryContact?: string;
+
+  /**
+   * 允许登录供应商端(0-否,1-是)
+   */
+  isRegister?: string;
+
+  /**
+   * 状态(0正常 1停用)
+   */
+  status?: string;
+
+  /**
+   * 邮箱
+   */
+  email?: string;
+
+  /**
+   * 传真
+   */
+  fax?: string;
+
+  /**
+   * 备注
+   */
+  remark?: string;
+}
+
+export interface ContactForm extends BaseEntity {
+  /**
+   * 供应商编号
+   */
+  supplierNo?: string;
+
+  /**
+   * 供应商ID
+   */
+  supplierId?: string | number;
+
+  /**
+   * 用户ID
+   */
+  userNo?: string;
+
+  /**
+   * A10标识号
+   */
+  abutment_no?: string;
+
+  /**
+   * 员工姓名
+   */
+  userName?: string;
+
+  /**
+   * 手机号
+   */
+  phone?: string;
+
+  /**
+   * 角色
+   */
+  roleNo?: string;
+
+  /**
+   * 部门
+   */
+  departmentNo?: string;
+
+  /**
+   * 职位
+   */
+  position?: string;
+
+  /**
+   * 主要联系人(0-否,1-是)
+   */
+  isPrimaryContact?: string;
+
+  /**
+   * 允许登录供应商端(0-否,1-是)
+   */
+  isRegister?: string;
+
+  /**
+   * 状态(0正常 1停用)
+   */
+  status?: string;
+
+  /**
+   * 邮箱
+   */
+  email?: string;
+
+  /**
+   * 传真
+   */
+  fax?: string;
+
+  /**
+   * 备注
+   */
+  remark?: string;
+}
+
+export interface ContactQuery extends PageQuery {
+
+  /**
+   * 供应商编号
+   */
+  supplierNo?: string;
+
+  /**
+   * 供应商ID
+   */
+  supplierId?: string | number;
+
+  /**
+   * 用户ID
+   */
+  userNo?: string;
+
+  /**
+   * 员工姓名
+   */
+  userName?: string;
+
+  /**
+   * 手机号
+   */
+  phone?: string;
+
+  /**
+   * 状态(0正常 1停用)
+   */
+  status?: string;
+
+    /**
+     * 日期范围参数
+     */
+    params?: any;
+}

+ 411 - 0
src/components/RegionCascader/index.vue

@@ -0,0 +1,411 @@
+<template>
+  <div class="region-cascader">
+    <div class="region-selector">
+      <!-- 左侧:省份列表 -->
+      <div class="region-list province-list">
+        <div class="list-header">省份</div>
+        <div class="list-body">
+          <div 
+            v-for="province in provinces" 
+            :key="province.value"
+            class="region-item"
+            :class="{ 'is-active': isProvinceSelected(province.value), 'is-checked': isProvinceChecked(province.value) }"
+            @click="handleProvinceClick(province)"
+          >
+            <el-checkbox 
+              v-if="multiple"
+              :model-value="isProvinceChecked(province.value)"
+              @click.stop
+              @change="handleProvinceCheck(province, $event)"
+            >
+              {{ province.label }}
+            </el-checkbox>
+            <span v-else>{{ province.label }}</span>
+            <el-icon v-if="province.children && province.children.length > 0" class="arrow-icon">
+              <ArrowRight />
+            </el-icon>
+          </div>
+        </div>
+      </div>
+      
+      <!-- 中间:城市列表 -->
+      <div class="region-list city-list">
+        <div class="list-header">城市</div>
+        <div class="list-body">
+          <div 
+            v-for="city in currentCities" 
+            :key="city.value"
+            class="region-item"
+            :class="{ 'is-active': isCitySelected(city.value), 'is-checked': isCityChecked(city.value) }"
+            @click="handleCityClick(city)"
+          >
+            <el-checkbox 
+              v-if="multiple"
+              :model-value="isCityChecked(city.value)"
+              @click.stop
+              @change="handleCityCheck(city, $event)"
+            >
+              {{ city.label }}
+            </el-checkbox>
+            <span v-else>{{ city.label }}</span>
+            <el-icon v-if="city.children && city.children.length > 0" class="arrow-icon">
+              <ArrowRight />
+            </el-icon>
+          </div>
+          <div v-if="currentCities.length === 0" class="empty-text">
+            请先选择省份
+          </div>
+        </div>
+      </div>
+      
+      <!-- 右侧:区县列表 -->
+      <div class="region-list district-list">
+        <div class="list-header">区县</div>
+        <div class="list-body">
+          <div 
+            v-for="district in currentDistricts" 
+            :key="district.value"
+            class="region-item"
+            :class="{ 'is-checked': isDistrictChecked(district.value) }"
+            @click="handleDistrictClick(district)"
+          >
+            <el-checkbox 
+              v-if="multiple"
+              :model-value="isDistrictChecked(district.value)"
+              @change="handleDistrictCheck(district, $event)"
+            >
+              {{ district.label }}
+            </el-checkbox>
+            <span v-else>{{ district.label }}</span>
+          </div>
+          <div v-if="currentDistricts.length === 0" class="empty-text">
+            请先选择城市
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { regionData } from 'element-china-area-data';
+import { ArrowRight } from '@element-plus/icons-vue';
+
+interface RegionData {
+  label: string;
+  value: string;
+  children?: RegionData[];
+}
+
+interface Props {
+  modelValue?: string[]; // 选中的区域代码数组
+  multiple?: boolean; // 是否多选模式,默认 true
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: string[]): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: () => [],
+  multiple: true
+});
+
+const emit = defineEmits<Emits>();
+
+// 所有省份
+const provinces = ref<RegionData[]>(regionData as RegionData[]);
+
+// 当前选中的省份
+const currentProvince = ref<RegionData | null>(null);
+
+// 当前选中的城市
+const currentCity = ref<RegionData | null>(null);
+
+// 当前省份下的城市列表
+const currentCities = computed(() => {
+  return currentProvince.value?.children || [];
+});
+
+// 当前城市下的区县列表
+const currentDistricts = computed(() => {
+  return currentCity.value?.children || [];
+});
+
+// 选中的区域代码
+const selectedRegions = ref<string[]>(props.modelValue || []);
+
+// 监听 props 变化
+watch(() => props.modelValue, (newVal) => {
+  selectedRegions.value = newVal || [];
+}, { immediate: true });
+
+/** 判断省份是否被选中(显示箭头高亮) */
+const isProvinceSelected = (provinceCode: string) => {
+  return currentProvince.value?.value === provinceCode;
+};
+
+/** 判断省份是否被勾选 */
+const isProvinceChecked = (provinceCode: string) => {
+  const province = provinces.value.find(p => p.value === provinceCode);
+  if (!province || !province.children) return false;
+  
+  // 递归检查该省份下所有区县是否都被选中
+  const allDistrictCodes: string[] = [];
+  province.children.forEach(city => {
+    if (city.children) {
+      city.children.forEach(district => {
+        allDistrictCodes.push(district.value);
+      });
+    }
+  });
+  
+  return allDistrictCodes.length > 0 && allDistrictCodes.every(code => selectedRegions.value.includes(code));
+};
+
+/** 判断城市是否被选中(显示箭头高亮) */
+const isCitySelected = (cityCode: string) => {
+  return currentCity.value?.value === cityCode;
+};
+
+/** 判断城市是否被勾选 */
+const isCityChecked = (cityCode: string) => {
+  const city = currentCities.value.find(c => c.value === cityCode);
+  if (!city || !city.children) return false;
+  
+  // 检查该城市下所有区县是否都被选中
+  return city.children.every(district => selectedRegions.value.includes(district.value));
+};
+
+/** 判断区县是否被勾选 */
+const isDistrictChecked = (districtCode: string) => {
+  return selectedRegions.value.includes(districtCode);
+};
+
+/** 点击省份 */
+const handleProvinceClick = (province: RegionData) => {
+  currentProvince.value = province;
+  currentCity.value = null; // 重置城市选择
+};
+
+/** 点击城市 */
+const handleCityClick = (city: RegionData) => {
+  currentCity.value = city;
+  // 单选模式下不做选中操作
+  if (!props.multiple) {
+    // 单选模式下只展开城市,不自动选择
+  }
+};
+
+/** 点击区县(单选模式) */
+const handleDistrictClick = (district: RegionData) => {
+  if (!props.multiple) {
+    // 单选模式:清空其他选中,只选中当前区县
+    selectedRegions.value = [district.value];
+    emit('update:modelValue', selectedRegions.value);
+  }
+};
+
+/** 勾选/取消省份 */
+const handleProvinceCheck = (province: RegionData, checked: boolean | string | number) => {
+  const isChecked = !!checked;
+  if (!province.children) return;
+  
+  // 获取该省份下所有区县代码
+  const districtCodes: string[] = [];
+  province.children.forEach(city => {
+    if (city.children) {
+      city.children.forEach(district => {
+        districtCodes.push(district.value);
+      });
+    }
+  });
+  
+  if (isChecked) {
+    // 添加该省份下所有区县
+    const newRegions = [...selectedRegions.value];
+    districtCodes.forEach(code => {
+      if (!newRegions.includes(code)) {
+        newRegions.push(code);
+      }
+    });
+    selectedRegions.value = newRegions;
+  } else {
+    // 移除该省份下所有区县
+    selectedRegions.value = selectedRegions.value.filter(code => !districtCodes.includes(code));
+  }
+  
+  emit('update:modelValue', selectedRegions.value);
+};
+
+/** 勾选/取消城市 */
+const handleCityCheck = (city: RegionData, checked: boolean | string | number) => {
+  const isChecked = !!checked;
+  if (!city.children) return;
+  
+  const districtCodes = city.children.map(district => district.value);
+  
+  if (isChecked) {
+    // 添加该城市下所有区县
+    const newRegions = [...selectedRegions.value];
+    districtCodes.forEach(code => {
+      if (!newRegions.includes(code)) {
+        newRegions.push(code);
+      }
+    });
+    selectedRegions.value = newRegions;
+  } else {
+    // 移除该城市下所有区县
+    selectedRegions.value = selectedRegions.value.filter(code => !districtCodes.includes(code));
+  }
+  
+  emit('update:modelValue', selectedRegions.value);
+};
+
+/** 勾选/取消区县 */
+const handleDistrictCheck = (district: RegionData, checked: boolean | string | number) => {
+  const isChecked = !!checked;
+  if (isChecked) {
+    selectedRegions.value.push(district.value);
+  } else {
+    selectedRegions.value = selectedRegions.value.filter(code => code !== district.value);
+  }
+  
+  emit('update:modelValue', selectedRegions.value);
+};
+
+/** 获取选中的区域名称列表 */
+const getSelectedRegionNames = () => {
+  const names: string[] = [];
+  
+  provinces.value.forEach(province => {
+    if (province.children) {
+      province.children.forEach(city => {
+        if (city.children) {
+          city.children.forEach(district => {
+            if (selectedRegions.value.includes(district.value)) {
+              names.push(`${province.label}-${city.label}-${district.label}`);
+            }
+          });
+        }
+      });
+    }
+  });
+  
+  return names;
+};
+
+// 暴露方法给父组件
+defineExpose({
+  getSelectedRegionNames
+});
+</script>
+
+<style scoped>
+.region-cascader {
+  width: 100%;
+}
+
+.region-selector {
+  display: flex;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  height: 400px;
+  overflow: hidden;
+}
+
+.region-list {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  border-right: 1px solid #dcdfe6;
+  min-width: 200px;
+}
+
+.region-list:last-child {
+  border-right: none;
+}
+
+.list-header {
+  padding: 12px 16px;
+  background: #f5f7fa;
+  border-bottom: 1px solid #dcdfe6;
+  font-weight: 500;
+  font-size: 14px;
+  color: #303133;
+}
+
+.list-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+
+.region-item {
+  padding: 8px 16px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  transition: background-color 0.2s;
+}
+
+.region-item:hover {
+  background: #f5f7fa;
+}
+
+.region-item.is-active {
+  background: #ecf5ff;
+  color: #409eff;
+}
+
+.region-item.is-checked {
+  font-weight: 500;
+}
+
+.arrow-icon {
+  color: #909399;
+  font-size: 12px;
+  margin-left: 8px;
+}
+
+.region-item.is-active .arrow-icon {
+  color: #409eff;
+}
+
+.empty-text {
+  padding: 20px;
+  text-align: center;
+  color: #909399;
+  font-size: 14px;
+}
+
+/* 复选框样式调整 */
+.region-item :deep(.el-checkbox) {
+  width: 100%;
+}
+
+.region-item :deep(.el-checkbox__label) {
+  flex: 1;
+  padding-left: 8px;
+}
+
+/* 滚动条样式 */
+.list-body::-webkit-scrollbar {
+  width: 6px;
+}
+
+.list-body::-webkit-scrollbar-thumb {
+  background: #dcdfe6;
+  border-radius: 3px;
+}
+
+.list-body::-webkit-scrollbar-thumb:hover {
+  background: #c0c4cc;
+}
+
+.list-body::-webkit-scrollbar-track {
+  background: transparent;
+}
+</style>