feat: 完善商机管理模块,移除线索自动创建商机逻辑

- 商机管理页面:
  - 实现crmStats真实数据计算(商机总数、金额、本月赢单、转化率)
  - 添加环比变化率计算功能
  - 修复金额字段类型转换问题(字符串转数字)
  - 完善Pipeline View阶段过滤和列显示
  - 添加商机搜索、详情查看、编辑和删除功能

- 后端改造:
  - 移除线索转化自动创建商机的逻辑(业务流程修正)
  - 新增经销商选择器API(/crm/dealer/portal/select)
  - 实现经销商列表查询接口

- 新增50条商机测试数据SQL脚本

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-21 09:47:40 +00:00
parent 3f643ef31f
commit d42ad5e1e1
8 changed files with 888 additions and 30 deletions

View File

@@ -5,6 +5,7 @@
*/
import type {
CrmDealerVo,
CrmLeadBo,
CrmLeadFollowBo,
CrmLeadFollowVo,
@@ -21,6 +22,7 @@ import type {
// 导出类型供外部使用
export type {
CrmDealerVo,
CrmLeadBo,
CrmLeadFollowBo,
CrmLeadFollowVo,
@@ -139,3 +141,17 @@ export function updateOpportunity(data: CrmOpportunityBo): Promise<R<void>> {
export function deleteOpportunity(opportunityIds: string): Promise<R<void>> {
return request.delete(`/crm/opportunity/${opportunityIds}`).json();
}
/**
* ========================================
* CRM 经销商管理模块 API 调用
* ========================================
*/
/**
* 获取经销商选择器列表
* 用于商机创建、商机分配等场景
*/
export function getDealerSelectList(keyword?: string): Promise<R<CrmDealerVo[]>> {
return request.get('/crm/dealer/portal/select', { keyword }).json();
}

View File

@@ -217,4 +217,47 @@ export interface TableDataInfo<T> {
msg: string;
rows: T[];
total: number;
}
/**
* ========================================
* CRM 经销商管理模块类型定义
* ========================================
*/
/**
* 经销商视图对象(响应)
*/
export interface CrmDealerVo {
dealerId: number;
tenantId: string;
customerCode?: string; // ERP客户编码
dealerName: string; // 经销商名称
dealerCode: string; // 经销商编码
contactName?: string; // 联系人
mobile?: string; // 手机
province?: string; // 省
city?: string; // 市
level?: string; // 等级A/B/C
levelName?: string; // 等级名称(翻译)
lifecycle?: string; // 生命周期
lifecycleName?: string; // 生命周期名称(翻译)
signedAt?: string; // 签约时间
storeCount?: number; // 门店数
teamSize?: number; // 团队规模
totalOrderAmount?: number; // 累计订单金额
totalPaymentAmount?: number; // 累计回款金额
activityScore?: number; // 活跃评分
riskScore?: number; // 风险评分
ownerUserId?: number; // 负责人
ownerUserName?: string; // 负责人姓名(翻译)
sourceLeadId?: number; // 来源线索ID
status?: string; // 状态
statusName?: string; // 状态名称(翻译)
createBy: number;
createByName?: string;
createTime: string;
updateBy?: number;
updateByName?: string;
updateTime?: string;
}

View File

@@ -1,18 +1,125 @@
<script setup lang="ts">
import type { CrmOpportunityVo } from '@/api/crm';
import { ElMessage } from 'element-plus';
import type { CrmDealerVo, CrmOpportunityBo, CrmOpportunityVo } from '@/api/crm';
import type { UserInfo } from '@/api/user';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { getOpportunityList } from '@/api/crm';
import { createOpportunity, deleteOpportunity, getDealerSelectList, getOpportunityList, updateOpportunity } from '@/api/crm';
import { getUserSelectList } from '@/api/user';
// 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' },
]);
// Stats - 使用真实数据计算
const crmStats = computed(() => {
// 商机总数
const total = opportunityList.value.length;
// 商机金额总和amount可能返回字符串需转换为数字
const totalAmount = opportunityList.value.reduce((sum, o) => {
const amount = typeof o.amount === 'string' ? parseFloat(o.amount) : (o.amount || 0);
return sum + amount;
}, 0);
// 本月赢单数量closing阶段
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1;
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
// 本月赢单
const monthlyWins = opportunityList.value.filter((o) => {
if (o.stage !== 'closing') return false;
const createTime = new Date(o.createTime);
return createTime.getMonth() === currentMonth && createTime.getFullYear() === currentYear;
}).length;
// 上月赢单(用于计算环比)
const lastMonthWins = opportunityList.value.filter((o) => {
if (o.stage !== 'closing') return false;
const createTime = new Date(o.createTime);
return createTime.getMonth() === lastMonth && createTime.getFullYear() === lastMonthYear;
}).length;
// 本月新增商机
const monthlyNewOpps = opportunityList.value.filter((o) => {
const createTime = new Date(o.createTime);
return createTime.getMonth() === currentMonth && createTime.getFullYear() === currentYear;
}).length;
// 上月新增商机(用于计算环比)
const lastMonthNewOpps = opportunityList.value.filter((o) => {
const createTime = new Date(o.createTime);
return createTime.getMonth() === lastMonth && createTime.getFullYear() === lastMonthYear;
}).length;
// 本月赢单金额
const monthlyWinAmount = opportunityList.value.filter((o) => {
if (o.stage !== 'closing') return false;
const createTime = new Date(o.createTime);
return createTime.getMonth() === currentMonth && createTime.getFullYear() === currentYear;
}).reduce((sum, o) => {
const amount = typeof o.amount === 'string' ? parseFloat(o.amount) : (o.amount || 0);
return sum + amount;
}, 0);
// 上月赢单金额
const lastMonthWinAmount = opportunityList.value.filter((o) => {
if (o.stage !== 'closing') return false;
const createTime = new Date(o.createTime);
return createTime.getMonth() === lastMonth && createTime.getFullYear() === lastMonthYear;
}).reduce((sum, o) => {
const amount = typeof o.amount === 'string' ? parseFloat(o.amount) : (o.amount || 0);
return sum + amount;
}, 0);
// 转化率closing阶段占比
const winCount = opportunityList.value.filter(o => o.stage === 'closing').length;
const conversionRate = total > 0 ? Math.round((winCount / total) * 100) : 0;
// 计算环比变化率
function calcChangeRate(current: number, previous: number): { rate: string, up: boolean } {
if (previous === 0) {
// 上月无数据时,显示新增状态
return { rate: current > 0 ? '+新增' : '0%', up: current > 0 };
}
const change = ((current - previous) / previous) * 100;
const sign = change >= 0 ? '+' : '';
return { rate: `${sign}${change.toFixed(1)}%`, up: change >= 0 };
}
// 商机总数变化(本月新增 vs 上月新增)
const totalChange = calcChangeRate(monthlyNewOpps, lastMonthNewOpps);
// 商机金额变化(本月赢单金额 vs 上月赢单金额)
const amountChange = calcChangeRate(monthlyWinAmount, lastMonthWinAmount);
// 本月赢单变化(本月赢单 vs 上月赢单)
const winsChange = calcChangeRate(monthlyWins, lastMonthWins);
// 转化率变化基于closing阶段本月vs上月占比
const currentConversionBase = monthlyNewOpps > 0 ? Math.round((monthlyWins / monthlyNewOpps) * 100) : 0;
const lastConversionBase = lastMonthNewOpps > 0 ? Math.round((lastMonthWins / lastMonthNewOpps) * 100) : 0;
const conversionChange = calcChangeRate(currentConversionBase, lastConversionBase);
return [
{ label: '商机总数', value: total.toString(), icon: 'TrendCharts' as const, change: totalChange.rate, up: totalChange.up, bg: '#eef5ff', color: '#1d5af3' },
{ label: '商机金额', value: formatAmount(totalAmount), icon: 'Money' as const, change: amountChange.rate, up: amountChange.up, bg: '#f0fdf4', color: '#16a34a' },
{ label: '本月赢单', value: monthlyWins.toString(), icon: 'Trophy' as const, change: winsChange.rate, up: winsChange.up, bg: '#fef3c7', color: '#d97706' },
{ label: '转化率', value: `${conversionRate}%`, icon: 'DataLine' as const, change: conversionChange.rate, up: conversionChange.up, bg: '#fef2f2', color: '#dc2626' },
];
});
// 格式化金额显示
function formatAmount(amount: number): string {
if (amount >= 100000000) {
return `¥${(amount / 100000000).toFixed(2)}亿`;
}
if (amount >= 10000) {
return `¥${(amount / 10000).toFixed(2)}`;
}
return `¥${amount.toLocaleString()}`;
}
// Pipeline
const activeStage = ref('all');
@@ -29,6 +136,9 @@ const pipeline = [
const opportunityList = ref<CrmOpportunityVo[]>([]);
const opportunityLoading = ref(false);
// 搜索关键词
const searchKeyword = ref('');
// 加载商机列表
async function loadOpportunities() {
opportunityLoading.value = true;
@@ -56,9 +166,238 @@ function updatePipelineCounts() {
}
function getCardsForStage(stage: string) {
if (stage === 'all')
return opportunityList.value;
return opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === stage);
// 先按阶段过滤
let cards = stage === 'all'
? opportunityList.value
: opportunityList.value.filter((o: CrmOpportunityVo) => o.stage === stage);
// 再按搜索关键词过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
cards = cards.filter((o: CrmOpportunityVo) =>
o.opportunityName?.toLowerCase().includes(keyword)
|| o.dealerName?.toLowerCase().includes(keyword)
|| o.ownerUserName?.toLowerCase().includes(keyword)
|| o.productName?.toLowerCase().includes(keyword),
);
}
return cards;
}
// 根据activeStage过滤显示的Pipeline列
const filteredPipeline = computed(() => {
// 实际阶段列(排除"全部"虚拟列)
const actualStages = pipeline.filter(s => s.key !== 'all');
if (activeStage.value === 'all') {
// 全部Tab显示所有实际阶段列线索、谈判中、方案、赢单
return actualStages;
}
// 只显示选中阶段的列
return actualStages.filter(s => s.key === activeStage.value);
});
// ============== 新建商机模块 ==============
const showAddOpportunityDialog = ref(false);
const opportunityForm = ref<CrmOpportunityBo>({
dealerId: 0,
opportunityName: '',
stage: 'lead',
amount: 0,
probability: 10,
expectedCloseDate: '',
ownerUserId: 0,
productName: '',
description: '',
});
const dealerList = ref<CrmDealerVo[]>([]);
const dealerLoading = ref(false);
const userList = ref<UserInfo[]>([]);
const userLoading = ref(false);
// 打开新建商机Dialog
function openAddOpportunityDialog(stage?: string) {
// 根据点击位置自动设置商机阶段
// 如果在特定阶段的列中点击,自动设置该阶段
// 如果在全部列或toolbar按钮点击默认设置为"线索"
const defaultStage = stage && stage !== 'all' ? stage : 'lead';
opportunityForm.value = {
dealerId: 0,
opportunityName: '',
stage: defaultStage,
amount: 0,
probability: getProbabilityByStage(defaultStage),
expectedCloseDate: '',
ownerUserId: 0,
productName: '',
description: '',
};
showAddOpportunityDialog.value = true;
loadDealerList();
loadUserList();
}
// 根据阶段获取默认概率
function getProbabilityByStage(stage: string): number {
const probabilityMap: Record<string, number> = {
lead: 10,
negotiation: 30,
proposal: 50,
closing: 90,
};
return probabilityMap[stage] || 10;
}
// 加载经销商列表
async function loadDealerList(keyword?: string) {
dealerLoading.value = true;
try {
const res = await getDealerSelectList(keyword);
dealerList.value = res.data || [];
}
catch (error: any) {
ElMessage.error(error?.message || '加载经销商列表失败');
}
finally {
dealerLoading.value = false;
}
}
// 加载用户列表
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;
}
}
// 提交新建商机
async function submitOpportunity() {
if (!opportunityForm.value.dealerId) {
ElMessage.warning('请选择经销商');
return;
}
if (!opportunityForm.value.opportunityName) {
ElMessage.warning('请输入商机名称');
return;
}
try {
await createOpportunity(opportunityForm.value);
ElMessage.success('商机创建成功');
showAddOpportunityDialog.value = false;
loadOpportunities();
}
catch (error: any) {
ElMessage.error(error?.message || '创建商机失败');
}
}
// ============== 商机详情模块 ==============
const showDetailDialog = ref(false);
const currentOpportunity = ref<CrmOpportunityVo | null>(null);
// 打开商机详情
function openOpportunityDetail(opp: CrmOpportunityVo) {
currentOpportunity.value = opp;
showDetailDialog.value = true;
}
// ============== 商机编辑模块 ==============
const showEditDialog = ref(false);
const editForm = ref<CrmOpportunityBo>({
opportunityId: 0,
dealerId: 0,
opportunityName: '',
stage: '',
amount: 0,
probability: 0,
expectedCloseDate: '',
ownerUserId: 0,
productName: '',
description: '',
});
// 打开编辑Dialog
function openEditDialog() {
if (!currentOpportunity.value)
return;
editForm.value = {
opportunityId: currentOpportunity.value.opportunityId,
dealerId: currentOpportunity.value.dealerId,
opportunityName: currentOpportunity.value.opportunityName,
stage: currentOpportunity.value.stage,
amount: Number(currentOpportunity.value.amount) || 0,
probability: currentOpportunity.value.probability || 0,
expectedCloseDate: currentOpportunity.value.expectedCloseDate || '',
ownerUserId: currentOpportunity.value.ownerUserId || 0,
productName: currentOpportunity.value.productName || '',
description: currentOpportunity.value.description || '',
};
showDetailDialog.value = false;
showEditDialog.value = true;
loadDealerList();
loadUserList();
}
// 提交编辑
async function submitEdit() {
if (!editForm.value.opportunityName) {
ElMessage.warning('请输入商机名称');
return;
}
try {
await updateOpportunity(editForm.value);
ElMessage.success('商机更新成功');
showEditDialog.value = false;
loadOpportunities();
}
catch (error: any) {
ElMessage.error(error?.message || '更新商机失败');
}
}
// ============== 商机删除模块 ==============
async function handleDeleteOpportunity() {
if (!currentOpportunity.value)
return;
try {
await ElMessageBox.confirm(
`确定要删除商机"${currentOpportunity.value.opportunityName}"吗?删除后无法恢复。`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
);
await deleteOpportunity(currentOpportunity.value.opportunityId.toString());
ElMessage.success('商机删除成功');
showDetailDialog.value = false;
loadOpportunities();
}
catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '删除商机失败');
}
}
}
onMounted(() => {
@@ -113,8 +452,15 @@ onMounted(() => {
</div>
</div>
<div class="toolbar-right">
<el-input placeholder="搜索商机..." clearable style="width: 220px" />
<el-button type="primary" round>
<el-input
v-model="searchKeyword"
placeholder="搜索商机..."
clearable
style="width: 220px"
@keyup.enter="() => {}"
@clear="() => {}"
/>
<el-button type="primary" round @click="() => openAddOpportunityDialog()">
<el-icon><Plus /></el-icon> 新建商机
</el-button>
</div>
@@ -122,7 +468,12 @@ onMounted(() => {
<!-- Pipeline View -->
<div v-loading="opportunityLoading" class="pipeline-view">
<div v-for="stage in pipeline" :key="stage.key" class="pipeline-col">
<!-- 根据activeStage过滤显示的列 -->
<div
v-for="stage in filteredPipeline"
:key="stage.key"
class="pipeline-col"
>
<div class="col-header">
<span class="col-title">
<span class="col-dot" :style="{ background: stage.color }" />
@@ -131,7 +482,7 @@ onMounted(() => {
<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 v-for="opp in getCardsForStage(stage.key)" :key="opp.opportunityId" class="opportunity-card" @click="openOpportunityDetail(opp)">
<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>
@@ -159,7 +510,7 @@ onMounted(() => {
</div>
</div>
<div class="opp-footer">
<span class="opp-amount">{{ opp.amount ? `¥${opp.amount.toLocaleString()}` : '--' }}</span>
<span class="opp-amount">{{ opp.amount ? `¥${Number(opp.amount).toLocaleString()}` : '--' }}</span>
<div class="opp-progress">
<el-progress :percentage="opp.probability || 0" :stroke-width="4" :show-text="false" :color="stage.color" />
</div>
@@ -170,7 +521,7 @@ onMounted(() => {
</div>
</div>
</div>
<div class="add-card-placeholder">
<div class="add-card-placeholder" @click="() => openAddOpportunityDialog(stage.key)">
<el-icon :size="20">
<Plus />
</el-icon>
@@ -179,6 +530,277 @@ onMounted(() => {
</div>
</div>
</div>
<!-- 新建商机Dialog -->
<el-dialog v-model="showAddOpportunityDialog" title="新建商机" width="600px" :close-on-click-modal="false">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
为经销商创建商机记录具体的销售机会
</el-alert>
<el-form :model="opportunityForm" label-width="120px">
<el-form-item label="经销商" required>
<el-select
v-model="opportunityForm.dealerId"
placeholder="请选择经销商"
filterable
:loading="dealerLoading"
style="width: 100%"
@filter-change="(keyword: string) => loadDealerList(keyword)"
>
<el-option
v-for="dealer in dealerList"
:key="dealer.dealerId"
:label="`${dealer.dealerName}${dealer.dealerCode}`"
:value="dealer.dealerId"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ dealer.dealerName }}</span>
<span style="color: #8492a6; font-size: 13px">{{ dealer.dealerCode }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="商机名称" required>
<el-input v-model="opportunityForm.opportunityName" placeholder="请输入商机名称首批进货订单" />
</el-form-item>
<el-form-item label="商机阶段">
<el-select v-model="opportunityForm.stage" style="width: 100%">
<el-option label="线索" value="lead" />
<el-option label="谈判中" value="negotiation" />
<el-option label="方案" value="proposal" />
<el-option label="赢单" value="closing" />
</el-select>
</el-form-item>
<el-form-item label="商机金额">
<el-input-number v-model="opportunityForm.amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="成功概率">
<el-slider v-model="opportunityForm.probability" :min="0" :max="100" :step="5" show-input />
</el-form-item>
<el-form-item label="预计成交日期">
<el-date-picker
v-model="opportunityForm.expectedCloseDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择预计成交日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="负责人">
<el-select
v-model="opportunityForm.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}`"
:value="user.userId"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ user.nickName }}</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-item label="产品名称">
<el-input v-model="opportunityForm.productName" placeholder="请输入产品名称可选" />
</el-form-item>
<el-form-item label="商机描述">
<el-input v-model="opportunityForm.description" type="textarea" :rows="3" placeholder="请输入商机描述可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddOpportunityDialog = false">
取消
</el-button>
<el-button type="primary" @click="submitOpportunity">
创建商机
</el-button>
</template>
</el-dialog>
<!-- 商机详情Dialog -->
<el-dialog v-model="showDetailDialog" title="商机详情" width="600px" :close-on-click-modal="false">
<el-form label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机名称">
<span>{{ currentOpportunity?.opportunityName || '--' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经销商">
<span>{{ currentOpportunity?.dealerName || '--' }}</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机阶段">
<el-tag :type="currentOpportunity?.stage === 'closing' ? 'success' : currentOpportunity?.stage === 'lead' ? 'info' : 'warning'" size="small">
{{ currentOpportunity?.stageName || currentOpportunity?.stage || '--' }}
</el-tag>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-tag type="primary" size="small" effect="plain">
{{ currentOpportunity?.statusName || '进行中' }}
</el-tag>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机金额">
<span class="amount-value">{{ currentOpportunity?.amount ? `¥${Number(currentOpportunity.amount).toLocaleString()}` : '--' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="成功概率">
<el-progress :percentage="currentOpportunity?.probability || 0" :stroke-width="6" style="width: 120px" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责人">
<span>{{ currentOpportunity?.ownerUserName || '--' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="预计成交日期">
<span>{{ currentOpportunity?.expectedCloseDate || '--' }}</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="产品名称">
<span>{{ currentOpportunity?.productName || '--' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="创建时间">
<span>{{ currentOpportunity?.createTime || '--' }}</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="商机描述">
<span>{{ currentOpportunity?.description || '--' }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDetailDialog = false">
关闭
</el-button>
<el-button type="primary" @click="openEditDialog">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-button type="danger" @click="handleDeleteOpportunity">
<el-icon><Delete /></el-icon> 删除
</el-button>
</template>
</el-dialog>
<!-- 编辑商机Dialog -->
<el-dialog v-model="showEditDialog" title="编辑商机" width="600px" :close-on-click-modal="false">
<el-form :model="editForm" label-width="120px">
<el-form-item label="经销商" required>
<el-select
v-model="editForm.dealerId"
placeholder="请选择经销商"
filterable
:loading="dealerLoading"
style="width: 100%"
@filter-change="(keyword: string) => loadDealerList(keyword)"
>
<el-option
v-for="dealer in dealerList"
:key="dealer.dealerId"
:label="`${dealer.dealerName}${dealer.dealerCode}`"
:value="dealer.dealerId"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ dealer.dealerName }}</span>
<span style="color: #8492a6; font-size: 13px">{{ dealer.dealerCode }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="商机名称" required>
<el-input v-model="editForm.opportunityName" placeholder="请输入商机名称" />
</el-form-item>
<el-form-item label="商机阶段">
<el-select v-model="editForm.stage" style="width: 100%">
<el-option label="线索" value="lead" />
<el-option label="谈判中" value="negotiation" />
<el-option label="方案" value="proposal" />
<el-option label="赢单" value="closing" />
</el-select>
</el-form-item>
<el-form-item label="商机金额">
<el-input-number v-model="editForm.amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="成功概率">
<el-slider v-model="editForm.probability" :min="0" :max="100" :step="5" show-input />
</el-form-item>
<el-form-item label="预计成交日期">
<el-date-picker
v-model="editForm.expectedCloseDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择预计成交日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="负责人">
<el-select
v-model="editForm.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}`"
:value="user.userId"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<span>{{ user.nickName }}</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-item label="产品名称">
<el-input v-model="editForm.productName" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="商机描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="请输入商机描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">
取消
</el-button>
<el-button type="primary" @click="submitEdit">
保存修改
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -187,6 +809,12 @@ onMounted(() => {
padding: 20px;
}
.amount-value {
font-size: 16px;
font-weight: 600;
color: var(--color-accent);
}
.crm-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);