feat(survey): 勘察业务重构为设备树明细模式并新增勘察完成流转
- 设备树明细模式:customer_equipment 新增 parent_id 自引用与树形查询(getTree/tree 接口),勘察明细按设备节点挂接(survey_detail 新增 equip_id,saveDetails 全量替换),新增迁移 20261024_survey_equipment_tree.sql 并同步 db_init;客户拜访页发起勘察改为两步向导路由(CustomerVisitSurveyCreate),form.vue 重构为两步向导 - 勘察明细页(detail.vue):三栏布局(设备树/拓扑图/属性与勘察登记);设备拓扑图修复显示不完整(tree roam 拖拽缩放+全展开+弹窗增高);新增/编辑设备表单「所在位置」改为「所属建筑」下拉并增加「所在楼层」;选择建筑后按建筑地上/地下层数动态生成楼层下拉(负N层在前,切换建筑清空楼层) - 设备管理台账页:设备新增弹窗同步「所属建筑/所在楼层」动态下拉 - 台账页:进度跟踪主按钮改为跳转勘察明细页(DispatchSurveyDetail)而非编辑页;「审核测绘成果」按钮与复核弹窗标题改名「技术复核」 - 勘察完成流转:新增 PUT /api/survey-orders/:id/complete(现场勘察中→待技术复核,可见性校验+审计日志),勘察明细页新增「勘察完成」按钮(先保存明细再流转状态,成功后跳回台账页) - 验证:server tsc 与 client vue-tsc 编译通过;浏览器端到端验证通过(技术复核按钮/弹窗标题、勘察完成按钮仅勘察中显示、进度跟踪跳转数字id)
This commit is contained in:
1 parent
2d55947762
commit
dff366a823
19 files changed
+2076
-423
No files matched your search
@@ -0,0 +1,77 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
// 客户设备类型:配电间/配电井/配电箱/配电柜
|
||||||
|
export const EQUIP_TYPE_OPTIONS = [
|
||||||
|
{ label: '配电间', value: '配电间' },
|
||||||
|
{ label: '配电井', value: '配电井' },
|
||||||
|
{ label: '配电箱', value: '配电箱' },
|
||||||
|
{ label: '配电柜', value: '配电柜' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface CustomerEquipmentNode {
|
||||||
|
id: number;
|
||||||
|
equipCode: string;
|
||||||
|
customerId: number;
|
||||||
|
equipName: string;
|
||||||
|
equipType?: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
buildingId?: number | null;
|
||||||
|
floorNo?: string | null;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
remark?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
// 树形:子节点(tree 接口返回)
|
||||||
|
children?: CustomerEquipmentNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerEquipmentCreateParams {
|
||||||
|
customerId: number;
|
||||||
|
equipName: string;
|
||||||
|
equipType: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
buildingId?: number | null;
|
||||||
|
floorNo?: string | null;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
remark?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomerEquipmentUpdateParams = Partial<CustomerEquipmentCreateParams>;
|
||||||
|
|
||||||
|
export interface CustomerEquipmentQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
customerId?: number;
|
||||||
|
equipType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerEquipmentListResult {
|
||||||
|
list: CustomerEquipmentNode[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerEquipmentApi = {
|
||||||
|
// 客户设备树(按 parentId 嵌套,根节点 parentId 为空)
|
||||||
|
getTree: (customerId: number) =>
|
||||||
|
request.get<CustomerEquipmentNode[]>('/customer-equipments/tree', { params: { customerId } }),
|
||||||
|
|
||||||
|
getList: (params: CustomerEquipmentQuery) =>
|
||||||
|
request.get<CustomerEquipmentListResult>('/customer-equipments', { params }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<CustomerEquipmentNode>(`/customer-equipments/${id}`),
|
||||||
|
|
||||||
|
create: (data: CustomerEquipmentCreateParams) =>
|
||||||
|
request.post<CustomerEquipmentNode>('/customer-equipments', data),
|
||||||
|
|
||||||
|
update: (id: number, data: CustomerEquipmentUpdateParams) =>
|
||||||
|
request.put<CustomerEquipmentNode>(`/customer-equipments/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) => request.delete(`/customer-equipments/${id}`)
|
||||||
|
};
|
||||||
+18
-18
@@ -4,16 +4,16 @@ import request from '@/utils/request';
|
|||||||
|
|
||||||
export interface CustomerEquipment {
|
export interface CustomerEquipment {
|
||||||
id: number;
|
id: number;
|
||||||
equipmentNo: string;
|
equipCode: string;
|
||||||
equipmentName: string;
|
equipName: string;
|
||||||
equipType?: string;
|
equipType?: string;
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
customerName?: string;
|
parentId?: number | null;
|
||||||
building?: string;
|
buildingId?: number | null;
|
||||||
floor?: string;
|
buildingName?: string;
|
||||||
|
floorNo?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
photo?: string;
|
photoUrl?: string;
|
||||||
status?: number;
|
|
||||||
remark?: string;
|
remark?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -24,7 +24,7 @@ export interface CustomerEquipmentQuery {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
building?: string;
|
buildingId?: number;
|
||||||
equipType?: string;
|
equipType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,26 +36,26 @@ export interface CustomerEquipmentListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerEquipmentCreateParams {
|
export interface CustomerEquipmentCreateParams {
|
||||||
equipmentName: string;
|
equipName: string;
|
||||||
equipType?: string;
|
equipType?: string;
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
building?: string;
|
parentId?: number | null;
|
||||||
floor?: string;
|
buildingId?: number | null;
|
||||||
|
floorNo?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
photo?: string;
|
photoUrl?: string;
|
||||||
status?: number;
|
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerEquipmentUpdateParams {
|
export interface CustomerEquipmentUpdateParams {
|
||||||
equipmentName?: string;
|
equipName?: string;
|
||||||
equipType?: string;
|
equipType?: string;
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
building?: string;
|
parentId?: number | null;
|
||||||
floor?: string;
|
buildingId?: number | null;
|
||||||
|
floorNo?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
photo?: string;
|
photoUrl?: string;
|
||||||
status?: number;
|
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ export interface SurveyOrderWorker {
|
|||||||
completedCount?: number;
|
completedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
|
// 勘察明细:一条 = 设备节点(优先)+ 现场照片;兼容历史 建筑/楼层/房间
|
||||||
export interface SurveyOrderDetail {
|
export interface SurveyOrderDetail {
|
||||||
id?: number;
|
id?: number;
|
||||||
surveyOrderId?: number;
|
surveyOrderId?: number;
|
||||||
|
// 客户设备 ID(→ customer_equipment,勘察明细挂接到设备节点)
|
||||||
|
equipId?: number | null;
|
||||||
buildingId?: number | null;
|
buildingId?: number | null;
|
||||||
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
||||||
buildingName?: string | null;
|
buildingName?: string | null;
|
||||||
@@ -75,6 +77,18 @@ export interface SurveyOrderDetail {
|
|||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 客户设备树节点(工单详情附带,勘察明细按设备节点拓扑展示)
|
||||||
|
// 与 @/api/customerEquipment 的 CustomerEquipmentNode 对齐(equip_code/customer_id/equip_name 必填,equip_type 可空)
|
||||||
|
export interface SurveyEquipNode {
|
||||||
|
id: number;
|
||||||
|
equipCode: string;
|
||||||
|
customerId: number;
|
||||||
|
equipName: string;
|
||||||
|
equipType?: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
children?: SurveyEquipNode[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SurveyOrderInfo {
|
export interface SurveyOrderInfo {
|
||||||
id: number;
|
id: number;
|
||||||
surveyNo: string;
|
surveyNo: string;
|
||||||
@@ -124,8 +138,10 @@ export interface SurveyOrderInfo {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
|
// 详情附带:勘察明细(设备节点/建筑楼层房间 + 现场照片)
|
||||||
details?: SurveyOrderDetail[];
|
details?: SurveyOrderDetail[];
|
||||||
|
// 详情附带:客户设备树(勘察明细按设备节点拓扑展示)
|
||||||
|
equipmentTree?: SurveyEquipNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderQuery {
|
export interface SurveyOrderQuery {
|
||||||
@@ -260,6 +276,9 @@ export const surveyOrderApi = {
|
|||||||
review: (id: number, data: SurveyOrderReviewParams) =>
|
review: (id: number, data: SurveyOrderReviewParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}/review`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}/review`, data),
|
||||||
|
|
||||||
|
// 勘察完成:现场勘察中 → 待技术复核
|
||||||
|
complete: (id: number) => request.put<SurveyOrderInfo>(`/survey-orders/${id}/complete`),
|
||||||
|
|
||||||
// 催办
|
// 催办
|
||||||
urge: (id: number) => request.post<SurveyOrderInfo>(`/survey-orders/${id}/urge`),
|
urge: (id: number) => request.post<SurveyOrderInfo>(`/survey-orders/${id}/urge`),
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -79,6 +79,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
parentPath: "/customer/visit"
|
parentPath: "/customer/visit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "customer/visit/survey/create",
|
||||||
|
name: "CustomerVisitSurveyCreate",
|
||||||
|
component: () => import("@/views/dispatch/survey/form.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "发起勘察派单",
|
||||||
|
permission: "customer:visit",
|
||||||
|
parentTitle: "客户拜访",
|
||||||
|
parentPath: "/customer/visit"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "customer/topology",
|
path: "customer/topology",
|
||||||
name: "CustomerTopology",
|
name: "CustomerTopology",
|
||||||
@@ -133,18 +145,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/dispatch/survey/index.vue"),
|
component: () => import("@/views/dispatch/survey/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "dispatch/survey/create",
|
|
||||||
name: "DispatchSurveyCreate",
|
|
||||||
component: () => import("@/views/dispatch/survey/form.vue"),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: "新建勘察派单",
|
|
||||||
permission: "project:survey",
|
|
||||||
parentTitle: "现场勘察派单",
|
|
||||||
parentPath: "/dispatch/survey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "dispatch/survey/edit/:id",
|
path: "dispatch/survey/edit/:id",
|
||||||
name: "DispatchSurveyEdit",
|
name: "DispatchSurveyEdit",
|
||||||
@@ -157,6 +157,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
parentPath: "/dispatch/survey"
|
parentPath: "/dispatch/survey"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey/:id/details",
|
||||||
|
name: "DispatchSurveyDetail",
|
||||||
|
component: () => import("@/views/dispatch/survey/detail.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "勘察明细",
|
||||||
|
permission: "project:survey",
|
||||||
|
parentTitle: "现场勘察派单",
|
||||||
|
parentPath: "/dispatch/survey"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "dispatch/install",
|
path: "dispatch/install",
|
||||||
name: "DispatchInstall",
|
name: "DispatchInstall",
|
||||||
|
|||||||
@@ -795,10 +795,10 @@ function handleFollow(record: CustomerVisitInfo) {
|
|||||||
router.push({ name: 'CustomerVisitEdit', params: { id: record.id }, query: { action: 'follow' } });
|
router.push({ name: 'CustomerVisitEdit', params: { id: record.id }, query: { action: 'follow' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
// 一键发起勘察派单:跳转两步向导并预填客户(不创建、不回写状态)
|
||||||
function handleSurvey(record: CustomerVisitInfo) {
|
function handleSurvey(record: CustomerVisitInfo) {
|
||||||
if (!record.customerId) return;
|
if (!record.customerId) return;
|
||||||
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
|
router.push({ name: 'CustomerVisitSurveyCreate', query: { customerId: String(record.customerId) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看关联工程
|
// 查看关联工程
|
||||||
|
|||||||
@@ -38,11 +38,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label">所属建筑</label>
|
<label class="filter-label">所属建筑</label>
|
||||||
<a-input
|
<a-select
|
||||||
v-model:value="searchForm.building"
|
v-model:value="searchForm.buildingId"
|
||||||
placeholder="建筑名称"
|
placeholder="全部建筑"
|
||||||
allow-clear
|
allow-clear
|
||||||
@pressEnter="handleSearch"
|
:options="searchBuildingOptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
@@ -103,12 +103,12 @@
|
|||||||
@change="handleTableChange"
|
@change="handleTableChange"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'equipmentNo'">
|
<template v-if="column.key === 'equipCode'">
|
||||||
<span class="mono">{{ record.equipmentNo || '-' }}</span>
|
<span class="mono">{{ record.equipCode || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'equipmentName'">
|
<template v-else-if="column.key === 'equipName'">
|
||||||
<span class="equip-name">{{ record.equipmentName }}</span>
|
<span class="equip-name">{{ record.equipName }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'equipType'">
|
<template v-else-if="column.key === 'equipType'">
|
||||||
@@ -120,10 +120,10 @@
|
|||||||
{{ customerNameMap[record.customerId] || '-' }}
|
{{ customerNameMap[record.customerId] || '-' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'photo'">
|
<template v-else-if="column.key === 'photoUrl'">
|
||||||
<a-image
|
<a-image
|
||||||
v-if="record.photo"
|
v-if="record.photoUrl"
|
||||||
:src="record.photo"
|
:src="record.photoUrl"
|
||||||
:width="44"
|
:width="44"
|
||||||
:height="44"
|
:height="44"
|
||||||
style="border-radius: 6px"
|
style="border-radius: 6px"
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
<div v-if="isEdit && editId" class="no-bar">
|
<div v-if="isEdit && editId" class="no-bar">
|
||||||
<div class="no-item">
|
<div class="no-item">
|
||||||
<span class="no-label">设备编码</span>
|
<span class="no-label">设备编码</span>
|
||||||
<span class="no-value mono">{{ editNo || '-' }}</span>
|
<span class="no-value mono">{{ editCode || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,8 +195,8 @@
|
|||||||
<span class="section-sub">Equipment Identity</span>
|
<span class="section-sub">Equipment Identity</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-form-item label="设备名称" name="equipmentName">
|
<a-form-item label="设备名称" name="equipName">
|
||||||
<a-input v-model:value="formData.equipmentName" placeholder="请输入设备名称" maxlength="128" />
|
<a-input v-model:value="formData.equipName" placeholder="请输入设备名称" maxlength="128" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="设备类型" name="equipType">
|
<a-form-item label="设备类型" name="equipType">
|
||||||
<a-select
|
<a-select
|
||||||
@@ -208,6 +208,15 @@
|
|||||||
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="父级设备" name="parentId">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.parentId"
|
||||||
|
placeholder="不选则为根设备"
|
||||||
|
allow-clear
|
||||||
|
:options="visibleParentOptions"
|
||||||
|
:loading="parentLoading"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="所属客户" name="customerId">
|
<a-form-item label="所属客户" name="customerId">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="formData.customerId"
|
v-model:value="formData.customerId"
|
||||||
@@ -228,11 +237,18 @@
|
|||||||
<span class="section-sub">Location</span>
|
<span class="section-sub">Location</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-form-item label="所属建筑" name="building">
|
<a-form-item label="所属建筑" name="buildingId">
|
||||||
<a-input v-model:value="formData.building" placeholder="请输入所属建筑" maxlength="128" />
|
<a-select
|
||||||
|
v-model:value="formData.buildingId"
|
||||||
|
placeholder="请选择所属建筑"
|
||||||
|
allow-clear
|
||||||
|
:options="buildingOptions"
|
||||||
|
:loading="buildingLoading"
|
||||||
|
@change="handleBuildingChange"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="所在楼层" name="floor">
|
<a-form-item label="所在楼层" name="floorNo">
|
||||||
<a-input v-model:value="formData.floor" placeholder="请输入所在楼层" maxlength="64" />
|
<a-select v-model:value="formData.floorNo" placeholder="请选择所在楼层" allow-clear :options="floorOptions" :not-found-content="floorOptions.length ? null : '请先选择所属建筑'" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="具体位置" name="location">
|
<a-form-item label="具体位置" name="location">
|
||||||
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
|
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
|
||||||
@@ -262,7 +278,7 @@
|
|||||||
:disabled="photoUploading"
|
:disabled="photoUploading"
|
||||||
>
|
>
|
||||||
<a-button :loading="photoUploading">
|
<a-button :loading="photoUploading">
|
||||||
{{ formData.photo ? '替换' : '上传' }}
|
{{ formData.photoUrl ? '替换' : '上传' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,29 +307,53 @@ import {
|
|||||||
type CustomerEquipment,
|
type CustomerEquipment,
|
||||||
type CustomerEquipmentQuery,
|
type CustomerEquipmentQuery,
|
||||||
} from '@/api/device';
|
} from '@/api/device';
|
||||||
|
import {
|
||||||
|
customerEquipmentApi as equipmentTreeApi,
|
||||||
|
type CustomerEquipmentNode,
|
||||||
|
} from '@/api/customerEquipment';
|
||||||
|
import {
|
||||||
|
customerBuildingApi,
|
||||||
|
type CustomerBuildingInfo,
|
||||||
|
} from '@/api/customerBuilding';
|
||||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
import { uploadApi } from '@/api/upload';
|
import { uploadApi } from '@/api/upload';
|
||||||
import { useTableScroll } from '@/composables/useTableScroll';
|
import { useTableScroll } from '@/composables/useTableScroll';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
const { wrapperRef, tableHeight } = useTableScroll();
|
const { wrapperRef, tableHeight } = useTableScroll();
|
||||||
|
|
||||||
// 设备类型字典(配电间/配电井/配电箱)
|
// 设备类型字典(配电间/配电井/配电箱/配电柜)
|
||||||
const equipTypeOptions = ['配电间', '配电井', '配电箱'];
|
const equipTypeOptions = ['配电间', '配电井', '配电箱', '配电柜'];
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSource = ref<CustomerEquipment[]>([]);
|
const dataSource = ref<CustomerEquipment[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const editNo = ref<string>('');
|
const editCode = ref<string>('');
|
||||||
|
|
||||||
const searchForm = reactive<CustomerEquipmentQuery>({
|
const searchForm = reactive<CustomerEquipmentQuery>({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
customerId: undefined as any,
|
customerId: undefined as any,
|
||||||
building: '',
|
buildingId: undefined as any,
|
||||||
equipType: undefined as any,
|
equipType: undefined as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 搜索/表单:建筑选项
|
||||||
|
const searchBuildingOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
const buildingOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
const buildingList = ref<CustomerBuildingInfo[]>([]);
|
||||||
|
const buildingLoading = ref(false);
|
||||||
|
// 表单:父设备选项(按所选客户设备树扁平化)
|
||||||
|
interface FlatEquipOption {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
parentId: number | null;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
const parentOptions = ref<FlatEquipOption[]>([]);
|
||||||
|
const parentLoading = ref(false);
|
||||||
|
|
||||||
const modalVisible = ref(false);
|
const modalVisible = ref(false);
|
||||||
const modalTitle = ref('');
|
const modalTitle = ref('');
|
||||||
const modalLoading = ref(false);
|
const modalLoading = ref(false);
|
||||||
@@ -328,30 +368,31 @@ const photoUploading = ref(false);
|
|||||||
const photoPreview = ref<string>('');
|
const photoPreview = ref<string>('');
|
||||||
|
|
||||||
const formData = reactive<Partial<CustomerEquipment>>({
|
const formData = reactive<Partial<CustomerEquipment>>({
|
||||||
equipmentName: '',
|
equipName: '',
|
||||||
equipType: undefined,
|
equipType: undefined,
|
||||||
customerId: undefined,
|
parentId: undefined as any,
|
||||||
building: '',
|
customerId: undefined as any,
|
||||||
floor: '',
|
buildingId: undefined as any,
|
||||||
|
floorNo: '',
|
||||||
location: '',
|
location: '',
|
||||||
photo: '',
|
photoUrl: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
equipName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||||
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '设备编码', dataIndex: 'equipmentNo', key: 'equipmentNo', ellipsis: true, width: 150 },
|
{ title: '设备编码', dataIndex: 'equipCode', key: 'equipCode', ellipsis: true, width: 150 },
|
||||||
{ title: '设备名称', dataIndex: 'equipmentName', key: 'equipmentName', ellipsis: true },
|
{ title: '设备名称', dataIndex: 'equipName', key: 'equipName', ellipsis: true },
|
||||||
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
|
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
|
||||||
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||||
{ title: '所属建筑', dataIndex: 'building', key: 'building', ellipsis: true, width: 140 },
|
{ title: '所属建筑', dataIndex: 'buildingName', key: 'buildingName', ellipsis: true, width: 140 },
|
||||||
{ title: '所在楼层', dataIndex: 'floor', key: 'floor', ellipsis: true, width: 100 },
|
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
|
||||||
{ title: '具体位置', dataIndex: 'location', key: 'location', ellipsis: true },
|
{ title: '具体位置', dataIndex: 'location', key: 'location', ellipsis: true },
|
||||||
{ title: '照片', dataIndex: 'photo', key: 'photo', align: 'center' as const, width: 70 },
|
{ title: '照片', dataIndex: 'photoUrl', key: 'photoUrl', align: 'center' as const, width: 70 },
|
||||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -367,8 +408,135 @@ const pagination = computed(() => ({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchList();
|
fetchList();
|
||||||
fetchCustomerOptions();
|
fetchCustomerOptions();
|
||||||
|
loadSearchBuildings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 建筑/父设备选项 ====================
|
||||||
|
async function loadSearchBuildings(customerId?: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await customerBuildingApi.getList({ customerId, page: 1, pageSize: 999 });
|
||||||
|
if (res.code === 200) {
|
||||||
|
searchBuildingOptions.value = (res.data.list as CustomerBuildingInfo[]).map((b) => ({
|
||||||
|
label: b.buildingName,
|
||||||
|
value: b.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略加载失败 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBuildingOptions(customerId?: number) {
|
||||||
|
buildingLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerBuildingApi.getList({ customerId, page: 1, pageSize: 999 });
|
||||||
|
if (res.code === 200) {
|
||||||
|
buildingList.value = (res.data.list as CustomerBuildingInfo[]) || [];
|
||||||
|
buildingOptions.value = buildingList.value.map((b) => ({
|
||||||
|
label: b.buildingName,
|
||||||
|
value: b.id,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
buildingList.value = [];
|
||||||
|
buildingOptions.value = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
buildingList.value = [];
|
||||||
|
buildingOptions.value = [];
|
||||||
|
} finally {
|
||||||
|
buildingLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按建筑地上/地下层数生成楼层选项(与空间拓扑页规则一致)
|
||||||
|
function buildFloorOptions(building?: CustomerBuildingInfo): { label: string; value: string }[] {
|
||||||
|
if (!building) return [];
|
||||||
|
const down = building.floorDownCount ?? 0;
|
||||||
|
const up = building.floorCount ?? 0;
|
||||||
|
const options: { label: string; value: string }[] = [];
|
||||||
|
for (let i = down; i >= 1; i--) options.push({ label: `负${i}层`, value: String(-i) });
|
||||||
|
for (let i = 1; i <= up; i++) options.push({ label: `${i}层`, value: String(i) });
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorOptions = computed<{ label: string; value: string }[]>(() =>
|
||||||
|
buildFloorOptions(buildingList.value.find((b) => b.id === formData.buildingId))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换所属建筑时清空已选楼层
|
||||||
|
function handleBuildingChange() {
|
||||||
|
formData.floorNo = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备树扁平化:label 带层级缩进,供父设备下拉使用
|
||||||
|
function flattenEquipTree(nodes: CustomerEquipmentNode[], parentId: number | null = null, depth = 0): FlatEquipOption[] {
|
||||||
|
const out: FlatEquipOption[] = [];
|
||||||
|
for (const n of nodes || []) {
|
||||||
|
out.push({ label: `${' '.repeat(depth)}${n.equipName}`, value: n.id, parentId, depth });
|
||||||
|
out.push(...flattenEquipTree(n.children || [], n.id, depth + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadParentOptions(customerId?: number) {
|
||||||
|
if (!customerId) {
|
||||||
|
parentOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parentLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await equipmentTreeApi.getTree(customerId);
|
||||||
|
if (res.code === 200) {
|
||||||
|
parentOptions.value = flattenEquipTree(res.data as CustomerEquipmentNode[]);
|
||||||
|
} else {
|
||||||
|
parentOptions.value = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parentOptions.value = [];
|
||||||
|
} finally {
|
||||||
|
parentLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑态:父设备下拉排除自身及其子孙(防循环)
|
||||||
|
const visibleParentOptions = computed(() => {
|
||||||
|
if (!isEdit.value || !editId.value) return parentOptions.value;
|
||||||
|
const banned = new Set<number>([editId.value]);
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
for (const o of parentOptions.value) {
|
||||||
|
if (o.parentId == null) continue;
|
||||||
|
const arr = childrenOf.get(o.parentId) || [];
|
||||||
|
arr.push(o.value);
|
||||||
|
childrenOf.set(o.parentId, arr);
|
||||||
|
}
|
||||||
|
const stack = [...(childrenOf.get(editId.value) || [])];
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.pop()!;
|
||||||
|
if (banned.has(n)) continue;
|
||||||
|
banned.add(n);
|
||||||
|
stack.push(...(childrenOf.get(n) || []));
|
||||||
|
}
|
||||||
|
return parentOptions.value.filter((o) => !banned.has(o.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单所属客户变化 → 联动刷新建筑与父设备选项
|
||||||
|
watch(
|
||||||
|
() => formData.customerId,
|
||||||
|
(val) => {
|
||||||
|
loadBuildingOptions(val as any);
|
||||||
|
loadParentOptions(val as any);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 搜索客户变化 → 联动刷新建筑选项并清空已选建筑
|
||||||
|
watch(
|
||||||
|
() => searchForm.customerId,
|
||||||
|
(val) => {
|
||||||
|
searchForm.buildingId = undefined as any;
|
||||||
|
loadSearchBuildings(val as any);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -410,7 +578,7 @@ function handleSearch() {
|
|||||||
function handleReset() {
|
function handleReset() {
|
||||||
searchForm.keyword = '';
|
searchForm.keyword = '';
|
||||||
searchForm.customerId = undefined as any;
|
searchForm.customerId = undefined as any;
|
||||||
searchForm.building = '';
|
searchForm.buildingId = undefined as any;
|
||||||
searchForm.equipType = undefined as any;
|
searchForm.equipType = undefined as any;
|
||||||
searchForm.page = 1;
|
searchForm.page = 1;
|
||||||
fetchList();
|
fetchList();
|
||||||
@@ -447,7 +615,7 @@ async function handlePhotoUpload(options: any) {
|
|||||||
try {
|
try {
|
||||||
const res: any = await uploadApi.uploadImage(f);
|
const res: any = await uploadApi.uploadImage(f);
|
||||||
if (res.code === 200 && res.data) {
|
if (res.code === 200 && res.data) {
|
||||||
formData.photo = res.data.key;
|
formData.photoUrl = res.data.key;
|
||||||
photoPreview.value = res.data.url;
|
photoPreview.value = res.data.url;
|
||||||
onSuccess?.(res.data);
|
onSuccess?.(res.data);
|
||||||
message.success('上传成功');
|
message.success('上传成功');
|
||||||
@@ -466,7 +634,7 @@ async function handlePhotoUpload(options: any) {
|
|||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
isEdit.value = false;
|
isEdit.value = false;
|
||||||
editId.value = null;
|
editId.value = null;
|
||||||
editNo.value = '';
|
editCode.value = '';
|
||||||
modalTitle.value = '新增设备';
|
modalTitle.value = '新增设备';
|
||||||
resetForm();
|
resetForm();
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
@@ -475,31 +643,34 @@ function handleAdd() {
|
|||||||
function handleEdit(record: CustomerEquipment) {
|
function handleEdit(record: CustomerEquipment) {
|
||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
editId.value = record.id;
|
editId.value = record.id;
|
||||||
editNo.value = record.equipmentNo;
|
editCode.value = record.equipCode;
|
||||||
modalTitle.value = '编辑设备';
|
modalTitle.value = '编辑设备';
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
formData.equipmentName = record.equipmentName;
|
// 先设客户,触发建筑/父设备选项联动加载;再设其余字段
|
||||||
|
formData.customerId = record.customerId as any;
|
||||||
|
formData.equipName = record.equipName;
|
||||||
formData.equipType = record.equipType || undefined;
|
formData.equipType = record.equipType || undefined;
|
||||||
formData.customerId = record.customerId;
|
formData.parentId = record.parentId ?? undefined as any;
|
||||||
formData.building = record.building || '';
|
formData.buildingId = record.buildingId ?? undefined as any;
|
||||||
formData.floor = record.floor || '';
|
formData.floorNo = record.floorNo || '';
|
||||||
formData.location = record.location || '';
|
formData.location = record.location || '';
|
||||||
formData.photo = record.photo || '';
|
formData.photoUrl = record.photoUrl || '';
|
||||||
formData.remark = record.remark || '';
|
formData.remark = record.remark || '';
|
||||||
photoPreview.value = record.photo || '';
|
photoPreview.value = record.photoUrl || '';
|
||||||
|
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
formData.equipmentName = '';
|
formData.equipName = '';
|
||||||
formData.equipType = undefined;
|
formData.equipType = undefined;
|
||||||
formData.customerId = undefined;
|
formData.parentId = undefined as any;
|
||||||
formData.building = '';
|
formData.customerId = undefined as any;
|
||||||
formData.floor = '';
|
formData.buildingId = undefined as any;
|
||||||
|
formData.floorNo = '';
|
||||||
formData.location = '';
|
formData.location = '';
|
||||||
formData.photo = '';
|
formData.photoUrl = '';
|
||||||
formData.remark = '';
|
formData.remark = '';
|
||||||
photoPreview.value = '';
|
photoPreview.value = '';
|
||||||
|
|
||||||
@@ -518,13 +689,14 @@ async function handleSubmit() {
|
|||||||
modalLoading.value = true;
|
modalLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const submitData: any = {
|
const submitData: any = {
|
||||||
equipmentName: formData.equipmentName,
|
equipName: formData.equipName,
|
||||||
equipType: formData.equipType,
|
equipType: formData.equipType,
|
||||||
customerId: formData.customerId,
|
customerId: formData.customerId,
|
||||||
building: formData.building,
|
parentId: formData.parentId ?? null,
|
||||||
floor: formData.floor,
|
buildingId: formData.buildingId ?? null,
|
||||||
|
floorNo: formData.floorNo,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
photo: formData.photo,
|
photoUrl: formData.photoUrl,
|
||||||
remark: formData.remark,
|
remark: formData.remark,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
|||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<span class="link" @click="goLedger">项目管理</span>
|
<span class="link" @click="goLedger">项目管理</span>
|
||||||
<span class="sep">/</span>
|
<span class="sep">/</span>
|
||||||
<span class="link" @click="goLedger">现场勘察派单</span>
|
<span class="link" @click="goLedger">{{ isEdit ? '现场勘察派单' : '客户拜访' }}</span>
|
||||||
<span class="sep">/</span>
|
<span class="sep">/</span>
|
||||||
<span v-if="surveyNo" class="mono-badge">{{ surveyNo }}</span>
|
<span v-if="surveyNo" class="mono-badge">{{ surveyNo }}</span>
|
||||||
<span v-else class="cur">{{ pageTitle }}</span>
|
<span v-else class="cur">{{ pageTitle }}</span>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isReadonly" class="step-badge ro">只读查看</span>
|
<span v-if="isReadonly" class="step-badge ro">只读查看</span>
|
||||||
<span v-else class="step-badge">STEP 0{{ currentStep }} / 03</span>
|
<span v-else class="step-badge">STEP 0{{ currentStep }} / 02</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="step-bar">
|
<div class="step-bar">
|
||||||
@@ -223,123 +223,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== 第 2 步 ==================== -->
|
<!-- ==================== 只读:勘察明细(按设备节点) ==================== -->
|
||||||
<section id="wizard-step-2" class="section">
|
<section v-if="isReadonly" id="wizard-step-2" class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="bar bar-secondary"></span>
|
<span class="bar bar-secondary"></span>
|
||||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||||
<h2>第 2 步:勘察明细</h2>
|
<h2>勘察明细</h2>
|
||||||
</div>
|
|
||||||
<div v-if="!isReadonly" class="head-actions">
|
|
||||||
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
|
|
||||||
<a-button size="small" type="primary" ghost @click="addDetailRow">
|
|
||||||
<template #icon><PlusOutlined /></template>
|
|
||||||
添加明细
|
|
||||||
</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="head-hint">按客户设备节点登记 · 仅供查看</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-body">
|
<div class="detail-body">
|
||||||
<!-- 编辑态:明细编辑行 -->
|
<div v-if="readonlyDetails.length === 0" class="detail-empty">暂无勘察明细</div>
|
||||||
<template v-if="!isReadonly">
|
|
||||||
<div v-if="detailRows.length === 0" class="detail-empty">
|
|
||||||
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
|
|
||||||
</div>
|
|
||||||
<div v-else class="detail-rows">
|
<div v-else class="detail-rows">
|
||||||
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
|
<div v-for="(row, idx) in readonlyDetails" :key="idx" class="detail-ro-row">
|
||||||
<div class="detail-row-head">
|
|
||||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
|
||||||
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
|
|
||||||
<template #icon><DeleteOutlined /></template>
|
|
||||||
删除
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="dfield">
|
|
||||||
<label class="dfield-label">建筑 <b class="req">*</b></label>
|
|
||||||
<a-select
|
|
||||||
v-model:value="row.buildingId"
|
|
||||||
placeholder="选择建筑"
|
|
||||||
:loading="topologyLoading"
|
|
||||||
:options="buildingSelectOptions"
|
|
||||||
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleRowBuildingChange(row)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="dfield">
|
|
||||||
<label class="dfield-label">楼层 <b class="req">*</b></label>
|
|
||||||
<a-select
|
|
||||||
v-model:value="row.floorNo"
|
|
||||||
placeholder="选择楼层"
|
|
||||||
:disabled="!row.buildingId"
|
|
||||||
:options="floorOptionsFor(row.buildingId)"
|
|
||||||
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleRowFloorChange(row)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="dfield">
|
|
||||||
<label class="dfield-label">房间 <span class="opt">选填</span></label>
|
|
||||||
<a-select
|
|
||||||
v-model:value="row.roomId"
|
|
||||||
placeholder="可不选"
|
|
||||||
allow-clear
|
|
||||||
:disabled="!row.floorNo"
|
|
||||||
:options="roomOptionsFor(row)"
|
|
||||||
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleRowRoomChange(row)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="dfield span-all">
|
|
||||||
<label class="dfield-label">现场照片</label>
|
|
||||||
<div class="detail-photos">
|
|
||||||
<a-image-preview-group>
|
|
||||||
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
|
|
||||||
<a-image :src="p.url" :width="76" :height="57" />
|
|
||||||
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
|
|
||||||
</div>
|
|
||||||
</a-image-preview-group>
|
|
||||||
<a-upload
|
|
||||||
:show-upload-list="false"
|
|
||||||
:custom-request="(o: any) => handlePhotoUpload(o, row)"
|
|
||||||
:before-upload="beforePhotoUpload"
|
|
||||||
accept="image/*"
|
|
||||||
>
|
|
||||||
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
|
|
||||||
<PlusOutlined />
|
|
||||||
<span>上传照片</span>
|
|
||||||
</button>
|
|
||||||
</a-upload>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="dfield span-all">
|
|
||||||
<label class="dfield-label">备注</label>
|
|
||||||
<input
|
|
||||||
v-model="row.remark"
|
|
||||||
class="ipt"
|
|
||||||
type="text"
|
|
||||||
maxlength="500"
|
|
||||||
placeholder="该条明细的现场说明,如设备位置、隐患点等"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 只读态:明细展示 -->
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
|
|
||||||
<div v-else class="detail-rows">
|
|
||||||
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
|
|
||||||
<div class="detail-ro-head">
|
<div class="detail-ro-head">
|
||||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
<span class="detail-ro-scope">
|
<span class="detail-ro-scope">
|
||||||
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
{{ row.equipName || row.buildingName || '未标注位置' }}
|
||||||
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
<template v-if="row.equipType"> · {{ row.equipType }}</template>
|
||||||
|
<template v-if="row.floorLabel && !row.equipName"> · {{ row.floorLabel }}</template>
|
||||||
|
<template v-if="row.roomName && !row.equipName"> · {{ row.roomName }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.photos.length" class="detail-ro-photos">
|
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||||
@@ -347,7 +252,7 @@
|
|||||||
<a-image
|
<a-image
|
||||||
v-for="(p, pi) in row.photos"
|
v-for="(p, pi) in row.photos"
|
||||||
:key="pi"
|
:key="pi"
|
||||||
:src="p.url"
|
:src="p"
|
||||||
:width="110"
|
:width="110"
|
||||||
:height="82"
|
:height="82"
|
||||||
style="object-fit: cover; border-radius: 8px"
|
style="object-fit: cover; border-radius: 8px"
|
||||||
@@ -357,17 +262,16 @@
|
|||||||
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== 第 3 步 ==================== -->
|
<!-- ==================== 第 2 步 ==================== -->
|
||||||
<section id="wizard-step-3" class="section">
|
<section id="wizard-step-2" class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="bar bar-tertiary"></span>
|
<span class="bar bar-tertiary"></span>
|
||||||
<ToolOutlined class="sec-icon clr-tertiary" />
|
<ToolOutlined class="sec-icon clr-tertiary" />
|
||||||
<h2>第 3 步:指派电工与服务报酬</h2>
|
<h2>第 2 步:指派电工与服务报酬</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="head-hint clr-tertiary">根据持证资质与历史履约智能优选</span>
|
<span class="head-hint clr-tertiary">根据持证资质与历史履约智能优选</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,8 +481,6 @@ import {
|
|||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
StarFilled,
|
StarFilled,
|
||||||
PlusOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
@@ -593,21 +495,17 @@ import {
|
|||||||
type SurveyQuote,
|
type SurveyQuote,
|
||||||
type SurveyOrderCreateParams,
|
type SurveyOrderCreateParams,
|
||||||
type SurveyOrderInfo,
|
type SurveyOrderInfo,
|
||||||
type SurveyOrderDetail,
|
|
||||||
} from '@/api/surveyOrder';
|
} from '@/api/surveyOrder';
|
||||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
|
|
||||||
import { uploadApi } from '@/api/upload';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// ==================== 步骤元数据 ====================
|
// ==================== 步骤元数据(两步向导:基本信息 → 指派电工与报酬) ====================
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||||
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
{ value: 2, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SLA_OPTIONS = [
|
const SLA_OPTIONS = [
|
||||||
@@ -696,166 +594,48 @@ const customerSelectOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||||
|
|
||||||
// ==================== 客户空间拓扑 ====================
|
// ==================== 只读态勘察明细(按设备节点展示) ====================
|
||||||
const topology = ref<CustomerTopologyResult | null>(null);
|
interface ReadonlyDetailRow {
|
||||||
const topologyLoading = ref(false);
|
equipName?: string;
|
||||||
const buildingSelectOptions = computed(() =>
|
equipType?: string;
|
||||||
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildingById(id?: number): TopologyBuilding | undefined {
|
|
||||||
if (!id) return undefined;
|
|
||||||
return (topology.value?.buildings || []).find((b) => b.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
|
|
||||||
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
|
|
||||||
if (!b) return [];
|
|
||||||
const up = b.floorCount ?? 0;
|
|
||||||
const down = b.floorDownCount ?? 0;
|
|
||||||
const opts: { label: string; value: string }[] = [];
|
|
||||||
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
|
|
||||||
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
|
|
||||||
return opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function floorOptionsFor(buildingId?: number) {
|
|
||||||
return buildFloorOptions(buildingById(buildingId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function floorLabel(floorNo?: string | null) {
|
|
||||||
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
|
||||||
const n = Number(floorNo);
|
|
||||||
if (Number.isNaN(n)) return String(floorNo);
|
|
||||||
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roomOptionsFor(row: DetailRow) {
|
|
||||||
const b = buildingById(row.buildingId);
|
|
||||||
if (!b || !row.floorNo) return [];
|
|
||||||
return b.floors
|
|
||||||
.filter((f) => (f.floorNo || '') === row.floorNo)
|
|
||||||
.flatMap((f) => f.rooms)
|
|
||||||
.map((r) => ({ label: r.roomName, value: r.id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTopology() {
|
|
||||||
if (!formState.customerId) {
|
|
||||||
topology.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
topologyLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res: any = await customerCircuitApi.getTopology(formState.customerId);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
topology.value = res.data;
|
|
||||||
} else {
|
|
||||||
topology.value = { buildings: [] };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
topology.value = { buildings: [] };
|
|
||||||
} finally {
|
|
||||||
topologyLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 勘察明细 ====================
|
|
||||||
interface DetailPhoto {
|
|
||||||
// 对象存储 key(提交时使用)
|
|
||||||
key: string;
|
|
||||||
// 预签名访问 URL(预览使用)
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRow {
|
|
||||||
uid: number;
|
|
||||||
buildingId?: number;
|
|
||||||
buildingName?: string;
|
buildingName?: string;
|
||||||
floorNo?: string;
|
floorLabel?: string;
|
||||||
roomId?: number;
|
|
||||||
roomName?: string;
|
roomName?: string;
|
||||||
photos: DetailPhoto[];
|
photos: string[];
|
||||||
remark: string;
|
remark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DETAIL_PHOTOS = 9;
|
// 由设备树建立 equipId → 设备名/类型 映射,明细行按设备节点展示;历史建筑/楼层/房间数据兼容展示
|
||||||
let detailUidSeed = 0;
|
const readonlyDetails = computed<ReadonlyDetailRow[]>(() => {
|
||||||
const nextDetailUid = () => ++detailUidSeed;
|
const d = detailRecord.value;
|
||||||
|
if (!d || !Array.isArray(d.details)) return [];
|
||||||
const detailRows = ref<DetailRow[]>([]);
|
const equipMap = new Map<number, any>();
|
||||||
|
const walk = (nodes: any[]) => {
|
||||||
function addDetailRow() {
|
for (const n of nodes || []) {
|
||||||
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
|
if (n?.id != null) equipMap.set(Number(n.id), n);
|
||||||
|
walk(n?.children);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
function removeDetailRow(index: number) {
|
walk(d.equipmentTree);
|
||||||
detailRows.value.splice(index, 1);
|
return d.details.map((dt: any) => {
|
||||||
}
|
const eq = dt.equipId != null ? equipMap.get(Number(dt.equipId)) : undefined;
|
||||||
|
const floor = dt.floorNo === undefined || dt.floorNo === null || dt.floorNo === '' ? undefined : String(dt.floorNo);
|
||||||
function handleRowBuildingChange(row: DetailRow) {
|
let floorLabel = undefined;
|
||||||
row.buildingName = buildingById(row.buildingId)?.buildingName;
|
if (floor !== undefined) {
|
||||||
row.floorNo = undefined;
|
const n = Number(floor);
|
||||||
row.roomId = undefined;
|
floorLabel = Number.isNaN(n) ? floor : n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
row.roomName = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRowFloorChange(row: DetailRow) {
|
|
||||||
row.roomId = undefined;
|
|
||||||
row.roomName = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRowRoomChange(row: DetailRow) {
|
|
||||||
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
|
|
||||||
row.roomName = room?.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function beforePhotoUpload(file: File) {
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('仅支持上传图片文件');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
message.error('图片大小不能超过 10MB');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePhotoUpload(options: any, row: DetailRow) {
|
|
||||||
const { file, onSuccess, onError } = options;
|
|
||||||
try {
|
|
||||||
const res: any = await uploadApi.uploadImage(file as File);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
row.photos.push({ key: res.data.key, url: res.data.url });
|
|
||||||
onSuccess?.(res.data);
|
|
||||||
} else {
|
|
||||||
throw new Error(res.message || '上传失败');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '照片上传失败');
|
|
||||||
onError?.(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePhoto(row: DetailRow, index: number) {
|
|
||||||
row.photos.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
|
|
||||||
function buildDetails(): SurveyOrderDetail[] {
|
|
||||||
return detailRows.value
|
|
||||||
.filter((row) => !!row.buildingId && !!row.floorNo)
|
|
||||||
.map((row) => ({
|
|
||||||
buildingId: row.buildingId,
|
|
||||||
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
|
|
||||||
floorNo: row.floorNo,
|
|
||||||
roomId: row.roomId ?? undefined,
|
|
||||||
roomName: row.roomName || undefined,
|
|
||||||
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
|
|
||||||
remark: row.remark.trim() || undefined,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
equipName: eq?.equipName ?? undefined,
|
||||||
|
equipType: eq?.equipType ?? undefined,
|
||||||
|
buildingName: dt.buildingName ?? undefined,
|
||||||
|
floorLabel,
|
||||||
|
roomName: dt.roomName ?? undefined,
|
||||||
|
photos: (dt.photoUrls || []).filter((u: string) => !!u),
|
||||||
|
remark: dt.remark || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 报酬与电工 ====================
|
// ==================== 报酬与电工 ====================
|
||||||
const quote = ref<SurveyQuote>({
|
const quote = ref<SurveyQuote>({
|
||||||
@@ -876,8 +656,8 @@ const step1Done = computed(
|
|||||||
!!formState.siteLocation.trim() &&
|
!!formState.siteLocation.trim() &&
|
||||||
Number(formState.loopCount) > 0
|
Number(formState.loopCount) > 0
|
||||||
);
|
);
|
||||||
const step3Done = computed(() => !!formState.workerId);
|
const step2Done = computed(() => !!formState.workerId);
|
||||||
const currentStep = computed(() => (step1Done.value ? (step3Done.value ? 3 : 2) : 1));
|
const currentStep = computed(() => (step1Done.value ? 2 : 1));
|
||||||
const filledCount = computed(
|
const filledCount = computed(
|
||||||
() => [!!formState.customerId, Number(formState.loopCount) > 0, !!formState.workerId].filter(Boolean).length
|
() => [!!formState.customerId, Number(formState.loopCount) > 0, !!formState.workerId].filter(Boolean).length
|
||||||
);
|
);
|
||||||
@@ -899,7 +679,8 @@ function clearError(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goLedger() {
|
function goLedger() {
|
||||||
router.push({ name: 'DispatchSurvey' });
|
// 编辑态返回勘察台账;新建态(从客户拜访发起)返回客户拜访
|
||||||
|
router.push({ name: isEdit.value ? 'DispatchSurvey' : 'CustomerVisit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCustomerChange() {
|
function handleCustomerChange() {
|
||||||
@@ -913,9 +694,6 @@ function handleCustomerChange() {
|
|||||||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
|
||||||
detailRows.value = [];
|
|
||||||
loadTopology();
|
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,17 +780,6 @@ async function loadDetail() {
|
|||||||
d.planEnd ? dayjs(d.planEnd) : null,
|
d.planEnd ? dayjs(d.planEnd) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
|
|
||||||
detailRows.value = (d.details || []).map((dt) => ({
|
|
||||||
uid: nextDetailUid(),
|
|
||||||
buildingId: dt.buildingId ?? undefined,
|
|
||||||
buildingName: dt.buildingName ?? undefined,
|
|
||||||
floorNo: dt.floorNo ?? undefined,
|
|
||||||
roomId: dt.roomId ?? undefined,
|
|
||||||
roomName: dt.roomName ?? undefined,
|
|
||||||
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
|
|
||||||
remark: dt.remark || '',
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message || '获取勘察派单详情失败');
|
message.error(res.message || '获取勘察派单详情失败');
|
||||||
}
|
}
|
||||||
@@ -1066,9 +833,8 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
|||||||
workerId: formState.workerId,
|
workerId: formState.workerId,
|
||||||
estimatedFee: quote.value.total,
|
estimatedFee: quote.value.total,
|
||||||
remark: formState.remark.trim() || undefined,
|
remark: formState.remark.trim() || undefined,
|
||||||
source: 'ledger',
|
source: isEdit.value ? 'ledger' : 'visit',
|
||||||
isDraft,
|
isDraft,
|
||||||
details: buildDetails(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,16 +888,16 @@ async function handleSubmit(draft: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
// 客户拜访「一键发起勘察派单」跳转预填:/customer/visit/survey/create?customerId=xx
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
const preCustomerId = Number(route.query.customerId || 0);
|
||||||
if (preCustomerId && !isEdit.value) {
|
if (preCustomerId && !isEdit.value) {
|
||||||
formState.customerId = preCustomerId;
|
formState.customerId = preCustomerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCustomers();
|
await loadCustomers();
|
||||||
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
// 编辑态先取详情(含 customerId 回填)
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
await Promise.all([loadQuote(), loadWorkers()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1264,7 +1030,7 @@ onMounted(async () => {
|
|||||||
/* 步骤条 */
|
/* 步骤条 */
|
||||||
.step-bar {
|
.step-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.step-item {
|
.step-item {
|
||||||
|
|||||||
@@ -31,10 +31,6 @@
|
|||||||
<template #icon><ThunderboltOutlined /></template>
|
<template #icon><ThunderboltOutlined /></template>
|
||||||
智能派单
|
智能派单
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><PlusOutlined /></template>
|
|
||||||
发起勘察派单
|
|
||||||
</a-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +264,7 @@
|
|||||||
<template #overlay>
|
<template #overlay>
|
||||||
<a-menu @click="({ key }) => handleMore(key as string, record)">
|
<a-menu @click="({ key }) => handleMore(key as string, record)">
|
||||||
<a-menu-item key="detail">查看详情</a-menu-item>
|
<a-menu-item key="detail">查看详情</a-menu-item>
|
||||||
|
<a-menu-item key="details">勘察明细</a-menu-item>
|
||||||
<a-menu-item v-if="canEdit(record)" key="edit">编辑</a-menu-item>
|
<a-menu-item v-if="canEdit(record)" key="edit">编辑</a-menu-item>
|
||||||
<a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
|
<a-menu-item v-if="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
|
||||||
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
|
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
|
||||||
@@ -409,10 +406,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 审核测绘成果 -->
|
<!-- 技术复核 -->
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:open="reviewVisible"
|
v-model:open="reviewVisible"
|
||||||
title="审核测绘成果"
|
title="技术复核"
|
||||||
:confirm-loading="reviewLoading"
|
:confirm-loading="reviewLoading"
|
||||||
width="560px"
|
width="560px"
|
||||||
@ok="handleReviewSubmit"
|
@ok="handleReviewSubmit"
|
||||||
@@ -514,6 +511,15 @@
|
|||||||
<div v-else class="detail-text">—</div>
|
<div v-else class="detail-text">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 勘察明细拓扑图:客户设备树 + 明细登记标记 -->
|
||||||
|
<div v-if="detailRecord" class="detail-block">
|
||||||
|
<div class="detail-label">勘察明细拓扑图</div>
|
||||||
|
<div v-if="detailRecord?.equipmentTree?.length" class="detail-topo-wrap">
|
||||||
|
<div ref="detailTopoRef" class="detail-topo-chart"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="detail-topo-empty">该客户暂无设备,录入设备后将在此按拓扑图展示勘察明细</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="detailRecord.details?.length" class="detail-block">
|
<div v-if="detailRecord.details?.length" class="detail-block">
|
||||||
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||||
<div class="detail-list">
|
<div class="detail-list">
|
||||||
@@ -521,8 +527,11 @@
|
|||||||
<div class="detail-row-head">
|
<div class="detail-row-head">
|
||||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
<span class="detail-row-scope">
|
<span class="detail-row-scope">
|
||||||
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
{{ equipNameOf(row.equipId) || row.buildingName || '—' }}
|
||||||
|
<template v-if="!equipNameOf(row.equipId)">
|
||||||
|
· {{ floorLabel(row.floorNo) }}
|
||||||
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.photoUrls?.length" class="photo-list">
|
<div v-if="row.photoUrls?.length" class="photo-list">
|
||||||
@@ -547,13 +556,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PlusOutlined,
|
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
FilterOutlined,
|
FilterOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
@@ -809,10 +818,7 @@ onMounted(() => {
|
|||||||
fetchList();
|
fetchList();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchWorkerOptions();
|
fetchWorkerOptions();
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
// 勘察派单仅在客户拜访中发起:台账不再响应 ?customerId 自动跳转
|
||||||
if (preCustomerId) {
|
|
||||||
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== 筛选与分页 ====================
|
// ==================== 筛选与分页 ====================
|
||||||
@@ -849,10 +855,6 @@ function handleExport() {
|
|||||||
message.info('导出台账数据功能暂未开放');
|
message.info('导出台账数据功能暂未开放');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAdd() {
|
|
||||||
router.push({ name: 'DispatchSurveyCreate' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 行内操作 ====================
|
// ==================== 行内操作 ====================
|
||||||
const canEdit = (record: SurveyOrderInfo) =>
|
const canEdit = (record: SurveyOrderInfo) =>
|
||||||
record.status === SURVEY_STATUS.PENDING_ASSIGN || record.status === SURVEY_STATUS.SURVEYING;
|
record.status === SURVEY_STATUS.PENDING_ASSIGN || record.status === SURVEY_STATUS.SURVEYING;
|
||||||
@@ -866,7 +868,7 @@ const canCancel = (record: SurveyOrderInfo) =>
|
|||||||
function primaryAction(record: SurveyOrderInfo): { label: string } | null {
|
function primaryAction(record: SurveyOrderInfo): { label: string } | null {
|
||||||
if (record.status === SURVEY_STATUS.PENDING_ASSIGN) return { label: '指派电工' };
|
if (record.status === SURVEY_STATUS.PENDING_ASSIGN) return { label: '指派电工' };
|
||||||
if (record.status === SURVEY_STATUS.SURVEYING) return { label: '进度跟踪' };
|
if (record.status === SURVEY_STATUS.SURVEYING) return { label: '进度跟踪' };
|
||||||
if (record.status === SURVEY_STATUS.PENDING_REVIEW) return { label: '审核测绘成果' };
|
if (record.status === SURVEY_STATUS.PENDING_REVIEW) return { label: '技术复核' };
|
||||||
if (record.status === SURVEY_STATUS.ARCHIVED) return { label: '生成方案' };
|
if (record.status === SURVEY_STATUS.ARCHIVED) return { label: '生成方案' };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -875,13 +877,14 @@ function handlePrimary(record: SurveyOrderInfo) {
|
|||||||
const action = primaryAction(record);
|
const action = primaryAction(record);
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
if (action.label === '指派电工') return openAssign(record, false);
|
if (action.label === '指派电工') return openAssign(record, false);
|
||||||
if (action.label === '进度跟踪') return goDetail(record);
|
if (action.label === '进度跟踪') return router.push({ name: 'DispatchSurveyDetail', params: { id: record.id } });
|
||||||
if (action.label === '审核测绘成果') return openReview(record);
|
if (action.label === '技术复核') return openReview(record);
|
||||||
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
|
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMore(key: string, record: SurveyOrderInfo) {
|
function handleMore(key: string, record: SurveyOrderInfo) {
|
||||||
if (key === 'detail') return openDetail(record);
|
if (key === 'detail') return openDetail(record);
|
||||||
|
if (key === 'details') return router.push({ name: 'DispatchSurveyDetail', params: { id: record.id } });
|
||||||
if (key === 'edit') return goDetail(record);
|
if (key === 'edit') return goDetail(record);
|
||||||
if (key === 'reassign') return openAssign(record, true);
|
if (key === 'reassign') return openAssign(record, true);
|
||||||
if (key === 'urge') return handleUrge(record);
|
if (key === 'urge') return handleUrge(record);
|
||||||
@@ -1123,6 +1126,90 @@ async function handleCancelSubmit() {
|
|||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||||||
|
|
||||||
|
// 详情拓扑图(勘察明细按设备树展示)
|
||||||
|
const detailTopoRef = ref<HTMLDivElement | null>(null);
|
||||||
|
let detailTopoChart: echarts.ECharts | null = null;
|
||||||
|
|
||||||
|
function equipNameOf(equipId?: number | null): string {
|
||||||
|
if (equipId == null) return '';
|
||||||
|
const find = (nodes: any[]): string => {
|
||||||
|
for (const n of nodes || []) {
|
||||||
|
if (Number(n.id) === Number(equipId)) return n.equipName || '';
|
||||||
|
const hit = find(n.children || []);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return find(detailRecord.value?.equipmentTree || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailTopo() {
|
||||||
|
const record = detailRecord.value;
|
||||||
|
if (!record || !detailTopoRef.value) return;
|
||||||
|
if (!detailTopoChart) detailTopoChart = echarts.init(detailTopoRef.value);
|
||||||
|
const detailByEquip = new Map<number, { photos: number; remark: boolean }>();
|
||||||
|
for (const dt of record.details || []) {
|
||||||
|
if (dt.equipId == null) continue;
|
||||||
|
detailByEquip.set(Number(dt.equipId), {
|
||||||
|
photos: dt.photos?.length || 0,
|
||||||
|
remark: !!dt.remark,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const build = (nodes: any[]): any[] =>
|
||||||
|
(nodes || []).map((n) => {
|
||||||
|
const d = detailByEquip.get(Number(n.id));
|
||||||
|
const hasDetail = !!d;
|
||||||
|
return {
|
||||||
|
name: n.equipName || '未命名设备',
|
||||||
|
id: n.id,
|
||||||
|
equipType: n.equipType,
|
||||||
|
hasDetail,
|
||||||
|
photos: d?.photos || 0,
|
||||||
|
remark: !!d?.remark,
|
||||||
|
itemStyle: { color: hasDetail ? '#1677ff' : '#bcc6d4' },
|
||||||
|
children: build(n.children),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
detailTopoChart.setOption(
|
||||||
|
{
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
triggerOn: 'mousemove',
|
||||||
|
formatter: (p: any) => {
|
||||||
|
const d = p.data;
|
||||||
|
if (!d?.equipType) return '';
|
||||||
|
const reg = d.hasDetail
|
||||||
|
? `已登记勘察(照片 ${d.photos} 张${d.remark ? ' · 含备注' : ''})`
|
||||||
|
: '未登记勘察';
|
||||||
|
return `${d.equipType}:${d.name}<br/>${reg}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'tree',
|
||||||
|
data: [{ name: record.customerName || '客户', children: build(record.equipmentTree) }],
|
||||||
|
top: '6%',
|
||||||
|
left: '8%',
|
||||||
|
bottom: '8%',
|
||||||
|
right: '10%',
|
||||||
|
roam: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 10,
|
||||||
|
initialTreeDepth: -1,
|
||||||
|
expandAndCollapse: true,
|
||||||
|
label: { position: 'left', verticalAlign: 'middle', align: 'right', fontSize: 12 },
|
||||||
|
leaves: { label: { position: 'right', verticalAlign: 'middle', align: 'left' } },
|
||||||
|
emphasis: { focus: 'descendant' },
|
||||||
|
lineStyle: { color: '#c7d0da', width: 1.2 },
|
||||||
|
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||||
|
animationDuration: 400,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(record: SurveyOrderInfo) {
|
async function openDetail(record: SurveyOrderInfo) {
|
||||||
detailRecord.value = record;
|
detailRecord.value = record;
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
@@ -1132,8 +1219,22 @@ async function openDetail(record: SurveyOrderInfo) {
|
|||||||
} catch {
|
} catch {
|
||||||
// 详情获取失败时保留列表行数据
|
// 详情获取失败时保留列表行数据
|
||||||
}
|
}
|
||||||
|
await nextTick();
|
||||||
|
renderDetailTopo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(detailVisible, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
detailTopoChart?.dispose();
|
||||||
|
detailTopoChart = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
detailTopoChart?.dispose();
|
||||||
|
detailTopoChart = null;
|
||||||
|
});
|
||||||
|
|
||||||
function goDetail(record: SurveyOrderInfo) {
|
function goDetail(record: SurveyOrderInfo) {
|
||||||
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
|
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
|
||||||
}
|
}
|
||||||
@@ -1906,6 +2007,33 @@ function maskPhone(phone?: string) {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-topo-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 440px;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-topo-empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 440px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #a4b0bf;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px dashed #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-topo-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
+14
-8
@@ -438,6 +438,7 @@ CREATE TABLE customer_equipment (
|
|||||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
equip_name VARCHAR(128) NOT NULL,
|
equip_name VARCHAR(128) NOT NULL,
|
||||||
equip_type VARCHAR(32) NOT NULL,
|
equip_type VARCHAR(32) NOT NULL,
|
||||||
|
parent_id BIGINT REFERENCES customer_equipment(id),
|
||||||
building_id BIGINT REFERENCES customer_building(id),
|
building_id BIGINT REFERENCES customer_building(id),
|
||||||
floor_no VARCHAR(16),
|
floor_no VARCHAR(16),
|
||||||
location VARCHAR(256),
|
location VARCHAR(256),
|
||||||
@@ -446,12 +447,13 @@ CREATE TABLE customer_equipment (
|
|||||||
photo_url VARCHAR(512),
|
photo_url VARCHAR(512),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE customer_equipment IS '客户智电改造设备信息表(配电间/井/箱)';
|
COMMENT ON TABLE customer_equipment IS '客户智电改造设备信息表(配电间/井/箱/柜)';
|
||||||
COMMENT ON COLUMN customer_equipment.id IS '自增主键';
|
COMMENT ON COLUMN customer_equipment.id IS '自增主键';
|
||||||
COMMENT ON COLUMN customer_equipment.equip_code IS '客户设备编码(唯一)';
|
COMMENT ON COLUMN customer_equipment.equip_code IS '客户设备编码(唯一)';
|
||||||
COMMENT ON COLUMN customer_equipment.customer_id IS '客户 ID(→ customer)';
|
COMMENT ON COLUMN customer_equipment.customer_id IS '客户 ID(→ customer)';
|
||||||
COMMENT ON COLUMN customer_equipment.equip_name IS '客户设备名称';
|
COMMENT ON COLUMN customer_equipment.equip_name IS '客户设备名称';
|
||||||
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱';
|
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱/配电柜';
|
||||||
|
COMMENT ON COLUMN customer_equipment.parent_id IS '父设备 ID(自引用,→ customer_equipment,空为根设备)';
|
||||||
COMMENT ON COLUMN customer_equipment.building_id IS '所属建筑 ID(→ customer_building)';
|
COMMENT ON COLUMN customer_equipment.building_id IS '所属建筑 ID(→ customer_building)';
|
||||||
COMMENT ON COLUMN customer_equipment.floor_no IS '所在楼层';
|
COMMENT ON COLUMN customer_equipment.floor_no IS '所在楼层';
|
||||||
COMMENT ON COLUMN customer_equipment.location IS '所在位置';
|
COMMENT ON COLUMN customer_equipment.location IS '所在位置';
|
||||||
@@ -461,6 +463,7 @@ COMMENT ON COLUMN customer_equipment.photo_url IS '未改造前设备照片';
|
|||||||
COMMENT ON COLUMN customer_equipment.created_at IS '创建时间';
|
COMMENT ON COLUMN customer_equipment.created_at IS '创建时间';
|
||||||
CREATE INDEX idx_equip_customer ON customer_equipment (customer_id);
|
CREATE INDEX idx_equip_customer ON customer_equipment (customer_id);
|
||||||
CREATE INDEX idx_equip_building ON customer_equipment (building_id);
|
CREATE INDEX idx_equip_building ON customer_equipment (building_id);
|
||||||
|
CREATE INDEX idx_equip_parent ON customer_equipment (parent_id);
|
||||||
|
|
||||||
-- 14. 客户访问账号表
|
-- 14. 客户访问账号表
|
||||||
DROP TABLE IF EXISTS customer_account CASCADE;
|
DROP TABLE IF EXISTS customer_account CASCADE;
|
||||||
@@ -768,6 +771,7 @@ DROP TABLE IF EXISTS survey_detail CASCADE;
|
|||||||
CREATE TABLE survey_detail (
|
CREATE TABLE survey_detail (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
||||||
|
equip_id BIGINT REFERENCES customer_equipment(id),
|
||||||
building_id BIGINT,
|
building_id BIGINT,
|
||||||
building_name VARCHAR(128),
|
building_name VARCHAR(128),
|
||||||
floor_no VARCHAR(16),
|
floor_no VARCHAR(16),
|
||||||
@@ -778,19 +782,21 @@ CREATE TABLE survey_detail (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
|
COMMENT ON TABLE survey_detail IS '勘察工单明细表(关联客户设备 + 现场照片)';
|
||||||
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
||||||
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
|
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
|
||||||
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
|
COMMENT ON COLUMN survey_detail.equip_id IS '客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)';
|
||||||
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
|
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building,兼容历史数据)';
|
||||||
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
|
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,兼容历史数据)';
|
||||||
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
|
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1(兼容历史数据)';
|
||||||
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
|
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空,兼容历史数据)';
|
||||||
|
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空,兼容历史数据)';
|
||||||
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
||||||
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
||||||
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
||||||
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
|
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
|
||||||
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
|
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
|
||||||
|
CREATE INDEX idx_survey_detail_equip ON survey_detail (equip_id);
|
||||||
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
|
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 勘察派单重构:客户设备树形化 + 勘察明细关联设备
|
||||||
|
-- 1) customer_equipment 增加 parent_id(自引用,树形层级:配电间→配电箱/井/柜,支持多级)
|
||||||
|
-- 2) customer_equipment.equip_type 增加「配电柜」选项(注释同步)
|
||||||
|
-- 3) survey_detail 增加 equip_id(关联 customer_equipment,明细照片/备注挂到设备节点)
|
||||||
|
-- 幂等:仅新增缺失的列/索引,重复执行结果一致
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. customer_equipment.parent_id
|
||||||
|
ALTER TABLE customer_equipment
|
||||||
|
ADD COLUMN IF NOT EXISTS parent_id BIGINT REFERENCES customer_equipment(id);
|
||||||
|
COMMENT ON COLUMN customer_equipment.parent_id IS '父设备ID(自引用,→ customer_equipment,空为根设备)';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equip_parent ON customer_equipment (parent_id);
|
||||||
|
|
||||||
|
-- 2. 设备类型注释补充「配电柜」
|
||||||
|
COMMENT ON COLUMN customer_equipment.equip_type IS '设备类型:配电间/配电井/配电箱/配电柜';
|
||||||
|
|
||||||
|
-- 3. survey_detail.equip_id
|
||||||
|
ALTER TABLE survey_detail
|
||||||
|
ADD COLUMN IF NOT EXISTS equip_id BIGINT REFERENCES customer_equipment(id);
|
||||||
|
COMMENT ON COLUMN survey_detail.equip_id IS '客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_detail_equip ON survey_detail (equip_id);
|
||||||
@@ -8,6 +8,10 @@ export class CustomerEquipmentController {
|
|||||||
@Inject() customerEquipmentService!: CustomerEquipmentService;
|
@Inject() customerEquipmentService!: CustomerEquipmentService;
|
||||||
|
|
||||||
@Get("/") async list(@Query() query: any) { return this.customerEquipmentService.list(query, this.ctx); }
|
@Get("/") async list(@Query() query: any) { return this.customerEquipmentService.list(query, this.ctx); }
|
||||||
|
|
||||||
|
// 静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||||
|
@Get("/tree") async tree(@Query() query: any) { return this.customerEquipmentService.tree(query, this.ctx); }
|
||||||
|
|
||||||
@Get("/:id") async detail(@Param("id") id: string) { return this.customerEquipmentService.detail(Number(id), this.ctx); }
|
@Get("/:id") async detail(@Param("id") id: string) { return this.customerEquipmentService.detail(Number(id), this.ctx); }
|
||||||
@Post("/") async create(@Body() body: any) { return this.customerEquipmentService.create(body, this.ctx); }
|
@Post("/") async create(@Body() body: any) { return this.customerEquipmentService.create(body, this.ctx); }
|
||||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerEquipmentService.update(Number(id), body, this.ctx); }
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerEquipmentService.update(Number(id), body, this.ctx); }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
import { SurveyOrderService } from "../service/survey_order.service";
|
import { SurveyOrderService } from "../service/survey_order.service";
|
||||||
|
import { SurveyOrderSaveDetailsDTO } from "../dto/survey_order.dto";
|
||||||
|
|
||||||
@Controller("/api/survey-orders")
|
@Controller("/api/survey-orders")
|
||||||
export class SurveyOrderController {
|
export class SurveyOrderController {
|
||||||
@@ -21,7 +22,7 @@ export class SurveyOrderController {
|
|||||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
||||||
|
|
||||||
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
|
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
|
||||||
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
|
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: SurveyOrderSaveDetailsDTO) {
|
||||||
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ export class SurveyOrderController {
|
|||||||
return this.surveyOrderService.review(Number(id), body, this.ctx);
|
return this.surveyOrderService.review(Number(id), body, this.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勘察完成:现场勘察中 → 待技术复核
|
||||||
|
@Put("/:id/complete") async complete(@Param("id") id: string) {
|
||||||
|
return this.surveyOrderService.complete(Number(id), this.ctx);
|
||||||
|
}
|
||||||
|
|
||||||
// 催办
|
// 催办
|
||||||
@Post("/:id/urge") async urge(@Param("id") id: string) { return this.surveyOrderService.urge(Number(id), this.ctx); }
|
@Post("/:id/urge") async urge(@Param("id") id: string) { return this.surveyOrderService.urge(Number(id), this.ctx); }
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
MaxLength
|
MaxLength,
|
||||||
|
ValidateNested
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
export class SurveyOrderQueryDTO {
|
export class SurveyOrderQueryDTO {
|
||||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||||
@@ -73,6 +75,21 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsArray() declare details?: any[];
|
@IsOptional() @IsArray() declare details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勘察明细条目:按设备节点维护(equipId 必填),photos 为对象 key 数组,remark 备注
|
||||||
|
export class SurveyDetailItemDTO {
|
||||||
|
@IsNotEmpty({ message: '设备不能为空' }) @IsInt() equipId!: number;
|
||||||
|
@IsOptional() @IsArray() @IsString({ each: true }) photos?: string[];
|
||||||
|
@IsOptional() @IsString() @MaxLength(255) @Matches(/^[^<>]*$/) remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存勘察明细(全量替换;空数组表示清空明细)
|
||||||
|
export class SurveyOrderSaveDetailsDTO {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SurveyDetailItemDTO)
|
||||||
|
details!: SurveyDetailItemDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
||||||
export class SurveyOrderAssignDTO {
|
export class SurveyOrderAssignDTO {
|
||||||
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
|
@IsNotEmpty({ message: '请选择共享电工' }) @IsInt() workerId!: number;
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import CustomerBuilding from "./customer_building.entity";
|
|||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: "customer_equipment",
|
tableName: "customer_equipment",
|
||||||
comment: "客户设备台账表",
|
comment: "客户设备台账表(树形层级)",
|
||||||
timestamps: false,
|
timestamps: false,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ name: "idx_equip_customer", fields: ["customer_id"] },
|
{ name: "idx_equip_customer", fields: ["customer_id"] },
|
||||||
{ name: "idx_equip_building", fields: ["building_id"] },
|
{ name: "idx_equip_building", fields: ["building_id"] },
|
||||||
|
{ name: "idx_equip_parent", fields: ["parent_id"] },
|
||||||
{ name: "uk_equip_code", unique: true, fields: ["equip_code"] }
|
{ name: "uk_equip_code", unique: true, fields: ["equip_code"] }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -24,9 +25,12 @@ export default class CustomerEquipment extends Model {
|
|||||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "equip_name", comment: "设备名称" })
|
@Column({ type: DataType.STRING(128), allowNull: false, field: "equip_name", comment: "设备名称" })
|
||||||
equipName!: string;
|
equipName!: string;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型" })
|
@Column({ type: DataType.STRING(32), field: "equip_type", comment: "设备类型:配电间/配电井/配电箱/配电柜" })
|
||||||
equipType!: string;
|
equipType!: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.BIGINT, field: "parent_id", comment: "父设备ID(自引用,空为根设备)" })
|
||||||
|
parentId!: number;
|
||||||
|
|
||||||
@ForeignKey(() => CustomerBuilding)
|
@ForeignKey(() => CustomerBuilding)
|
||||||
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑ID" })
|
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑ID" })
|
||||||
buildingId!: number;
|
buildingId!: number;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||||
import SurveyOrder from "./survey_order.entity";
|
import SurveyOrder from "./survey_order.entity";
|
||||||
|
import CustomerEquipment from "./customer_equipment.entity";
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: "survey_detail",
|
tableName: "survey_detail",
|
||||||
comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
|
comment: "勘察工单明细表(关联客户设备 + 现场照片)",
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
createdAt: "created_at",
|
createdAt: "created_at",
|
||||||
updatedAt: "updated_at",
|
updatedAt: "updated_at",
|
||||||
indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
|
indexes: [
|
||||||
|
{ name: "idx_survey_detail_order", fields: ["survey_order_id"] },
|
||||||
|
{ name: "idx_survey_detail_equip", fields: ["equip_id"] }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export default class SurveyDetail extends Model {
|
export default class SurveyDetail extends Model {
|
||||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||||
@@ -17,7 +21,11 @@ export default class SurveyDetail extends Model {
|
|||||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
|
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
|
||||||
surveyOrderId!: number;
|
surveyOrderId!: number;
|
||||||
|
|
||||||
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
|
@ForeignKey(() => CustomerEquipment)
|
||||||
|
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备ID(→ customer_equipment,勘察明细挂接到设备节点)" })
|
||||||
|
equipId!: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building,兼容历史数据)" })
|
||||||
buildingId!: number;
|
buildingId!: number;
|
||||||
|
|
||||||
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
|
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
|
||||||
|
|||||||
@@ -110,10 +110,17 @@ export class CustomerEquipmentService {
|
|||||||
where,
|
where,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
order: [["created_at", "DESC"]]
|
order: [["created_at", "DESC"]],
|
||||||
|
include: [{ model: this.buildingModel, as: "building", attributes: ["buildingName"], required: false }]
|
||||||
});
|
});
|
||||||
|
|
||||||
return { code: 200, message: "获取客户设备列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
|
const list = rows.map((r) => {
|
||||||
|
const j = r.toJSON();
|
||||||
|
j.buildingName = j.building?.buildingName ?? null;
|
||||||
|
delete j.building;
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
return { code: 200, message: "获取客户设备列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(id: number, ctx: Context): Promise<any> {
|
async detail(id: number, ctx: Context): Promise<any> {
|
||||||
@@ -123,9 +130,72 @@ export class CustomerEquipmentService {
|
|||||||
return { code: 200, message: "获取客户设备详情成功", data: record.toJSON() };
|
return { code: 200, message: "获取客户设备详情成功", data: record.toJSON() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验 parentId:父设备存在、同属客户、非自身/子节点(防循环)
|
||||||
|
private async validateParent(parentId: any, customerId: number, selfId?: number): Promise<string | null> {
|
||||||
|
if (parentId === undefined || parentId === null || parentId === "") return null;
|
||||||
|
const pid = Number(parentId);
|
||||||
|
if (!pid) return "父设备不存在";
|
||||||
|
if (selfId !== undefined && pid === selfId) return "父设备不能是自身";
|
||||||
|
if (selfId !== undefined) {
|
||||||
|
const descendants = await this.collectDescendants(selfId);
|
||||||
|
if (descendants.includes(pid)) return "不能将子设备设为父设备(会形成循环)";
|
||||||
|
}
|
||||||
|
const parent = await this.equipmentModel.findByPk(pid);
|
||||||
|
if (!parent) return "父设备不存在";
|
||||||
|
if (parent.customerId !== customerId) return "父设备不属于该客户";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集节点所有子节点 id(直接查询子树,仅用于防循环校验)
|
||||||
|
private async collectDescendants(equipId: number): Promise<number[]> {
|
||||||
|
const ids: number[] = [];
|
||||||
|
const rows = await this.equipmentModel.findAll({ attributes: ["id", "parentId"] });
|
||||||
|
const byParent = new Map<number, number[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const pid = r.parentId;
|
||||||
|
if (pid == null) continue;
|
||||||
|
const arr = byParent.get(Number(pid)) || [];
|
||||||
|
arr.push(Number(r.id));
|
||||||
|
byParent.set(Number(pid), arr);
|
||||||
|
}
|
||||||
|
const stack = [...(byParent.get(equipId) || [])];
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.pop()!;
|
||||||
|
if (ids.includes(n)) continue;
|
||||||
|
ids.push(n);
|
||||||
|
stack.push(...(byParent.get(n) || []));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按客户组装嵌套设备树(根节点 parent_id 为空)
|
||||||
|
async tree(query: any, ctx: Context): Promise<any> {
|
||||||
|
const customerId = Number(query.customerId);
|
||||||
|
if (!customerId) return { code: 400, message: "缺少客户ID参数", data: [] };
|
||||||
|
if (!(await this.isCustomerOwner(ctx.state.user.userId, customerId))) return { code: 403, message: "无权限访问该客户的设备", data: [] };
|
||||||
|
|
||||||
|
const rows = await this.equipmentModel.findAll({ where: { customerId }, order: [["created_at", "ASC"]] });
|
||||||
|
const list = rows.map((r) => r.toJSON());
|
||||||
|
const nodeMap = new Map<number, any>();
|
||||||
|
const roots: any[] = [];
|
||||||
|
// BIGINT 由 pg 返回为字符串,统一转 number 作为键,确保父子挂接正确
|
||||||
|
for (const node of list) nodeMap.set(Number(node.id), { ...node, children: [] });
|
||||||
|
for (const node of list) {
|
||||||
|
const mapped = nodeMap.get(Number(node.id))!;
|
||||||
|
const pid = node.parentId;
|
||||||
|
const parent = pid != null ? nodeMap.get(Number(pid)) : undefined;
|
||||||
|
if (parent) parent.children.push(mapped);
|
||||||
|
else roots.push(mapped);
|
||||||
|
}
|
||||||
|
return { code: 200, message: "获取客户设备树成功", data: roots };
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: any, ctx: Context): Promise<any> {
|
async create(data: any, ctx: Context): Promise<any> {
|
||||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建设备", data: null };
|
if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建设备", data: null };
|
||||||
|
|
||||||
|
const parentErr = await this.validateParent(data.parentId, data.customerId);
|
||||||
|
if (parentErr) return { code: 400, message: parentErr, data: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.equipCode = await this.generateEquipCode();
|
data.equipCode = await this.generateEquipCode();
|
||||||
const created = await this.equipmentModel.create(data);
|
const created = await this.equipmentModel.create(data);
|
||||||
@@ -152,6 +222,9 @@ export class CustomerEquipmentService {
|
|||||||
delete data.customerId;
|
delete data.customerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentErr = await this.validateParent(data.parentId, record.customerId, id);
|
||||||
|
if (parentErr) return { code: 400, message: parentErr, data: null };
|
||||||
|
|
||||||
delete data.equipCode;
|
delete data.equipCode;
|
||||||
delete data.createdAt;
|
delete data.createdAt;
|
||||||
delete data.created_at;
|
delete data.created_at;
|
||||||
@@ -171,6 +244,8 @@ export class CustomerEquipmentService {
|
|||||||
const record = await this.equipmentModel.findByPk(id);
|
const record = await this.equipmentModel.findByPk(id);
|
||||||
if (!record) return { code: 404, message: "客户设备不存在", data: null };
|
if (!record) return { code: 404, message: "客户设备不存在", data: null };
|
||||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该设备", data: null };
|
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该设备", data: null };
|
||||||
|
const childCount = await this.equipmentModel.count({ where: { parentId: id } });
|
||||||
|
if (childCount > 0) return { code: 400, message: `该设备下还有 ${childCount} 个子设备,请先删除子设备`, data: null };
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
return { code: 200, message: "删除客户设备成功", data: null };
|
return { code: 200, message: "删除客户设备成功", data: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SurveyOrder from "../entity/survey_order.entity";
|
|||||||
import Customer from "../entity/customer.entity";
|
import Customer from "../entity/customer.entity";
|
||||||
import Worker from "../entity/worker.entity";
|
import Worker from "../entity/worker.entity";
|
||||||
import SurveyDetail from "../entity/survey_detail.entity";
|
import SurveyDetail from "../entity/survey_detail.entity";
|
||||||
|
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||||
import DispatchPrice from "../entity/dispatch_price.entity";
|
import DispatchPrice from "../entity/dispatch_price.entity";
|
||||||
import SysUser from "../entity/sysUser.entity";
|
import SysUser from "../entity/sysUser.entity";
|
||||||
import SysUserRole from "../entity/sysUserRole.entity";
|
import SysUserRole from "../entity/sysUserRole.entity";
|
||||||
@@ -38,6 +39,9 @@ export class SurveyOrderService {
|
|||||||
@InjectRepository(SurveyDetail)
|
@InjectRepository(SurveyDetail)
|
||||||
detailModel!: Repository<SurveyDetail>;
|
detailModel!: Repository<SurveyDetail>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerEquipment)
|
||||||
|
equipModel!: Repository<CustomerEquipment>;
|
||||||
|
|
||||||
@InjectRepository(DispatchPrice)
|
@InjectRepository(DispatchPrice)
|
||||||
priceModel!: Repository<DispatchPrice>;
|
priceModel!: Repository<DispatchPrice>;
|
||||||
|
|
||||||
@@ -251,16 +255,19 @@ export class SurveyOrderService {
|
|||||||
return { baseFee, loopFee, loopCount: count, total };
|
return { baseFee, loopFee, loopCount: count, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
|
// 明细入参清洗:equipId(设备节点)优先;兼容历史 建筑/楼层/房间 格式;photos 数组转 JSON 字符串
|
||||||
|
// 有效行条件:有 equipId 或(建筑 + 楼层都非空),否则丢弃
|
||||||
private normalizeDetails(input: any): any[] {
|
private normalizeDetails(input: any): any[] {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
return input
|
return input
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const photos = this.parsePhotos(row?.photos);
|
const photos = this.parsePhotos(row?.photos);
|
||||||
|
const equipId = row?.equipId !== undefined && row?.equipId !== null && row?.equipId !== "" ? Number(row.equipId) : null;
|
||||||
const buildingId = Number(row?.buildingId) || null;
|
const buildingId = Number(row?.buildingId) || null;
|
||||||
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
|
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
|
||||||
const roomId = Number(row?.roomId) || null;
|
const roomId = Number(row?.roomId) || null;
|
||||||
return {
|
return {
|
||||||
|
equipId,
|
||||||
buildingId,
|
buildingId,
|
||||||
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
|
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
|
||||||
floorNo: floorNo.slice(0, 16),
|
floorNo: floorNo.slice(0, 16),
|
||||||
@@ -270,8 +277,37 @@ export class SurveyOrderService {
|
|||||||
remark: row?.remark ? String(row.remark).slice(0, 255) : null
|
remark: row?.remark ? String(row.remark).slice(0, 255) : null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// 建筑与楼层是明细的定位要素,缺失的行直接丢弃
|
.filter((row) => row.equipId || (row.buildingId && row.floorNo));
|
||||||
.filter((row) => row.buildingId && row.floorNo);
|
}
|
||||||
|
|
||||||
|
// 校验明细中的 equipId 均属于工单客户,返回错误信息(null 表示通过)
|
||||||
|
private async validateDetailEquips(order: SurveyOrder, details: any): Promise<string | null> {
|
||||||
|
const ids = [...new Set<number>((Array.isArray(details) ? details : []).map((d: any) => Number(d?.equipId)).filter(Boolean))];
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const equips = await this.equipModel.findAll({ where: { id: { [Op.in]: ids }, customerId: order.customerId }, attributes: ["id"] });
|
||||||
|
const found = new Set<number>(equips.map((e) => Number(e.id)));
|
||||||
|
const missing = ids.filter((i) => !found.has(i));
|
||||||
|
if (missing.length) return `存在不属于该客户的设备明细(设备ID: ${missing.join(", ")})`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装客户设备树(详情拓扑图展示用)
|
||||||
|
private async buildEquipTree(customerId: number): Promise<any[]> {
|
||||||
|
if (!customerId) return [];
|
||||||
|
const rows = await this.equipModel.findAll({ where: { customerId }, order: [["created_at", "ASC"]] });
|
||||||
|
const list = rows.map((r) => r.toJSON());
|
||||||
|
const nodeMap = new Map<number, any>();
|
||||||
|
const roots: any[] = [];
|
||||||
|
// BIGINT 由 pg 返回为字符串,统一转 number 作为键,确保父子挂接正确
|
||||||
|
for (const node of list) nodeMap.set(Number(node.id), { ...node, children: [] });
|
||||||
|
for (const node of list) {
|
||||||
|
const mapped = nodeMap.get(Number(node.id))!;
|
||||||
|
const pid = node.parentId;
|
||||||
|
const parent = pid != null ? nodeMap.get(Number(pid)) : undefined;
|
||||||
|
if (parent) parent.children.push(mapped);
|
||||||
|
else roots.push(mapped);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
|
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
|
||||||
@@ -387,6 +423,8 @@ export class SurveyOrderService {
|
|||||||
const vo = this.toVO(record);
|
const vo = this.toVO(record);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
vo.details = await this.getDetails(id);
|
vo.details = await this.getDetails(id);
|
||||||
|
// 客户设备树(勘察明细按设备节点拓扑展示)
|
||||||
|
vo.equipmentTree = await this.buildEquipTree(Number(record.customerId));
|
||||||
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
||||||
const photoKeys: string[] = vo.photos || [];
|
const photoKeys: string[] = vo.photos || [];
|
||||||
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
||||||
@@ -403,6 +441,9 @@ export class SurveyOrderService {
|
|||||||
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
|
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const equipErr = await this.validateDetailEquips(record, dto?.details);
|
||||||
|
if (equipErr) return { code: 400, message: equipErr, data: null };
|
||||||
|
|
||||||
await this.replaceDetails(id, dto?.details);
|
await this.replaceDetails(id, dto?.details);
|
||||||
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
|
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
|
||||||
}
|
}
|
||||||
@@ -576,6 +617,19 @@ export class SurveyOrderService {
|
|||||||
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", data: vo };
|
return { code: 200, message: dto.result === "pass" ? "复核通过,工单已归档" : "已退回现场勘察中", data: vo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勘察完成:现场勘察中 → 待技术复核
|
||||||
|
async complete(id: number, ctx: Context): Promise<any> {
|
||||||
|
const record = await this.surveyModel.findByPk(id);
|
||||||
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
|
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
|
||||||
|
if (record.status !== STATUS.SURVEYING) return { code: 400, message: "当前状态不可提交技术复核", data: null };
|
||||||
|
await record.update({ status: STATUS.PENDING_REVIEW });
|
||||||
|
const vo = this.toVO(record);
|
||||||
|
await this.decorate([vo]);
|
||||||
|
await this.logService.logAction(ctx, "勘察派单", "勘察完成", record.surveyNo, `勘察完成提交技术复核 勘察派单 ${record.surveyNo}`);
|
||||||
|
return { code: 200, message: "勘察完成,已提交技术复核", data: vo };
|
||||||
|
}
|
||||||
|
|
||||||
async urge(id: number, ctx: Context): Promise<any> {
|
async urge(id: number, ctx: Context): Promise<any> {
|
||||||
const record = await this.surveyModel.findByPk(id);
|
const record = await this.surveyModel.findByPk(id);
|
||||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
|
|||||||
@@ -143,3 +143,20 @@ alarm_dispatch 表(报警派单信息表)
|
|||||||
- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出
|
- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出
|
||||||
- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001)
|
- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001)
|
||||||
|
|
||||||
|
勘察派单重构(20261024):客户设备表树形化 + 勘察明细关联设备
|
||||||
|
|
||||||
|
背景:现场勘察流程中,客户设备(customer_equipment)需表达配电层级(配电间→配电箱/井/柜,支持多级嵌套);勘察明细(survey_detail)需按设备树维护并拓扑展示。
|
||||||
|
|
||||||
|
| 表 | 字段变更 | 类型/键 | 说明 |
|
||||||
|
| :-- | :------ | :----- | :--- |
|
||||||
|
| customer_equipment | 新增 parent_id | BIGINT,FK(自引用) | 父设备 ID,空为根设备,支持多级嵌套 |
|
||||||
|
| customer_equipment | equip_type 取值扩展 | VARCHAR(32) | 设备类型:配电间/配电井/配电箱/配电柜 |
|
||||||
|
| survey_detail | 新增 equip_id | BIGINT,FK(→ customer_equipment) | 勘察明细挂接到设备节点,photos/remark 仍挂在明细上 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 新增接口:`GET /api/customer-equipments/tree?customerId=xx` 返回按 parent_id 组装的嵌套设备树(数据权限与列表一致)
|
||||||
|
- 创建/更新设备支持 `parentId`,校验父设备存在、同属客户、非自身/子节点(防循环)
|
||||||
|
- 勘察明细保存(`PUT /api/survey-orders/:id/details`)新增 `equipId` 必填校验(设备属于工单客户),全量替换
|
||||||
|
- survey_detail 原 building/floor/room 字段保留为可空,兼容历史数据;新流程以设备树为主
|
||||||
|
|
||||||
Reference in new issue
Block a user