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:
大壮
2026-05-20 09:46:59 +00:00
parent 6ad14b07dc
commit 3f643ef31f
59 changed files with 11876 additions and 18 deletions

View 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();
}

View 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;
}

View 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();
};

View File

@@ -0,0 +1,12 @@
/**
* 用户信息(简化版,用于选择器)
*/
export interface UserInfo {
userId: number;
userName: string;
nickName: string;
deptName?: string;
phonenumber?: string;
avatar?: string;
status: string;
}

View File

@@ -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>

View 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>

View 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>

View File

@@ -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',