feat: 更新批量编辑模态框,添加编辑模式切换功能,支持属性编辑和机器人绑定,优化相关逻辑和界面

This commit is contained in:
xudan 2025-09-28 16:25:48 +08:00
parent d3b2bba880
commit 1f87271ebc
2 changed files with 182 additions and 143 deletions

View File

@ -3,104 +3,110 @@
v-model:open="modalVisible" v-model:open="modalVisible"
title="批量编辑" title="批量编辑"
width="500px" width="500px"
:ok-button-props="{ disabled: !hasChanges }" :ok-button-props="{ disabled: !hasChanges && editMode === 'property' }"
@ok="handleConfirm" @ok="handleConfirm"
@cancel="handleCancel" @cancel="handleCancel"
> >
<div class="batch-edit-content"> <div class="batch-edit-content">
<!-- 选中元素统计 --> <a-radio-group v-model:value="editMode" button-style="solid" style="margin-bottom: 16px">
<div class="selection-info"> <a-radio-button value="property">属性编辑</a-radio-button>
<a-alert <a-radio-button value="binding">机器人绑定</a-radio-button>
:message="`已选择 ${selectedItems.length} 个元素`" </a-radio-group>
:description="selectionDescription"
type="info"
show-icon
/>
</div>
<!-- 点位批量编辑 --> <!-- 属性编辑 -->
<div v-if="pointItems.length > 0" class="edit-section"> <div v-if="editMode === 'property'">
<h4>点位编辑 ({{ pointItems.length }} )</h4> <!-- 选中元素统计 -->
<a-form layout="vertical"> <div class="selection-info">
<a-form-item label="点位类型"> <a-alert
<a-select :message="`已选择 ${selectedItems.length} 个元素`"
v-model:value="pointType" :description="selectionDescription"
placeholder="选择点位类型" type="info"
allow-clear show-icon
@change="markChanged" />
> </div>
<a-select-option
v-for="[key, value] in MAP_POINT_TYPES"
:key="value"
:value="value"
>
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<!-- 路线批量编辑 --> <!-- 点位批量编辑 -->
<div v-if="lineItems.length > 0" class="edit-section"> <div v-if="pointItems.length > 0" class="edit-section">
<h4>路线编辑 ({{ lineItems.length }} )</h4> <h4>点位编辑 ({{ pointItems.length }} )</h4>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="通行类型"> <a-form-item label="点位类型">
<a-select <a-select v-model:value="pointType" placeholder="选择点位类型" allow-clear @change="markChanged">
v-model:value="routePassType" <a-select-option v-for="[key, value] in MAP_POINT_TYPES" :key="value" :value="value">
placeholder="选择通行类型" {{ key }}
allow-clear </a-select-option>
@change="markChanged" </a-select>
> </a-form-item>
<a-select-option </a-form>
v-for="[key, value] in MAP_ROUTE_PASS_TYPES" </div>
:key="value"
:value="value"
>
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<!-- 预览区域 --> <!-- 路线批量编辑 -->
<div v-if="hasChanges" class="preview-section"> <div v-if="lineItems.length > 0" class="edit-section">
<h4>预览更改</h4> <h4>路线编辑 ({{ lineItems.length }} )</h4>
<div class="preview-list"> <a-form layout="vertical">
<div <a-form-item label="通行类型">
v-for="item in selectedItems" <a-select v-model:value="routePassType" placeholder="选择通行类型" allow-clear @change="markChanged">
:key="item.id" <a-select-option v-for="[key, value] in MAP_ROUTE_PASS_TYPES" :key="value" :value="value">
class="preview-item" {{ key }}
> </a-select-option>
<span class="item-name">{{ getItemName(item) }}</span> </a-select>
<span class="item-type">{{ getItemType(item) }}</span> </a-form-item>
<div class="changes"> </a-form>
<a-tag v-if="getPointChanges(item).length" color="blue" class="change-tag"> </div>
点位: {{ getPointChanges(item).join(', ') }}
</a-tag> <!-- 预览区域 -->
<a-tag v-if="getRouteChanges(item).length" color="green" class="change-tag"> <div v-if="hasChanges" class="preview-section">
路线: {{ getRouteChanges(item).join(', ') }} <h4>预览更改</h4>
</a-tag> <div class="preview-list">
<div v-for="item in selectedItems" :key="item.id" class="preview-item">
<span class="item-name">{{ getItemName(item) }}</span>
<span class="item-type">{{ getItemType(item) }}</span>
<div class="changes">
<a-tag v-if="getPointChanges(item).length" color="blue" class="change-tag">
点位: {{ getPointChanges(item).join(', ') }}
</a-tag>
<a-tag v-if="getRouteChanges(item).length" color="green" class="change-tag">
路线: {{ getRouteChanges(item).join(', ') }}
</a-tag>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 机器人绑定 -->
<div v-if="editMode === 'binding'">
<div class="selection-info">
<a-alert :message="`已选择 ${pointItems.length} 个点位`" type="info" show-icon />
</div>
<div v-if="chargingPoints.length > 0" class="edit-section">
<h4>充电点 ({{ chargingPoints.length }} )</h4>
<a-button type="primary" @click="handleBatchBind(chargingPoints)">批量绑定机器人</a-button>
</div>
<div v-if="parkingPoints.length > 0" class="edit-section">
<h4>停靠点 ({{ parkingPoints.length }} )</h4>
<a-button type="primary" @click="handleBatchBind(parkingPoints)">批量绑定机器人</a-button>
</div>
<div
v-if="chargingPoints.length === 0 && parkingPoints.length === 0 && pointItems.length > 0"
class="edit-section"
>
<h4>无符合条件的点位</h4>
<p>仅充电点和停靠点支持批量绑定机器人</p>
</div>
</div>
</div> </div>
</a-modal> </a-modal>
<robot-bind-modal :token="editorToken" ref="robotBindModalRef" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, type InjectionKey, nextTick, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
import type { MapPen } from '../../apis/map'; import type { MapPen } from '../../apis/map';
import { import { MAP_POINT_TYPES, MAP_ROUTE_PASS_TYPES, MapPointType, MapRoutePassType } from '../../apis/map';
MAP_POINT_TYPES,
MAP_ROUTE_PASS_TYPES,
MapPointType,
MapRoutePassType
} from '../../apis/map';
import type { EditorService } from '../../services/editor.service'; import type { EditorService } from '../../services/editor.service';
import type { RobotBindModalRef } from './robot-bind-modal.vue';
import RobotBindModal from './robot-bind-modal.vue';
// //
const getPointTypeConfig = (type: MapPointType, editor: EditorService) => { const getPointTypeConfig = (type: MapPointType, editor: EditorService) => {
@ -108,12 +114,12 @@ const getPointTypeConfig = (type: MapPointType, editor: EditorService) => {
const height = type < 10 ? 24 : 60; const height = type < 10 ? 24 : 60;
const lineWidth = type < 10 ? 2 : 3; const lineWidth = type < 10 ? 2 : 3;
const iconSize = type < 10 ? 4 : 10; const iconSize = type < 10 ? 4 : 10;
// 使 editor.service.ts #mapPointImage // 使 editor.service.ts #mapPointImage
const theme = editor.data().theme; const theme = editor.data().theme;
// 使 public/point/ // 使 public/point/
const image = type < 10 ? '' : `./point/${type}-${theme}.png`; const image = type < 10 ? '' : `./point/${type}-${theme}.png`;
return { width, height, lineWidth, iconSize, image }; return { width, height, lineWidth, iconSize, image };
}; };
@ -133,17 +139,29 @@ const emit = defineEmits<Emits>();
const modalVisible = computed({ const modalVisible = computed({
get: () => props.visible, get: () => props.visible,
set: (value) => emit('update:visible', value) set: (value) => emit('update:visible', value),
}); });
// 线 // 线
const pointItems = computed(() => const pointItems = computed(() => props.selectedItems.filter((item) => item.name === 'point'));
props.selectedItems.filter(item => item.name === 'point')
);
const lineItems = computed(() => const lineItems = computed(() => props.selectedItems.filter((item) => item.name === 'line'));
props.selectedItems.filter(item => item.name === 'line')
); //
const editMode = ref<'property' | 'binding'>('property');
//
const robotBindModalRef = ref<RobotBindModalRef>();
const editorToken: InjectionKey<ShallowRef<EditorService>> = Symbol('editor');
provide(editorToken, shallowRef(props.editor));
const chargingPoints = computed(() => pointItems.value.filter((item) => item.point?.type === MapPointType.充电点));
const parkingPoints = computed(() => pointItems.value.filter((item) => item.point?.type === MapPointType.停靠点));
const handleBatchBind = (pens: MapPen[]) => {
robotBindModalRef.value?.open(pens);
};
// //
const pointType = ref<MapPointType>(); const pointType = ref<MapPointType>();
@ -161,14 +179,18 @@ const resetForm = () => {
pointType.value = undefined; pointType.value = undefined;
routePassType.value = undefined; routePassType.value = undefined;
hasChanges.value = false; hasChanges.value = false;
editMode.value = 'property';
}; };
// //
watch(() => props.visible, (visible) => { watch(
if (visible) { () => props.visible,
resetForm(); (visible) => {
} if (visible) {
}); resetForm();
}
},
);
// //
const selectionDescription = computed(() => { const selectionDescription = computed(() => {
@ -182,8 +204,8 @@ const selectionDescription = computed(() => {
// //
const getItemName = (item: MapPen) => { const getItemName = (item: MapPen) => {
if(item.tags?.includes('route')){ if (item.tags?.includes('route')) {
return item.desc||item.label || item.id || '未命名'; return item.desc || item.label || item.id || '未命名';
} }
return item.label || item.id || '未命名'; return item.label || item.id || '未命名';
}; };
@ -192,7 +214,7 @@ const getItemName = (item: MapPen) => {
const getItemType = (item: MapPen) => { const getItemType = (item: MapPen) => {
if (item.name === 'point') { if (item.name === 'point') {
const pointTypeName = Object.keys(MapPointType).find( const pointTypeName = Object.keys(MapPointType).find(
key => MapPointType[key as keyof typeof MapPointType] === item.point?.type (key) => MapPointType[key as keyof typeof MapPointType] === item.point?.type,
); );
return `点位-${pointTypeName || '未知'}`; return `点位-${pointTypeName || '未知'}`;
} else if (item.name === 'line') { } else if (item.name === 'line') {
@ -204,90 +226,93 @@ const getItemType = (item: MapPen) => {
// //
const getPointChanges = (item: MapPen) => { const getPointChanges = (item: MapPen) => {
if (item.name !== 'point' || !pointType.value) return []; if (item.name !== 'point' || !pointType.value) return [];
const currentType = item.point?.type; const currentType = item.point?.type;
if (currentType === pointType.value) return []; if (currentType === pointType.value) return [];
const currentTypeName = Object.keys(MapPointType).find( const currentTypeName = Object.keys(MapPointType).find(
key => MapPointType[key as keyof typeof MapPointType] === currentType (key) => MapPointType[key as keyof typeof MapPointType] === currentType,
); );
const newTypeName = Object.keys(MapPointType).find( const newTypeName = Object.keys(MapPointType).find(
key => MapPointType[key as keyof typeof MapPointType] === pointType.value (key) => MapPointType[key as keyof typeof MapPointType] === pointType.value,
); );
return [`类型: ${currentTypeName || '未知'}${newTypeName}`]; return [`类型: ${currentTypeName || '未知'}${newTypeName}`];
}; };
// 线 // 线
const getRouteChanges = (item: MapPen) => { const getRouteChanges = (item: MapPen) => {
if (item.name !== 'line') return []; if (item.name !== 'line') return [];
const changes: string[] = []; const changes: string[] = [];
if (routePassType.value !== undefined && item.route?.pass !== routePassType.value) { if (routePassType.value !== undefined && item.route?.pass !== routePassType.value) {
const currentPassName = Object.keys(MapRoutePassType).find( const currentPassName = Object.keys(MapRoutePassType).find(
key => MapRoutePassType[key as keyof typeof MapRoutePassType] === item.route?.pass (key) => MapRoutePassType[key as keyof typeof MapRoutePassType] === item.route?.pass,
); );
const newPassName = Object.keys(MapRoutePassType).find( const newPassName = Object.keys(MapRoutePassType).find(
key => MapRoutePassType[key as keyof typeof MapRoutePassType] === routePassType.value (key) => MapRoutePassType[key as keyof typeof MapRoutePassType] === routePassType.value,
); );
changes.push(`通行: ${currentPassName || '未知'}${newPassName}`); changes.push(`通行: ${currentPassName || '未知'}${newPassName}`);
} }
return changes; return changes;
}; };
// //
const handleConfirm = () => { const handleConfirm = () => {
if (editMode.value !== 'property') {
modalVisible.value = false;
return;
}
const updates: Array<{ id: string; updates: Partial<MapPen> }> = []; const updates: Array<{ id: string; updates: Partial<MapPen> }> = [];
// //
pointItems.value.forEach(item => { pointItems.value.forEach((item) => {
if (pointType.value) { if (pointType.value) {
const pointConfig = getPointTypeConfig(pointType.value, props.editor); const pointConfig = getPointTypeConfig(pointType.value, props.editor);
const rect = props.editor.getPointRect(item); const rect = props.editor.getPointRect(item);
updates.push({ updates.push({
id: item.id!, id: item.id!,
updates: { updates: {
// //
point: { point: {
...item.point, ...item.point,
type: pointType.value type: pointType.value,
}, },
// //
...pointConfig, ...pointConfig,
// //
x: rect ? rect.x - pointConfig.width / 2 : item.x, x: rect ? rect.x - pointConfig.width / 2 : item.x,
y: rect ? rect.y - pointConfig.height / 2 : item.y, y: rect ? rect.y - pointConfig.height / 2 : item.y,
},
}
}); });
} }
}); });
// 线 // 线
lineItems.value.forEach(item => { lineItems.value.forEach((item) => {
if (item.route && routePassType.value !== undefined) { if (item.route && routePassType.value !== undefined) {
const routeUpdates: Partial<MapPen> = { const routeUpdates: Partial<MapPen> = {
route: { route: {
...item.route, ...item.route,
pass: routePassType.value pass: routePassType.value,
} },
}; };
updates.push({ updates.push({
id: item.id!, id: item.id!,
updates: routeUpdates updates: routeUpdates,
}); });
} }
}); });
emit('update', updates); emit('update', updates);
// //
modalVisible.value = false; modalVisible.value = false;
// //
nextTick(() => { nextTick(() => {
// 使 setTimeout // 使 setTimeout
@ -304,7 +329,7 @@ const handleCancel = () => {
}; };
defineOptions({ defineOptions({
name: 'BatchEditModal' name: 'BatchEditModal',
}); });
</script> </script>
@ -315,28 +340,28 @@ defineOptions({
.selection-info { .selection-info {
margin-bottom: 16px; margin-bottom: 16px;
} }
.edit-section { .edit-section {
margin-bottom: 16px; margin-bottom: 16px;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
h4 { h4 {
margin: 0 0 12px 0; margin: 0 0 12px 0;
font-size: 14px; font-size: 14px;
} }
} }
.preview-section { .preview-section {
h4 { h4 {
margin: 0 0 12px 0; margin: 0 0 12px 0;
font-size: 14px; font-size: 14px;
} }
.preview-list { .preview-list {
max-height: 150px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
.preview-item { .preview-item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -344,32 +369,32 @@ defineOptions({
margin-bottom: 6px; margin-bottom: 6px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
.item-name { .item-name {
font-weight: 500; font-weight: 500;
margin-right: 10px; margin-right: 10px;
min-width: 80px; min-width: 80px;
} }
.item-type { .item-type {
margin-right: 10px; margin-right: 10px;
min-width: 100px; min-width: 100px;
} }
.changes { .changes {
flex: 1; flex: 1;
.ant-tag { .ant-tag {
margin-right: 6px; margin-right: 6px;
margin-bottom: 2px; margin-bottom: 2px;
font-size: 11px; font-size: 11px;
} }
.change-tag { .change-tag {
font-weight: 500; font-weight: 500;
border: 1px solid currentColor; border: 1px solid currentColor;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
// //
&::before { &::before {
content: ''; content: '';
@ -395,22 +420,22 @@ defineOptions({
.edit-section { .edit-section {
background: theme.get-color(fill3); background: theme.get-color(fill3);
border: 1px solid theme.get-color(border2); border: 1px solid theme.get-color(border2);
h4 { h4 {
color: theme.get-color(primary); color: theme.get-color(primary);
} }
} }
.preview-section { .preview-section {
h4 { h4 {
color: theme.get-color(success); color: theme.get-color(success);
} }
.preview-list { .preview-list {
.preview-item { .preview-item {
background: theme.get-color(bg_component); background: theme.get-color(bg_component);
border: 1px solid theme.get-color(border1); border: 1px solid theme.get-color(border1);
.item-type { .item-type {
color: theme.get-color(text3); color: theme.get-color(text3);
} }

View File

@ -12,13 +12,23 @@ const editor = inject(props.token)!;
export type RobotBindModalRef = Ref; export type RobotBindModalRef = Ref;
type Ref = { type Ref = {
open: (pen: MapPen) => void; open: (pen: MapPen | MapPen[]) => void;
}; };
const targetPens = ref<MapPen[]>([]);
const open: Ref['open'] = (pen) => { const open: Ref['open'] = (pen) => {
if (!pen?.id) return; if (!pen) return;
const pens = Array.isArray(pen) ? pen : [pen];
if (pens.length === 0) return;
keyword.value = ''; keyword.value = '';
id.value = pen.id; targetPens.value = pens;
selected.value = pen.point?.robots ?? [];
if (pens.length === 1) {
selected.value = pens[0].point?.robots ?? [];
} else {
selected.value = [];
}
show.value = true; show.value = true;
}; };
defineExpose<Ref>({ open }); defineExpose<Ref>({ open });
@ -28,11 +38,15 @@ const keyword = ref<string>('');
const robots = computed<RobotInfo[]>(() => editor.value.robots.filter(({ label }) => label.includes(keyword.value))); const robots = computed<RobotInfo[]>(() => editor.value.robots.filter(({ label }) => label.includes(keyword.value)));
const id = ref<string>('');
const selected = ref<string[]>([]); const selected = ref<string[]>([]);
const submit = () => { const submit = () => {
editor.value.updatePoint(id.value, { robots: toRaw(selected.value) }); const robotIds = toRaw(selected.value);
targetPens.value.forEach((pen) => {
if (pen.id) {
editor.value.updatePoint(pen.id, { robots: robotIds });
}
});
show.value = false; show.value = false;
}; };
</script> </script>