feat: 完成客户拜访模块全功能开发

本次提交实现了完整的客户拜访业务系统:
1.  新增对象存储上传服务,支持S3兼容协议的RustFS存储
2.  完善客户拜访实体与数据库表结构,补充全业务字段
3.  开发拜访台账与记录的前后端接口,包含增删改查、批量创建、状态流转等能力
4.  新增拜访表单页面与路由,支持父级菜单补全面包屑
5.  新增勘察列表页预填客户ID跳转逻辑
6.  补充UI设计文档与页面截图
7.  调整日志中间件跳过上传路由,优化RBAC权限配置
This commit is contained in:
andyesfly committed 2026-09-14 11:42:10 +08:00
1 parent 68af363ca9
commit 1c83e09fa1
33 files changed
+6920 -587

No files matched your search

+186 -16
View File
@@ -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}`)
};
+23
View File
@@ -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
});
}
};
+15 -4
View File
@@ -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
})
// 监听路由变化,更新菜单选中状态
+24
View File
@@ -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
+10 -2
View File
@@ -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() {