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
+2095
-442
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 {
|
||||
id: number;
|
||||
equipmentNo: string;
|
||||
equipmentName: string;
|
||||
equipCode: string;
|
||||
equipName: string;
|
||||
equipType?: string;
|
||||
customerId?: number;
|
||||
customerName?: string;
|
||||
building?: string;
|
||||
floor?: string;
|
||||
parentId?: number | null;
|
||||
buildingId?: number | null;
|
||||
buildingName?: string;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
photo?: string;
|
||||
status?: number;
|
||||
photoUrl?: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -24,7 +24,7 @@ export interface CustomerEquipmentQuery {
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
customerId?: number;
|
||||
building?: string;
|
||||
buildingId?: number;
|
||||
equipType?: string;
|
||||
}
|
||||
|
||||
@@ -36,26 +36,26 @@ export interface CustomerEquipmentListResult {
|
||||
}
|
||||
|
||||
export interface CustomerEquipmentCreateParams {
|
||||
equipmentName: string;
|
||||
equipName: string;
|
||||
equipType?: string;
|
||||
customerId?: number;
|
||||
building?: string;
|
||||
floor?: string;
|
||||
parentId?: number | null;
|
||||
buildingId?: number | null;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
photo?: string;
|
||||
status?: number;
|
||||
photoUrl?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface CustomerEquipmentUpdateParams {
|
||||
equipmentName?: string;
|
||||
equipName?: string;
|
||||
equipType?: string;
|
||||
customerId?: number;
|
||||
building?: string;
|
||||
floor?: string;
|
||||
parentId?: number | null;
|
||||
buildingId?: number | null;
|
||||
floorNo?: string;
|
||||
location?: string;
|
||||
photo?: string;
|
||||
status?: number;
|
||||
photoUrl?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +57,12 @@ export interface SurveyOrderWorker {
|
||||
completedCount?: number;
|
||||
}
|
||||
|
||||
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
|
||||
// 勘察明细:一条 = 设备节点(优先)+ 现场照片;兼容历史 建筑/楼层/房间
|
||||
export interface SurveyOrderDetail {
|
||||
id?: number;
|
||||
surveyOrderId?: number;
|
||||
// 客户设备 ID(→ customer_equipment,勘察明细挂接到设备节点)
|
||||
equipId?: number | null;
|
||||
buildingId?: number | null;
|
||||
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
||||
buildingName?: string | null;
|
||||
@@ -75,6 +77,18 @@ export interface SurveyOrderDetail {
|
||||
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 {
|
||||
id: number;
|
||||
surveyNo: string;
|
||||
@@ -124,8 +138,10 @@ export interface SurveyOrderInfo {
|
||||
updatedAt?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
|
||||
// 详情附带:勘察明细(设备节点/建筑楼层房间 + 现场照片)
|
||||
details?: SurveyOrderDetail[];
|
||||
// 详情附带:客户设备树(勘察明细按设备节点拓扑展示)
|
||||
equipmentTree?: SurveyEquipNode[];
|
||||
}
|
||||
|
||||
export interface SurveyOrderQuery {
|
||||
@@ -260,6 +276,9 @@ export const surveyOrderApi = {
|
||||
review: (id: number, data: SurveyOrderReviewParams) =>
|
||||
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`),
|
||||
|
||||
|
||||
+24
-12
@@ -79,6 +79,18 @@ const routes: RouteRecordRaw[] = [
|
||||
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",
|
||||
name: "CustomerTopology",
|
||||
@@ -133,18 +145,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/dispatch/survey/index.vue"),
|
||||
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",
|
||||
name: "DispatchSurveyEdit",
|
||||
@@ -157,6 +157,18 @@ const routes: RouteRecordRaw[] = [
|
||||
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",
|
||||
name: "DispatchInstall",
|
||||
|
||||
@@ -795,10 +795,10 @@ function handleFollow(record: CustomerVisitInfo) {
|
||||
router.push({ name: 'CustomerVisitEdit', params: { id: record.id }, query: { action: 'follow' } });
|
||||
}
|
||||
|
||||
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
||||
// 一键发起勘察派单:跳转两步向导并预填客户(不创建、不回写状态)
|
||||
function handleSurvey(record: CustomerVisitInfo) {
|
||||
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 class="filter-item">
|
||||
<label class="filter-label">所属建筑</label>
|
||||
<a-input
|
||||
v-model:value="searchForm.building"
|
||||
placeholder="建筑名称"
|
||||
<a-select
|
||||
v-model:value="searchForm.buildingId"
|
||||
placeholder="全部建筑"
|
||||
allow-clear
|
||||
@pressEnter="handleSearch"
|
||||
:options="searchBuildingOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
@@ -103,12 +103,12 @@
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'equipmentNo'">
|
||||
<span class="mono">{{ record.equipmentNo || '-' }}</span>
|
||||
<template v-if="column.key === 'equipCode'">
|
||||
<span class="mono">{{ record.equipCode || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'equipmentName'">
|
||||
<span class="equip-name">{{ record.equipmentName }}</span>
|
||||
<template v-else-if="column.key === 'equipName'">
|
||||
<span class="equip-name">{{ record.equipName }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'equipType'">
|
||||
@@ -120,10 +120,10 @@
|
||||
{{ customerNameMap[record.customerId] || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'photo'">
|
||||
<template v-else-if="column.key === 'photoUrl'">
|
||||
<a-image
|
||||
v-if="record.photo"
|
||||
:src="record.photo"
|
||||
v-if="record.photoUrl"
|
||||
:src="record.photoUrl"
|
||||
:width="44"
|
||||
:height="44"
|
||||
style="border-radius: 6px"
|
||||
@@ -175,7 +175,7 @@
|
||||
<div v-if="isEdit && editId" class="no-bar">
|
||||
<div class="no-item">
|
||||
<span class="no-label">设备编码</span>
|
||||
<span class="no-value mono">{{ editNo || '-' }}</span>
|
||||
<span class="no-value mono">{{ editCode || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,8 +195,8 @@
|
||||
<span class="section-sub">Equipment Identity</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="设备名称" name="equipmentName">
|
||||
<a-input v-model:value="formData.equipmentName" placeholder="请输入设备名称" maxlength="128" />
|
||||
<a-form-item label="设备名称" name="equipName">
|
||||
<a-input v-model:value="formData.equipName" placeholder="请输入设备名称" maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="equipType">
|
||||
<a-select
|
||||
@@ -208,6 +208,15 @@
|
||||
<a-select-option v-for="t in equipTypeOptions" :key="t" :value="t">{{ t }}</a-select-option>
|
||||
</a-select>
|
||||
</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-select
|
||||
v-model:value="formData.customerId"
|
||||
@@ -228,11 +237,18 @@
|
||||
<span class="section-sub">Location</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="所属建筑" name="building">
|
||||
<a-input v-model:value="formData.building" placeholder="请输入所属建筑" maxlength="128" />
|
||||
<a-form-item label="所属建筑" name="buildingId">
|
||||
<a-select
|
||||
v-model:value="formData.buildingId"
|
||||
placeholder="请选择所属建筑"
|
||||
allow-clear
|
||||
:options="buildingOptions"
|
||||
:loading="buildingLoading"
|
||||
@change="handleBuildingChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="所在楼层" name="floor">
|
||||
<a-input v-model:value="formData.floor" placeholder="请输入所在楼层" maxlength="64" />
|
||||
<a-form-item label="所在楼层" name="floorNo">
|
||||
<a-select v-model:value="formData.floorNo" placeholder="请选择所在楼层" allow-clear :options="floorOptions" :not-found-content="floorOptions.length ? null : '请先选择所属建筑'" />
|
||||
</a-form-item>
|
||||
<a-form-item label="具体位置" name="location">
|
||||
<a-input v-model:value="formData.location" placeholder="请输入具体位置" maxlength="128" />
|
||||
@@ -262,7 +278,7 @@
|
||||
:disabled="photoUploading"
|
||||
>
|
||||
<a-button :loading="photoUploading">
|
||||
{{ formData.photo ? '替换' : '上传' }}
|
||||
{{ formData.photoUrl ? '替换' : '上传' }}
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
@@ -291,29 +307,53 @@ import {
|
||||
type CustomerEquipment,
|
||||
type CustomerEquipmentQuery,
|
||||
} 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 { uploadApi } from '@/api/upload';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
|
||||
// 设备类型字典(配电间/配电井/配电箱)
|
||||
const equipTypeOptions = ['配电间', '配电井', '配电箱'];
|
||||
// 设备类型字典(配电间/配电井/配电箱/配电柜)
|
||||
const equipTypeOptions = ['配电间', '配电井', '配电箱', '配电柜'];
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<CustomerEquipment[]>([]);
|
||||
const total = ref(0);
|
||||
const editNo = ref<string>('');
|
||||
const editCode = ref<string>('');
|
||||
|
||||
const searchForm = reactive<CustomerEquipmentQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
customerId: undefined as any,
|
||||
building: '',
|
||||
buildingId: 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 modalTitle = ref('');
|
||||
const modalLoading = ref(false);
|
||||
@@ -328,30 +368,31 @@ const photoUploading = ref(false);
|
||||
const photoPreview = ref<string>('');
|
||||
|
||||
const formData = reactive<Partial<CustomerEquipment>>({
|
||||
equipmentName: '',
|
||||
equipName: '',
|
||||
equipType: undefined,
|
||||
customerId: undefined,
|
||||
building: '',
|
||||
floor: '',
|
||||
parentId: undefined as any,
|
||||
customerId: undefined as any,
|
||||
buildingId: undefined as any,
|
||||
floorNo: '',
|
||||
location: '',
|
||||
photo: '',
|
||||
photoUrl: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
equipName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
equipType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '设备编码', dataIndex: 'equipmentNo', key: 'equipmentNo', ellipsis: true, width: 150 },
|
||||
{ title: '设备名称', dataIndex: 'equipmentName', key: 'equipmentName', ellipsis: true },
|
||||
{ title: '设备编码', dataIndex: 'equipCode', key: 'equipCode', ellipsis: true, width: 150 },
|
||||
{ title: '设备名称', dataIndex: 'equipName', key: 'equipName', ellipsis: true },
|
||||
{ title: '设备类型', dataIndex: 'equipType', key: 'equipType', ellipsis: true, width: 100 },
|
||||
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||
{ title: '所属建筑', dataIndex: 'building', key: 'building', ellipsis: true, width: 140 },
|
||||
{ title: '所在楼层', dataIndex: 'floor', key: 'floor', ellipsis: true, width: 100 },
|
||||
{ title: '所属建筑', dataIndex: 'buildingName', key: 'buildingName', ellipsis: true, width: 140 },
|
||||
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -367,8 +408,135 @@ const pagination = computed(() => ({
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
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() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -410,7 +578,7 @@ function handleSearch() {
|
||||
function handleReset() {
|
||||
searchForm.keyword = '';
|
||||
searchForm.customerId = undefined as any;
|
||||
searchForm.building = '';
|
||||
searchForm.buildingId = undefined as any;
|
||||
searchForm.equipType = undefined as any;
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
@@ -447,7 +615,7 @@ async function handlePhotoUpload(options: any) {
|
||||
try {
|
||||
const res: any = await uploadApi.uploadImage(f);
|
||||
if (res.code === 200 && res.data) {
|
||||
formData.photo = res.data.key;
|
||||
formData.photoUrl = res.data.key;
|
||||
photoPreview.value = res.data.url;
|
||||
onSuccess?.(res.data);
|
||||
message.success('上传成功');
|
||||
@@ -466,7 +634,7 @@ async function handlePhotoUpload(options: any) {
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
editNo.value = '';
|
||||
editCode.value = '';
|
||||
modalTitle.value = '新增设备';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
@@ -475,31 +643,34 @@ function handleAdd() {
|
||||
function handleEdit(record: CustomerEquipment) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
editNo.value = record.equipmentNo;
|
||||
editCode.value = record.equipCode;
|
||||
modalTitle.value = '编辑设备';
|
||||
resetForm();
|
||||
|
||||
formData.equipmentName = record.equipmentName;
|
||||
// 先设客户,触发建筑/父设备选项联动加载;再设其余字段
|
||||
formData.customerId = record.customerId as any;
|
||||
formData.equipName = record.equipName;
|
||||
formData.equipType = record.equipType || undefined;
|
||||
formData.customerId = record.customerId;
|
||||
formData.building = record.building || '';
|
||||
formData.floor = record.floor || '';
|
||||
formData.parentId = record.parentId ?? undefined as any;
|
||||
formData.buildingId = record.buildingId ?? undefined as any;
|
||||
formData.floorNo = record.floorNo || '';
|
||||
formData.location = record.location || '';
|
||||
formData.photo = record.photo || '';
|
||||
formData.photoUrl = record.photoUrl || '';
|
||||
formData.remark = record.remark || '';
|
||||
photoPreview.value = record.photo || '';
|
||||
photoPreview.value = record.photoUrl || '';
|
||||
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.equipmentName = '';
|
||||
formData.equipName = '';
|
||||
formData.equipType = undefined;
|
||||
formData.customerId = undefined;
|
||||
formData.building = '';
|
||||
formData.floor = '';
|
||||
formData.parentId = undefined as any;
|
||||
formData.customerId = undefined as any;
|
||||
formData.buildingId = undefined as any;
|
||||
formData.floorNo = '';
|
||||
formData.location = '';
|
||||
formData.photo = '';
|
||||
formData.photoUrl = '';
|
||||
formData.remark = '';
|
||||
photoPreview.value = '';
|
||||
|
||||
@@ -518,13 +689,14 @@ async function handleSubmit() {
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const submitData: any = {
|
||||
equipmentName: formData.equipmentName,
|
||||
equipName: formData.equipName,
|
||||
equipType: formData.equipType,
|
||||
customerId: formData.customerId,
|
||||
building: formData.building,
|
||||
floor: formData.floor,
|
||||
parentId: formData.parentId ?? null,
|
||||
buildingId: formData.buildingId ?? null,
|
||||
floorNo: formData.floorNo,
|
||||
location: formData.location,
|
||||
photo: formData.photo,
|
||||
photoUrl: formData.photoUrl,
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
<div class="breadcrumb">
|
||||
<span class="link" @click="goLedger">项目管理</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="link" @click="goLedger">现场勘察派单</span>
|
||||
<span class="link" @click="goLedger">{{ isEdit ? '现场勘察派单' : '客户拜访' }}</span>
|
||||
<span class="sep">/</span>
|
||||
<span v-if="surveyNo" class="mono-badge">{{ surveyNo }}</span>
|
||||
<span v-else class="cur">{{ pageTitle }}</span>
|
||||
@@ -35,7 +35,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<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 class="step-bar">
|
||||
@@ -223,151 +223,55 @@
|
||||
</div>
|
||||
</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-title">
|
||||
<span class="bar bar-secondary"></span>
|
||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||
<h2>第 2 步:勘察明细</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>
|
||||
<h2>勘察明细</h2>
|
||||
</div>
|
||||
<span class="head-hint">按客户设备节点登记 · 仅供查看</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<!-- 编辑态:明细编辑行 -->
|
||||
<template v-if="!isReadonly">
|
||||
<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-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 v-if="readonlyDetails.length === 0" class="detail-empty">暂无勘察明细</div>
|
||||
<div v-else class="detail-rows">
|
||||
<div v-for="(row, idx) in readonlyDetails" :key="idx" class="detail-ro-row">
|
||||
<div class="detail-ro-head">
|
||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||
<span class="detail-ro-scope">
|
||||
{{ row.equipName || row.buildingName || '未标注位置' }}
|
||||
<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>
|
||||
</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">
|
||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||
<span class="detail-ro-scope">
|
||||
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||
<a-image-preview-group>
|
||||
<a-image
|
||||
v-for="(p, pi) in row.photos"
|
||||
:key="pi"
|
||||
:src="p.url"
|
||||
:width="110"
|
||||
:height="82"
|
||||
style="object-fit: cover; border-radius: 8px"
|
||||
/>
|
||||
</a-image-preview-group>
|
||||
</div>
|
||||
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||
<a-image-preview-group>
|
||||
<a-image
|
||||
v-for="(p, pi) in row.photos"
|
||||
:key="pi"
|
||||
:src="p"
|
||||
:width="110"
|
||||
:height="82"
|
||||
style="object-fit: cover; border-radius: 8px"
|
||||
/>
|
||||
</a-image-preview-group>
|
||||
</div>
|
||||
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 第 3 步 ==================== -->
|
||||
<section id="wizard-step-3" class="section">
|
||||
<!-- ==================== 第 2 步 ==================== -->
|
||||
<section id="wizard-step-2" class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">
|
||||
<span class="bar bar-tertiary"></span>
|
||||
<ToolOutlined class="sec-icon clr-tertiary" />
|
||||
<h2>第 3 步:指派电工与服务报酬</h2>
|
||||
<h2>第 2 步:指派电工与服务报酬</h2>
|
||||
</div>
|
||||
<span class="head-hint clr-tertiary">根据持证资质与历史履约智能优选</span>
|
||||
</div>
|
||||
@@ -577,8 +481,6 @@ import {
|
||||
CheckOutlined,
|
||||
CheckCircleFilled,
|
||||
StarFilled,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
ApartmentOutlined,
|
||||
FileDoneOutlined,
|
||||
ToolOutlined,
|
||||
@@ -593,21 +495,17 @@ import {
|
||||
type SurveyQuote,
|
||||
type SurveyOrderCreateParams,
|
||||
type SurveyOrderInfo,
|
||||
type SurveyOrderDetail,
|
||||
} from '@/api/surveyOrder';
|
||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||
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 route = useRoute();
|
||||
|
||||
// ==================== 步骤元数据 ====================
|
||||
// ==================== 步骤元数据(两步向导:基本信息 → 指派电工与报酬) ====================
|
||||
const STEPS = [
|
||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||
{ value: 2, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||
];
|
||||
|
||||
const SLA_OPTIONS = [
|
||||
@@ -696,166 +594,48 @@ const customerSelectOptions = computed(() =>
|
||||
);
|
||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||
|
||||
// ==================== 客户空间拓扑 ====================
|
||||
const topology = ref<CustomerTopologyResult | null>(null);
|
||||
const topologyLoading = ref(false);
|
||||
const buildingSelectOptions = computed(() =>
|
||||
(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;
|
||||
// ==================== 只读态勘察明细(按设备节点展示) ====================
|
||||
interface ReadonlyDetailRow {
|
||||
equipName?: string;
|
||||
equipType?: string;
|
||||
buildingName?: string;
|
||||
floorNo?: string;
|
||||
roomId?: number;
|
||||
floorLabel?: string;
|
||||
roomName?: string;
|
||||
photos: DetailPhoto[];
|
||||
photos: string[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
const MAX_DETAIL_PHOTOS = 9;
|
||||
let detailUidSeed = 0;
|
||||
const nextDetailUid = () => ++detailUidSeed;
|
||||
|
||||
const detailRows = ref<DetailRow[]>([]);
|
||||
|
||||
function addDetailRow() {
|
||||
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
|
||||
}
|
||||
|
||||
function removeDetailRow(index: number) {
|
||||
detailRows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleRowBuildingChange(row: DetailRow) {
|
||||
row.buildingName = buildingById(row.buildingId)?.buildingName;
|
||||
row.floorNo = undefined;
|
||||
row.roomId = undefined;
|
||||
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 || '上传失败');
|
||||
// 由设备树建立 equipId → 设备名/类型 映射,明细行按设备节点展示;历史建筑/楼层/房间数据兼容展示
|
||||
const readonlyDetails = computed<ReadonlyDetailRow[]>(() => {
|
||||
const d = detailRecord.value;
|
||||
if (!d || !Array.isArray(d.details)) return [];
|
||||
const equipMap = new Map<number, any>();
|
||||
const walk = (nodes: any[]) => {
|
||||
for (const n of nodes || []) {
|
||||
if (n?.id != null) equipMap.set(Number(n.id), n);
|
||||
walk(n?.children);
|
||||
}
|
||||
} 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,
|
||||
}));
|
||||
}
|
||||
};
|
||||
walk(d.equipmentTree);
|
||||
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);
|
||||
let floorLabel = undefined;
|
||||
if (floor !== undefined) {
|
||||
const n = Number(floor);
|
||||
floorLabel = Number.isNaN(n) ? floor : n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||
}
|
||||
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>({
|
||||
@@ -876,8 +656,8 @@ const step1Done = computed(
|
||||
!!formState.siteLocation.trim() &&
|
||||
Number(formState.loopCount) > 0
|
||||
);
|
||||
const step3Done = computed(() => !!formState.workerId);
|
||||
const currentStep = computed(() => (step1Done.value ? (step3Done.value ? 3 : 2) : 1));
|
||||
const step2Done = computed(() => !!formState.workerId);
|
||||
const currentStep = computed(() => (step1Done.value ? 2 : 1));
|
||||
const filledCount = computed(
|
||||
() => [!!formState.customerId, Number(formState.loopCount) > 0, !!formState.workerId].filter(Boolean).length
|
||||
);
|
||||
@@ -899,7 +679,8 @@ function clearError(key: string) {
|
||||
}
|
||||
|
||||
function goLedger() {
|
||||
router.push({ name: 'DispatchSurvey' });
|
||||
// 编辑态返回勘察台账;新建态(从客户拜访发起)返回客户拜访
|
||||
router.push({ name: isEdit.value ? 'DispatchSurvey' : 'CustomerVisit' });
|
||||
}
|
||||
|
||||
function handleCustomerChange() {
|
||||
@@ -913,9 +694,6 @@ function handleCustomerChange() {
|
||||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||||
}
|
||||
}
|
||||
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
||||
detailRows.value = [];
|
||||
loadTopology();
|
||||
loadWorkers();
|
||||
}
|
||||
|
||||
@@ -1002,17 +780,6 @@ async function loadDetail() {
|
||||
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 {
|
||||
message.error(res.message || '获取勘察派单详情失败');
|
||||
}
|
||||
@@ -1066,9 +833,8 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
||||
workerId: formState.workerId,
|
||||
estimatedFee: quote.value.total,
|
||||
remark: formState.remark.trim() || undefined,
|
||||
source: 'ledger',
|
||||
source: isEdit.value ? 'ledger' : 'visit',
|
||||
isDraft,
|
||||
details: buildDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1122,16 +888,16 @@ async function handleSubmit(draft: boolean) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
||||
// 客户拜访「一键发起勘察派单」跳转预填:/customer/visit/survey/create?customerId=xx
|
||||
const preCustomerId = Number(route.query.customerId || 0);
|
||||
if (preCustomerId && !isEdit.value) {
|
||||
formState.customerId = preCustomerId;
|
||||
}
|
||||
|
||||
await loadCustomers();
|
||||
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
||||
// 编辑态先取详情(含 customerId 回填)
|
||||
await loadDetail();
|
||||
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
||||
await Promise.all([loadQuote(), loadWorkers()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1264,7 +1030,7 @@ onMounted(async () => {
|
||||
/* 步骤条 */
|
||||
.step-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.step-item {
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
智能派单
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
发起勘察派单
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +264,7 @@
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMore(key as string, record)">
|
||||
<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="record.status === SURVEY_STATUS.SURVEYING" key="reassign">改派</a-menu-item>
|
||||
<a-menu-item v-if="canUrge(record)" key="urge">催办</a-menu-item>
|
||||
@@ -409,10 +406,10 @@
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 审核测绘成果 -->
|
||||
<!-- 技术复核 -->
|
||||
<a-modal
|
||||
v-model:open="reviewVisible"
|
||||
title="审核测绘成果"
|
||||
title="技术复核"
|
||||
:confirm-loading="reviewLoading"
|
||||
width="560px"
|
||||
@ok="handleReviewSubmit"
|
||||
@@ -514,6 +511,15 @@
|
||||
<div v-else class="detail-text">—</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 class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||
<div class="detail-list">
|
||||
@@ -521,8 +527,11 @@
|
||||
<div class="detail-row-head">
|
||||
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||
<span class="detail-row-scope">
|
||||
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||
{{ equipNameOf(row.equipId) || row.buildingName || '—' }}
|
||||
<template v-if="!equipNameOf(row.equipId)">
|
||||
· {{ floorLabel(row.floorNo) }}
|
||||
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.photoUrls?.length" class="photo-list">
|
||||
@@ -547,13 +556,13 @@
|
||||
</template>
|
||||
|
||||
<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 { message, Modal } from 'ant-design-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
DownloadOutlined,
|
||||
FilterOutlined,
|
||||
DownOutlined,
|
||||
@@ -809,10 +818,7 @@ onMounted(() => {
|
||||
fetchList();
|
||||
fetchStats();
|
||||
fetchWorkerOptions();
|
||||
const preCustomerId = Number(route.query.customerId || 0);
|
||||
if (preCustomerId) {
|
||||
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
|
||||
}
|
||||
// 勘察派单仅在客户拜访中发起:台账不再响应 ?customerId 自动跳转
|
||||
});
|
||||
|
||||
// ==================== 筛选与分页 ====================
|
||||
@@ -849,10 +855,6 @@ function handleExport() {
|
||||
message.info('导出台账数据功能暂未开放');
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
router.push({ name: 'DispatchSurveyCreate' });
|
||||
}
|
||||
|
||||
// ==================== 行内操作 ====================
|
||||
const canEdit = (record: SurveyOrderInfo) =>
|
||||
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 {
|
||||
if (record.status === SURVEY_STATUS.PENDING_ASSIGN) 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: '生成方案' };
|
||||
return null;
|
||||
}
|
||||
@@ -875,13 +877,14 @@ function handlePrimary(record: SurveyOrderInfo) {
|
||||
const action = primaryAction(record);
|
||||
if (!action) return;
|
||||
if (action.label === '指派电工') return openAssign(record, false);
|
||||
if (action.label === '进度跟踪') return goDetail(record);
|
||||
if (action.label === '审核测绘成果') return openReview(record);
|
||||
if (action.label === '进度跟踪') return router.push({ name: 'DispatchSurveyDetail', params: { id: record.id } });
|
||||
if (action.label === '技术复核') return openReview(record);
|
||||
if (action.label === '生成方案') return message.info('生成项目方案功能暂未开放');
|
||||
}
|
||||
|
||||
function handleMore(key: string, record: SurveyOrderInfo) {
|
||||
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 === 'reassign') return openAssign(record, true);
|
||||
if (key === 'urge') return handleUrge(record);
|
||||
@@ -1123,6 +1126,90 @@ async function handleCancelSubmit() {
|
||||
const detailVisible = ref(false);
|
||||
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) {
|
||||
detailRecord.value = record;
|
||||
detailVisible.value = true;
|
||||
@@ -1132,8 +1219,22 @@ async function openDetail(record: SurveyOrderInfo) {
|
||||
} catch {
|
||||
// 详情获取失败时保留列表行数据
|
||||
}
|
||||
await nextTick();
|
||||
renderDetailTopo();
|
||||
}
|
||||
|
||||
watch(detailVisible, (open) => {
|
||||
if (!open) {
|
||||
detailTopoChart?.dispose();
|
||||
detailTopoChart = null;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
detailTopoChart?.dispose();
|
||||
detailTopoChart = null;
|
||||
});
|
||||
|
||||
function goDetail(record: SurveyOrderInfo) {
|
||||
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
|
||||
}
|
||||
@@ -1906,6 +2007,33 @@ function maskPhone(phone?: string) {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in new issue
Block a user