feat: 完成CRM商机和线索管理模块开发
## 新增功能 ### 商机中心 (/opportunity) - Stats统计卡片(商机总数、金额、赢单、转化率) - Pipeline商机管道(阶段Tab:全部/线索/谈判中/方案/赢单) - 商机列表真实数据渲染(来自crm_opportunity表) - 商机卡片详情(经销商、负责人、金额、概率) - Pipeline计数实时更新 ### 线索中心 (/lead) - 线索列表完整功能(CRUD) - 线索详情Drawer(基础信息 + 跟进记录Timeline) - 新建线索(含ERP客户关联、手机号验证) - 添加跟进记录(跟进方式、内容、下次时间) - 分配负责人(用户选择器,显示真实姓名) - 线索转经销商(自动创建商机) - 删除线索(逻辑删除) ## 后端开发 ### 数据库表 - crm_lead(线索表) - crm_lead_follow(线索跟进记录表) - crm_dealer(经销商表) - crm_opportunity(商机表) - crm_opportunity_follow(商机跟进记录表) - 数据字典初始化 ### API接口 - 线索管理:CRUD、详情、跟进、分配、转化 - 商机管理:列表查询 - 用户选择器:员工门户专用API ### 核心功能 - 线索转化自动创建经销商和商机 - 负责人翻译显示真实姓名(修复) - 手机号前后端双重格式验证(修复) ## 前端开发 ### 页面架构改进 - 商机中心:保留原CRM设计风格(Stats + Pipeline) - 线索中心:独立页面(完整线索管理) - 左侧菜单:独立菜单项(商机中心、线索中心) ### API模块 - src/api/crm/:线索和商机API类型定义和调用方法 - src/api/user/:用户选择器API ### 样式设计 - 商机中心:100%保持原CRM Pipeline设计风格 - 使用CSS变量系统(var(--radius-lg), var(--shadow-sm)等) - Pipeline Tab白色圆角设计 - 商机卡片阴影和hover效果 - 头像堆叠显示 ## 配置修改 - Gateway路由:添加CRM模块路由配置 - Gateway路由:添加system模块路由配置 - Aside菜单:拆分商机中心和线索中心 ## Bug修复 - 修复负责人显示手机号问题(UserNameTranslationImpl返回昵称) - 修复手机号格式验证缺失(前后端双重验证) - 修复商机管道设计风格不一致(完整复制原CRM样式) ## 文档 - CRM销售模块详细设计说明书V3.md - CRM线索转化API契约 - CRM线索转化开发计划 - CRM线索转化测试指引 - CRM线索管理测试指引 - CRM商机管理测试指引 - CRM架构改进报告 - CRM Bug修复报告 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
141
hzhub-portal-employee/src/api/crm/index.ts
Normal file
141
hzhub-portal-employee/src/api/crm/index.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* CRM 线索管理模块 API 调用
|
||||
* 服务归属:hzhub-system (端口 8083)
|
||||
* API前缀:/crm/lead(通过 Gateway 路由)
|
||||
*/
|
||||
|
||||
import type {
|
||||
CrmLeadBo,
|
||||
CrmLeadFollowBo,
|
||||
CrmLeadFollowVo,
|
||||
CrmLeadVo,
|
||||
CrmOpportunityBo,
|
||||
CrmOpportunityVo,
|
||||
LeadAssignRequest,
|
||||
LeadConvertRequest,
|
||||
LeadQueryParams,
|
||||
OpportunityQueryParams,
|
||||
R,
|
||||
TableDataInfo,
|
||||
} from './types';
|
||||
|
||||
// 导出类型供外部使用
|
||||
export type {
|
||||
CrmLeadBo,
|
||||
CrmLeadFollowBo,
|
||||
CrmLeadFollowVo,
|
||||
CrmLeadVo,
|
||||
CrmOpportunityBo,
|
||||
CrmOpportunityVo,
|
||||
LeadAssignRequest,
|
||||
LeadConvertRequest,
|
||||
LeadQueryParams,
|
||||
OpportunityQueryParams,
|
||||
R,
|
||||
TableDataInfo,
|
||||
} from './types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 获取线索列表(分页)
|
||||
*/
|
||||
export function getLeadList(params: LeadQueryParams): Promise<TableDataInfo<CrmLeadVo>> {
|
||||
return request.get('/crm/lead/list', params).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取线索详情
|
||||
*/
|
||||
export function getLeadDetail(leadId: number): Promise<R<CrmLeadVo>> {
|
||||
return request.get(`/crm/lead/${leadId}`).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增线索
|
||||
*/
|
||||
export function createLead(data: CrmLeadBo): Promise<R<void>> {
|
||||
return request.post('/crm/lead', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑线索
|
||||
*/
|
||||
export function updateLead(data: CrmLeadBo): Promise<R<void>> {
|
||||
return request.put('/crm/lead', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除线索(支持批量)
|
||||
*/
|
||||
export function deleteLead(leadIds: string): Promise<R<void>> {
|
||||
return request.delete(`/crm/lead/${leadIds}`).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配线索
|
||||
*/
|
||||
export function assignLead(data: LeadAssignRequest): Promise<R<void>> {
|
||||
return request.put('/crm/lead/assign', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取线索跟进记录列表
|
||||
*/
|
||||
export function getLeadFollowRecords(leadId: number): Promise<R<CrmLeadFollowVo[]>> {
|
||||
return request.get(`/crm/lead/follow/${leadId}`).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加线索跟进记录
|
||||
*/
|
||||
export function addLeadFollow(data: CrmLeadFollowBo): Promise<R<void>> {
|
||||
return request.post('/crm/lead/follow', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索转经销商(第二阶段实现)
|
||||
*/
|
||||
export function convertLeadToDealer(data: LeadConvertRequest): Promise<R<void>> {
|
||||
return request.post('/crm/lead/convert', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* ========================================
|
||||
* CRM 商机管理模块 API 调用
|
||||
* ========================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取商机列表(分页)
|
||||
*/
|
||||
export function getOpportunityList(params: OpportunityQueryParams): Promise<TableDataInfo<CrmOpportunityVo>> {
|
||||
return request.get('/crm/opportunity/list', params).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商机详情
|
||||
*/
|
||||
export function getOpportunityDetail(opportunityId: number): Promise<R<CrmOpportunityVo>> {
|
||||
return request.get(`/crm/opportunity/${opportunityId}`).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增商机
|
||||
*/
|
||||
export function createOpportunity(data: CrmOpportunityBo): Promise<R<void>> {
|
||||
return request.post('/crm/opportunity', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑商机
|
||||
*/
|
||||
export function updateOpportunity(data: CrmOpportunityBo): Promise<R<void>> {
|
||||
return request.put('/crm/opportunity', data).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商机(支持批量)
|
||||
*/
|
||||
export function deleteOpportunity(opportunityIds: string): Promise<R<void>> {
|
||||
return request.delete(`/crm/opportunity/${opportunityIds}`).json();
|
||||
}
|
||||
220
hzhub-portal-employee/src/api/crm/types.ts
Normal file
220
hzhub-portal-employee/src/api/crm/types.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* CRM 线索管理模块类型定义
|
||||
* API契约参考:docs/crm-api-contract-v3.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* 线索视图对象(响应)
|
||||
*/
|
||||
export interface CrmLeadVo {
|
||||
leadId: number;
|
||||
tenantId: string;
|
||||
customerCode?: string; // ERP客户编码
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
mobile: string;
|
||||
wechat?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
sourceType?: string;
|
||||
sourceTypeName?: string;
|
||||
activityName?: string;
|
||||
referrerName?: string;
|
||||
industry?: string;
|
||||
industryName?: string;
|
||||
companyScale?: string;
|
||||
storeCount?: number;
|
||||
intentLevel?: string;
|
||||
intentLevelName?: string;
|
||||
aiScore?: number;
|
||||
riskLevel?: string;
|
||||
riskLevelName?: string;
|
||||
ownerUserId?: number;
|
||||
ownerUserName?: string;
|
||||
leadStatus: string;
|
||||
leadStatusName?: string;
|
||||
convertedDealerId?: number;
|
||||
nextFollowTime?: string;
|
||||
remark?: string;
|
||||
createBy: number;
|
||||
createByName?: string;
|
||||
createTime: string;
|
||||
updateBy?: number;
|
||||
updateByName?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索业务对象(请求)
|
||||
*/
|
||||
export interface CrmLeadBo {
|
||||
leadId?: number;
|
||||
customerCode?: string; // ERP客户编码(可选)
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
mobile: string;
|
||||
wechat?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
regionId?: number;
|
||||
sourceType?: string;
|
||||
activityName?: string;
|
||||
referrerName?: string;
|
||||
industry?: string;
|
||||
companyScale?: string;
|
||||
storeCount?: number;
|
||||
ownerUserId?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索查询参数
|
||||
*/
|
||||
export interface LeadQueryParams {
|
||||
companyName?: string; // 公司名称(模糊查询)
|
||||
mobile?: string; // 手机号
|
||||
intentLevel?: string; // AI意向等级
|
||||
riskLevel?: string; // 风险等级
|
||||
ownerUserId?: number; // 负责人ID
|
||||
leadStatus?: string; // 线索状态
|
||||
sourceType?: string; // 来源类型
|
||||
customerCode?: string; // ERP客户编码
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索跟进记录视图对象
|
||||
*/
|
||||
export interface CrmLeadFollowVo {
|
||||
followId: number;
|
||||
leadId: number;
|
||||
followType: string;
|
||||
followTypeName?: string;
|
||||
content: string;
|
||||
aiSummary?: string;
|
||||
nextFollowTime?: string;
|
||||
followUserId: number;
|
||||
followUserName?: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索跟进记录业务对象
|
||||
*/
|
||||
export interface CrmLeadFollowBo {
|
||||
followId?: number;
|
||||
leadId: number;
|
||||
followType: string;
|
||||
content: string;
|
||||
nextFollowTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索分配请求
|
||||
*/
|
||||
export interface LeadAssignRequest {
|
||||
leadId: number;
|
||||
ownerUserId: number; // 新负责人ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 线索转经销商请求(第二阶段)
|
||||
*/
|
||||
export interface LeadConvertRequest {
|
||||
leadId: number;
|
||||
dealerName: string;
|
||||
dealerCode: string;
|
||||
customerCode?: string; // ERP客户编码(可选)
|
||||
signedAt?: string; // 签约时间
|
||||
level?: string; // 经销商等级 (A/B/C)
|
||||
}
|
||||
|
||||
/**
|
||||
* ========================================
|
||||
* CRM 商机管理模块类型定义
|
||||
* ========================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* 商机视图对象(响应)
|
||||
*/
|
||||
export interface CrmOpportunityVo {
|
||||
opportunityId: number;
|
||||
tenantId: string;
|
||||
dealerId: number;
|
||||
dealerName?: string; // 经销商名称(翻译)
|
||||
opportunityName: string;
|
||||
stage: string;
|
||||
stageName?: string; // 阶段名称(翻译)
|
||||
amount?: number; // 商机金额
|
||||
probability?: number; // 成功概率(百分比)
|
||||
expectedCloseDate?: string; // 预计成交日期
|
||||
actualCloseDate?: string; // 实际成交日期
|
||||
ownerUserId?: number;
|
||||
ownerUserName?: string; // 负责人姓名(翻译)
|
||||
productName?: string;
|
||||
description?: string;
|
||||
sourceLeadId?: number; // 来源线索ID
|
||||
status: string;
|
||||
statusName?: string; // 状态名称(翻译)
|
||||
createBy: number;
|
||||
createByName?: string;
|
||||
createTime: string;
|
||||
updateBy?: number;
|
||||
updateByName?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商机业务对象(请求)
|
||||
*/
|
||||
export interface CrmOpportunityBo {
|
||||
opportunityId?: number;
|
||||
dealerId: number; // 经销商ID(必填)
|
||||
opportunityName: string; // 商机名称(必填)
|
||||
stage?: string; // 商机阶段
|
||||
amount?: number; // 商机金额
|
||||
probability?: number; // 成功概率(0-100)
|
||||
expectedCloseDate?: string; // 预计成交日期
|
||||
actualCloseDate?: string; // 实际成交日期
|
||||
ownerUserId?: number; // 负责人
|
||||
productName?: string;
|
||||
description?: string;
|
||||
sourceLeadId?: number; // 来源线索ID
|
||||
status?: string; // 状态
|
||||
}
|
||||
|
||||
/**
|
||||
* 商机查询参数
|
||||
*/
|
||||
export interface OpportunityQueryParams {
|
||||
dealerId?: number; // 经销商ID
|
||||
opportunityName?: string; // 商机名称(模糊查询)
|
||||
stage?: string; // 商机阶段
|
||||
ownerUserId?: number; // 负责人ID
|
||||
status?: string; // 状态
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应格式 R<T>
|
||||
*/
|
||||
export interface R<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应格式 TableDataInfo<T>
|
||||
*/
|
||||
export interface TableDataInfo<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
rows: T[];
|
||||
total: number;
|
||||
}
|
||||
13
hzhub-portal-employee/src/api/user/index.ts
Normal file
13
hzhub-portal-employee/src/api/user/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { UserInfo } from './types';
|
||||
import { get } from '@/utils/request';
|
||||
|
||||
// 导出类型供外部使用
|
||||
export type { UserInfo } from './types';
|
||||
|
||||
/**
|
||||
* 获取员工门户用户选择列表
|
||||
* 用于线索分配、经销商分配等场景
|
||||
*/
|
||||
export const getUserSelectList = (keyword?: string) => {
|
||||
return get<UserInfo[]>('/system/user/portal/select', { keyword }).json();
|
||||
};
|
||||
12
hzhub-portal-employee/src/api/user/types.ts
Normal file
12
hzhub-portal-employee/src/api/user/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 用户信息(简化版,用于选择器)
|
||||
*/
|
||||
export interface UserInfo {
|
||||
userId: number;
|
||||
userName: string;
|
||||
nickName: string;
|
||||
deptName?: string;
|
||||
phonenumber?: string;
|
||||
avatar?: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- Aside 侧边栏 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useDesignStore, useUserStore } from '@/stores';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -50,9 +50,15 @@ const menuGroups = ref<MenuGroup[]>([
|
||||
{
|
||||
label: '业务管理',
|
||||
items: [
|
||||
{ id: 'approval', name: '审批中心', icon: 'Stamp', route: '/approval' },
|
||||
{ id: 'dealer', name: '经销商管理', icon: 'Shop', route: '/dealer' },
|
||||
{ id: 'crm', name: '销售CRM', icon: 'TrendCharts', route: '/crm' },
|
||||
{ id: 'approval', name: '审批中心', icon: 'Stamp', route: '/approval' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '销售CRM',
|
||||
items: [
|
||||
{ id: 'lead', name: '线索中心', icon: 'UserFilled', route: '/lead' },
|
||||
{ id: 'opportunity', name: '商机中心', icon: 'TrendCharts', route: '/opportunity' },
|
||||
{ id: 'dealer', name: '客户管理', icon: 'Shop', route: '/dealer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -77,7 +83,7 @@ watch(
|
||||
(newPath) => {
|
||||
activeMenu.value = newPath;
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 菜单点击
|
||||
@@ -95,9 +101,9 @@ function handleMenuClick(item: MenuItem) {
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect width="28" height="28" rx="8" fill="#1d5af3"/>
|
||||
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
|
||||
<rect width="28" height="28" rx="8" fill="#1d5af3" />
|
||||
<path d="M8 14L12 10L16 14L20 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M8 18L12 14L16 18L20 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
@@ -111,7 +117,9 @@ function handleMenuClick(item: MenuItem) {
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div v-for="group in menuGroups" :key="group.label" class="nav-group">
|
||||
<div v-if="!designStore.isCollapse" class="nav-group-label">{{ group.label }}</div>
|
||||
<div v-if="!designStore.isCollapse" class="nav-group-label">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
@@ -119,7 +127,9 @@ function handleMenuClick(item: MenuItem) {
|
||||
:class="{ active: activeMenu === item.route, collapsed: designStore.isCollapse }"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<el-icon :size="20"><component :is="item.icon" /></el-icon>
|
||||
<el-icon :size="20">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<transition name="fade">
|
||||
<span v-if="!designStore.isCollapse" class="nav-label">{{ item.name }}</span>
|
||||
</transition>
|
||||
@@ -301,4 +311,4 @@ function handleMenuClick(item: MenuItem) {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
807
hzhub-portal-employee/src/pages/lead/index.vue
Normal file
807
hzhub-portal-employee/src/pages/lead/index.vue
Normal file
@@ -0,0 +1,807 @@
|
||||
<script setup lang="ts">
|
||||
import type { CrmLeadBo, CrmLeadFollowBo, CrmLeadFollowVo, CrmLeadVo, LeadConvertRequest } from '@/api/crm';
|
||||
import type { UserInfo } from '@/api/user';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { addLeadFollow, assignLead, convertLeadToDealer, createLead, deleteLead, getLeadFollowRecords, getLeadList } from '@/api/crm';
|
||||
import { getUserSelectList } from '@/api/user';
|
||||
|
||||
// 线索列表数据
|
||||
const leadList = ref<CrmLeadVo[]>([]);
|
||||
const leadLoading = ref(false);
|
||||
const leadTotal = ref(0);
|
||||
|
||||
// 线索筛选参数
|
||||
const leadFilters = ref({
|
||||
keyword: '',
|
||||
intentLevel: '',
|
||||
ownerUserId: '',
|
||||
leadStatus: '',
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
// 线索详情Drawer
|
||||
const showDetailDrawer = ref(false);
|
||||
const currentLead = ref<CrmLeadVo | null>(null);
|
||||
const followRecords = ref<CrmLeadFollowVo[]>([]);
|
||||
|
||||
// 跟进记录Drawer
|
||||
const showFollowDrawer = ref(false);
|
||||
const followForm = ref<CrmLeadFollowBo>({
|
||||
leadId: 0,
|
||||
followType: 'phone',
|
||||
content: '',
|
||||
nextFollowTime: undefined,
|
||||
});
|
||||
|
||||
// 新建线索Dialog
|
||||
const showAddLeadDialog = ref(false);
|
||||
const leadForm = ref<CrmLeadBo>({
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
mobile: '',
|
||||
wechat: '',
|
||||
province: '',
|
||||
city: '',
|
||||
regionId: undefined,
|
||||
sourceType: '',
|
||||
activityName: '',
|
||||
referrerName: '',
|
||||
industry: '',
|
||||
companyScale: '',
|
||||
storeCount: undefined,
|
||||
ownerUserId: undefined,
|
||||
remark: '',
|
||||
});
|
||||
|
||||
// 手机号验证规则
|
||||
const mobileValidator = (value: string) => {
|
||||
if (!value) {
|
||||
return '请输入手机号';
|
||||
}
|
||||
const mobileRegex = /^1[3-9]\d{9}$/;
|
||||
if (!mobileRegex.test(value)) {
|
||||
return '手机号格式不正确';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 分配线索Dialog
|
||||
const showAssignDialog = ref(false);
|
||||
const assignForm = ref({
|
||||
leadId: 0,
|
||||
ownerUserId: 0,
|
||||
});
|
||||
const userList = ref<UserInfo[]>([]);
|
||||
const userLoading = ref(false);
|
||||
|
||||
// 转经销商Dialog
|
||||
const showConvertDialog = ref(false);
|
||||
const convertForm = ref<LeadConvertRequest>({
|
||||
leadId: 0,
|
||||
dealerName: '',
|
||||
dealerCode: '',
|
||||
customerCode: '',
|
||||
signedAt: '',
|
||||
level: 'C',
|
||||
});
|
||||
|
||||
// 加载线索列表
|
||||
async function loadLeads() {
|
||||
leadLoading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
companyName: leadFilters.value.keyword,
|
||||
intentLevel: leadFilters.value.intentLevel,
|
||||
ownerUserId: leadFilters.value.ownerUserId ? Number(leadFilters.value.ownerUserId) : undefined,
|
||||
leadStatus: leadFilters.value.leadStatus,
|
||||
pageNum: leadFilters.value.pageNum,
|
||||
pageSize: leadFilters.value.pageSize,
|
||||
};
|
||||
const res = await getLeadList(params);
|
||||
leadList.value = res.rows || [];
|
||||
leadTotal.value = res.total || 0;
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载线索列表失败');
|
||||
}
|
||||
finally {
|
||||
leadLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号脱敏
|
||||
function maskPhone(phone: string) {
|
||||
if (!phone || phone.length < 7)
|
||||
return phone || '--';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
// AI意向Badge类型
|
||||
function getIntentBadgeType(level: string): 'danger' | 'warning' | 'info' {
|
||||
if (level === 'high')
|
||||
return 'danger';
|
||||
if (level === 'medium')
|
||||
return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
// 查看ERP客户详情
|
||||
function viewErpCustomer(lead: CrmLeadVo) {
|
||||
if (lead.customerCode) {
|
||||
ElMessage.info(`ERP客户编码:${lead.customerCode},可在经销商页面查看详情`);
|
||||
}
|
||||
}
|
||||
|
||||
// 线索详情
|
||||
async function showLeadDetail(lead: CrmLeadVo) {
|
||||
currentLead.value = lead;
|
||||
showDetailDrawer.value = true;
|
||||
await loadFollowRecords(lead.leadId);
|
||||
}
|
||||
|
||||
// 加载跟进记录
|
||||
async function loadFollowRecords(leadId: number) {
|
||||
try {
|
||||
const res = await getLeadFollowRecords(leadId);
|
||||
followRecords.value = res.data || [];
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载跟进记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 打开跟进Drawer
|
||||
function openFollowDrawer(lead: CrmLeadVo) {
|
||||
followForm.value.leadId = lead.leadId;
|
||||
followForm.value.followType = 'phone';
|
||||
followForm.value.content = '';
|
||||
followForm.value.nextFollowTime = undefined;
|
||||
showFollowDrawer.value = true;
|
||||
}
|
||||
|
||||
// 提交跟进记录
|
||||
async function submitFollow() {
|
||||
if (!followForm.value.content) {
|
||||
ElMessage.warning('请输入跟进内容');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await addLeadFollow(followForm.value);
|
||||
ElMessage.success('跟进记录已保存');
|
||||
showFollowDrawer.value = false;
|
||||
await loadLeads();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '保存跟进记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新建线索Dialog
|
||||
function openAddLeadDialog() {
|
||||
leadForm.value = {
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
mobile: '',
|
||||
wechat: '',
|
||||
province: '',
|
||||
city: '',
|
||||
regionId: undefined,
|
||||
sourceType: '',
|
||||
activityName: '',
|
||||
referrerName: '',
|
||||
industry: '',
|
||||
companyScale: '',
|
||||
storeCount: undefined,
|
||||
ownerUserId: undefined,
|
||||
remark: '',
|
||||
};
|
||||
showAddLeadDialog.value = true;
|
||||
}
|
||||
|
||||
// 提交新建线索
|
||||
async function submitLead() {
|
||||
if (!leadForm.value.companyName || !leadForm.value.contactName || !leadForm.value.mobile) {
|
||||
ElMessage.warning('请填写必填信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const mobileError = mobileValidator(leadForm.value.mobile);
|
||||
if (mobileError) {
|
||||
ElMessage.warning(mobileError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createLead(leadForm.value);
|
||||
ElMessage.success('线索创建成功');
|
||||
showAddLeadDialog.value = false;
|
||||
await loadLeads();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '创建线索失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 打开转化Dialog
|
||||
function convertToDealer(lead: CrmLeadVo) {
|
||||
convertForm.value = {
|
||||
leadId: lead.leadId,
|
||||
dealerName: lead.companyName,
|
||||
dealerCode: '',
|
||||
customerCode: lead.customerCode || '',
|
||||
signedAt: '',
|
||||
level: 'C',
|
||||
};
|
||||
showConvertDialog.value = true;
|
||||
}
|
||||
|
||||
// 提交转化
|
||||
async function submitConvert() {
|
||||
if (!convertForm.value.dealerName || !convertForm.value.dealerCode) {
|
||||
ElMessage.warning('请填写经销商名称和编码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await convertLeadToDealer(convertForm.value);
|
||||
ElMessage.success('线索转化成功,已创建经销商');
|
||||
showConvertDialog.value = false;
|
||||
loadLeads();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '转化失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表(用于分配)
|
||||
async function loadUserList(keyword?: string) {
|
||||
userLoading.value = true;
|
||||
try {
|
||||
const res = await getUserSelectList(keyword);
|
||||
userList.value = res.data || [];
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载用户列表失败');
|
||||
}
|
||||
finally {
|
||||
userLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开分配Dialog
|
||||
function openAssignDialog(lead: CrmLeadVo) {
|
||||
assignForm.value.leadId = lead.leadId;
|
||||
assignForm.value.ownerUserId = lead.ownerUserId || 0;
|
||||
showAssignDialog.value = true;
|
||||
loadUserList();
|
||||
}
|
||||
|
||||
// 提交分配
|
||||
async function submitAssign() {
|
||||
if (!assignForm.value.ownerUserId) {
|
||||
ElMessage.warning('请选择负责人');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignLead({ leadId: assignForm.value.leadId, ownerUserId: assignForm.value.ownerUserId });
|
||||
ElMessage.success('分配成功');
|
||||
showAssignDialog.value = false;
|
||||
loadLeads();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '分配失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除线索
|
||||
async function handleDeleteLead(lead: CrmLeadVo) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除线索"${lead.companyName}"吗?删除后无法恢复。`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
await deleteLead(lead.leadId.toString());
|
||||
ElMessage.success('删除成功');
|
||||
loadLeads();
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error?.message || '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLeads();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lead-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">线索中心</h1>
|
||||
<p class="page-desc">管理销售线索,跟进转化</p>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="leads-filter-bar">
|
||||
<el-input
|
||||
v-model="leadFilters.keyword"
|
||||
placeholder="搜索公司、联系人..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="loadLeads"
|
||||
/>
|
||||
<el-select
|
||||
v-model="leadFilters.intentLevel"
|
||||
placeholder="AI意向等级"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="loadLeads"
|
||||
>
|
||||
<el-option label="高意向" value="high" />
|
||||
<el-option label="中意向" value="medium" />
|
||||
<el-option label="低意向" value="low" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="leadFilters.leadStatus"
|
||||
placeholder="线索状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="loadLeads"
|
||||
>
|
||||
<el-option label="新线索" value="new" />
|
||||
<el-option label="跟进中" value="following" />
|
||||
<el-option label="已转化" value="converted" />
|
||||
<el-option label="已作废" value="invalid" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadLeads">
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button type="success" @click="openAddLeadDialog">
|
||||
<el-icon><Plus /></el-icon> 新建线索
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 线索列表表格 -->
|
||||
<el-table v-loading="leadLoading" :data="leadList" stripe style="width: 100%">
|
||||
<el-table-column prop="companyName" label="公司名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="lead-name-cell">
|
||||
<el-tag v-if="row.intentLevel === 'high'" type="danger" effect="plain">
|
||||
{{ row.companyName }}
|
||||
</el-tag>
|
||||
<span v-else>{{ row.companyName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactName" label="联系人" width="100" />
|
||||
<el-table-column prop="mobile" label="手机" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ maskPhone(row.mobile) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customerCode" label="ERP编码" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.customerCode" type="primary" @click="viewErpCustomer(row)">
|
||||
{{ row.customerCode }}
|
||||
</el-link>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="intentLevelName" label="AI意向" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-badge :value="row.aiScore || 0" :type="row.intentLevel ? getIntentBadgeType(row.intentLevel) : 'info'">
|
||||
{{ row.intentLevelName || '--' }}
|
||||
</el-badge>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ownerUserName" label="负责人" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-avatar v-if="row.ownerUserName" :size="32">
|
||||
{{ row.ownerUserName.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="leadStatusName" label="状态" width="100" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showLeadDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="openFollowDrawer(row)">
|
||||
跟进
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.leadStatus !== 'converted'"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@click="openAssignDialog(row)"
|
||||
>
|
||||
分配
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.leadStatus !== 'converted'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="convertToDealer(row)"
|
||||
>
|
||||
转经销商
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDeleteLead(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="leadFilters.pageNum"
|
||||
v-model:page-size="leadFilters.pageSize"
|
||||
:total="leadTotal"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
style="margin-top: 16px; justify-content: flex-end"
|
||||
@current-change="loadLeads"
|
||||
/>
|
||||
|
||||
<!-- 线索详情Drawer -->
|
||||
<el-drawer v-model="showDetailDrawer" title="线索详情" size="50%">
|
||||
<el-descriptions v-if="currentLead" :column="2" border>
|
||||
<el-descriptions-item label="公司名称">
|
||||
{{ currentLead.companyName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">
|
||||
{{ currentLead.contactName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
{{ currentLead.mobile }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="ERP客户编码">
|
||||
<el-link v-if="currentLead.customerCode" type="primary" @click="viewErpCustomer(currentLead)">
|
||||
{{ currentLead.customerCode }}
|
||||
</el-link>
|
||||
<span v-else>--</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">
|
||||
{{ currentLead.sourceTypeName || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="行业">
|
||||
{{ currentLead.industryName || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="门店数">
|
||||
{{ currentLead.storeCount || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="AI意向等级">
|
||||
<el-progress :percentage="currentLead.aiScore || 0" :color="currentLead.intentLevel ? getIntentBadgeType(currentLead.intentLevel) === 'danger' ? '#dc2626' : getIntentBadgeType(currentLead.intentLevel) === 'warning' ? '#f59e0b' : '#0ea5e9' : '#0ea5e9'" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="currentLead.riskLevel === 'high' ? 'danger' : currentLead.riskLevel === 'medium' ? 'warning' : 'info'">
|
||||
{{ currentLead.riskLevelName || '--' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">
|
||||
{{ currentLead.ownerUserName || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
{{ currentLead.leadStatusName || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下次跟进时间">
|
||||
{{ currentLead.nextFollowTime || '--' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ currentLead.createTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ currentLead.remark || '--' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 跟进记录Timeline -->
|
||||
<el-divider content-position="left">
|
||||
跟进记录
|
||||
</el-divider>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="follow in followRecords" :key="follow.followId" :timestamp="follow.createTime" placement="top">
|
||||
<el-card>
|
||||
<div class="follow-header">
|
||||
<span class="follow-type">{{ follow.followTypeName || '--' }}</span>
|
||||
<span class="follow-user">{{ follow.followUserName || '--' }}</span>
|
||||
</div>
|
||||
<div class="follow-content">
|
||||
{{ follow.content }}
|
||||
</div>
|
||||
<div v-if="follow.aiSummary" class="follow-ai-summary">
|
||||
<el-icon><MagicStick /></el-icon> AI摘要:{{ follow.aiSummary }}
|
||||
</div>
|
||||
<div v-if="follow.nextFollowTime" class="follow-next">
|
||||
下次跟进:{{ follow.nextFollowTime }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 跟进记录Drawer -->
|
||||
<el-drawer v-model="showFollowDrawer" title="跟进记录" size="40%">
|
||||
<el-form :model="followForm" label-width="100px">
|
||||
<el-form-item label="跟进方式">
|
||||
<el-select v-model="followForm.followType" placeholder="选择跟进方式">
|
||||
<el-option label="电话" value="phone" />
|
||||
<el-option label="企业微信" value="wecom" />
|
||||
<el-option label="拜访" value="visit" />
|
||||
<el-option label="邮件" value="email" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容">
|
||||
<el-input v-model="followForm.content" type="textarea" :rows="4" placeholder="请输入跟进内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下次跟进时间">
|
||||
<el-date-picker
|
||||
v-model="followForm.nextFollowTime"
|
||||
type="datetime"
|
||||
placeholder="选择下次跟进时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showFollowDrawer = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitFollow">
|
||||
保存跟进
|
||||
</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 新建线索Dialog -->
|
||||
<el-dialog v-model="showAddLeadDialog" title="新建线索" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="leadForm" label-width="100px">
|
||||
<el-form-item label="公司名称" required>
|
||||
<el-input v-model="leadForm.companyName" placeholder="请输入公司名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人" required>
|
||||
<el-input v-model="leadForm.contactName" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" required>
|
||||
<el-input v-model="leadForm.mobile" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信号">
|
||||
<el-input v-model="leadForm.wechat" placeholder="请输入微信号(可选)" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="省份">
|
||||
<el-input v-model="leadForm.province" placeholder="请输入省份" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="leadForm.city" placeholder="请输入城市" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="来源类型">
|
||||
<el-select v-model="leadForm.sourceType" placeholder="选择来源类型" style="width: 100%">
|
||||
<el-option label="活动" value="activity" />
|
||||
<el-option label="推荐" value="referral" />
|
||||
<el-option label="网站" value="website" />
|
||||
<el-option label="展会" value="exhibition" />
|
||||
<el-option label="企业微信" value="wecom" />
|
||||
<el-option label="ERP客户" value="erp" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="leadForm.sourceType === 'activity'" label="活动名称">
|
||||
<el-input v-model="leadForm.activityName" placeholder="请输入活动名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="leadForm.sourceType === 'referral'" label="推荐人">
|
||||
<el-input v-model="leadForm.referrerName" placeholder="请输入推荐人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="行业">
|
||||
<el-input v-model="leadForm.industry" placeholder="请输入行业" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店数">
|
||||
<el-input-number v-model="leadForm.storeCount" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="leadForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddLeadDialog = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitLead">
|
||||
创建线索
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配线索Dialog -->
|
||||
<el-dialog v-model="showAssignDialog" title="分配线索" width="500px" :close-on-click-modal="false">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
选择负责人来跟进此线索
|
||||
</el-alert>
|
||||
|
||||
<el-form :model="assignForm" label-width="100px">
|
||||
<el-form-item label="负责人" required>
|
||||
<el-select
|
||||
v-model="assignForm.ownerUserId"
|
||||
placeholder="请选择负责人"
|
||||
filterable
|
||||
:loading="userLoading"
|
||||
style="width: 100%"
|
||||
@filter-change="(keyword: string) => loadUserList(keyword)"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in userList"
|
||||
:key="user.userId"
|
||||
:label="`${user.nickName}(${user.userName})${user.deptName ? ' - ' + user.deptName : ''}`"
|
||||
:value="user.userId"
|
||||
>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<span>{{ user.nickName }}({{ user.userName }})</span>
|
||||
<span v-if="user.deptName" style="color: #8492a6; font-size: 13px">{{ user.deptName }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showAssignDialog = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitAssign">
|
||||
确认分配
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转经销商Dialog -->
|
||||
<el-dialog v-model="showConvertDialog" title="线索转经销商" width="600px" :close-on-click-modal="false">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
将线索转化为正式经销商,创建经销商档案
|
||||
</el-alert>
|
||||
|
||||
<el-form :model="convertForm" label-width="120px">
|
||||
<el-form-item label="经销商名称" required>
|
||||
<el-input v-model="convertForm.dealerName" placeholder="默认为线索公司名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="经销商编码" required>
|
||||
<el-input v-model="convertForm.dealerCode" placeholder="请输入经销商编码,如DL20260001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ERP客户编码">
|
||||
<el-input v-model="convertForm.customerCode" placeholder="可选,关联ERP客户" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签约时间">
|
||||
<el-date-picker
|
||||
v-model="convertForm.signedAt"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择签约时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="经销商等级">
|
||||
<el-select v-model="convertForm.level" style="width: 100%">
|
||||
<el-option label="A级经销商" value="A" />
|
||||
<el-option label="B级经销商" value="B" />
|
||||
<el-option label="C级经销商(默认)" value="C" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showConvertDialog = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="success" @click="submitConvert">
|
||||
确认转化
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lead-page {
|
||||
padding: 20px;
|
||||
background: #f8f7f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.leads-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lead-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.follow-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.follow-type {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.follow-user {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.follow-content {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.follow-ai-summary {
|
||||
padding: 8px 12px;
|
||||
background: #f8f7f5;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.follow-next {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.leads-filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
494
hzhub-portal-employee/src/pages/opportunity/index.vue
Normal file
494
hzhub-portal-employee/src/pages/opportunity/index.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<script setup lang="ts">
|
||||
import type { CrmOpportunityVo } from '@/api/crm';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { getOpportunityList } from '@/api/crm';
|
||||
|
||||
// Stats
|
||||
const opportunityTotal = ref(0);
|
||||
|
||||
const crmStats = computed(() => [
|
||||
{ label: '商机总数', value: opportunityTotal.value.toLocaleString(), icon: 'TrendCharts' as const, change: '+5.2%', up: true, bg: '#eef5ff', color: '#1d5af3' },
|
||||
{ label: '商机金额', value: '¥4,680万', icon: 'Money' as const, change: '+12.8%', up: true, bg: '#f0fdf4', color: '#16a34a' },
|
||||
{ label: '本月赢单', value: '23', icon: 'Trophy' as const, change: '+15%', up: true, bg: '#fef3c7', color: '#d97706' },
|
||||
{ label: '转化率', value: '32%', icon: 'DataLine' as const, change: '-2.1%', up: false, bg: '#fef2f2', color: '#dc2626' },
|
||||
]);
|
||||
|
||||
// Pipeline
|
||||
const activeStage = ref('all');
|
||||
|
||||
const pipeline = [
|
||||
{ key: 'all', label: '全部', color: '#1d5af3', count: 0 },
|
||||
{ key: 'lead', label: '线索', color: '#94a3b8', count: 0 },
|
||||
{ key: 'negotiation', label: '谈判中', color: '#f59e0b', count: 0 },
|
||||
{ key: 'proposal', label: '方案', color: '#8b5cf6', count: 0 },
|
||||
{ key: 'closing', label: '赢单', color: '#22c55e', count: 0 },
|
||||
];
|
||||
|
||||
// 商机列表数据(真实数据)
|
||||
const opportunityList = ref<CrmOpportunityVo[]>([]);
|
||||
const opportunityLoading = ref(false);
|
||||
|
||||
// 加载商机列表
|
||||
async function loadOpportunities() {
|
||||
opportunityLoading.value = true;
|
||||
try {
|
||||
const res = await getOpportunityList({ pageNum: 1, pageSize: 100 });
|
||||
opportunityList.value = res.rows || [];
|
||||
opportunityTotal.value = res.total || 0;
|
||||
updatePipelineCounts();
|
||||
}
|
||||
catch (error: any) {
|
||||
ElMessage.error(error?.message || '加载商机列表失败');
|
||||
}
|
||||
finally {
|
||||
opportunityLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新pipeline计数
|
||||
function updatePipelineCounts() {
|
||||
pipeline[0].count = opportunityList.value.length;
|
||||
pipeline[1].count = opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === 'lead').length;
|
||||
pipeline[2].count = opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === 'negotiation').length;
|
||||
pipeline[3].count = opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === 'proposal').length;
|
||||
pipeline[4].count = opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === 'closing').length;
|
||||
}
|
||||
|
||||
function getCardsForStage(stage: string) {
|
||||
if (stage === 'all')
|
||||
return opportunityList.value;
|
||||
return opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === stage);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOpportunities();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<!-- Header Stats -->
|
||||
<div class="crm-stats">
|
||||
<div
|
||||
v-for="s in crmStats"
|
||||
:key="s.label"
|
||||
class="crm-stat-card"
|
||||
>
|
||||
<div class="stat-icon-wrap" :style="{ background: s.bg }">
|
||||
<el-icon :size="22" :style="{ color: s.color }">
|
||||
<component :is="s.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-num">{{ s.value }}</span>
|
||||
<span class="stat-lbl">{{ s.label }}</span>
|
||||
</div>
|
||||
<div class="stat-change" :class="s.up ? 'up' : 'down'">
|
||||
<el-icon :size="12">
|
||||
<component :is="s.up ? 'Top' : 'Bottom'" />
|
||||
</el-icon>
|
||||
{{ s.change }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Section -->
|
||||
<div class="pipeline-section">
|
||||
<!-- Tabs & Filters -->
|
||||
<div class="crm-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="pipeline-tabs">
|
||||
<div
|
||||
v-for="stage in pipeline"
|
||||
:key="stage.key"
|
||||
class="pipe-tab"
|
||||
:class="{ active: activeStage === stage.key }"
|
||||
@click="activeStage = stage.key"
|
||||
>
|
||||
<span class="pipe-dot" :style="{ background: stage.color }" />
|
||||
{{ stage.label }}
|
||||
<span class="pipe-count">{{ stage.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-input placeholder="搜索商机..." clearable style="width: 220px" />
|
||||
<el-button type="primary" round>
|
||||
<el-icon><Plus /></el-icon> 新建商机
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline View -->
|
||||
<div v-loading="opportunityLoading" class="pipeline-view">
|
||||
<div v-for="stage in pipeline" :key="stage.key" class="pipeline-col">
|
||||
<div class="col-header">
|
||||
<span class="col-title">
|
||||
<span class="col-dot" :style="{ background: stage.color }" />
|
||||
{{ stage.label }}
|
||||
</span>
|
||||
<span class="col-count">{{ stage.count }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<div v-for="opp in getCardsForStage(stage.key)" :key="opp.opportunityId" class="opportunity-card">
|
||||
<div class="opp-header">
|
||||
<span class="opp-name">{{ opp.opportunityName }}</span>
|
||||
<el-tag v-if="opp.stage === 'closing'" type="success" size="small" effect="plain" round>
|
||||
赢单
|
||||
</el-tag>
|
||||
<el-tag v-else-if="opp.stage === 'lead'" type="info" size="small" effect="plain" round>
|
||||
线索
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="opp-company">
|
||||
{{ opp.dealerName || '经销商' }}
|
||||
</div>
|
||||
<div class="opp-details">
|
||||
<div class="opp-detail">
|
||||
<el-icon :size="13">
|
||||
<User />
|
||||
</el-icon>
|
||||
<span>{{ opp.ownerUserName || '--' }}</span>
|
||||
</div>
|
||||
<div class="opp-detail">
|
||||
<el-icon :size="13">
|
||||
<Calendar />
|
||||
</el-icon>
|
||||
<span>{{ opp.expectedCloseDate || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opp-footer">
|
||||
<span class="opp-amount">{{ opp.amount ? `¥${opp.amount.toLocaleString()}` : '--' }}</span>
|
||||
<div class="opp-progress">
|
||||
<el-progress :percentage="opp.probability || 0" :stroke-width="4" :show-text="false" :color="stage.color" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="opp-avatars">
|
||||
<div v-if="opp.ownerUserName" class="mini-avatar" :style="{ background: '#1d5af3' }">
|
||||
{{ opp.ownerUserName.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-card-placeholder">
|
||||
<el-icon :size="20">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.crm-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.crm-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.crm-stat-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-lbl {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
&.up { color: #16a34a; }
|
||||
&.down { color: #dc2626; }
|
||||
}
|
||||
|
||||
.pipeline-section {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.crm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pipeline-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.pipe-tab {
|
||||
padding: 7px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(29, 90, 243, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pipe-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pipe-count {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
|
||||
.active > .pipe-count { background: rgba(255, 255, 255, 0.2); }
|
||||
}
|
||||
|
||||
.pipeline-view {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.pipeline-col {
|
||||
min-width: 280px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.col-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.col-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.col-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.opportunity-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.opp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.opp-name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.opp-company {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.opp-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.opp-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.opp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.opp-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.opp-progress {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.opp-avatars {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mini-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -6px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.add-card-placeholder {
|
||||
border: 2px dashed #e8e6e1;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
color: #c4c0b8;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: #f8faff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.crm-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.crm-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pipeline-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,21 +33,31 @@ export const layoutRouter: RouteRecordRaw[] = [
|
||||
name: 'dealer',
|
||||
component: () => import('@/pages/dealer/index.vue'),
|
||||
meta: {
|
||||
title: '经销商管理',
|
||||
subtitle: '经销商信息管理',
|
||||
title: '客户管理',
|
||||
subtitle: '客户信息管理',
|
||||
icon: 'Shop',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/crm',
|
||||
name: 'crm',
|
||||
component: () => import('@/pages/crm/index.vue'),
|
||||
path: '/opportunity',
|
||||
name: 'opportunity',
|
||||
component: () => import('@/pages/opportunity/index.vue'),
|
||||
meta: {
|
||||
title: '销售CRM',
|
||||
subtitle: '客户关系管理',
|
||||
title: '商机中心',
|
||||
subtitle: '商机管道管理',
|
||||
icon: 'TrendCharts',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/lead',
|
||||
name: 'lead',
|
||||
component: () => import('@/pages/lead/index.vue'),
|
||||
meta: {
|
||||
title: '线索中心',
|
||||
subtitle: '线索跟进转化',
|
||||
icon: 'UserFilled',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/supply',
|
||||
name: 'supply',
|
||||
|
||||
Reference in New Issue
Block a user