Files
shanxia2.0/docs/crm-frontend-migration-guide.md
T
2026-06-01 08:21:21 +08:00

1045 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CRM 前端代码迁移替换指南
> **策略**:拷贝 CRM 原版 Vue 代码 → 机械替换框架差异 → 适配山峡2.0 设计体系
> **源系统**:山峡 CRM(RuoYi-Vue-Plus,`shanxia/crm/web/`)
> **目标系统**:山峡一体化门户(`shanxia2.0/web-admin/`)
> **版本**:v1.0 | 2026-05-30
---
## 目录
- [1. 迁移模块清单与文件映射](#1-迁移模块清单与文件映射)
- [2. 通用替换规则(所有文件必做)](#2-通用替换规则所有文件必做)
- [3. 页面外层布局替换](#3-页面外层布局替换)
- [4. 弹窗/抽屉替换规则](#4-弹窗抽屉替换规则)
- [5. 表单布局替换规则](#5-表单布局替换规则)
- [6. 图片/附件上传替换规则](#6-图片附件上传替换规则)
- [7. 字典/状态展示替换规则](#7-字典状态展示替换规则)
- [8. 分页组件替换规则](#8-分页组件替换规则)
- [9. 工具栏与操作按钮替换规则](#9-工具栏与操作按钮替换规则)
- [10. 消息提示/确认框替换规则](#10-消息提示确认框替换规则)
- [11. 权限指令替换规则](#11-权限指令替换规则)
- [12. API 对接规则](#12-api-对接规则)
- [13. 各模块特殊注意事项](#13-各模块特殊注意事项)
---
## 1. 迁移模块清单与文件映射
### 1.1 产品分类
| CRM 源文件 | 山峡2.0 目标位置 | 说明 |
|---|---|---|
| `crm/product/category/index.vue` (360行) | `views/crm/product-category/index.vue` | 树形 CRUD,弹窗内联 |
> CRM 把新增/编辑弹窗内联在 index.vue 中,**保留内联方式**,删除山峡2.0 原有的 `CategoryFormDialog.vue`。
### 1.2 产品管理
| CRM 源文件 | 山峡2.0 目标位置 | 说明 |
|---|---|---|
| `crm/product/index.vue` (849行) | `views/crm/product/index.vue` | 含表单弹窗+详情弹窗+导入导出,全部内联 |
> 删除山峡2.0 原有的 `ProductFormDialog.vue` 和 `ProductViewDialog.vue`。
### 1.3 客户管理
| CRM 源文件 | 山峡2.0 目标位置 | 说明 |
|---|---|---|
| `crm/customer/index.vue` (793行) | `views/crm/customer/index.vue` | 列表页,含视图切换+快速检索+列设置 |
| `crm/customer/form.vue` (568行) | `views/crm/customer/CustomerFormDialog.vue` | 新增/编辑弹窗 |
| `crm/customer/detail.vue` (1195行) | `views/crm/customer/CustomerDetailDialog.vue` | **详情抽屉→弹窗**,含联系人/跟进/操作日志 tab |
| `crm/customer/transferForm.vue` | `views/crm/customer/TransferDialog.vue` | 转移弹窗 |
| `crm/customer/intoPoolForm.vue` | `views/crm/customer/IntoPoolDialog.vue` | 放入公海弹窗 |
| `crm/customer/followForm.vue` | `views/crm/customer/FollowFormDialog.vue` | 跟进记录弹窗 |
| `crm/customer/dealForm.vue` | `views/crm/customer/DealDialog.vue` | 成交弹窗 |
| `crm/customer/components/contactList.vue` | `views/crm/customer/ContactList.vue` | 联系人列表子组件 |
| `crm/customer/components/contactForm.vue` | `views/crm/customer/ContactFormDialog.vue` | 联系人表单弹窗 |
> **不迁移**:`components/InvoiceInfo.vue`(开票信息)、`statistics/index.vue`(统计分析)、`tag/index.vue`(标签管理)。
### 1.4 公海池
| CRM 源文件 | 山峡2.0 目标位置 | 说明 |
|---|---|---|
| `crm/pool/index.vue` | `views/crm/customer/pool/index.vue` | 公海池列表,含统计+领取+分配 |
| `crm/pool/components/claimDialog.vue` | `views/crm/customer/pool/ClaimDialog.vue` | **新增文件**,认领弹窗 |
| `crm/pool/components/assignDialog.vue` | `views/crm/customer/pool/AssignDialog.vue` | 分配弹窗 |
| — | `views/crm/customer/pool/RuleManager.vue` | 规则管理页,从 CRM 对应文件拷贝替换 |
---
## 2. 通用替换规则(所有文件必做)
以下替换为 **全局机械替换**,适用于所有迁移文件。
### 2.1 移除 `getCurrentInstance` 和 `proxy`
**CRM 写法**:
```vue
<script setup lang="ts">
import { ref, reactive, getCurrentInstance, toRefs } from 'vue';
const { proxy } = getCurrentInstance() as any;
const { crm_customer_type } = toRefs<any>(proxy?.useDict('crm_customer_type'));
</script>
```
**替换为**:
```vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
// 不需要 getCurrentInstance
// 不需要 proxy
</script>
```
### 2.2 字典获取方式
**CRM 写法**:
```ts
import { useDict } from '@/utils/dict';
const { crm_customer_type, crm_customer_level } = toRefs<any>(proxy?.useDict('crm_customer_type', 'crm_customer_level'));
```
**替换为**(山峡2.0 有 `useDictTag` composable,**推荐使用**):
```ts
import { useDictTag } from '@/composables/useDictTag'
// 为每种字典创建一个 composable 实例
const { getLabel: getCustomerTypeLabel, getTagType: getCustomerTypeTagType } = useDictTag('crm_customer_type')
const { getLabel: getCustomerLevelLabel, getTagType: getCustomerLevelTagType } = useDictTag('crm_customer_level')
```
或直接使用 dictStore:
```ts
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
// dictStore.getLabel(dictType, value) 获取标签文本
// dictStore.getTagType(dictType, value) 获取 tag 语义色
// dictStore.getOptions(dictType) 获取选项列表(用于下拉)
```
> **`useDictTag` composable** 位于 `src/composables/useDictTag.ts`,封装了 `getLabel`、`getTagType`、`options` 三个方法。
> **dictStore** 位于 `src/store/modules/dict.ts`,字典数据通过 `fetchAllDictData()` 一次性加载,每个字典项包含 `label`、`value`、`tagType` 三个字段。
### 2.3 `<dict-tag>` → `<el-tag>`
**CRM 写法**:
```vue
<dict-tag :options="crm_customer_type" :value="scope.row.customerType" />
```
**替换为**(使用 `useDictTag`):
```vue
<el-tag :type="getCustomerTypeTagType(scope.row.customerType)" size="small">
{{ getCustomerTypeLabel(scope.row.customerType) }}
</el-tag>
```
> **不需要手写 `getXxxTagType` 映射函数**——山峡2.0 的 `useDictTag` 已内置 `getTagType`,
> tagType 由后端字典数据中的 `tagType` 字段决定(数据库 `sys_dict_data` 表的 `css_class` 字段)。
Tag 语义色规则(来自工程规范):
| 状态含义 | el-tag type |
|---|---|
| 正常/启用/已完成/通过 | `success` |
| 进行中/待确认/已过期 | `warning` |
| 异常/已作废/拒绝 | `danger` |
| 待执行/待生效/停用/默认 | `info` |
| 特殊分类/来源类型 | `primary`(或不设 type,使用默认绿色) |
### 2.4 消息提示
| CRM 写法 | 替换为 |
|---|---|
| `proxy?.$modal.msgSuccess('xxx')` | `ElMessage.success('xxx')` |
| `proxy?.$modal.msgWarning('xxx')` | `ElMessage.warning('xxx')` |
| `proxy?.$modal.msgError('xxx')` | `ElMessage.error('xxx')` |
| `await proxy?.$modal.confirm('xxx')` | `await ElMessageBox.confirm('xxx', '提示', { type: 'warning' })` |
**需要添加 import**:
```ts
import { ElMessage, ElMessageBox } from 'element-plus'
```
### 2.5 移除 RuoYi 工具导入
| CRM 导入 | 处理方式 |
|---|---|
| `import { addDateRange } from '@/utils/ruoyi'` | 删除。日期范围参数改为手动拼接到查询参数 |
| `import { parseTime } from '@/utils/ruoyi'` | 用 `dayjs` 或原生 `Date` 格式化替代 |
| `import { hasPermi } from '@/utils/permission'` | 删除或替换为山峡2.0 权限方案(见第 11 节) |
| `import { scrollTo } from '@/utils/scroll-to'` | 删除(山峡2.0 不需要) |
### 2.6 `el-button` 图标
**CRM 写法**:
```vue
<el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
```
**替换为**(山峡2.0 要求显式 `<el-icon>`):
```vue
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
```
> icon 属性名如 `"Plus"` → `import { Plus } from '@element-plus/icons-vue'` 后用 `<el-icon>` 包裹。
### 2.7 移除搜索动画
**CRM 写法**:
```vue
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
```
**替换为**(移除 transition 包裹,山峡2.0 不使用该动画):
```vue
<!-- 直接显示筛选区域,不使用过渡动画 -->
```
或保留 `v-show` 但去掉 `transition`:
```vue
<div v-show="showSearch">
```
---
## 3. 页面外层布局替换
这是改造量最大的一块。CRM 的页面结构和山峡2.0 完全不同。
### 3.1 CRM 原始结构
```vue
<template>
<div class="p-2">
<!-- 查询表单 -->
<transition ...>
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="客户名称" prop="customerName">
<el-input ... />
</el-form-item>
</el-col>
<!-- 更多筛选条件 -->
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</transition>
<el-card shadow="never">
<template #header>
<div class="module-title mb-4">
<span class="module-title-bar"></span>
<span class="module-title-text">客户管理</span>
</div>
<!-- 工具栏按钮 -->
<el-row :gutter="10" class="mb8">
<el-col :span="4">...</el-col>
<el-col :span="1.5"><el-button ...>新增</el-button></el-col>
</el-row>
<right-toolbar ... />
</template>
<!-- 数据表格 -->
<el-table ... />
<!-- 分页 -->
<pagination ... />
</el-card>
</div>
</template>
```
### 3.2 山峡2.0 目标结构
```vue
<template>
<div class="page-container">
<section class="panel">
<!-- 标题栏 -->
<div class="panel-top">
<h2>客户管理</h2>
</div>
<!-- 筛选区 -->
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="客户名称/编号" clearable @keyup.enter="handleSearch" />
</el-form-item>
<!-- 更多筛选项 -->
</div>
<div class="filter-actions-row">
<span class="filter-hint">按客户名称、编号模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 工具栏 -->
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增客户
</el-button>
<!-- 更多按钮 -->
</div>
</div>
<!-- 数据表格 -->
<div class="list-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe ...>
<!-- 表格列 -->
</el-table>
</div>
<!-- 分页 -->
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</section>
</div>
</template>
```
### 3.3 结构转换对照表
| CRM 元素 | 山峡2.0 对应 | 说明 |
|---|---|---|
| `<div class="p-2">` | `<div class="page-container">` | 最外层容器 |
| `<el-card shadow="hover">` 包裹搜索 | `<div class="filter-block">` | 筛选区,无 el-card 包裹 |
| `<el-form label-width="90px">` | `<el-form class="filter-form">` | label-position 默认,不加 label-width |
| `<el-row>/<el-col>` 栅格 | `<div class="filter-grid">` 内的 `<el-form-item>` | 自动网格布局 |
| 搜索/重置按钮 `<el-col :span="24">` | `<div class="filter-actions-row">` | 操作按钮行 |
| `<el-card shadow="never">` 内容区 | `<section class="panel">` 内直接放内容 | 无 el-card 包裹 |
| `module-title` 标题 | `<div class="panel-top"><h2>标题</h2></div>` | 标题栏 |
| `<el-row :gutter="10">` 工具栏 | `<div class="list-toolbar">` | 工具栏区域 |
| `<right-toolbar>` 组件 | 删除 | 山峡2.0 不使用,工具栏按钮手动排列 |
| `<el-table>` | `<el-table class="list-table">` | 加 list-table class |
| `<pagination>` 组件 | `<el-pagination>` 原生组件 | 见第 8 节 |
---
## 4. 弹窗/抽屉替换规则
### 4.1 客户详情:`el-drawer` → `el-dialog`
**CRM 写法**(`detail.vue`):
```vue
<el-drawer v-model="visible" title="客户详情" direction="rtl" size="80%" destroy-on-close>
<div class="app-container">
<el-row :gutter="20">
<el-col :span="8"><!-- 左侧基本信息 --></el-col>
<el-col :span="16"><!-- 右侧 tab 区域 --></el-col>
</el-row>
</div>
</el-drawer>
```
**替换为**:
```vue
<el-dialog
v-model="innerVisible"
:title="detail.customerName || '客户详情'"
width="960px"
destroy-on-close
align-center
style="backdrop-filter: none"
class="dialog-form-layout"
>
<!-- 内部保持 el-tabs 结构不变 -->
</el-dialog>
```
> **关键变更**:
> - `el-drawer` → `el-dialog`,尺寸从 `80%` 改为固定 `960px`
> - 移除 `direction="rtl"`
> - 左右两栏布局可保留但调整比例
> - 加 `style="backdrop-filter: none"` 禁止毛玻璃(工程规范要求)
### 4.2 表单弹窗
**CRM 写法**(`form.vue` 等):
```vue
<el-dialog :title="dialog.title" v-model="dialog.visible" width="960px" append-to-body>
```
**替换为**:
```vue
<el-dialog
v-model="innerVisible"
:title="isEdit ? '编辑客户' : '新增客户'"
width="960px"
destroy-on-close
align-center
:close-on-click-modal="false"
style="backdrop-filter: none"
class="dialog-form-layout"
>
```
> **变更点**:
> - `dialog.visible` → `innerVisible`(统一命名)
> - 加 `destroy-on-close`、`align-center`、`close-on-click-modal="false"`
> - 加 `style="backdrop-filter: none"`
> - 加 `class="dialog-form-layout"`
> - `append-to-body` 可保留(嵌套弹窗场景需要)
### 4.3 弹窗宽度参考
| 弹窗类型 | CRM 宽度 | 山峡2.0 建议宽度 |
|---|---|---|
| 新增/编辑表单 | 960px | 960px |
| 客户详情 | drawer 80% | 960px(el-dialog) |
| 转移弹窗 | 600px | 600px |
| 成交弹窗 | 600px | 600px |
| 放入公海弹窗 | 600px | 600px |
| 跟进记录弹窗 | 1200px | 960px(缩小) |
| 联系人表单 | 800px | 800px |
| 列设置弹窗 | 600px | 600px |
---
## 5. 表单布局替换规则
### 5.1 CRM 表单布局
```vue
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户简称" prop="shortName">
<el-input v-model="form.shortName" />
</el-form-item>
</el-col>
</el-row>
</el-form>
```
### 5.2 山峡2.0 表单布局
```vue
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="formData.customerName" />
</el-form-item>
<el-form-item label="客户简称" prop="shortName">
<el-input v-model="formData.shortName" />
</el-form-item>
<!-- 全宽字段 -->
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" />
</el-form-item>
</div>
</el-form>
```
### 5.3 表单转换对照
| CRM | 山峡2.0 | 说明 |
|---|---|---|
| `label-width="120px"` | `label-position="top"` | 标签在上方,不设固定宽度 |
| `<el-row :gutter="20"><el-col :span="12">` | `<div class="form-grid-2">` | 自动两列网格 |
| `<el-divider>` 分组 | `<h3>` 或标题文字 | 可保留 divider 作为视觉分隔 |
| `form` 变量名 | `formData` | 统一命名 |
| `rules` 变量名 | `formRules` | 统一命名 |
| 全宽字段 `<el-col :span="24">` | `class="form-col-span-2"` | 跨两列 |
> **注意**:CRM 的 `form.vue` 使用 `el-divider` 分区(基本信息/详细信息/附加信息),这个分区方式可以保留作为视觉分隔,但外层的 `el-row/el-col` 改为 `form-grid-2`。
---
## 6. 图片/附件上传替换规则
### 6.1 CRM 的图片组件
CRM 使用两个自定义组件:
**ImageUpload**(`@/components/ImageUpload/index.vue`):
- Props: `modelValue`(ossId 逗号分隔的字符串)、`limit`、`fileType`、`disabled`
- 功能:上传图片到 OSS,返回 ossId 字符串
- 使用场景:客户表单中的营业执照、经营许可证、其他证件
**ImagePreview**(`@/components/ImagePreview/index.vue`):
- Props: `src`(ossId)、`width`、`height`、`isOssId`
- 功能:根据 ossId 获取图片 URL 并预览
- 使用场景:详情页、跟进记录附件展示
### 6.2 山峡2.0 的附件方案
山峡2.0 使用 `fetchUploadAttachment` API 上传文件:
```ts
import { fetchUploadAttachment } from '@/api/admin'
// 上传文件
const formData = new FormData()
formData.append('file', file)
const result = await fetchUploadAttachment(formData)
// result.fileUrl 为可访问的文件 URL
```
### 6.3 替换方案
**场景 A:图片上传(表单中)**
CRM 写法:
```vue
<ImageUpload v-model="form.businessLicenseImages" :limit="5" :file-type="['jpg', 'jpeg', 'png', 'pdf']" />
```
替换为 — 使用原生 `el-upload` + `fetchUploadAttachment`:
```vue
<el-upload
:file-list="licenseFileList"
list-type="picture-card"
:auto-upload="false"
:on-change="(file) => handleFileUpload(file, 'businessLicenseImages')"
accept=".jpg,.jpeg,.png,.pdf"
:limit="5"
>
<el-icon><Plus /></el-icon>
</el-upload>
```
```ts
async function handleFileUpload(uploadFile: UploadFile, field: string) {
if (!uploadFile.raw) return
const formData = new FormData()
formData.append('file', uploadFile.raw)
const result = await fetchUploadAttachment(formData)
// 将 result.fileUrl 追加到 form[field]
}
```
**场景 B:图片预览(详情页)**
CRM 写法:
```vue
<ImagePreview :src="file.ossId" :isOssId="true" class="upload-thumb-image" />
```
替换为 — 直接用 `el-image`:
```vue
<el-image
:src="file.url || file.fileUrl"
fit="cover"
:preview-src-list="[file.url || file.fileUrl]"
preview-teleported
style="width: 100px; height: 100px"
/>
```
**场景 C:附件上传(跟进记录,支持多文件类型)**
CRM 写法:
```vue
<el-upload drag list-type="picture-card" :action="uploadAction" :headers="uploadHeaders" ...>
```
替换为 — 使用 `el-upload` 的 `http-request` 自定义上传:
```vue
<el-upload
:file-list="attachmentList"
:http-request="handleCustomUpload"
:on-remove="handleRemoveAttachment"
:on-preview="handlePreviewFile"
:limit="15"
multiple
accept="image/*,video/*,.pdf,.doc,.docx,.txt"
>
<el-button type="primary">
<el-icon><Upload /></el-icon>选择文件
</el-button>
<template #tip>
<div class="el-upload__tip">支持图片、视频、PDF、Word等格式,最多15个文件。</div>
</template>
</el-upload>
```
```ts
async function handleCustomUpload(options: UploadRequestOptions) {
const formData = new FormData()
formData.append('file', options.file)
const result = await fetchUploadAttachment(formData)
options.onSuccess(result)
}
```
### 6.4 图片存储字段差异
| CRM | 山峡2.0 | 说明 |
|---|---|---|
| `ossId`(逗号分隔的 OSS ID) | `fileUrl`(完整 URL) | CRM 通过 ossId 调 API 获取 URL,山峡2.0 直接存 URL |
| `listByIds(ossIds)` 获取 URL 列表 | 不需要,URL 直接可用 | 无需额外请求 |
---
## 7. 字典/状态展示替换规则
详见第 2.3 节。这里补充完整的使用场景:
### 7.1 下拉选择中的字典
**CRM 写法**:
```vue
<el-select v-model="queryParams.customerType">
<el-option v-for="dict in crm_customer_type" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
```
**替换为**:
```vue
<el-select v-model="searchForm.customerType" clearable>
<el-option
v-for="opt in dictStore.getDictItems('crm_customer_type')"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</el-select>
```
### 7.2 表格中的字典展示
**CRM 写法**:
```vue
<el-table-column label="客户类型" prop="customerType">
<template #default="scope">
<dict-tag :options="crm_customer_type" :value="scope.row.customerType" />
</template>
</el-table-column>
```
**替换为**:
```vue
<el-table-column label="客户类型" prop="customerType" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="getTagType('crm_customer_type', row.customerType)" size="small">
{{ dictStore.getDictLabel('crm_customer_type', row.customerType) }}
</el-tag>
</template>
</el-table-column>
```
### 7.3 详情页中的字典展示
**CRM 写法**:
```vue
<el-descriptions-item label="客户类型">
<dict-tag :options="crm_customer_type" :value="customerInfo.customerType" />
</el-descriptions-item>
```
**替换为**:
```vue
<el-descriptions-item label="客户类型">
<el-tag :type="getTagType('crm_customer_type', customerInfo.customerType)" size="small">
{{ dictStore.getDictLabel('crm_customer_type', customerInfo.customerType) }}
</el-tag>
</el-descriptions-item>
```
### 7.4 涉及的字典类型清单
| 字典编码 | 说明 | 使用模块 |
|---|---|---|
| `crm_customer_type` | 客户类型 | 客户管理 |
| `crm_customer_level` | 客户级别 | 客户管理、公海池 |
| `crm_clue_source` | 客户来源 | 客户管理、公海池 |
| `crm_customer_status` | 客户状态 | 客户管理、公海池 |
| `crm_deal_status` | 成交状态 | 客户管理、公海池 |
| `crm_industry` | 所属行业 | 客户管理、公海池 |
| `crm_customer_park` | 所在园区 | 客户管理、公海池 |
| `crm_company_scale` | 企业规模 | 客户管理、公海池 |
| `crm_follow_type` | 跟进方式 | 客户管理(跟进记录) |
| `crm_contact_role` | 联系人角色 | 客户管理(联系人) |
| `crm_product_status` | 产品状态 | 产品管理 |
| `crm_product_unit` | 产品单位 | 产品管理 |
| `sys_normal_disable` | 启用/停用 | 产品分类、公海池规则 |
---
## 8. 分页组件替换规则
### 8.1 CRM 写法
```vue
<!-- 模板中 -->
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- script 中 -->
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
// ...
})
```
### 8.2 山峡2.0 写法
```vue
<!-- 模板中 -->
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
<!-- script 中 -->
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
```
### 8.3 数据结构对照
| CRM | 山峡2.0 |
|---|---|
| `queryParams.pageNum` | `pagination.page` |
| `queryParams.pageSize` | `pagination.pageSize` |
| `total` (ref) | `pagination.total` |
| `@pagination` 事件 | `@size-change` + `@current-change` |
### 8.4 API 请求参数对照
| CRM 参数名 | 山峡2.0 参数名 | 说明 |
|---|---|---|
| `pageNum` | `page` | 页码 |
| `pageSize` | `pageSize` | 每页条数(不变) |
---
## 9. 工具栏与操作按钮替换规则
### 9.1 移除 `<right-toolbar>`
CRM 在每个列表页工具栏末尾使用 `<right-toolbar>`,山峡2.0 不使用。
**CRM 写法**:
```vue
<el-row :gutter="10" class="mb8">
<el-col :span="1.5"><el-button ...>新增</el-button></el-col>
<el-col :span="1.5"><el-button ...>修改</el-button></el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
```
**替换为**:
```vue
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<!-- 更多按钮 -->
</div>
</div>
```
### 9.2 按钮 class 规范
| 场景 | class | type |
|---|---|---|
| 主操作(仅一个) | `btn-main` | `primary` |
| 次要操作 | `btn-ghost` | 无 |
| 危险操作 | 无 | `danger` |
### 9.3 视图切换
CRM 客户管理使用 `el-radio-group` + `el-radio-button` 切换视图:
```vue
<el-radio-group v-model="activeView" @change="handleViewChange">
<el-radio-button value="mine">我的客户</el-radio-button>
<el-radio-button value="all">全部客户</el-radio-button>
<el-radio-button value="toFollow">待跟进</el-radio-button>
<el-radio-button value="key">重点客户</el-radio-button>
</el-radio-group>
```
> **保留此组件**。`el-radio-group` + `el-radio-button` 是 Element Plus 原生组件,无需替换。放在 `panel-top` 标题下方即可。
---
## 10. 消息提示/确认框替换规则
详见第 2.4 节。补充完整替换表:
| CRM 方法 | 山峡2.0 替换 |
|---|---|
| `proxy?.$modal.msgSuccess(msg)` | `ElMessage.success(msg)` |
| `proxy?.$modal.msgWarning(msg)` | `ElMessage.warning(msg)` |
| `proxy?.$modal.msgError(msg)` | `ElMessage.error(msg)` |
| `proxy?.$modal.confirm(msg)` | `ElMessageBox.confirm(msg, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })` |
| `proxy?.$modal.confirm(msg).then(() => {...})` | `ElMessageBox.confirm(msg, '提示', { type: 'warning' }).then(() => {...})` |
| `proxy?.$modal.alert(msg)` | `ElMessageBox.alert(msg, '提示')` |
---
## 11. 权限指令替换规则
### 11.1 CRM 权限方式
```ts
import { hasPermi } from '@/utils/permission'
// 模板中
<el-button v-if="hasPermi(['crm:customer:add'])" ...>新增</el-button>
```
### 11.2 山峡2.0 权限方式
山峡2.0 使用 `v-auth` 指令(`src/directives/core/auth.ts`),基于当前路由 `meta.authList` 中的权限标识:
```vue
<!-- 模板中,直接用 v-auth 指令 -->
<el-button v-auth="'add'">新增</el-button>
<el-button v-auth="'edit'">编辑</el-button>
<el-button v-auth="'delete'">删除</el-button>
```
> **注意**:`v-auth` 的权限标识来自路由 `meta.authList[].authMark`,不是 CRM 的 `crm:customer:add` 格式。
> 需要在路由配置中正确设置 `authList`。
### 11.3 替换规则
| CRM 写法 | 山峡2.0 写法 |
|---|---|
| `v-if="hasPermi(['crm:customer:add'])"` | `v-auth="'add'"` |
| `v-if="hasPermi(['crm:customer:edit'])"` | `v-auth="'edit'"` |
| `v-if="hasPermi(['crm:customer:remove'])"` | `v-auth="'delete'"` |
| `v-if="hasPermi(['crm:customer:export'])"` | `v-auth="'export'"` |
| `v-if="hasPermi(['crm:customer:import'])"` | `v-auth="'import'"` |
> 如果路由的 `authList` 暂未配置完整,可以**先移除权限判断**(删除 `v-if` / `v-auth`),后续统一补上。
---
## 12. API 对接规则
### 12.1 API 文件结构
CRM 前端 API 文件位于 `shanxia/crm/web/src/api/crm/`,山峡2.0 位于 `shanxia2.0/web-admin/src/api/crm/`。
**需确认**:山峡2.0 后端(Python FastAPI)的 API 路径和返回格式是否已经就绪。
### 12.2 请求方式对照
| CRM 写法 | 山峡2.0 写法 |
|---|---|
| `import request from '@/utils/request'` | `import request from '@/api/request'` 或 `import { ... } from '@/api/crm/xxx'` |
| `request({ url: '/crm/product/list', method: 'get', params: query })` | 需确认山峡2.0 的 request 封装格式 |
### 12.3 返回格式对照
**CRM(RuoYi)返回格式**:
```json
{
"code": 200,
"msg": "操作成功",
"rows": [...],
"total": 100
}
```
**山峡2.0 返回格式**(需确认,可能为):
```json
{
"code": 200,
"data": {
"list": [...],
"total": 100
}
}
```
> ⚠️ **关键**:API 返回格式的字段名(`rows` vs `list`,顶层 vs `data` 包裹)需要逐个确认。这是最容易出 bug 的地方。
### 12.4 类型定义
CRM 前端有独立的类型文件(如 `api/crm/customer/types.ts`、`api/crm/product/index.ts` 内定义的 interface)。拷贝代码时需要:
1. 将 CRM 的类型定义拷贝到山峡2.0 对应 API 文件中
2. 或在山峡2.0 的 API 文件中重新定义(字段名可能因后端不同而有差异)
---
## 13. 各模块特殊注意事项
### 13.1 产品分类
- CRM 使用 `el-table` 的 `:tree-props` 展示树形结构,**保留**
- CRM 有展开/折叠按钮(`toggleExpandAll`),**保留**
- CRM 使用 `el-dialog` 内联新增/编辑表单,**保留**
- 分类名称输入框使用 `check-strictly` 的 `el-tree-select`,**保留**
- 字典只用到 `sys_normal_disable`(启用/停用)
### 13.2 产品管理
- CRM 内联了表单弹窗 + 详情弹窗 + 导入功能(849行),**保留内联**
- CRM 使用 `ImageUpload` 上传产品图片,**需替换**(见第 6 节)
- CRM 使用 `ImagePreview` 展示产品图片,**需替换**
- CRM 有快速检索框 + 列设置功能,**保留**
- CRM 导入使用原生 `el-upload` + `action` URL,**需改为山峡2.0 的上传方式**
- CRM 详情使用内联 `el-dialog` 展示 `el-descriptions`,**保留**
### 13.3 客户管理
- **视图切换**(我的客户/全部/待跟进/重点):CRM 使用 `el-radio-group` + `el-radio-button`,**保留**
- **快速检索**:CRM 在工具栏中有独立检索输入框,**保留**
- **列设置**:CRM 有自定义的列显示/隐藏配置弹窗(`columnSettingDialog`),**保留**
- **客户详情**:CRM 使用 `el-drawer` 80% 宽度,**改为 el-dialog 960px**(见第 4.1 节)
- **详情内部布局**:CRM 用左右分栏(`el-col :span="8"` + `el-col :span="16"`),改为 `el-dialog` 后可改为上下布局或保持左右但缩小比例
- **详情 Tab 页**:CRM 有"跟进记录"、"联系人"、"操作日志" tab,**全部保留**
- **跟进记录**:
- CRM 有附件上传功能(`el-upload` + `ImagePreview`),**需替换上传方式**
- CRM 使用 `el-timeline` 展示跟进记录,**保留**
- **联系人**:CRM 使用独立的 `contactList.vue` + `contactForm.vue`,**保留**
- **发票信息**:CRM 有独立的 `InvoiceInfo.vue`,**本次不迁移**
### 13.4 公海池
- CRM 有统计信息(`el-statistic` 展示公海池总数、今日新增等),**保留**
- CRM 有认领功能(`claimDialog.vue`),**迁移**(山峡2.0 目前没有认领弹窗)
- CRM 有分配功能(`assignDialog.vue`),山峡2.0 已有,**用 CRM 版本替换**
- CRM 大量使用 `dict-tag`,**全部替换为 el-tag**(见第 7 节)
- CRM 公海池规则管理是独立页面(不在本次 4 个模块内),山峡2.0 已有 `RuleManager.vue`,**用 CRM 版本替换**
### 13.5 公共 CSS 注意事项
CRM 使用的自定义 CSS class 需要检查是否需要迁移:
| CRM CSS class | 说明 | 处理 |
|---|---|---|
| `.module-title` | 模块标题样式 | 山峡2.0 用 `.panel-top > h2`,删除 |
| `.module-title-bar` | 标题前竖线 | 山峡2.0 用 `::before` 伪元素,自动生效 |
| `.quick-search-input` | 快速检索框样式 | 保留,需拷贝对应 CSS |
| `.column-setting-content` | 列设置弹窗样式 | 保留,需拷贝对应 CSS |
| `.upload-thumb-image` | 上传缩略图 | 替换为山峡2.0 的图片展示方式 |
| `.attachment-section` | 附件展示区域 | 保留,需拷贝对应 CSS |
| `.upload-file-card` | 上传文件卡片 | 保留,需拷贝对应 CSS |
| `.certificate-images` | 证件图片区域 | 保留,需拷贝对应 CSS |
| `.preview-error` | 图片加载失败样式 | 保留,需拷贝对应 CSS |
> CRM 文件 `<style>` 部分的自定义 CSS **需要一并拷贝**到山峡2.0 对应文件中。
---
## 附录 A:导入/导出替换
### CRM 的导入导出
```ts
// 导出
import { exportProduct } from '@/api/crm/product'
const blob = await exportProduct(queryParams)
// RuoYi 内部处理下载
// 导入
import { importTemplate } from '@/api/crm/product'
// 使用 el-upload + action URL
```
### 山峡2.0 的导入导出
```ts
// 导出 - 手动下载
const res = await request.post('/api/admin/crm/product/export', searchForm, { responseType: 'blob' })
const url = URL.createObjectURL(new Blob([res]))
const link = document.createElement('a')
link.href = url
link.download = '产品列表.xlsx'
link.click()
// 导入 - 使用 el-upload 的 on-change
const uploadRef = ref()
async function handleUploadChange(file: UploadFile) {
if (!file.raw) return
const formData = new FormData()
formData.append('file', file.raw)
await request.post('/api/admin/crm/product/import', formData)
ElMessage.success('导入成功')
handleSearch()
}
```
---
## 附录 B:执行检查清单
每个文件拷贝后,按此清单逐项检查:
- [ ] 移除 `getCurrentInstance` 和 `proxy` 引用
- [ ] 替换 `proxy?.useDict()` → `useDictStore()`
- [ ] 替换所有 `<dict-tag>` → `<el-tag>`
- [ ] 替换 `proxy?.$modal.msg*` → `ElMessage.*`
- [ ] 替换 `proxy?.$modal.confirm` → `ElMessageBox.confirm`
- [ ] 替换 `el-button icon="Xxx"` → `<el-icon><Xxx /></el-icon>`
- [ ] 移除 `<right-toolbar>`
- [ ] 移除 `<transition>` 搜索动画
- [ ] 移除 RuoYi 工具导入(`addDateRange`, `parseTime`, `hasPermi`)
- [ ] 外层布局改为 `.page-container` > `.panel` 结构
- [ ] 筛选区改为 `.filter-block` + `.filter-grid`
- [ ] 工具栏改为 `.list-toolbar`
- [ ] 弹窗加 `backdrop-filter: none` + `class="dialog-form-layout"`
- [ ] `el-drawer` → `el-dialog`
- [ ] 表单加 `label-position="top"`,改为 `.form-grid-2`
- [ ] 替换 `<pagination>` → `<el-pagination>`
- [ ] 替换 `ImageUpload` / `ImagePreview` → 山峡2.0 上传方案
- [ ] API 路径和返回格式对齐山峡2.0 后端
- [ ] 拷贝 `<style>` 中的自定义 CSS
- [ ] `pnpm dev` 启动验证页面渲染