feat: 完成客户拜访模块全功能开发
本次提交实现了完整的客户拜访业务系统: 1. 新增对象存储上传服务,支持S3兼容协议的RustFS存储 2. 完善客户拜访实体与数据库表结构,补充全业务字段 3. 开发拜访台账与记录的前后端接口,包含增删改查、批量创建、状态流转等能力 4. 新增拜访表单页面与路由,支持父级菜单补全面包屑 5. 新增勘察列表页预填客户ID跳转逻辑 6. 补充UI设计文档与页面截图 7. 调整日志中间件跳过上传路由,优化RBAC权限配置
This commit is contained in:
1 parent
68af363ca9
commit
1c83e09fa1
33 files changed
+6920
-587
No files matched your search
+186
-16
@@ -1,5 +1,67 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 拜访类型:1首访摸排 2方案沟通 3商务二访 4维保回访 5应急巡检
|
||||
export const VISIT_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '首访摸排' },
|
||||
{ value: 2, label: '方案沟通' },
|
||||
{ value: 3, label: '商务二访' },
|
||||
{ value: 4, label: '维保回访' },
|
||||
{ value: 5, label: '应急巡检' }
|
||||
];
|
||||
|
||||
// 拜访方式:1现场拜访 2商务拜访 3电话沟通 4线上会议
|
||||
export const VISIT_MODE_OPTIONS = [
|
||||
{ value: 1, label: '现场拜访' },
|
||||
{ value: 2, label: '商务拜访' },
|
||||
{ value: 3, label: '电话沟通' },
|
||||
{ value: 4, label: '线上会议' }
|
||||
];
|
||||
|
||||
// 优先级:1特急P1 2加急P2 3常规P3
|
||||
export const VISIT_PRIORITY_OPTIONS = [
|
||||
{ value: 1, label: '特急 · P1高优' },
|
||||
{ value: 2, label: '加急 · P2高优' },
|
||||
{ value: 3, label: '常规 · P3回访' }
|
||||
];
|
||||
|
||||
// 意向评级:1=A高意向 2=B中意向 3=C低意向
|
||||
export const VISIT_INTENT_OPTIONS = [
|
||||
{ value: 1, label: 'A级 · 高意向' },
|
||||
{ value: 2, label: 'B级 · 中意向' },
|
||||
{ value: 3, label: 'C级 · 低意向' }
|
||||
];
|
||||
|
||||
// 状态:0待执行 1已完成待复核 2取证待技术复核 3已立项转勘察 4已归档 5已取消
|
||||
export const VISIT_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待执行' },
|
||||
{ value: 1, label: '已完成待复核' },
|
||||
{ value: 2, label: '取证待技术复核' },
|
||||
{ value: 3, label: '已立项转勘察' },
|
||||
{ value: 4, label: '已归档' },
|
||||
{ value: 5, label: '已取消' }
|
||||
];
|
||||
|
||||
export const VISIT_STATUS = {
|
||||
PENDING: 0,
|
||||
DONE: 1,
|
||||
EVIDENCE: 2,
|
||||
SURVEY: 3,
|
||||
ARCHIVED: 4,
|
||||
CANCELED: 5
|
||||
};
|
||||
|
||||
// 关联客户(列表 include 的精简字段)
|
||||
export interface CustomerVisitCustomer {
|
||||
id: number;
|
||||
customerNo?: string;
|
||||
customerName?: string;
|
||||
customerLevel?: string;
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
address?: string;
|
||||
salesmanId?: number;
|
||||
}
|
||||
|
||||
export interface CustomerVisitInfo {
|
||||
id: number;
|
||||
visitNo: string;
|
||||
@@ -10,7 +72,40 @@ export interface CustomerVisitInfo {
|
||||
visitTime?: string;
|
||||
salesmanId?: number;
|
||||
willPlan?: number;
|
||||
createdAt: string;
|
||||
visitType?: number;
|
||||
visitMode?: number;
|
||||
priority?: number;
|
||||
subject?: string;
|
||||
planTime?: string;
|
||||
planEndTime?: string;
|
||||
checkinTime?: string;
|
||||
checkinStatus?: number;
|
||||
checkinLng?: number;
|
||||
checkinLat?: number;
|
||||
spaceDesc?: string;
|
||||
intentLevel?: number;
|
||||
intentDesc?: string;
|
||||
demands?: string;
|
||||
companion?: string;
|
||||
receptionPerson?: string;
|
||||
receptionTitle?: string;
|
||||
receptionPhone?: string;
|
||||
resultRating?: string;
|
||||
status: number;
|
||||
nextVisitTime?: string;
|
||||
nextAction?: string;
|
||||
// 现场照片对象 key 列表(持久化字段)
|
||||
photos?: string[];
|
||||
// 现场照片预签名访问 URL 列表(由后端动态生成)
|
||||
photoUrls?: string[];
|
||||
surveyOrderId?: number;
|
||||
projectNo?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// 关联客户与业务员信息(列表/详情附带)
|
||||
customer?: CustomerVisitCustomer;
|
||||
salesmanName?: string;
|
||||
salesmanPhone?: string;
|
||||
}
|
||||
|
||||
export interface CustomerVisitQuery {
|
||||
@@ -19,6 +114,11 @@ export interface CustomerVisitQuery {
|
||||
keyword?: string;
|
||||
customerId?: number;
|
||||
salesmanId?: number;
|
||||
visitType?: number;
|
||||
status?: number;
|
||||
priority?: number;
|
||||
planStartDate?: string;
|
||||
planEndDate?: string;
|
||||
}
|
||||
|
||||
export interface CustomerVisitListResult {
|
||||
@@ -28,39 +128,109 @@ export interface CustomerVisitListResult {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CustomerVisitCreateParams {
|
||||
customerId: number;
|
||||
visitPerson: string;
|
||||
visitPhone?: string;
|
||||
visitContent?: string;
|
||||
visitTime?: string;
|
||||
salesmanId?: number;
|
||||
willPlan?: number;
|
||||
export interface CustomerVisitStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
todayPlan: number;
|
||||
urgentPending: number;
|
||||
conversionRate: number;
|
||||
pendingReview: number;
|
||||
completed: number;
|
||||
converted: number;
|
||||
intentTotal: number;
|
||||
}
|
||||
|
||||
export interface CustomerVisitUpdateParams {
|
||||
customerId?: number;
|
||||
export interface CustomerVisitCreateParams {
|
||||
customerId: number;
|
||||
visitPerson?: string;
|
||||
visitPhone?: string;
|
||||
visitContent?: string;
|
||||
visitTime?: string;
|
||||
salesmanId?: number;
|
||||
willPlan?: number;
|
||||
visitType?: number;
|
||||
visitMode?: number;
|
||||
priority?: number;
|
||||
subject?: string;
|
||||
planTime?: string;
|
||||
planEndTime?: string;
|
||||
spaceDesc?: string;
|
||||
intentLevel?: number;
|
||||
intentDesc?: string;
|
||||
demands?: string;
|
||||
companion?: string;
|
||||
receptionPerson?: string;
|
||||
receptionTitle?: string;
|
||||
receptionPhone?: string;
|
||||
resultRating?: string;
|
||||
status?: number;
|
||||
nextVisitTime?: string;
|
||||
nextAction?: string;
|
||||
photos?: string[];
|
||||
}
|
||||
|
||||
export type CustomerVisitUpdateParams = Partial<CustomerVisitCreateParams> & {
|
||||
checkinStatus?: number;
|
||||
checkinTime?: string;
|
||||
checkinLng?: number;
|
||||
checkinLat?: number;
|
||||
};
|
||||
|
||||
export interface CustomerVisitBatchCreateParams {
|
||||
customerIds: number[];
|
||||
planTime: string;
|
||||
planEndTime?: string;
|
||||
salesmanId?: number;
|
||||
visitType?: number;
|
||||
visitMode?: number;
|
||||
priority?: number;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export interface CustomerVisitBatchCreateResult {
|
||||
total: number;
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export interface CustomerVisitStatusParams {
|
||||
status: number;
|
||||
checkinTime?: string;
|
||||
resultRating?: string;
|
||||
surveyOrderId?: number;
|
||||
projectNo?: string;
|
||||
}
|
||||
|
||||
export interface CustomerVisitReassignParams {
|
||||
salesmanId: number;
|
||||
}
|
||||
|
||||
export const customerVisitApi = {
|
||||
getList: (params: CustomerVisitQuery) =>
|
||||
request.get<CustomerVisitListResult>('/customer-visits', { params }),
|
||||
|
||||
// KPI 统计卡
|
||||
getStats: () => request.get<CustomerVisitStats>('/customer-visits/stats'),
|
||||
|
||||
getDetail: (id: number) =>
|
||||
request.get<CustomerVisitInfo>(`/customer-visits/${id}`),
|
||||
|
||||
create: (data: CustomerVisitCreateParams) =>
|
||||
request.post('/customer-visits', data),
|
||||
request.post<CustomerVisitInfo>('/customer-visits', data),
|
||||
|
||||
// 批量安排拜访:多选客户生成统一的待执行计划
|
||||
batchCreate: (data: CustomerVisitBatchCreateParams) =>
|
||||
request.post<CustomerVisitBatchCreateResult>('/customer-visits/batch', data),
|
||||
|
||||
update: (id: number, data: CustomerVisitUpdateParams) =>
|
||||
request.put(`/customer-visits/${id}`, data),
|
||||
request.put<CustomerVisitInfo>(`/customer-visits/${id}`, data),
|
||||
|
||||
delete: (id: number) =>
|
||||
request.delete(`/customer-visits/${id}`),
|
||||
};
|
||||
// 状态流转:执行跟进 / 审核现场取证 / 归档 / 取消
|
||||
updateStatus: (id: number, data: CustomerVisitStatusParams) =>
|
||||
request.put<CustomerVisitInfo>(`/customer-visits/${id}/status`, data),
|
||||
|
||||
// 改派:更换跟进业务员
|
||||
reassign: (id: number, data: CustomerVisitReassignParams) =>
|
||||
request.put<CustomerVisitInfo>(`/customer-visits/${id}/reassign`, data),
|
||||
|
||||
delete: (id: number) => request.delete(`/customer-visits/${id}`)
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface UploadImageResult {
|
||||
// 对象存储 key(用于随表单持久化)
|
||||
key: string;
|
||||
// 预签名访问 URL(用于即时预览)
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 上传现场照片到对象存储(RustFS),返回对象 key 与预签名 URL
|
||||
export const uploadApi = {
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
return request.post<UploadImageResult>('/upload/image', formData, {
|
||||
// 显式声明 multipart,避免 axios 将 FormData 序列化为 JSON
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -228,10 +228,21 @@ interface BreadcrumbItem {
|
||||
|
||||
const breadcrumbList = computed<BreadcrumbItem[]>(() => {
|
||||
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||
return matched.map(item => ({
|
||||
title: item.meta?.title as string,
|
||||
path: item.path,
|
||||
}))
|
||||
const items: BreadcrumbItem[] = []
|
||||
for (const item of matched) {
|
||||
// 详情/表单类页面路由与之平铺,需在 meta 中声明上级菜单以补齐缺失层级
|
||||
if (item.meta?.parentTitle) {
|
||||
items.push({
|
||||
title: item.meta.parentTitle as string,
|
||||
path: item.meta.parentPath as string | undefined,
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
title: item.meta?.title as string,
|
||||
path: item.path,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// 监听路由变化,更新菜单选中状态
|
||||
|
||||
@@ -55,6 +55,30 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/customer/visit/index.vue"),
|
||||
meta: { requiresAuth: true, title: "客户拜访", permission: "customer:visit" }
|
||||
},
|
||||
{
|
||||
path: "customer/visit/create",
|
||||
name: "CustomerVisitCreate",
|
||||
component: () => import("@/views/customer/visit/form.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "新增客户拜访记录",
|
||||
permission: "customer:visit",
|
||||
parentTitle: "客户拜访",
|
||||
parentPath: "/customer/visit"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "customer/visit/edit/:id",
|
||||
name: "CustomerVisitEdit",
|
||||
component: () => import("@/views/customer/visit/form.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "编辑客户拜访记录",
|
||||
permission: "customer:visit",
|
||||
parentTitle: "客户拜访",
|
||||
parentPath: "/customer/visit"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "customer/topology",
|
||||
name: "CustomerTopology",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -125,6 +125,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -133,6 +134,7 @@ import { customerApi } from '@/api/customer';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<SurveyOrderInfo[]>([]);
|
||||
@@ -177,9 +179,15 @@ const pagination = computed(() => ({
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
fetchList();
|
||||
fetchCustomerOptions();
|
||||
await fetchCustomerOptions();
|
||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey?customerId=xx
|
||||
const preCustomerId = Number(route.query.customerId || 0);
|
||||
if (preCustomerId) {
|
||||
handleAdd();
|
||||
formData.customerId = preCustomerId;
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
|
||||
Reference in new issue
Block a user