From 1c83e09fa1c64632ad9ee16ad7c8b0b075ae2cfe Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Mon, 14 Sep 2026 11:42:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E6=8B=9C=E8=AE=BF=E6=A8=A1=E5=9D=97=E5=85=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交实现了完整的客户拜访业务系统: 1. 新增对象存储上传服务,支持S3兼容协议的RustFS存储 2. 完善客户拜访实体与数据库表结构,补充全业务字段 3. 开发拜访台账与记录的前后端接口,包含增删改查、批量创建、状态流转等能力 4. 新增拜访表单页面与路由,支持父级菜单补全面包屑 5. 新增勘察列表页预填客户ID跳转逻辑 6. 补充UI设计文档与页面截图 7. 调整日志中间件跳过上传路由,优化RBAC权限配置 --- client/src/api/customerVisit.ts | 202 +- client/src/api/upload.ts | 23 + client/src/layouts/index.vue | 19 +- client/src/router/index.ts | 24 + client/src/views/customer/visit/form.vue | 1706 +++++++++++++++++ client/src/views/customer/visit/index.vue | 1581 +++++++++++---- client/src/views/project/survey/index.vue | 12 +- database/db_init.sql | 96 +- .../20260912_customer_visit_ext.sql | 88 + server/package.json | 3 + server/src/config/config.default.ts | 30 + server/src/configuration.ts | 3 +- .../controller/customer_visit.controller.ts | 20 +- server/src/controller/upload.controller.ts | 28 + server/src/dto/customer_visit.dto.ts | 78 +- server/src/entity/customer_visit.entity.ts | 88 +- server/src/middleware/log.middleware.ts | 4 +- server/src/middleware/rbac.middleware.ts | 1 + server/src/service/customer_visit.service.ts | 320 +++- server/src/service/upload.service.ts | 122 ++ server/yarn.lock | 701 +++++-- 系统设计/UI/客户拜访台帐/DESIGN.md | 200 ++ 系统设计/UI/客户拜访台帐/code.html | 687 +++++++ 系统设计/UI/客户拜访台帐/screen.png | Bin 0 -> 478297 bytes 系统设计/UI/客户拜访记录/DESIGN.md | 200 ++ 系统设计/UI/客户拜访记录/code.html | 19 + 系统设计/UI/客户拜访记录/screen.png | Bin 0 -> 518771 bytes 系统设计/UI/项目勘察台帐/DESIGN.md | 200 ++ 系统设计/UI/项目勘察台帐/code.html | 744 +++++++ 系统设计/UI/项目勘察台帐/screen.png | Bin 0 -> 506470 bytes 系统设计/UI/项目勘察派单/DESIGN.md | 200 ++ 系统设计/UI/项目勘察派单/code.html | 108 ++ 系统设计/UI/项目勘察派单/screen.png | Bin 0 -> 9745 bytes 33 files changed, 6920 insertions(+), 587 deletions(-) create mode 100644 client/src/api/upload.ts create mode 100644 client/src/views/customer/visit/form.vue create mode 100644 database/migrations/20260912_customer_visit_ext.sql create mode 100644 server/src/controller/upload.controller.ts create mode 100644 server/src/service/upload.service.ts create mode 100644 系统设计/UI/客户拜访台帐/DESIGN.md create mode 100644 系统设计/UI/客户拜访台帐/code.html create mode 100644 系统设计/UI/客户拜访台帐/screen.png create mode 100644 系统设计/UI/客户拜访记录/DESIGN.md create mode 100644 系统设计/UI/客户拜访记录/code.html create mode 100644 系统设计/UI/客户拜访记录/screen.png create mode 100644 系统设计/UI/项目勘察台帐/DESIGN.md create mode 100644 系统设计/UI/项目勘察台帐/code.html create mode 100644 系统设计/UI/项目勘察台帐/screen.png create mode 100644 系统设计/UI/项目勘察派单/DESIGN.md create mode 100644 系统设计/UI/项目勘察派单/code.html create mode 100644 系统设计/UI/项目勘察派单/screen.png diff --git a/client/src/api/customerVisit.ts b/client/src/api/customerVisit.ts index 0e52fa2..d645c7c 100644 --- a/client/src/api/customerVisit.ts +++ b/client/src/api/customerVisit.ts @@ -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 & { + 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('/customer-visits', { params }), + // KPI 统计卡 + getStats: () => request.get('/customer-visits/stats'), + getDetail: (id: number) => request.get(`/customer-visits/${id}`), create: (data: CustomerVisitCreateParams) => - request.post('/customer-visits', data), + request.post('/customer-visits', data), + + // 批量安排拜访:多选客户生成统一的待执行计划 + batchCreate: (data: CustomerVisitBatchCreateParams) => + request.post('/customer-visits/batch', data), update: (id: number, data: CustomerVisitUpdateParams) => - request.put(`/customer-visits/${id}`, data), + request.put(`/customer-visits/${id}`, data), - delete: (id: number) => - request.delete(`/customer-visits/${id}`), -}; \ No newline at end of file + // 状态流转:执行跟进 / 审核现场取证 / 归档 / 取消 + updateStatus: (id: number, data: CustomerVisitStatusParams) => + request.put(`/customer-visits/${id}/status`, data), + + // 改派:更换跟进业务员 + reassign: (id: number, data: CustomerVisitReassignParams) => + request.put(`/customer-visits/${id}/reassign`, data), + + delete: (id: number) => request.delete(`/customer-visits/${id}`) +}; diff --git a/client/src/api/upload.ts b/client/src/api/upload.ts new file mode 100644 index 0000000..5841e4b --- /dev/null +++ b/client/src/api/upload.ts @@ -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('/upload/image', formData, { + // 显式声明 multipart,避免 axios 将 FormData 序列化为 JSON + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000 + }); + } +}; diff --git a/client/src/layouts/index.vue b/client/src/layouts/index.vue index 7dd6c7e..6600234 100644 --- a/client/src/layouts/index.vue +++ b/client/src/layouts/index.vue @@ -228,10 +228,21 @@ interface BreadcrumbItem { const breadcrumbList = computed(() => { 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 }) // 监听路由变化,更新菜单选中状态 diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 33d5a61..57b8631 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -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", diff --git a/client/src/views/customer/visit/form.vue b/client/src/views/customer/visit/form.vue new file mode 100644 index 0000000..bb0971b --- /dev/null +++ b/client/src/views/customer/visit/form.vue @@ -0,0 +1,1706 @@ + + + + + diff --git a/client/src/views/customer/visit/index.vue b/client/src/views/customer/visit/index.vue index 3acc7a8..1358d96 100644 --- a/client/src/views/customer/visit/index.vue +++ b/client/src/views/customer/visit/index.vue @@ -1,60 +1,104 @@