SFT/docs/superpowers/plans/2026-07-09-phase3-customer-...

56 KiB

阶段3客户管理(个人客户+企业客户) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现个人客户与企业客户的查询/新增/编辑/查看功能,对接真实后端接口(/api/customer-info/personal/*/api/enterprise-customer/*),并提供可复用的渠道选择、身份证OCR上传、营业执照上传、个人客户选择弹窗组件。

Architecture: 沿用阶段1/2已确立的架构:Vue3 <script setup> + Ant Design Vue + Pinia + 唯一 axios 实例(src/api/request.js);列表页用 ProTable.vue 单文件模式;因本阶段表单字段量大且含文件上传/OCR/子表单,新增/编辑改为独立路由页(PersonalForm.vue/EnterpriseForm.vue,以 ?mode=create|edit|detail&id= 区分三态),不再用 <a-modal>

Tech Stack: Vue 3(纯 JS,无 TS)、Vite、Ant Design Vue 4、Pinia、Axios、Vue Router 4;新增 dayjs 直接依赖(antd 已通过传递依赖安装,补充声明以便直接 import)。

Global Constraints

  • 项目规则:不写测试用例(用户 CLAUDE.md 明确"不要写测试用例")。因此本计划的"验证步骤"统一替换为:①npm run build 确认无语法/构建错误;②必要时用 npm run dev 启动后手动访问对应路由检查渲染与控制台无报错,而不是自动化测试。
  • 所有接口响应结构固定 {code, message, data},code===200 才算成功,失败仅展示 message,不做具体错误码分支(已有 request.js 拦截器统一 message.error,业务代码只需判断 code===200 决定后续动作)。
  • 日期字段格式统一 yyyy-MM-dd HH:mm:ss,但本阶段表单里的日期选择器(生效日/到期日/出生日期)只需精确到"日",提交时用 dayjs(value).format('YYYY-MM-DD 00:00:00') 补齐时分秒。
  • 个人客户、企业客户均无删除接口,列表页不提供删除入口(不违反"批量删除标准交互"惯例,只是本模块暂不适用,因为没有可调用的删除API)。
  • 渠道编号(channelNo)只是调用 OCR/上传接口的临时参数,不随表单持久化。
  • 严禁编造 swagger 未定义的字段或接口;所有假设已在设计文档 docs/superpowers/specs/2026-07-09-phase3-customer-management-design.md 第7节标注,实施中如发现新的能力缺口,同步追加到该文档与 缺失的后端接口.md

Task 1: 基础依赖与校验规则

Files:

  • Modify: package.json
  • Modify: src/utils/validators.js

Interfaces:

  • Produces: isValidIdCard(value), idCardValidatorRule(), isValidBusinessLicense(value), businessLicenseValidatorRule() (供 Task 8/10 表单使用)

  • Step 1: 声明 dayjs 直接依赖

package.jsondependencies 里在 "@ant-design/icons-vue": "^7.0.0" 后添加一行:

    "@ant-design/icons-vue": "^7.0.0",
    "dayjs": "^1.11.10"
  • Step 2: 安装依赖

Run: npm install Expected: 无报错,node_modules/dayjs 存在且 package.json/package-lock.json 已更新

  • Step 3: validators.js 补充证件号正则与校验规则

src/utils/validators.js 文件末尾(passwordStrengthValidatorRule 函数之后)追加:

// 18位身份证号,末位允许 X/x,不做校验码算术级校验(与手机号正则的"够用不过度设计"风格一致)
const ID_CARD_REGEX = /^\d{17}[\dXx]$/
// 统一社会信用代码/营业执照号:15-20位数字与大写字母组合,覆盖新旧两种编码位数
const BUSINESS_LICENSE_REGEX = /^[0-9A-Z]{15,20}$/

export function isValidIdCard(idCard) {
  return ID_CARD_REGEX.test(idCard || '')
}

export function isValidBusinessLicense(license) {
  return BUSINESS_LICENSE_REGEX.test(license || '')
}

export function idCardValidatorRule() {
  return {
    validator: (_rule, value) => {
      if (!value) return Promise.reject(new Error('请输入证件号码'))
      if (!isValidIdCard(value)) return Promise.reject(new Error('请输入正确的18位身份证号'))
      return Promise.resolve()
    }
  }
}

export function businessLicenseValidatorRule() {
  return {
    validator: (_rule, value) => {
      if (!value) return Promise.reject(new Error('请输入营业执照号'))
      if (!isValidBusinessLicense(value)) {
        return Promise.reject(new Error('请输入正确的营业执照号(15-20位数字/大写字母)'))
      }
      return Promise.resolve()
    }
  }
}
  • Step 4: 验证构建

Run: npm run build Expected: 构建成功,无报错

  • Step 5: Commit
git add package.json package-lock.json src/utils/validators.js
git commit -m "feat: 新增dayjs依赖与证件号/营业执照号校验规则"

Task 2: 客户管理 API 封装

Files:

  • Create: src/api/customer.js

Interfaces:

  • Produces: fetchPersonalCustomerListApi(data), fetchPersonalCustomerDetailApi(data), createPersonalCustomerApi(data), updatePersonalCustomerApi(data), ocrIdCardApi(data), uploadCustomerFileApi(data), fetchEnterpriseCustomerPageApi(data), fetchEnterpriseCustomerDetailApi(data), createEnterpriseCustomerApi(data), updateEnterpriseCustomerApi(data) — 均返回 axios Promise,res.data 结构为 {code,message,data}

  • Step 1: 创建 API 文件

// src/api/customer.js
import request from './request'

// ---------------- 个人客户 ----------------
export const fetchPersonalCustomerListApi = (data) => request.post('/api/customer-info/personal/list', data)
export const fetchPersonalCustomerDetailApi = (data) => request.post('/api/customer-info/personal/detail', data)
export const createPersonalCustomerApi = (data) => request.post('/api/customer-info/personal/create', data)
export const updatePersonalCustomerApi = (data) => request.post('/api/customer-info/personal/update', data)
export const ocrIdCardApi = (data) => request.post('/api/customer-info/personal/ocr-idcard', data)
// 通用文件上传:个人客户身份证正/反面、企业客户营业执照图片均调用此接口
export const uploadCustomerFileApi = (data) => request.post('/api/customer-info/personal/upload-file', data)

// ---------------- 企业客户 ----------------
export const fetchEnterpriseCustomerPageApi = (data) => request.post('/api/enterprise-customer/page', data)
export const fetchEnterpriseCustomerDetailApi = (data) => request.post('/api/enterprise-customer/detail', data)
export const createEnterpriseCustomerApi = (data) => request.post('/api/enterprise-customer/create', data)
export const updateEnterpriseCustomerApi = (data) => request.post('/api/enterprise-customer/update', data)
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/api/customer.js
git commit -m "feat: 新增客户管理API封装"

Task 3: OCR 结果映射工具

Files:

  • Create: src/utils/ocrMapping.js

Interfaces:

  • Consumes: 无(纯函数工具)

  • Produces: normalizeOcrDate(raw) 返回 'YYYY-MM-DD''';mapOcrIdCardResult(ocr) 返回可直接 Object.assign 进表单的字段对象(供 Task 9 PersonalForm.vue 使用)

  • Step 1: 创建映射工具

// src/utils/ocrMapping.js
// 身份证OCR识别结果(OcrIdCardResultVo)字段名与个人客户表单字段名不一致,此处做归一化映射。
// 凡荣e链OCR返回的日期格式未在接口文档中明确标注,做防御性解析:优先提取8位连续数字重组为 YYYY-MM-DD,
// 解析失败时返回空字符串,交由用户手动填写(符合需求文档"识别失败允许手动修正"规则)。
const GENDER_MAP = { : 'MALE', : 'FEMALE' }

export function normalizeOcrDate(raw) {
  if (!raw) return ''
  const digits = String(raw).replace(/[^\d]/g, '')
  if (digits.length !== 8) return ''
  return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
}

export function mapOcrIdCardResult(ocr) {
  const result = {}
  if (!ocr) return result
  if (ocr.name) result.customerName = ocr.name
  if (ocr.sex) result.gender = GENDER_MAP[ocr.sex] || 'UNKNOWN'
  if (ocr.nation) result.ethnicity = ocr.nation
  if (ocr.idcard) result.certificateNumber = ocr.idcard
  if (ocr.address) result.certificateAddress = ocr.address
  if (ocr.authority) result.issuingAuthority = ocr.authority
  if (ocr.birth) {
    const birth = normalizeOcrDate(ocr.birth)
    if (birth) result.birthDate = birth
  }
  if (ocr.validDate && String(ocr.validDate).includes('-')) {
    const [start, end] = String(ocr.validDate).split('-')
    const startDate = normalizeOcrDate(start)
    const endDate = normalizeOcrDate(end)
    if (startDate) result.certificateEffectiveDate = startDate
    if (endDate) result.certificateExpiryDate = endDate
  }
  return result
}
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/utils/ocrMapping.js
git commit -m "feat: 新增身份证OCR结果字段映射工具"

Task 4: 渠道选择器组件 ChannelSelect.vue

Files:

  • Create: src/components/ChannelSelect.vue

Interfaces:

  • Consumes: fetchCoreEnterprisePageApi from @/api/baseConfig(阶段2已提供)

  • Produces: <ChannelSelect v-model:modelValue="channelNo" />,选项 value 为核心企业的 enterpriseCode(供 Task 5/6/9/11 使用)

  • Step 1: 创建组件

<!-- src/components/ChannelSelect.vue -->
<template>
  <a-select
    :value="modelValue"
    :options="options"
    :loading="loading"
    show-search
    option-filter-prop="label"
    placeholder="请选择所属渠道"
    style="width: 220px"
    allow-clear
    @update:value="$emit('update:modelValue', $event)"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { fetchCoreEnterprisePageApi } from '@/api/baseConfig'

defineProps({
  modelValue: { type: String, default: undefined }
})
defineEmits(['update:modelValue'])

const options = ref([])
const loading = ref(false)

async function loadChannelOptions() {
  loading.value = true
  try {
    const res = await fetchCoreEnterprisePageApi({ page: 1, pageSize: 200 })
    if (res.data.code === 200) {
      options.value = (res.data.data.records || []).map((item) => ({
        label: item.enterpriseName,
        value: item.enterpriseCode
      }))
    }
  } finally {
    loading.value = false
  }
}

onMounted(loadChannelOptions)
</script>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/components/ChannelSelect.vue
git commit -m "feat: 新增渠道选择器组件"

Task 5: 身份证上传+OCR组件 IdCardUpload.vue

Files:

  • Create: src/components/IdCardUpload.vue

Interfaces:

  • Consumes: ocrIdCardApi, uploadCustomerFileApi from @/api/customer(Task 2)

  • Produces: <IdCardUpload :side="'front'|'back'" :channel-no="channelNo" v-model:file-no="idCardFrontFileNo" @ocr-result="handleOcrResult" @ocr-status="handleOcrStatus" />(供 Task 9 使用)

  • Step 1: 创建组件

<!-- src/components/IdCardUpload.vue -->
<template>
  <div class="id-card-upload">
    <a-upload
      list-type="picture-card"
      :show-upload-list="false"
      :before-upload="handleBeforeUpload"
      :disabled="!channelNo || uploading"
    >
      <img v-if="previewUrl" :src="previewUrl" class="preview-img" alt="预览" />
      <div v-else>
        <LoadingOutlined v-if="uploading" />
        <PlusOutlined v-else />
        <div class="upload-text">{{ label }}</div>
      </div>
    </a-upload>
    <div v-if="!channelNo" class="hint">请先选择所属渠道</div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { ocrIdCardApi, uploadCustomerFileApi } from '@/api/customer'

const props = defineProps({
  side: { type: String, required: true }, // 'front'人像面 | 'back'国徽面
  channelNo: { type: String, default: undefined }
})
const emit = defineEmits(['update:fileNo', 'ocr-result', 'ocr-status'])

const uploading = ref(false)
const previewUrl = ref('')
const label = computed(() => (props.side === 'front' ? '上传身份证人像面' : '上传身份证国徽面'))
const fileType = computed(() => (props.side === 'front' ? '01' : '02'))

function readAsBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })
}

async function handleBeforeUpload(file) {
  if (!props.channelNo) {
    message.warning('请先选择所属渠道')
    return false
  }
  uploading.value = true
  try {
    const fileBase64 = await readAsBase64(file)
    previewUrl.value = fileBase64
    const [ocrRes, uploadRes] = await Promise.all([
      ocrIdCardApi({ fileBase64, channelNo: props.channelNo, fileType: fileType.value }),
      uploadCustomerFileApi({ fileBase64, channelNo: props.channelNo })
    ])
    if (uploadRes.data.code === 200) {
      emit('update:fileNo', uploadRes.data.data.fileNo)
    }
    if (ocrRes.data.code === 200 && ocrRes.data.data && Object.keys(ocrRes.data.data).length > 0) {
      emit('ocr-result', ocrRes.data.data)
      emit('ocr-status', 'SUCCESS')
    } else {
      message.warning('识别失败,请手动填写')
      emit('ocr-status', 'FAILED')
    }
  } catch (e) {
    message.warning('识别失败,请手动填写')
    emit('ocr-status', 'FAILED')
  } finally {
    uploading.value = false
  }
  return false
}
</script>

<style scoped>
.id-card-upload {
  display: inline-block;
}
.preview-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.upload-text {
  margin-top: 8px;
  font-size: 12px;
}
.hint {
  color: #ff4d4f;
  font-size: 12px;
  margin-top: 4px;
}
</style>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/components/IdCardUpload.vue
git commit -m "feat: 新增身份证上传+OCR识别组件"

Task 6: 营业执照上传组件 LicenseUpload.vue

Files:

  • Create: src/components/LicenseUpload.vue

Interfaces:

  • Consumes: uploadCustomerFileApi from @/api/customer

  • Produces: <LicenseUpload :channel-no="channelNo" v-model:file-no="businessLicenseFileNo" />(供 Task 11 使用)

  • Step 1: 创建组件

<!-- src/components/LicenseUpload.vue -->
<template>
  <div class="license-upload">
    <a-upload
      list-type="picture-card"
      :show-upload-list="false"
      :before-upload="handleBeforeUpload"
      :disabled="!channelNo || uploading"
    >
      <img v-if="previewUrl" :src="previewUrl" class="preview-img" alt="预览" />
      <div v-else>
        <LoadingOutlined v-if="uploading" />
        <PlusOutlined v-else />
        <div class="upload-text">上传营业执照</div>
      </div>
    </a-upload>
    <div v-if="!channelNo" class="hint">请先选择所属渠道</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { uploadCustomerFileApi } from '@/api/customer'

const props = defineProps({
  channelNo: { type: String, default: undefined }
})
const emit = defineEmits(['update:fileNo'])

const uploading = ref(false)
const previewUrl = ref('')

function readAsBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })
}

async function handleBeforeUpload(file) {
  if (!props.channelNo) {
    message.warning('请先选择所属渠道')
    return false
  }
  uploading.value = true
  try {
    const fileBase64 = await readAsBase64(file)
    previewUrl.value = fileBase64
    const res = await uploadCustomerFileApi({ fileBase64, channelNo: props.channelNo })
    if (res.data.code === 200) {
      emit('update:fileNo', res.data.data.fileNo)
      message.success('上传成功')
    }
  } finally {
    uploading.value = false
  }
  return false
}
</script>

<style scoped>
.license-upload {
  display: inline-block;
}
.preview-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.upload-text {
  margin-top: 8px;
  font-size: 12px;
}
.hint {
  color: #ff4d4f;
  font-size: 12px;
  margin-top: 4px;
}
</style>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/components/LicenseUpload.vue
git commit -m "feat: 新增营业执照上传组件"

Task 7: 个人客户选择弹窗 PersonalCustomerPicker.vue

Files:

  • Create: src/components/PersonalCustomerPicker.vue

Interfaces:

  • Consumes: fetchPersonalCustomerListApi from @/api/customer(Task 2)、ProTable.vueOrgTreeSelect.vue(已有)

  • Produces: 模板 ref 暴露 show() 方法打开弹窗;选中后 emit('select', record),record 字段含 id,customerCode,customerName,certificateType,certificateNumber,mobilePhone(供 Task 9 法定代表人选择、Task 11 股东高管选择使用)

  • Step 1: 创建组件

<!-- src/components/PersonalCustomerPicker.vue -->
<template>
  <a-modal v-model:open="open" title="选择个人客户" width="800px" :footer="null">
    <ProTable
      :columns="columns"
      :fetch-data="loadList"
      :row-selection="false"
      :initial-search="{ organizationIdEq: undefined, customerCodeEq: '', customerNameLike: '' }"
    >
      <template #search="{ form }">
        <a-form-item label="所属机构">
          <OrgTreeSelect v-model="form.organizationIdEq" style="width: 180px" />
        </a-form-item>
        <a-form-item label="客户编号">
          <a-input v-model:value="form.customerCodeEq" placeholder="请输入客户编号" allow-clear style="width: 160px" />
        </a-form-item>
        <a-form-item label="客户名称">
          <a-input v-model:value="form.customerNameLike" placeholder="请输入客户名称" allow-clear style="width: 160px" />
        </a-form-item>
      </template>
      <template #bodyCell="{ column, record, index }">
        <template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
        <template v-else-if="column.dataIndex === 'action'">
          <a @click="handleSelect(record)">选择</a>
        </template>
      </template>
    </ProTable>
  </a-modal>
</template>

<script setup>
import { ref } from 'vue'
import ProTable from '@/components/ProTable.vue'
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
import { fetchPersonalCustomerListApi } from '@/api/customer'

const emit = defineEmits(['select'])

const open = ref(false)

const columns = [
  { title: '序号', dataIndex: 'index', width: 60 },
  { title: '客户编号', dataIndex: 'customerCode' },
  { title: '客户名称', dataIndex: 'customerName' },
  { title: '证件号码', dataIndex: 'certificateNumber' },
  { title: '手机号码', dataIndex: 'mobilePhone' },
  { title: '操作', dataIndex: 'action', width: 80 }
]

async function loadList(params) {
  const res = await fetchPersonalCustomerListApi(params)
  if (res.data.code === 200) {
    return { list: res.data.data.records || [], total: res.data.data.total || 0 }
  }
  return { list: [], total: 0 }
}

function handleSelect(record) {
  emit('select', record)
  open.value = false
}

function show() {
  open.value = true
}

defineExpose({ show })
</script>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/components/PersonalCustomerPicker.vue
git commit -m "feat: 新增个人客户选择弹窗组件"

Task 8: 个人客户列表页 PersonalList.vue

Files:

  • Create: src/views/customer/personal/PersonalList.vue

Interfaces:

  • Consumes: fetchPersonalCustomerListApi from @/api/customer,fetchOrgListAllApi from @/api/system,ProTable.vue,StatusTag.vue,OrgTreeSelect.vue

  • Produces: 路由跳转 /customer/personal/create/customer/personal/edit?id=/customer/personal/detail?id=(供 Task 9 消费)

  • Step 1: 创建列表页

<!-- src/views/customer/personal/PersonalList.vue -->
<template>
  <div class="personal-customer-list-page">
    <ProTable
      :columns="columns"
      :fetch-data="loadList"
      :row-selection="false"
      :initial-search="{ organizationIdEq: undefined, customerCodeEq: '', customerNameLike: '', certificateNumberEq: '' }"
    >
      <template #search="{ form }">
        <a-form-item label="所属机构">
          <OrgTreeSelect v-model="form.organizationIdEq" style="width: 180px" />
        </a-form-item>
        <a-form-item label="客户编号">
          <a-input v-model:value="form.customerCodeEq" placeholder="请输入客户编号" allow-clear style="width: 160px" />
        </a-form-item>
        <a-form-item label="客户名称">
          <a-input v-model:value="form.customerNameLike" placeholder="请输入客户名称" allow-clear style="width: 160px" />
        </a-form-item>
        <a-form-item label="证件号码">
          <a-input v-model:value="form.certificateNumberEq" placeholder="请输入证件号码" allow-clear style="width: 180px" />
        </a-form-item>
      </template>

      <template #actions>
        <a-button v-permission="'customer-personal:add'" type="primary" @click="goCreate">
          <PlusOutlined /> 新增
        </a-button>
      </template>

      <template #bodyCell="{ column, record, index }">
        <template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
        <template v-else-if="column.dataIndex === 'organizationName'">
          {{ orgNameMap[record.organizationId] || record.organizationId }}
        </template>
        <template v-else-if="column.dataIndex === 'certificateType'">身份证</template>
        <template v-else-if="column.dataIndex === 'ocrStatus'">
          <StatusTag :value="record.ocrStatus" :map="ocrStatusMap" />
        </template>
        <template v-else-if="column.dataIndex === 'action'">
          <a-space>
            <a @click="goDetail(record)">查看</a>
            <a v-permission="'customer-personal:edit'" @click="goEdit(record)">编辑</a>
          </a-space>
        </template>
      </template>
    </ProTable>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { PlusOutlined } from '@ant-design/icons-vue'
import ProTable from '@/components/ProTable.vue'
import StatusTag from '@/components/StatusTag.vue'
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
import { fetchPersonalCustomerListApi } from '@/api/customer'
import { fetchOrgListAllApi } from '@/api/system'

const router = useRouter()
const orgNameMap = ref({})

const ocrStatusMap = {
  PENDING: { text: '待识别', color: 'default' },
  PROCESSING: { text: '识别中', color: 'blue' },
  SUCCESS: { text: '识别成功', color: 'green' },
  FAILED: { text: '识别失败', color: 'red' }
}

const columns = [
  { title: '序号', dataIndex: 'index', width: 60 },
  { title: '客户编号', dataIndex: 'customerCode' },
  { title: '客户名称', dataIndex: 'customerName' },
  { title: '证件类型', dataIndex: 'certificateType' },
  { title: '证件号码', dataIndex: 'certificateNumber' },
  { title: '手机号码', dataIndex: 'mobilePhone' },
  { title: '所属机构', dataIndex: 'organizationName' },
  { title: '资料状态', dataIndex: 'ocrStatus' },
  { title: '操作', dataIndex: 'action', width: 140 }
]

async function loadOrgNameMap() {
  const res = await fetchOrgListAllApi()
  if (res.data.code === 200) {
    orgNameMap.value = Object.fromEntries((res.data.data || []).map((o) => [o.id, o.organizationName]))
  }
}

onMounted(loadOrgNameMap)

async function loadList(params) {
  const res = await fetchPersonalCustomerListApi(params)
  if (res.data.code === 200) {
    return { list: res.data.data.records || [], total: res.data.data.total || 0 }
  }
  return { list: [], total: 0 }
}

function goCreate() {
  router.push({ path: '/customer/personal/create' })
}

function goEdit(record) {
  router.push({ path: '/customer/personal/edit', query: { id: record.id } })
}

function goDetail(record) {
  router.push({ path: '/customer/personal/detail', query: { id: record.id } })
}
</script>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/views/customer/personal/PersonalList.vue
git commit -m "feat: 新增个人客户列表页"

Task 9: 个人客户表单页 PersonalForm.vue

Files:

  • Create: src/views/customer/personal/PersonalForm.vue

Interfaces:

  • Consumes: fetchPersonalCustomerDetailApi/createPersonalCustomerApi/updatePersonalCustomerApi (Task 2)、ChannelSelect.vue(Task 4)、IdCardUpload.vue(Task 5)、mapOcrIdCardResult(Task 3)、idCardValidatorRule/phoneValidatorRule(Task 1 / 已有)

  • Produces: 无(叶子页面),路由 query mode/id

  • Step 1: 创建表单页

<!-- src/views/customer/personal/PersonalForm.vue -->
<template>
  <div class="personal-customer-form-page">
    <a-page-header :title="pageTitle" @back="goBack" />
    <a-card>
      <a-form ref="formRef" layout="vertical" :model="form" :rules="rules" :disabled="isDetail">
        <a-form-item v-if="!isDetail" label="所属渠道" required>
          <ChannelSelect v-model:model-value="channelNo" />
        </a-form-item>

        <a-form-item label="身份证照片" required>
          <a-space size="large">
            <IdCardUpload
              side="front"
              :channel-no="channelNo"
              v-model:file-no="form.idCardFrontFileNo"
              @ocr-result="handleOcrResult"
              @ocr-status="(status) => (ocrStatus = status)"
            />
            <IdCardUpload
              side="back"
              :channel-no="channelNo"
              v-model:file-no="form.idCardBackFileNo"
              @ocr-result="handleOcrResult"
              @ocr-status="(status) => (ocrStatus = status)"
            />
          </a-space>
        </a-form-item>

        <a-form-item label="所属机构" name="organizationId">
          <OrgTreeSelect v-model="form.organizationId" placeholder="请选择所属机构" />
        </a-form-item>
        <a-form-item label="客户名称" name="customerName">
          <a-input v-model:value="form.customerName" placeholder="请输入客户名称(OCR自动填充)" />
        </a-form-item>
        <a-form-item label="证件类型">
          <a-input value="身份证" disabled />
        </a-form-item>
        <a-form-item label="证件号码" name="certificateNumber">
          <a-input v-model:value="form.certificateNumber" placeholder="请输入证件号码(OCR自动填充)" />
        </a-form-item>
        <a-form-item label="出生日期" name="birthDate">
          <a-date-picker v-model:value="birthDateValue" style="width: 100%" value-format="YYYY-MM-DD" />
        </a-form-item>
        <a-form-item label="性别" name="gender">
          <a-select v-model:value="form.gender">
            <a-select-option value="MALE">男</a-select-option>
            <a-select-option value="FEMALE">女</a-select-option>
            <a-select-option value="UNKNOWN">未知</a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="民族" name="ethnicity">
          <a-input v-model:value="form.ethnicity" placeholder="请输入民族(OCR自动填充)" />
        </a-form-item>
        <a-form-item label="证件生效日" name="certificateEffectiveDate">
          <a-date-picker v-model:value="effectiveDateValue" style="width: 100%" value-format="YYYY-MM-DD" />
        </a-form-item>
        <a-form-item label="证件到期日" name="certificateExpiryDate">
          <a-date-picker v-model:value="expiryDateValue" style="width: 100%" value-format="YYYY-MM-DD" />
          <div v-if="isExpired" class="expired-hint">证件已过期,请更换有效证件</div>
        </a-form-item>
        <a-form-item label="签发机关" name="issuingAuthority">
          <a-input v-model:value="form.issuingAuthority" placeholder="请输入签发机关(OCR自动填充)" />
        </a-form-item>
        <a-form-item label="证件地址" name="certificateAddress">
          <a-input v-model:value="form.certificateAddress" placeholder="请输入证件地址(OCR自动填充)" />
        </a-form-item>
        <a-form-item label="职业" name="occupation">
          <a-input v-model:value="form.occupation" placeholder="请输入职业" />
        </a-form-item>
        <a-form-item label="手机号码" name="mobilePhone">
          <a-input v-model:value="form.mobilePhone" placeholder="请输入11位手机号" />
        </a-form-item>
      </a-form>

      <a-space v-if="!isDetail">
        <a-button type="primary" :loading="submitting" @click="handleSubmit">提交</a-button>
        <a-button @click="goBack">取消</a-button>
      </a-space>
    </a-card>
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import ChannelSelect from '@/components/ChannelSelect.vue'
import IdCardUpload from '@/components/IdCardUpload.vue'
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
import {
  fetchPersonalCustomerDetailApi,
  createPersonalCustomerApi,
  updatePersonalCustomerApi
} from '@/api/customer'
import { mapOcrIdCardResult } from '@/utils/ocrMapping'
import { phoneValidatorRule, idCardValidatorRule } from '@/utils/validators'

const route = useRoute()
const router = useRouter()

const mode = computed(() => route.query.mode || 'create')
const isDetail = computed(() => mode.value === 'detail')
const pageTitle = computed(() => ({ create: '新增个人客户', edit: '编辑个人客户', detail: '查看个人客户' }[mode.value]))

const channelNo = ref(undefined)
const ocrStatus = ref('PENDING')
const submitting = ref(false)
const formRef = ref(null)

const form = reactive({
  id: '',
  organizationId: undefined,
  customerName: '',
  certificateNumber: '',
  birthDate: '',
  gender: 'UNKNOWN',
  ethnicity: '',
  certificateEffectiveDate: '',
  certificateExpiryDate: '',
  issuingAuthority: '',
  certificateAddress: '',
  occupation: '',
  mobilePhone: '',
  idCardFrontFileNo: '',
  idCardBackFileNo: ''
})

const birthDateValue = computed({
  get: () => (form.birthDate ? dayjs(form.birthDate) : undefined),
  set: (val) => (form.birthDate = val ? val.format('YYYY-MM-DD') : '')
})
const effectiveDateValue = computed({
  get: () => (form.certificateEffectiveDate ? dayjs(form.certificateEffectiveDate) : undefined),
  set: (val) => (form.certificateEffectiveDate = val ? val.format('YYYY-MM-DD') : '')
})
const expiryDateValue = computed({
  get: () => (form.certificateExpiryDate ? dayjs(form.certificateExpiryDate) : undefined),
  set: (val) => (form.certificateExpiryDate = val ? val.format('YYYY-MM-DD') : '')
})

const isExpired = computed(
  () => !!form.certificateExpiryDate && dayjs(form.certificateExpiryDate).isBefore(dayjs(), 'day')
)

const rules = {
  organizationId: [{ required: true, message: '请选择所属机构' }],
  customerName: [{ required: true, message: '请输入客户名称' }],
  certificateNumber: [idCardValidatorRule()],
  mobilePhone: [phoneValidatorRule()]
}

function handleOcrResult(ocrData) {
  Object.assign(form, mapOcrIdCardResult(ocrData))
}

async function loadDetail(id) {
  const res = await fetchPersonalCustomerDetailApi({ id })
  if (res.data.code === 200) {
    Object.assign(form, res.data.data)
  }
}

onMounted(() => {
  if (route.query.id) {
    loadDetail(route.query.id)
  }
})

function goBack() {
  router.push('/customer/personal')
}

async function handleSubmit() {
  try {
    await formRef.value.validate()
  } catch {
    return
  }
  submitting.value = true
  try {
    const toFullDate = (d) => (d ? `${d} 00:00:00` : undefined)
    const payload = {
      organizationId: form.organizationId,
      customerName: form.customerName,
      certificateType: 'ID_CARD',
      certificateNumber: form.certificateNumber,
      birthDate: toFullDate(form.birthDate),
      gender: form.gender,
      ethnicity: form.ethnicity,
      certificateEffectiveDate: toFullDate(form.certificateEffectiveDate),
      certificateExpiryDate: toFullDate(form.certificateExpiryDate),
      issuingAuthority: form.issuingAuthority,
      mobilePhone: form.mobilePhone,
      certificateAddress: form.certificateAddress,
      idCardFrontFileNo: form.idCardFrontFileNo,
      idCardBackFileNo: form.idCardBackFileNo,
      occupation: form.occupation,
      ocrStatus: ocrStatus.value
    }
    const res =
      mode.value === 'create'
        ? await createPersonalCustomerApi(payload)
        : await updatePersonalCustomerApi({ id: form.id, ...payload })
    if (res.data.code === 200) {
      message.success(mode.value === 'create' ? '新增成功' : '修改成功')
      goBack()
    }
  } finally {
    submitting.value = false
  }
}
</script>

<style scoped>
.expired-hint {
  color: #ff4d4f;
  font-size: 12px;
  margin-top: 4px;
}
</style>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/views/customer/personal/PersonalForm.vue
git commit -m "feat: 新增个人客户新增/编辑/查看表单页"

Task 10: 企业客户列表页 EnterpriseList.vue

Files:

  • Create: src/views/customer/enterprise/EnterpriseList.vue

Interfaces:

  • Consumes: fetchEnterpriseCustomerPageApi from @/api/customer,fetchOrgListAllApi from @/api/system

  • Produces: 路由跳转 /customer/enterprise/create/customer/enterprise/edit?id=/customer/enterprise/detail?id=(供 Task 11 消费)

  • Step 1: 创建列表页

<!-- src/views/customer/enterprise/EnterpriseList.vue -->
<template>
  <div class="enterprise-customer-list-page">
    <ProTable
      :columns="columns"
      :fetch-data="loadList"
      :row-selection="false"
      :initial-search="{ enterpriseName: '', businessLicense: '', mobilePhone: '' }"
    >
      <template #search="{ form }">
        <a-form-item label="企业名称">
          <a-input v-model:value="form.enterpriseName" placeholder="请输入企业名称" allow-clear style="width: 180px" />
        </a-form-item>
        <a-form-item label="营业执照号">
          <a-input v-model:value="form.businessLicense" placeholder="请输入营业执照号" allow-clear style="width: 200px" />
        </a-form-item>
        <a-form-item label="手机号码">
          <a-input v-model:value="form.mobilePhone" placeholder="请输入手机号码" allow-clear style="width: 160px" />
        </a-form-item>
      </template>

      <template #actions>
        <a-button v-permission="'customer-enterprise:add'" type="primary" @click="goCreate">
          <PlusOutlined /> 新增
        </a-button>
      </template>

      <template #bodyCell="{ column, record, index }">
        <template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
        <template v-else-if="column.dataIndex === 'organizationName'">
          {{ orgNameMap[record.organizationId] || record.organizationId }}
        </template>
        <template v-else-if="column.dataIndex === 'ocrStatus'">
          <StatusTag :value="record.ocrStatus" :map="ocrStatusMap" />
        </template>
        <template v-else-if="column.dataIndex === 'action'">
          <a-space>
            <a @click="goDetail(record)">查看</a>
            <a v-permission="'customer-enterprise:edit'" @click="goEdit(record)">编辑</a>
          </a-space>
        </template>
      </template>
    </ProTable>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { PlusOutlined } from '@ant-design/icons-vue'
import ProTable from '@/components/ProTable.vue'
import StatusTag from '@/components/StatusTag.vue'
import { fetchEnterpriseCustomerPageApi } from '@/api/customer'
import { fetchOrgListAllApi } from '@/api/system'

const router = useRouter()
const orgNameMap = ref({})

const ocrStatusMap = {
  PENDING: { text: '待识别', color: 'default' },
  PROCESSING: { text: '识别中', color: 'blue' },
  SUCCESS: { text: '识别成功', color: 'green' },
  FAILED: { text: '识别失败', color: 'red' }
}

const columns = [
  { title: '序号', dataIndex: 'index', width: 60 },
  { title: '客户编号', dataIndex: 'enterpriseCode' },
  { title: '客户名称', dataIndex: 'enterpriseName' },
  { title: '证件号', dataIndex: 'businessLicense' },
  { title: '所属机构', dataIndex: 'organizationName' },
  { title: '资料状态', dataIndex: 'ocrStatus' },
  { title: '创建时间', dataIndex: 'createTime' },
  { title: '操作', dataIndex: 'action', width: 140 }
]

async function loadOrgNameMap() {
  const res = await fetchOrgListAllApi()
  if (res.data.code === 200) {
    orgNameMap.value = Object.fromEntries((res.data.data || []).map((o) => [o.id, o.organizationName]))
  }
}

onMounted(loadOrgNameMap)

async function loadList(params) {
  const res = await fetchEnterpriseCustomerPageApi(params)
  if (res.data.code === 200) {
    return { list: res.data.data.records || [], total: res.data.data.total || 0 }
  }
  return { list: [], total: 0 }
}

function goCreate() {
  router.push({ path: '/customer/enterprise/create' })
}

function goEdit(record) {
  router.push({ path: '/customer/enterprise/edit', query: { id: record.id } })
}

function goDetail(record) {
  router.push({ path: '/customer/enterprise/detail', query: { id: record.id } })
}
</script>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/views/customer/enterprise/EnterpriseList.vue
git commit -m "feat: 新增企业客户列表页"

Task 11: 企业客户表单页 EnterpriseForm.vue

Files:

  • Create: src/views/customer/enterprise/EnterpriseForm.vue

Interfaces:

  • Consumes: fetchEnterpriseCustomerDetailApi/createEnterpriseCustomerApi/updateEnterpriseCustomerApi/fetchPersonalCustomerDetailApi (Task 2)、ChannelSelect.vue(Task 4)、LicenseUpload.vue(Task 6)、PersonalCustomerPicker.vue(Task 7)、businessLicenseValidatorRule/phoneValidatorRule(Task 1)

  • Produces: 无(叶子页面)

  • Step 1: 创建表单页

<!-- src/views/customer/enterprise/EnterpriseForm.vue -->
<template>
  <div class="enterprise-customer-form-page">
    <a-page-header :title="pageTitle" @back="goBack" />
    <a-card>
      <a-form ref="formRef" layout="vertical" :model="form" :rules="rules" :disabled="isDetail">
        <a-form-item v-if="!isDetail" label="所属渠道" required>
          <ChannelSelect v-model:model-value="channelNo" />
        </a-form-item>

        <a-form-item label="营业执照照片">
          <LicenseUpload :channel-no="channelNo" v-model:file-no="form.businessLicenseFileNo" />
          <div v-if="isEdit" class="hint">编辑模式下无法读取历史营业执照图片,如需变更请重新上传</div>
        </a-form-item>

        <a-form-item label="所属机构" name="organizationId">
          <OrgTreeSelect v-model="form.organizationId" placeholder="请选择所属机构" />
        </a-form-item>
        <a-form-item label="企业名称" name="enterpriseName">
          <a-input v-model:value="form.enterpriseName" placeholder="请输入企业名称" />
        </a-form-item>
        <a-form-item label="证件类型">
          <a-input value="营业执照" disabled />
        </a-form-item>
        <a-form-item label="营业执照号" name="businessLicense">
          <a-input v-model:value="form.businessLicense" placeholder="请输入营业执照号" />
        </a-form-item>
        <a-form-item label="证件生效日" name="certificateEffectiveDate">
          <a-date-picker v-model:value="effectiveDateValue" style="width: 100%" value-format="YYYY-MM-DD" />
        </a-form-item>
        <a-form-item label="证件到期日" name="certificateExpiryDate">
          <a-date-picker v-model:value="expiryDateValue" style="width: 100%" value-format="YYYY-MM-DD" />
          <div v-if="isExpired" class="expired-hint">证件已过期,请更换有效证件</div>
        </a-form-item>
        <a-form-item label="手机号码" name="mobilePhone">
          <a-input v-model:value="form.mobilePhone" placeholder="请输入11位手机号" />
        </a-form-item>
        <a-form-item label="证件地址" name="certificateAddress">
          <a-input v-model:value="form.certificateAddress" placeholder="请输入详细地址" />
        </a-form-item>
        <a-form-item label="经营范围" name="businessScope">
          <a-textarea v-model:value="form.businessScope" placeholder="请输入经营范围" :rows="3" />
        </a-form-item>

        <a-form-item label="法定代表人">
          <a-space>
            <span v-if="legalRepName">{{ legalRepName }}</span>
            <a-button v-if="!isDetail" @click="openLegalRepPicker">选择</a-button>
          </a-space>
        </a-form-item>

        <a-form-item v-if="isCreate" label="股东高管信息">
          <a-table :columns="relatedPersonColumns" :data-source="relatedPersonList" :pagination="false" row-key="individualId">
            <template #bodyCell="{ column, record, index }">
              <template v-if="column.dataIndex === 'customerName'">{{ record.customerName }}</template>
              <template v-else-if="column.dataIndex === 'relatedType'">
                <a-select v-model:value="record.relatedType" style="width: 140px">
                  <a-select-option value="SHAREHOLDER">股东</a-select-option>
                  <a-select-option value="EXECUTIVE">高管</a-select-option>
                  <a-select-option value="BENEFICIARY_OWNER">受益所有人</a-select-option>
                </a-select>
              </template>
              <template v-else-if="column.dataIndex === 'certificateNumber'">{{ record.certificateNumber }}</template>
              <template v-else-if="column.dataIndex === 'action'">
                <a @click="removeRelatedPerson(index)">删除</a>
              </template>
            </template>
          </a-table>
          <a-button style="margin-top: 8px" @click="openRelatedPersonPicker">添加股东/高管</a-button>
        </a-form-item>
        <a-form-item v-else label="股东高管信息">
          <div class="hint">股东高管信息仅支持新增时登记,当前接口不支持查询或修改,如需变更请联系技术支持核实数据</div>
        </a-form-item>
      </a-form>

      <a-space v-if="!isDetail">
        <a-button type="primary" :loading="submitting" @click="handleSubmit">提交</a-button>
        <a-button @click="goBack">取消</a-button>
      </a-space>
    </a-card>

    <PersonalCustomerPicker ref="legalRepPickerRef" @select="handleLegalRepSelect" />
    <PersonalCustomerPicker ref="relatedPersonPickerRef" @select="handleRelatedPersonSelect" />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import ChannelSelect from '@/components/ChannelSelect.vue'
import LicenseUpload from '@/components/LicenseUpload.vue'
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
import PersonalCustomerPicker from '@/components/PersonalCustomerPicker.vue'
import {
  fetchEnterpriseCustomerDetailApi,
  createEnterpriseCustomerApi,
  updateEnterpriseCustomerApi
} from '@/api/customer'
import { fetchPersonalCustomerDetailApi } from '@/api/customer'
import { phoneValidatorRule, businessLicenseValidatorRule } from '@/utils/validators'

const route = useRoute()
const router = useRouter()

const mode = computed(() => route.query.mode || 'create')
const isCreate = computed(() => mode.value === 'create')
const isEdit = computed(() => mode.value === 'edit')
const isDetail = computed(() => mode.value === 'detail')
const pageTitle = computed(() => ({ create: '新增企业客户', edit: '编辑企业客户', detail: '查看企业客户' }[mode.value]))

const channelNo = ref(undefined)
const submitting = ref(false)
const formRef = ref(null)
const legalRepName = ref('')
const legalRepPickerRef = ref(null)
const relatedPersonPickerRef = ref(null)
const relatedPersonList = ref([])

const form = reactive({
  id: '',
  organizationId: undefined,
  enterpriseName: '',
  businessLicense: '',
  certificateEffectiveDate: '',
  certificateExpiryDate: '',
  mobilePhone: '',
  certificateAddress: '',
  businessScope: '',
  businessLicenseFileNo: '',
  legalRepresentativeId: undefined
})

const effectiveDateValue = computed({
  get: () => (form.certificateEffectiveDate ? dayjs(form.certificateEffectiveDate) : undefined),
  set: (val) => (form.certificateEffectiveDate = val ? val.format('YYYY-MM-DD') : '')
})
const expiryDateValue = computed({
  get: () => (form.certificateExpiryDate ? dayjs(form.certificateExpiryDate) : undefined),
  set: (val) => (form.certificateExpiryDate = val ? val.format('YYYY-MM-DD') : '')
})
const isExpired = computed(
  () => !!form.certificateExpiryDate && dayjs(form.certificateExpiryDate).isBefore(dayjs(), 'day')
)

const rules = {
  organizationId: [{ required: true, message: '请选择所属机构' }],
  enterpriseName: [{ required: true, message: '请输入企业名称' }],
  businessLicense: [businessLicenseValidatorRule()],
  mobilePhone: [phoneValidatorRule()]
}

const relatedPersonColumns = [
  { title: '客户名称', dataIndex: 'customerName' },
  { title: '关联人类型', dataIndex: 'relatedType', width: 160 },
  { title: '证件号码', dataIndex: 'certificateNumber' },
  { title: '操作', dataIndex: 'action', width: 80 }
]

function openLegalRepPicker() {
  legalRepPickerRef.value.show()
}

function handleLegalRepSelect(record) {
  form.legalRepresentativeId = record.id
  legalRepName.value = record.customerName
}

function openRelatedPersonPicker() {
  relatedPersonPickerRef.value.show()
}

async function handleRelatedPersonSelect(record) {
  // IndividualCustomerListVo 不含证件生效日,需要额外查一次详情补全
  const detailRes = await fetchPersonalCustomerDetailApi({ id: record.id })
  const detail = detailRes.data.code === 200 ? detailRes.data.data : record
  relatedPersonList.value.push({
    individualId: record.id,
    customerName: record.customerName,
    relatedType: 'SHAREHOLDER',
    certificateType: detail.certificateType || 'ID_CARD',
    certificateNumber: detail.certificateNumber,
    certificateEffectiveDate: detail.certificateEffectiveDate,
    certificateExpiryDate: detail.certificateExpiryDate
  })
}

function removeRelatedPerson(index) {
  relatedPersonList.value.splice(index, 1)
}

async function loadDetail(id) {
  const res = await fetchEnterpriseCustomerDetailApi({ id })
  if (res.data.code === 200) {
    Object.assign(form, res.data.data)
    if (res.data.data.legalRepresentativeId) {
      const repRes = await fetchPersonalCustomerDetailApi({ id: res.data.data.legalRepresentativeId })
      if (repRes.data.code === 200) legalRepName.value = repRes.data.data.customerName
    }
  }
}

onMounted(() => {
  if (route.query.id) {
    loadDetail(route.query.id)
  }
})

function goBack() {
  router.push('/customer/enterprise')
}

async function handleSubmit() {
  try {
    await formRef.value.validate()
  } catch {
    return
  }
  submitting.value = true
  try {
    const toFullDate = (d) => (d ? `${d} 00:00:00` : undefined)
    const payload = {
      organizationId: form.organizationId,
      enterpriseName: form.enterpriseName,
      businessLicense: form.businessLicense,
      certificateType: 'BUSINESS_LICENSE',
      certificateNumber: form.businessLicense,
      certificateEffectiveDate: toFullDate(form.certificateEffectiveDate),
      certificateExpiryDate: toFullDate(form.certificateExpiryDate),
      mobilePhone: form.mobilePhone,
      certificateAddress: form.certificateAddress,
      businessScope: form.businessScope,
      businessLicenseFileNo: form.businessLicenseFileNo,
      legalRepresentativeId: form.legalRepresentativeId
    }
    let res
    if (isCreate.value) {
      const enterpriseRelatedPersonBtoList = relatedPersonList.value.map((item) => ({
        individualId: item.individualId,
        relatedType: item.relatedType,
        certificateType: item.certificateType,
        certificateNumber: item.certificateNumber,
        certificateEffectiveDate: toFullDate(item.certificateEffectiveDate),
        certificateExpiryDate: toFullDate(item.certificateExpiryDate)
      }))
      res = await createEnterpriseCustomerApi({
        createEnterpriseCustomerBto: { ...payload, enterpriseRelatedPersonBtoList },
        enableOcr: true
      })
    } else {
      res = await updateEnterpriseCustomerApi({ id: form.id, ...payload })
    }
    if (res.data.code === 200) {
      message.success(isCreate.value ? '新增成功' : '修改成功')
      goBack()
    }
  } finally {
    submitting.value = false
  }
}
</script>

<style scoped>
.expired-hint {
  color: #ff4d4f;
  font-size: 12px;
  margin-top: 4px;
}
.hint {
  color: #999;
  font-size: 12px;
  margin-top: 4px;
}
</style>
  • Step 2: 验证构建

Run: npm run build Expected: 构建成功

  • Step 3: Commit
git add src/views/customer/enterprise/EnterpriseForm.vue
git commit -m "feat: 新增企业客户新增/编辑/查看表单页"

Task 12: 路由注册

Files:

  • Modify: src/router/componentRegistry.js
  • Modify: src/router/dynamic.js

Interfaces:

  • Produces: extraChildRoutes 数组导出(供 dynamic.js 消费),使 /customer/personal/{create,edit,detail}/customer/enterprise/{create,edit,detail} 六个非菜单路由可用

  • Step 1: componentRegistry.js 新增菜单路由映射与非菜单子路由表

// src/router/componentRegistry.js
// routePath -> src/views 下组件相对路径(不含 .vue 扩展名)的手工映射表。
// 权限字典由权限管理页面动态维护,前端页面组件仍需要开发时手工注册到这里才能被路由解析。
export const routePathComponentMap = {
  '/system/user': 'system/user/UserList',
  '/system/role': 'system/role/RoleList',
  '/system/org': 'system/org/OrgList',
  '/system/permission': 'system/permission/PermissionList',
  '/base-config/channel': 'base-config/channel/ChannelList',
  '/base-config/manager': 'base-config/manager/ManagerList',
  '/customer/personal': 'customer/personal/PersonalList',
  '/customer/enterprise': 'customer/enterprise/EnterpriseList'
}

// 新增/编辑/查看等表单页不是后端菜单节点(不出现在侧边栏),但仍需要注册为
// MainLayout 下的可访问路由,由列表页通过 router.push 跳转进入。
export const extraChildRoutes = [
  { path: 'customer/personal/create', name: 'customer-personal-create', component: 'customer/personal/PersonalForm' },
  { path: 'customer/personal/edit', name: 'customer-personal-edit', component: 'customer/personal/PersonalForm' },
  { path: 'customer/personal/detail', name: 'customer-personal-detail', component: 'customer/personal/PersonalForm' },
  { path: 'customer/enterprise/create', name: 'customer-enterprise-create', component: 'customer/enterprise/EnterpriseForm' },
  { path: 'customer/enterprise/edit', name: 'customer-enterprise-edit', component: 'customer/enterprise/EnterpriseForm' },
  { path: 'customer/enterprise/detail', name: 'customer-enterprise-detail', component: 'customer/enterprise/EnterpriseForm' }
]
  • Step 2: dynamic.jsinstallDynamicRoutes 追加非菜单子路由

installDynamicRoutes 函数改为(文件顶部 import 增加 extraChildRoutes):

import MainLayout from '@/layouts/MainLayout.vue'
import { routePathComponentMap, extraChildRoutes } from './componentRegistry'

// ...(resolveComponent / collectFirstPath / buildRouteRecords 保持不变)...

export function installDynamicRoutes(router, menuTree) {
  const children = buildRouteRecords(menuTree)
  extraChildRoutes.forEach((route) => {
    children.push({
      path: route.path,
      name: route.name,
      component: resolveComponent(route.component)
    })
  })
  router.addRoute({
    path: '/',
    component: MainLayout,
    redirect: collectFirstPath(menuTree) || '/403',
    children
  })
}
  • Step 3: 验证构建

Run: npm run build Expected: 构建成功

  • Step 4: Commit
git add src/router/componentRegistry.js src/router/dynamic.js
git commit -m "feat: 注册客户管理列表与表单路由"

Task 13: 缺失后端接口文档更新 + 整体验证

Files:

  • Modify: 缺失的后端接口.md

Interfaces:

  • 无代码接口,仅文档

  • Step 1: 在 缺失的后端接口.md 的 "Part 1:已提供接口契约速查" 追加"客户管理"一节

参照文件现有小节格式(表格形式,列 接口 / 说明),在系统管理/基础配置节之后追加:

### 1.5 客户管理 —— 个人客户

| 接口 | 说明 |
| --- | --- |
| `POST /api/customer-info/personal/list` | `{organizationIdEq,customerCodeEq,customerNameLike,certificateNumberEq,page,pageSize}`,响应 `records: IndividualCustomerListVo[]`(仅 `id,organizationId,customerName,certificateType,certificateNumber,mobilePhone,ocrStatus,customerCode`,不含性别/创建人/创建时间) |
| `POST /api/customer-info/personal/detail` | `{id}``{customerCode}`,响应 `IndividualCustomerDetailVo` |
| `POST /api/customer-info/personal/create` | `CreateIndividualCustomerBto`,响应 `data`=新建id |
| `POST /api/customer-info/personal/update` | `UpdateIndividualCustomerBto` |
| `POST /api/customer-info/personal/ocr-idcard` | `{fileBase64,channelNo,fileType('01'\|'02')}`,响应 `OcrIdCardResultVo` |
| `POST /api/customer-info/personal/upload-file` | `{fileBase64,channelNo}`,响应 `{fileNo}`,个人身份证与企业营业执照图片均调用此通用接口 |

### 1.6 客户管理 —— 企业客户

| 接口 | 说明 |
| --- | --- |
| `POST /api/enterprise-customer/page` | `{page,pageSize,enterpriseName,businessLicense,mobilePhone}`,响应 `records: EnterpriseCustomerVo[]` |
| `POST /api/enterprise-customer/detail` | `{id}`,响应 `EnterpriseCustomerDetailVo` |
| `POST /api/enterprise-customer/create` | `{createEnterpriseCustomerBto,enableOcr}` |
| `POST /api/enterprise-customer/update` | `UpdateEnterpriseCustomerBto` |

在 "Part 2" 追加以下条目(接续现有编号):

- **个人客户/企业客户均无删除接口**,列表页暂不提供删除入口
- **营业执照无同步OCR识别接口**,`enterprise-customer/create` 的 `enableOcr` 参数用途/时序未文档化,前端按"手动填写+提交时告知后端可异步识别"实现
- **企业客户股东高管信息(`enterpriseRelatedPersonBtoList`)只能在新增时一次性提交**,`update`/`detail` 接口均不支持读取或修改,编辑模式下该区块前端替换为提示文案,不可编辑
- **企业客户 `businessLicenseFileNo` 编辑/详情接口读取不到**,编辑模式无法回显已上传的营业执照图片
- **企业客户分页查询不支持按所属机构/企业编号过滤**,仅支持企业名称/营业执照号/手机号三项
- **个人客户 `customerCode`(客户编号)生成机制未文档化**,前端创建时不传该字段,交由后端生成
  • Step 2: Commit 文档
git add "缺失的后端接口.md"
git commit -m "docs: 补充客户管理模块接口契约与能力缺口记录"
  • Step 3: 整体构建验证

Run: npm run build Expected: 构建成功,无报错

  • Step 4: 启动验证

Run: npm run dev(如端口被占用则更换端口重试,不终止占用进程) 手动检查:

  1. 浏览器访问登录页,登录后确认侧边栏出现"客户管理"菜单(需要后端权限管理已配置对应菜单节点,若未配置则此步骤暂时跳过,只验证直接访问路由 /customer/personal/customer/enterprise 不报 404/白屏)
  2. 个人客户列表页:查询区渲染正常,点击"新增"跳转到表单页且无控制台报错
  3. 表单页选择渠道后,点击身份证上传区域触发文件选择框(无需真实调后端验证,只需确认组件不报错)
  4. 企业客户列表/表单页同上检查 Expected: 无 Vue 报错、无 404、页面正常渲染
  • Step 5: 终止开发服务器

确认启动无误后按 Ctrl+C 或结束对应进程,不占用端口

Self-Review 记录

  • Spec 覆盖检查:设计文档第2-7节(接口契约/页面架构/交互设计/校验/业务规则/能力缺口)分别对应 Task 2、Task 8-12、Task 4-7、Task 1、Task 9/11、Task 13,无遗漏
  • 占位符扫描:全文无 TBD/TODO,标注的"以实测为准"均附带具体的防御性实现(如 normalizeOcrDate 解析失败返回空串,不是空实现)
  • 类型一致性:IdCardUpload/LicenseUploadupdate:fileNo 事件与 PersonalForm/EnterpriseFormv-model:file-no 绑定的字段名(idCardFrontFileNo/idCardBackFileNo/businessLicenseFileNo)一致;PersonalCustomerPickerselect 事件 payload 字段(id,customerCode,customerName,certificateType,certificateNumber,mobilePhone)与 EnterpriseForm 消费处字段名一致