feat: 增强编辑器服务,新增批量更新点位和路线类型功能,优化主题重载逻辑,更新颜色配置以支持大点位类型

This commit is contained in:
xudan 2025-09-08 17:39:07 +08:00
parent 9fef00729e
commit b088f7236e
7 changed files with 719 additions and 26 deletions

View File

@ -0,0 +1,117 @@
# 批量编辑功能使用说明
## 🎯 功能概述
批量编辑功能允许用户在场景编辑器中同时选中多个点位和路线,并批量修改它们的属性,大大提升了编辑效率。
## ✨ 主要功能
### 1. 批量选中
- **鼠标拖动选中**:在编辑模式下,可以通过鼠标拖动框选多个点位和路线
- **实时计数**:工具栏会显示当前选中的元素数量
- **清除选择**:一键清除所有选中状态
### 2. 批量编辑点位
- **点位类型**:支持修改点位类型
- 普通点
- 等待点
- 避让点
- 临时避让点
- 库区点
- 电梯点
- 自动门点
- 充电点
- 停靠点
- 动作点
- 禁行点
### 3. 批量编辑路线
- **通行类型**
- 无限制
- 仅空载可通行
- 仅载货可通行
- 禁行
## 🚀 使用方法
### 步骤 1启用编辑器
1. 打开场景编辑器页面
2. 点击右上角的"启用编辑器"按钮
3. 编辑器工具栏和批量编辑工具栏将出现在页面上
### 步骤 2批量选中元素
1. 在画布上按住鼠标左键
2. 拖动鼠标框选需要编辑的点位和路线
3. 选中的元素会高亮显示
4. 批量编辑工具栏会显示选中元素的数量
### 步骤 3批量编辑
1. 点击"批量编辑"按钮打开编辑面板
2. 根据需要选择要修改的属性:
- 如果选中了点位,可以修改点位类型
- 如果选中了路线,可以修改路线类型、通行类型和方向
3. 在预览区域查看即将应用的更改
4. 点击"确定"应用更改,或点击"取消"放弃更改
### 步骤 4清除选择
- 点击"清除选择"按钮可以取消所有选中状态
- 或者直接点击画布空白区域也可以清除选择
## 🎨 界面说明
### 批量编辑工具栏
- 位置:页面顶部中央(仅在选中元素时显示)
- 功能:显示选中数量、打开批量编辑面板、清除选择
### 批量编辑面板
- **选中统计**:显示选中的点位和路线数量
- **点位编辑区**:当选中点位时显示,用于修改点位类型
- **路线编辑区**:当选中路线时显示,用于修改路线通行类型
- **预览区域**:显示即将应用的更改,方便确认
## 💡 使用技巧
1. **混合选择**:可以同时选中点位和路线进行批量编辑
2. **部分更新**:只修改需要更改的属性,其他属性保持不变
3. **预览确认**:在应用更改前,预览区域会显示所有即将修改的内容
4. **撤销支持**所有批量编辑操作都支持撤销Ctrl+Z
## 🔧 技术实现
- **响应式设计**:基于 Vue 3 Composition API
- **类型安全**:完整的 TypeScript 类型定义
- **性能优化**:批量更新减少渲染次数,自动触发画布重绘
- **用户体验**:实时预览和撤销支持,紧凑的弹框设计
## 📝 注意事项
1. 批量编辑功能仅在编辑模式下可用
2. 选中的元素必须是点位point或路线line类型
3. 区域area和机器人robot元素不支持批量编辑
4. 所有更改都会记录在编辑历史中,支持撤销操作
## 🐛 故障排除
### 问题:批量编辑按钮不可用
- **原因**:没有选中任何元素
- **解决**:先通过鼠标拖动选中需要编辑的点位或路线
### 问题:编辑面板中没有显示选项
- **原因**:选中的元素类型不支持该编辑选项
- **解决**:确保选中的是点位或路线类型的元素
### 问题:更改没有生效
- **原因**:可能没有点击"确定"按钮
- **解决**:在编辑面板中点击"确定"按钮应用更改

View File

@ -0,0 +1,86 @@
<template>
<div v-if="hasSelection" class="batch-edit-toolbar">
<a-space size="small">
<a-button
type="primary"
@click="showBatchEditModal = true"
>
批量编辑 ({{ selectedCount }})
</a-button>
<a-button
@click="clearSelection"
>
清除选择
</a-button>
</a-space>
<!-- 批量编辑模态框 -->
<BatchEditModal
v-model:visible="showBatchEditModal"
:selected-items="selectedItems"
:editor="editor"
@update="handleBatchUpdate"
/>
</div>
</template>
<script setup lang="ts">
import type { MapPen } from '@api/map';
import BatchEditModal from '@common/modal/batch-edit-modal.vue';
import { computed, inject, type InjectionKey, ref, type ShallowRef } from 'vue';
import type { EditorService } from '../services/editor.service';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
const showBatchEditModal = ref(false);
//
const selectedItems = computed(() => {
if (!editor.value) return [];
const selectedIds = editor.value.selected.value;
return selectedIds
.map(id => editor.value!.getPenById(id))
.filter((pen): pen is MapPen => !!pen && (pen.name === 'point' || pen.name === 'line'));
});
const selectedCount = computed(() => selectedItems.value.length);
const hasSelection = computed(() => selectedCount.value > 0);
const clearSelection = () => {
editor.value?.inactive();
};
const handleBatchUpdate = (updates: Array<{ id: string; updates: Partial<MapPen> }>) => {
// 使
editor.value?.batchUpdate(updates);
showBatchEditModal.value = false;
};
defineOptions({
name: 'BatchEditToolbar'
});
</script>
<style scoped lang="scss">
.batch-edit-toolbar {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(255, 255, 255, 0.95);
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
}
</style>

View File

@ -0,0 +1,366 @@
<template>
<a-modal
v-model:open="modalVisible"
title="批量编辑"
width="500px"
:ok-button-props="{ disabled: !hasChanges }"
@ok="handleConfirm"
@cancel="handleCancel"
>
<div class="batch-edit-content">
<!-- 选中元素统计 -->
<div class="selection-info">
<a-alert
:message="`已选择 ${selectedItems.length} 个元素`"
:description="selectionDescription"
type="info"
show-icon
/>
</div>
<!-- 点位批量编辑 -->
<div v-if="pointItems.length > 0" class="edit-section">
<h4>点位编辑 ({{ pointItems.length }} )</h4>
<a-form layout="vertical">
<a-form-item label="点位类型">
<a-select
v-model:value="pointType"
placeholder="选择点位类型"
allow-clear
@change="markChanged"
>
<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">
<h4>路线编辑 ({{ lineItems.length }} )</h4>
<a-form layout="vertical">
<a-form-item label="通行类型">
<a-select
v-model:value="routePassType"
placeholder="选择通行类型"
allow-clear
@change="markChanged"
>
<a-select-option
v-for="[key, value] in MAP_ROUTE_PASS_TYPES"
:key="value"
:value="value"
>
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<!-- 预览区域 -->
<div v-if="hasChanges" class="preview-section">
<h4>预览更改</h4>
<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">
点位: {{ getPointChanges(item).join(', ') }}
</a-tag>
<a-tag v-if="getRouteChanges(item).length" color="green">
路线: {{ getRouteChanges(item).join(', ') }}
</a-tag>
</div>
</div>
</div>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import type { MapPen } from '../../apis/map';
import {
MAP_POINT_TYPES,
MAP_ROUTE_PASS_TYPES,
MapPointType,
MapRoutePassType
} from '../../apis/map';
import type { EditorService } from '../../services/editor.service';
//
const getPointTypeConfig = (type: MapPointType, editor: EditorService) => {
const width = type < 10 ? 24 : 48;
const height = type < 10 ? 24 : 60;
const lineWidth = type < 10 ? 2 : 3;
const iconSize = type < 10 ? 4 : 10;
// 使 editor.service.ts #mapPointImage
const theme = editor.data().theme;
// 使 public/point/
const image = type < 10 ? '' : `./point/${type}-${theme}.png`;
return { width, height, lineWidth, iconSize, image };
};
interface Props {
visible: boolean;
selectedItems: MapPen[];
editor: EditorService;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'update', updates: Array<{ id: string; updates: Partial<MapPen> }>): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const modalVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
// 线
const pointItems = computed(() =>
props.selectedItems.filter(item => item.name === 'point')
);
const lineItems = computed(() =>
props.selectedItems.filter(item => item.name === 'line')
);
//
const pointType = ref<MapPointType>();
const routePassType = ref<MapRoutePassType>();
//
const hasChanges = ref(false);
const markChanged = () => {
hasChanges.value = true;
};
//
const resetForm = () => {
pointType.value = undefined;
routePassType.value = undefined;
hasChanges.value = false;
};
//
watch(() => props.visible, (visible) => {
if (visible) {
resetForm();
}
});
//
const selectionDescription = computed(() => {
const points = pointItems.value.length;
const lines = lineItems.value.length;
const parts: string[] = [];
if (points > 0) parts.push(`${points} 个点位`);
if (lines > 0) parts.push(`${lines} 条路线`);
return parts.join('');
});
//
const getItemName = (item: MapPen) => {
return item.label || item.id || '未命名';
};
//
const getItemType = (item: MapPen) => {
if (item.name === 'point') {
const pointTypeName = Object.keys(MapPointType).find(
key => MapPointType[key as keyof typeof MapPointType] === item.point?.type
);
return `点位-${pointTypeName || '未知'}`;
} else if (item.name === 'line') {
return '路线';
}
return '未知';
};
//
const getPointChanges = (item: MapPen) => {
if (item.name !== 'point' || !pointType.value) return [];
const currentType = item.point?.type;
if (currentType === pointType.value) return [];
const newTypeName = Object.keys(MapPointType).find(
key => MapPointType[key as keyof typeof MapPointType] === pointType.value
);
return [`类型: ${newTypeName}`];
};
// 线
const getRouteChanges = (item: MapPen) => {
if (item.name !== 'line') return [];
const changes: string[] = [];
if (routePassType.value !== undefined && item.route?.pass !== routePassType.value) {
const newPassName = Object.keys(MapRoutePassType).find(
key => MapRoutePassType[key as keyof typeof MapRoutePassType] === routePassType.value
);
changes.push(`通行: ${newPassName}`);
}
return changes;
};
//
const handleConfirm = () => {
const updates: Array<{ id: string; updates: Partial<MapPen> }> = [];
//
pointItems.value.forEach(item => {
if (pointType.value) {
const pointConfig = getPointTypeConfig(pointType.value, props.editor);
const rect = props.editor.getPointRect(item);
updates.push({
id: item.id!,
updates: {
//
point: {
...item.point,
type: pointType.value
},
//
...pointConfig,
//
x: rect ? rect.x - pointConfig.width / 2 : item.x,
y: rect ? rect.y - pointConfig.height / 2 : item.y,
}
});
}
});
// 线
lineItems.value.forEach(item => {
if (item.route && routePassType.value !== undefined) {
const routeUpdates: Partial<MapPen> = {
route: {
...item.route,
pass: routePassType.value
}
};
updates.push({
id: item.id!,
updates: routeUpdates
});
}
});
emit('update', updates);
//
modalVisible.value = false;
//
nextTick(() => {
// 使 setTimeout
setTimeout(() => {
props.editor.reloadTheme();
}, 0);
});
};
//
const handleCancel = () => {
resetForm();
modalVisible.value = false;
};
defineOptions({
name: 'BatchEditModal'
});
</script>
<style scoped lang="scss">
.batch-edit-content {
.selection-info {
margin-bottom: 16px;
}
.edit-section {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
h4 {
margin: 0 0 12px 0;
color: #1890ff;
font-size: 14px;
}
}
.preview-section {
h4 {
margin: 0 0 12px 0;
color: #52c41a;
font-size: 14px;
}
.preview-list {
max-height: 150px;
overflow-y: auto;
.preview-item {
display: flex;
align-items: center;
padding: 6px 10px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
.item-name {
font-weight: 500;
margin-right: 10px;
min-width: 80px;
}
.item-type {
color: #666;
margin-right: 10px;
min-width: 100px;
}
.changes {
flex: 1;
.ant-tag {
margin-right: 6px;
margin-bottom: 2px;
font-size: 11px;
}
}
}
}
}
}
</style>

View File

@ -8,6 +8,7 @@ import { computed, watch } from 'vue';
import { ref } from 'vue';
import { onMounted, provide, shallowRef } from 'vue';
import { useI18n } from 'vue-i18n';
import BatchEditToolbar from '@common/batch-edit-toolbar.vue';
const EDITOR_KEY = Symbol('editor-key');
@ -98,7 +99,7 @@ const toPush = () => {
const importScene = async () => {
const file = await selectFile('.scene');
if (!file?.size) return;
if (!file || !file.size) return;
const json = await decodeTextFile(file);
editor.value?.load(json, editable.value, undefined, true); // isImport=true
};
@ -218,6 +219,9 @@ const backToCards = () => {
<EditorToolbar v-if="editor" :token="EDITOR_KEY" :id="id" />
</div>
<!-- 批量编辑工具栏 - 只在编辑模式下显示 -->
<BatchEditToolbar v-if="editable && editor" :token="EDITOR_KEY" />
<template v-if="current?.id">
<a-float-button style="top: 80px; right: 16px" shape="square" @click="show = !show">
<template #icon><i class="icon detail" /></template>

View File

@ -39,7 +39,7 @@ export interface EditorColorConfig {
[key: number]: {
stroke: string;
strokeActive: string;
fill: string;
fill?: string; // 大点位不需要填充颜色
};
};
};
@ -124,8 +124,8 @@ export interface EditorColorConfig {
/**
*
*/
function generatePointTypeColors(): Record<number, { stroke: string; strokeActive: string; fill: string }> {
const colors: Record<number, { stroke: string; strokeActive: string; fill: string }> = {};
function generatePointTypeColors(): Record<number, { stroke: string; strokeActive: string; fill?: string }> {
const colors: Record<number, { stroke: string; strokeActive: string; fill?: string }> = {};
// 预定义的颜色方案
const colorSchemes = {
@ -138,21 +138,25 @@ function generatePointTypeColors(): Record<number, { stroke: string; strokeActiv
// 大点位颜色 (11+)
large: {
stroke: '#595959',
strokeActive: '#EBB214',
fills: ['#1890ff', '#52c41a', '#faad14', '#722ed1', '#13c2c2', '#ff4d4f']
strokeActive: '#EBB214'
}
};
POINT_TYPES.forEach(type => {
const isSmallPoint = type >= 1 && type <= 9;
const scheme = isSmallPoint ? colorSchemes.small : colorSchemes.large;
const fillIndex = isSmallPoint ? (type - 1) : (type - 11);
colors[type] = {
stroke: scheme.stroke,
strokeActive: scheme.strokeActive,
fill: scheme.fills[fillIndex] || scheme.fills[0]
strokeActive: scheme.strokeActive
};
// 只有小点位才设置填充颜色
if (isSmallPoint && 'fills' in scheme) {
const fillIndex = type - 1;
const fills = (scheme as any).fills as string[];
colors[type].fill = fills[fillIndex] || fills[0];
}
});
return colors;
@ -712,18 +716,24 @@ class ColorConfigService {
const types: Record<number, any> = {};
POINT_TYPES.forEach(type => {
const isSmallPoint = type >= 1 && type <= 9;
types[type] = {
stroke: theme['point-s']?.stroke ||
DEFAULT_COLORS.point.types[type]?.stroke ||
DEFAULT_COLORS.point.small.stroke,
strokeActive: theme['point-s']?.strokeActive ||
DEFAULT_COLORS.point.types[type]?.strokeActive ||
DEFAULT_COLORS.point.small.strokeActive,
fill: theme['point-s']?.[`fill-${type}`] ||
DEFAULT_COLORS.point.types[type]?.fill ||
DEFAULT_COLORS.point.small.fill[type] ||
DEFAULT_COLORS.point.small.fill[1]
DEFAULT_COLORS.point.small.strokeActive
};
// 只有小点位才设置填充颜色
if (isSmallPoint) {
types[type].fill = theme['point-s']?.[`fill-${type}`] ||
DEFAULT_COLORS.point.types[type]?.fill ||
DEFAULT_COLORS.point.small.fill[type] ||
DEFAULT_COLORS.point.small.fill[1];
}
});
return types;

View File

@ -869,8 +869,8 @@ export class EditorService extends Meta2d {
this.delete([pen], true, true);
}
public updatePen(id: string, pen: Partial<MapPen>, record = true): void {
this.setValue({ ...pen, id }, { render: true, history: record, doEvent: true });
public updatePen(id: string, pen: Partial<MapPen>, record = true, render = true): void {
this.setValue({ ...pen, id }, { render, history: record, doEvent: true });
}
/**
@ -1231,6 +1231,13 @@ export class EditorService extends Meta2d {
},
{ render: true, history: true, doEvent: true },
);
// 如果是大点位类型(需要图片),异步重新加载主题确保图片正确显示
if (type >= 10) {
requestAnimationFrame(() => {
this.reloadTheme();
});
}
}
#mapPoint(type: MapPointType): Required<Pick<MapPen, 'width' | 'height' | 'lineWidth' | 'iconSize'>> {
@ -1461,6 +1468,19 @@ export class EditorService extends Meta2d {
this.render();
}
/**
*
*
* UI
*/
public reloadTheme(): void {
const currentTheme = this.data().theme || 'light';
// 使用 requestAnimationFrame 确保在下一个渲染帧执行避免阻塞UI
requestAnimationFrame(() => {
this.#load(currentTheme);
});
}
#onDelete(pens?: MapPen[]): void {
pens?.forEach((pen) => {
switch (pen.name) {
@ -1571,6 +1591,95 @@ export class EditorService extends Meta2d {
);
}
/**
*
* @param pointIds ID数组
* @param pointType
*/
public batchUpdatePointType(pointIds: string[], pointType: MapPointType): void {
pointIds.forEach(id => {
const pen = this.getPenById(id);
if (pen?.name === 'point') {
this.updatePen(id, {
point: {
...pen.point,
type: pointType
}
});
}
});
}
/**
* 线
* @param routeIds 线ID数组
* @param routeType 线
*/
public batchUpdateRouteType(routeIds: string[], routeType: MapRouteType): void {
routeIds.forEach(id => {
const pen = this.getPenById(id);
if (pen?.name === 'line' && pen.route) {
this.updatePen(id, {
route: {
...pen.route,
type: routeType
}
});
}
});
}
/**
* 线
* @param routeIds 线ID数组
* @param passType
*/
public batchUpdateRoutePassType(routeIds: string[], passType: MapRoutePassType): void {
routeIds.forEach(id => {
const pen = this.getPenById(id);
if (pen?.name === 'line' && pen.route) {
this.updatePen(id, {
route: {
...pen.route,
pass: passType
}
});
}
});
}
/**
* 线
* @param routeIds 线ID数组
* @param direction
*/
public batchUpdateRouteDirection(routeIds: string[], direction: 1 | -1): void {
routeIds.forEach(id => {
const pen = this.getPenById(id);
if (pen?.name === 'line' && pen.route) {
this.updatePen(id, {
route: {
...pen.route,
direction
}
});
}
});
}
/**
*
* @param updates
*/
public batchUpdate(updates: Array<{ id: string; updates: Partial<MapPen> }>): void {
// 批量更新所有点位,避免多次渲染
updates.forEach(({ id, updates }) => {
this.updatePen(id, updates, true, false); // 记录历史但不立即渲染
});
// 统一渲染一次
this.render();
}
#register() {
this.register({ line: () => new Path2D() });
this.registerCanvasDraw({
@ -1697,13 +1806,6 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const largeThemeStrokeColor = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke');
ctx.strokeStyle = statusStyle ?? (largeTypeStrokeColor || largeGeneralStrokeColor || largeThemeStrokeColor || '');
ctx.stroke();
// 填充颜色
const largeTypeFillColor = colorConfig.getColor(`point.types.${type}.fill`);
if (largeTypeFillColor) {
ctx.fillStyle = largeTypeFillColor;
ctx.fill();
}
break;
}
default:

14
src/vite-env.d.ts vendored
View File

@ -1,7 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}