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

1593 lines
56 KiB
Markdown

# 阶段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.json``dependencies` 里在 `"@ant-design/icons-vue": "^7.0.0"` 后添加一行:
```json
"@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` 函数之后)追加:
```js
// 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**
```bash
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 文件**
```js
// 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**
```bash
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: 创建映射工具**
```js
// 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**
```bash
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: 创建组件**
```vue
<!-- 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**
```bash
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: 创建组件**
```vue
<!-- 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**
```bash
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: 创建组件**
```vue
<!-- 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**
```bash
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.vue`、`OrgTreeSelect.vue`(已有)
- Produces: 模板 ref 暴露 `show()` 方法打开弹窗;选中后 `emit('select', record)`,`record` 字段含 `id,customerCode,customerName,certificateType,certificateNumber,mobilePhone`(供 Task 9 法定代表人选择、Task 11 股东高管选择使用)
- [ ] **Step 1: 创建组件**
```vue
<!-- 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**
```bash
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: 创建列表页**
```vue
<!-- 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**
```bash
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: 创建表单页**
```vue
<!-- 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**
```bash
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: 创建列表页**
```vue
<!-- 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**
```bash
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: 创建表单页**
```vue
<!-- 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**
```bash
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` 新增菜单路由映射与非菜单子路由表**
```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.js` 的 `installDynamicRoutes` 追加非菜单子路由**
`installDynamicRoutes` 函数改为(文件顶部 `import` 增加 `extraChildRoutes`):
```js
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**
```bash
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:已提供接口契约速查" 追加"客户管理"一节**
参照文件现有小节格式(表格形式,列 `接口` / `说明`),在系统管理/基础配置节之后追加:
```markdown
### 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" 追加以下条目(接续现有编号):
```markdown
- **个人客户/企业客户均无删除接口**,列表页暂不提供删除入口
- **营业执照无同步OCR识别接口**,`enterprise-customer/create` 的 `enableOcr` 参数用途/时序未文档化,前端按"手动填写+提交时告知后端可异步识别"实现
- **企业客户股东高管信息(`enterpriseRelatedPersonBtoList`)只能在新增时一次性提交**,`update`/`detail` 接口均不支持读取或修改,编辑模式下该区块前端替换为提示文案,不可编辑
- **企业客户 `businessLicenseFileNo` 编辑/详情接口读取不到**,编辑模式无法回显已上传的营业执照图片
- **企业客户分页查询不支持按所属机构/企业编号过滤**,仅支持企业名称/营业执照号/手机号三项
- **个人客户 `customerCode`(客户编号)生成机制未文档化**,前端创建时不传该字段,交由后端生成
```
- [ ] **Step 2: Commit 文档**
```bash
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`/`LicenseUpload` 的 `update:fileNo` 事件与 `PersonalForm`/`EnterpriseForm` 里 `v-model:file-no` 绑定的字段名(`idCardFrontFileNo`/`idCardBackFileNo`/`businessLicenseFileNo`)一致;`PersonalCustomerPicker` 的 `select` 事件 payload 字段(`id,customerCode,customerName,certificateType,certificateNumber,mobilePhone`)与 `EnterpriseForm` 消费处字段名一致