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:
wk authored and trae-solo-agent committed 2026-10-25 02:33:54 +08:00
1 parent 2d55947762
commit dff366a823
19 files changed
+2095 -442

No files matched your search

+77
View File
@@ -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
View File
@@ -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;
}
+21 -2
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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) } });
}
// 查看关联工程
+225 -53
View File
@@ -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
+80 -314
View File
@@ -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 {
+149 -21
View File
@@ -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;