This commit is contained in:
chndfang 2025-05-05 23:21:31 +08:00
parent 46710c9134
commit f48e48eb7b
35 changed files with 813 additions and 90 deletions

View File

@ -1,5 +1,9 @@
{
"code": 200,
"success": false,
"success": true,
"data": [
"mock-robot-1",
"mock-robot-2"
],
"message": "模拟提示"
}

View File

@ -4,7 +4,7 @@
"data": {
"id": "mock-scene-1",
"label": "模拟场景A",
"json": "{\"x\":0,\"y\":0,\"scale\":1,\"pens\":[],\"origin\":{\"x\":0,\"y\":0},\"center\":{\"x\":0,\"y\":0},\"paths\":{},\"template\":\"4c2a10f\",\"locked\":10,\"version\":\"1.0.78\",\"dataPoints\":[],\"robotGroups\":[{\"id\":\"mock-robot-group-1\",\"label\":\"模拟机器人组A\",\"robots\":[\"mock-robot-1\",\"mock-robot-2\",\"mock-robot-3\"]},{\"sid\":\"mock-scene-1\",\"id\":\"mock-robot-group-2\",\"label\":\"模拟机器人组B\",\"robots\":[\"mock-robot-4\"]}],\"robots\":[{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-1\",\"label\":\"模拟机器人A\",\"brand\":\"模拟品牌A\",\"type\":1},{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-2\",\"label\":\"模拟机器人B\",\"brand\":\"模拟品牌A\",\"type\":2},{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-3\",\"label\":\"模拟机器人C\",\"brand\":\"模拟品牌A\",\"type\":3},{\"gid\":\"mock-robot-group-2\",\"id\":\"mock-robot-4\",\"label\":\"模拟机器人D\",\"brand\":\"模拟品牌B\",\"type\":1}]}"
"json": "{\"x\":0,\"y\":0,\"scale\":1,\"pens\":[],\"origin\":{\"x\":0,\"y\":0},\"center\":{\"x\":0,\"y\":0},\"paths\":{},\"template\":\"4c2a10f\",\"locked\":10,\"version\":\"1.0.78\",\"dataPoints\":[],\"robotGroups\":[{\"id\":\"mock-robot-group-1\",\"label\":\"模拟机器人组A\",\"robots\":[\"mock-robot-1\",\"mock-robot-2\",\"mock-robot-3\"]},{\"sid\":\"mock-scene-1\",\"id\":\"mock-robot-group-2\",\"label\":\"模拟机器人组B\",\"robots\":[\"mock-robot-4\"]}],\"robots\":[{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-1\",\"label\":\"模拟机器人A\",\"brand\":\"模拟品牌A\",\"type\":1,\"ip\":\"127.0.1.1\"},{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-2\",\"label\":\"模拟机器人B\",\"brand\":\"模拟品牌A\",\"type\":2,\"ip\":\"127.0.1.2\"},{\"gid\":\"mock-robot-group-1\",\"id\":\"mock-robot-3\",\"label\":\"模拟机器人C\",\"brand\":\"模拟品牌A\",\"type\":3,\"ip\":\"127.0.1.3\"},{\"gid\":\"mock-robot-group-2\",\"id\":\"mock-robot-4\",\"label\":\"模拟机器人D\",\"brand\":\"模拟品牌B\",\"type\":1,\"ip\":\"127.0.2.1\"}]}"
},
"message": "模拟提示"
}

BIN
public/point/16-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,9 +1,6 @@
<script setup lang="ts">
import sLocale from '@core/locale.service';
import sTheme from '@core/theme.service';
import { computed } from 'vue';
const empty = computed<string>(() => new URL(`./assets/images/empty-${sTheme.theme}.png`, import.meta.url).href);
</script>
<template>
@ -16,7 +13,7 @@ const empty = computed<string>(() => new URL(`./assets/images/empty-${sTheme.the
>
<template #renderEmpty>
<a-flex justify="center" align="center" :gap="8" vertical>
<img height="40" :src="empty" />
<img height="40" :src="sTheme.empty" />
<a-typography-text disabled>{{ $t('暂无数据') }}</a-typography-text>
</a-flex>
</template>

View File

@ -55,6 +55,21 @@
&.ant-btn-sm {
padding: 0;
}
&.ant-btn-lg {
padding: 8px;
border-radius: 8px;
}
}
}
.ant-card {
background-color: get-color(sider_bg2);
border-radius: 8px;
box-shadow: 0 2px 8px 0 get-color(shadow1);
& > .ant-card-body {
padding: 16px;
}
}
@ -89,6 +104,7 @@
align-items: center;
font: 400 14px/22px Roboto;
color: get-color(text2);
vertical-align: top;
}
.ant-collapse.ant-collapse-ghost {
@ -136,6 +152,14 @@
}
}
.ant-divider {
border-color: get-color(border1);
&.ant-divider-vertical {
height: 1em;
}
}
.ant-dropdown-menu-root {
padding: 0;
overflow: hidden;
@ -155,12 +179,57 @@
}
}
.ant-empty {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
& > .ant-empty-image {
height: 40px;
margin: 0;
}
& > .ant-empty-description {
margin: 0;
font: 400 14px/22px Roboto;
color: get-color(text4);
}
}
.ant-flex {
& > * {
flex: none;
}
}
.ant-float-btn {
height: fit-content;
overflow: hidden;
background-color: transparent;
box-shadow: none !important;
&.ant-float-btn-square > .ant-badge > .ant-float-btn-body {
background-color: get-color(sider_bg2);
border-radius: 4px;
box-shadow: 0 1px 2px 0 get-color(shadow1);
& > .ant-float-btn-content {
padding: 0;
& > .ant-float-btn-icon {
width: auto;
height: auto;
margin: 0;
}
}
}
&:hover > .ant-badge > .ant-float-btn-body {
background-color: get-color(fill1);
}
}
.ant-form {
& > .ant-form-item {
margin-block-end: 8px;
@ -297,6 +366,17 @@
}
}
.ant-list.block .ant-list-item {
padding: 12px;
background-color: get-color(fill4);
border: none;
border-radius: 4px;
&:not(:first-child) {
margin-block-start: 8px;
}
}
.ant-modal.ant-modal-confirm .ant-modal-content {
padding: 32px 32px 24px;
background-color: get-color(popover);
@ -490,6 +570,8 @@
}
.ant-tabs {
height: 100%;
overflow: hidden;
border-radius: 8px;
& > .ant-tabs-nav {
@ -531,17 +613,61 @@
}
}
.ant-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 5px;
margin: 0;
font: 500 14px/22px Roboto;
color: get-color(text1);
vertical-align: top;
background-color: transparent;
border-color: get-color(border1);
border-radius: 4px;
box-shadow: none !important;
&.ant-tag-borderless {
padding: 4px 8px;
font: 400 14px/16px Roboto;
color: get-color(text2);
background-color: get-color(fill3);
border: none;
}
}
.ant-typography {
font: 400 14px/22px Roboto;
color: get-color(text2);
font: 500 16px/22px Roboto;
color: get-color(text1);
&.ant-typography-secondary {
font: 400 14px/22px Roboto;
color: get-color(text2);
}
&.ant-typography-disabled {
font: 400 14px/22px Roboto;
color: get-color(text4);
}
& > strong {
font: 500 16px/22px SourceHanSansSC;
color: get-color(text1);
font: 500 16px/22px Roboto;
}
& > code {
padding: 0;
margin: 0;
font: 400 14px/22px Roboto;
color: get-color(text3);
background-color: transparent;
border: none;
}
}
.toolbar {
padding: 8px;
background-color: get-color(sider_bg);
border-radius: 8px;
box-shadow: 0 2px 8px 0 get-color(shadow2);
}
}

View File

@ -4,10 +4,26 @@
@include themed {
.icon-btn {
&.panel-btn {
color: get-color(ffffffd9);
color: get-color(icon);
&:disabled {
color: get-color(ffffff2e);
color: get-color(icon-disabled);
}
}
&.tool-btn {
color: get-color(icon);
&:disabled {
color: get-color(icon-disabled);
}
&.active {
background-color: get-color(primary) !important;
}
&:not(:disabled):hover {
background-color: get-color(bg_layout);
}
}
}

View File

@ -12,6 +12,7 @@ export enum MapPointType {
,
,
,
,
= 99,
}

View File

@ -12,28 +12,38 @@ const enum API {
export async function getAllRobots(): Promise<Array<RobotInfo>> {
type D = RobotInfo[];
const data = await http.post<D>(API.);
return data ?? [];
try {
const data = await http.post<D>(API.);
return data ?? [];
} catch (error) {
console.debug(error);
return [];
}
}
export async function registerRobot(robot: Omit<RobotDetail, 'id'>): Promise<RobotInfo | null> {
type B = Omit<RobotDetail, 'id'>;
type D = RobotInfo;
const body = robot;
const data = await http.post<D, B>(API., body);
return data ?? null;
}
export async function seizeRobotByIds(ids: Array<RobotInfo['id']>): Promise<boolean> {
if (!ids.length) return false;
type B = { ids: string[] };
type D = void;
try {
const body = { ids };
await http.post<D, B>(API., body);
return true;
const body = robot;
const data = await http.post<D, B>(API., body);
return data ?? null;
} catch (error) {
console.debug(error);
return false;
return null;
}
}
export async function seizeRobotByIds(ids: Array<RobotInfo['id']>): Promise<Array<RobotInfo['id']>> {
if (!ids.length) return [];
type B = { ids: string[] };
type D = string[];
try {
const body = { ids };
const data = await http.post<D, B>(API., body);
return data ?? [];
} catch (error) {
console.debug(error);
return [];
}
}

View File

@ -15,8 +15,8 @@ export enum RobotType {
export const ROBOT_TYPE_OPTIONS = <Array<[string, RobotType]>>Object.entries(RobotType).filter(([, v]) => isNumber(v));
export enum RobotState {
'任务执行中' = 1,
'充电中',
'停靠中',
'故障中',
= 1,
,
,
,
}

View File

@ -1 +1,28 @@
$icons: (control, dot, dropdown, edit, exit, pen, plus, register, trash_fill, trash);
$icons: (
area1-active,
area1,
area2-active,
area2,
area3-active,
area3,
battery_charge,
battery,
connect_off,
connect_on,
control,
detail,
dot,
dropdown,
edit,
exit,
pen,
plus,
redo,
register,
robot,
save,
search,
trash_fill,
trash,
undo
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { type RobotInfo, RobotState, RobotType } from '@api/robot';
import type { EditorService } from '@core/editor.service';
import sTheme from '@core/theme.service';
import { computed, inject, type InjectionKey, type ShallowRef } from 'vue';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
current?: string;
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
const robot = computed<RobotInfo | null>(() => {
const id = props.current;
if (!id) return null;
return editor.value.getRobotById(id) ?? null;
});
const batteryIcon = computed<string>(() => (robot.value?.state === RobotState.充电中 ? 'battery_charge' : 'battery'));
const stateDot = computed<string>(() => `state-${robot.value?.state}`);
</script>
<template>
<a-card class="card-container" :bordered="false">
<template v-if="robot">
<a-row :gutter="[8, 8]">
<a-col :span="24">
<a-flex align="center" :gap="8">
<i class="icon robot" />
<a-typography-text class="title" style="flex: auto" :content="robot.label" ellipsis />
<a-tag :bordered="false">{{ $t(RobotType[robot.type]) }}</a-tag>
</a-flex>
</a-col>
<a-col :span="24">
<a-typography-text code>{{ robot.ip }}</a-typography-text>
</a-col>
<a-col :span="24">
<a-space align="center" :size="8">
<a-tag v-if="robot.isConnected">
<i class="icon connect_on size-18 mr-4" />
<span>{{ $t('已连接') }}</span>
</a-tag>
<a-tag v-else>
<i class="icon connect_off size-18 mr-4" />
<span>{{ $t('未连接') }}</span>
</a-tag>
<a-tag>
<i class="icon size-18 mr-4" :class="batteryIcon" />
<span>{{ robot.battery ?? 0 }}%</span>
</a-tag>
<a-tag v-if="robot.state">
<i class="dot mr-4" :class="stateDot" />
<span>{{ $t(RobotState[robot.state]) }}</span>
</a-tag>
</a-space>
</a-col>
</a-row>
<a-list class="block mt-16">
<a-list-item>
<a-typography-text type="secondary">{{ $t('接单状态') }}</a-typography-text>
<a-typography-text>{{ $t(robot.canOrder ? '接单' : '不可接单') }}</a-typography-text>
</a-list-item>
<a-list-item>
<a-typography-text type="secondary">{{ $t('急停状态') }}</a-typography-text>
<a-typography-text>{{ $t(robot.canStop ? '是' : '否') }}</a-typography-text>
</a-list-item>
<a-list-item>
<a-typography-text type="secondary">{{ $t('控制权') }}</a-typography-text>
<a-typography-text>{{ $t(robot.canControl ? '已抢占' : '当前无控制权') }}</a-typography-text>
</a-list-item>
</a-list>
</template>
<a-empty v-else :image="sTheme.empty" />
</a-card>
</template>
<style scoped lang="scss">
.ant-typography.title {
font-size: 20px;
line-height: 28px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.state {
&-1 {
background-color: #19f390;
}
&-2 {
background-color: #fdc11c;
}
&-3 {
background-color: #1982f3;
}
&-4 {
background-color: #888;
}
}
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { MapAreaType } from '@api/map';
import type { EditorService } from '@core/editor.service';
import { isEmpty } from 'lodash-es';
import { inject, type InjectionKey, ref, type ShallowRef, watch } from 'vue';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
editable?: boolean;
current?: string;
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
const mode = ref<MapAreaType>();
watch(editor.value.mouseBrush, (v) => {
if (!mode.value) return;
const [p1, p2] = v ?? [];
if (isEmpty(p1) || isEmpty(p2)) return;
editor.value.addArea(p1, p2, mode.value);
mode.value = undefined;
});
</script>
<template>
<a-space class="toolbar" :size="0">
<a-button
class="icon-btn tool-btn"
:class="{ active: mode === MapAreaType.库区 }"
size="large"
@click="mode = MapAreaType.库区"
>
<i class="icon" :class="mode === MapAreaType.库区 ? 'area1-active' : 'area1'" />
</a-button>
<a-button
class="icon-btn tool-btn ml-12"
:class="{ active: mode === MapAreaType.互斥区 }"
size="large"
@click="mode = MapAreaType.互斥区"
>
<i class="icon" :class="mode === MapAreaType.互斥区 ? 'area2-active' : 'area2'" />
</a-button>
<a-button
class="icon-btn tool-btn ml-12"
:class="{ active: mode === MapAreaType.非互斥区 }"
size="large"
@click="mode = MapAreaType.非互斥区"
>
<i class="icon" :class="mode === MapAreaType.非互斥区 ? 'area3-active' : 'area3'" />
</a-button>
<a-divider class="size-24 mh-8" type="vertical" />
<a-button class="icon-btn tool-btn" size="large">
<i class="mask save" />
</a-button>
<a-button class="icon-btn tool-btn ml-12" size="large">
<i class="mask undo" />
</a-button>
<a-button class="icon-btn tool-btn ml-12" size="large">
<i class="mask redo" />
</a-button>
<a-button class="icon-btn tool-btn ml-12" size="large" :disabled="!editor.selected.value.length">
<i class="mask trash" />
</a-button>
</a-space>
</template>

View File

@ -56,7 +56,9 @@ const submit = () => {
<template>
<a-modal :width="420" :title="$t('添加机器人')" v-model:open="show" :mask-closable="false" centered @ok="submit">
<a-input class="search" :placeholder="$t('请输入搜索关键字')" v-model:value="keyword">
<template #suffix><SearchOutlined /></template>
<template #suffix>
<i class="icon search size-16" />
</template>
</a-input>
<a-table

View File

@ -0,0 +1,110 @@
<script setup lang="ts">
import { type MapPen, MapPointType } from '@api/map';
import type { EditorService } from '@core/editor.service';
import { computed, inject, type InjectionKey, ref, type ShallowRef } from 'vue';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
current?: string;
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
const keyword = ref<string>('');
//#region
const points = computed<MapPen[]>(() =>
editor.value.points.value.filter(({ label }) => label?.includes(keyword.value)),
);
//#endregion
//#region 线
//#endregion
//#region
//#endregion
</script>
<template>
<a-input class="search mb-16" :placeholder="$t('请输入搜索关键字')" v-model:value="keyword">
<template #suffix>
<i class="icon search size-16" />
</template>
</a-input>
<a-collapse style="flex: auto; overflow-y: auto" expand-icon-position="end" ghost>
<template #expandIcon="v">
<i class="icon dropdown" :class="{ active: v?.isActive }" />
</template>
<a-collapse-panel :header="$t('互斥区')"></a-collapse-panel>
<a-collapse-panel :header="$t('非互斥区')"></a-collapse-panel>
<a-collapse-panel :header="$t('仅载货可通行路线')"></a-collapse-panel>
<a-collapse-panel :header="$t('仅空载可通行路线')"></a-collapse-panel>
<a-collapse-panel :header="$t('禁行路线')"></a-collapse-panel>
<a-collapse-panel :header="$t('等待点')">
<a-list rowKey="id" :data-source="points.filter(({ point }) => point?.type === MapPointType.等待点)">
<template #renderItem="{ item }">
<a-list-item
class="ph-16"
:class="{ selected: item.id === current }"
style="height: 36px"
@click="editor.active(item.id)"
>
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
<a-collapse-panel :header="$t('充电点')">
<a-list rowKey="id" :data-source="points.filter(({ point }) => point?.type === MapPointType.充电点)">
<template #renderItem="{ item }">
<a-list-item
class="ph-16"
:class="{ selected: item.id === current }"
style="height: 36px"
@click="editor.active(item.id)"
>
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
<a-collapse-panel :header="$t('停靠点')">
<a-list rowKey="id" :data-source="points.filter(({ point }) => point?.type === MapPointType.停靠点)">
<template #renderItem="{ item }">
<a-list-item
class="ph-16"
:class="{ selected: item.id === current }"
style="height: 36px"
@click="editor.active(item.id)"
>
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
<a-collapse-panel :header="$t('禁行点')">
<a-list rowKey="id" :data-source="points.filter(({ point }) => point?.type === MapPointType.禁行点)">
<template #renderItem="{ item }">
<a-list-item
class="ph-16"
:class="{ selected: item.id === current }"
style="height: 36px"
@click="editor.active(item.id)"
>
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</template>

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
import { type RobotGroup, type RobotInfo } from '@api/robot';
import { type RobotGroup, type RobotInfo, seizeRobotByIds } from '@api/robot';
import type { RobotAddModalRef } from '@common/modal/robot-add-modal.vue';
import type { RobotGroupRenameModalRef } from '@common/modal/robot-group-rename-modal.vue';
import type { RobotRegisterModalRef } from '@common/modal/robot-register-modal.vue';
import type { EditorService } from '@core/editor.service';
import { Modal } from 'ant-design-vue';
import { map } from 'lodash-es';
import { watch } from 'vue';
import { reactive } from 'vue';
import { computed, inject, type InjectionKey, ref, type ShallowRef, shallowRef } from 'vue';
import { useI18n } from 'vue-i18n';
@ -25,7 +26,10 @@ const emit = defineEmits<Events>();
const { t } = useI18n();
//#region
const seizeRobots = async () => {
const res = await seizeRobotByIds([...selected.keys()]);
editor.value.updateRobots(res, { canControl: true });
};
//#endregion
const keyword = ref<string>('');
@ -33,15 +37,39 @@ const keyword = ref<string>('');
//#region
const groups = computed<RobotGroup[]>(() => editor.value.robotGroups.value ?? []);
const robots = computed<RobotInfo[]>(() => editor.value.robots.filter(({ label }) => label.includes(keyword.value)));
const getGroupRobots = (ids: RobotGroup['robots']) =>
ids?.map((id) => editor.value.getRobotById(id)).filter((robot) => robot?.label.includes(keyword.value));
const selected = ref<RobotInfo['id'][]>([]);
const isAllSelected = computed<boolean>(() => robots.value.every(({ id }) => selected.value.includes(id)));
const selected = reactive<Set<RobotInfo['id']>>(new Set());
watch(
() => props.editable,
() => selected.clear(),
);
const isAllSelected = computed<boolean>(() => robots.value.every(({ id }) => selected.has(id)));
const selectAll = (checked: boolean) => {
selected.value = checked ? map(robots.value, 'id') : [];
if (checked) {
robots.value.forEach(({ id }) => selected.add(id));
} else {
selected.clear();
}
};
const checkGroupSelected = (ids: RobotGroup['robots']) => !!ids?.length && ids.every((id) => selected.has(id));
const selectGroup = (ids: RobotGroup['robots'], checked: boolean) => {
if (checked) {
ids?.forEach((id) => selected.add(id));
} else {
ids?.forEach((id) => selected.delete(id));
}
};
const selectRobot = (id: RobotInfo['id'], checked: boolean) => {
if (checked) {
selected.add(id);
} else {
selected.delete(id);
}
};
//#endregion
@ -60,6 +88,21 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
onOk: () => editor.value.deleteRobotGroup(id),
});
//#endregion
//#region
const toRemoveRobots = () =>
Modal.confirm({
class: 'confirm',
title: t('您确定要从场景中移除已选机器人吗?'),
centered: true,
cancelText: t('返回'),
okText: t('移除'),
onOk: () => {
editor.value.removeRobots([...selected.keys()]);
selected.clear();
},
});
//#endregion
</script>
<template>
@ -69,16 +112,30 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
<a-flex class="full" vertical>
<a-input class="search mb-16" :placeholder="$t('请输入搜索关键字')" v-model:value="keyword">
<template #suffix><SearchOutlined /></template>
<template #suffix>
<i class="icon search size-16" />
</template>
</a-input>
<a-flex v-if="editable" class="mb-8" style="height: 32px" justify="space-between" align="center">
<a-checkbox :checked="isAllSelected" @change="selectAll($event.target.checked)">{{ $t('全选') }}</a-checkbox>
<a-space align="center">
<a-button class="icon-btn panel-btn" size="small" :disabled="!selected.length">
<a-button
class="icon-btn panel-btn"
:title="$t('抢占控制权')"
size="small"
@click="seizeRobots"
:disabled="!selected.size"
>
<i class="mask control" />
</a-button>
<a-button class="icon-btn panel-btn" size="small" :disabled="!selected.length">
<a-button
class="icon-btn panel-btn"
:title="$t('移除机器人')"
size="small"
@click="toRemoveRobots"
:disabled="!selected.size"
>
<i class="mask trash_fill" />
</a-button>
</a-space>
@ -88,10 +145,11 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
<template #expandIcon="v">
<i class="icon dropdown" :class="{ active: v?.isActive }" />
</template>
<a-collapse-panel v-for="{ id, label, robots } in groups" :key="id" :header="label">
<a-collapse-panel v-for="{ id, label, robots } in groups" :key="id">
<template v-if="editable" #extra>
<a-dropdown placement="bottomRight">
<a-button class="icon-btn" size="small">
<a-button class="icon-btn" size="small" @click.stop>
<i class="icon dot" />
</a-button>
<template #overlay>
@ -124,7 +182,20 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
</template>
</a-dropdown>
</template>
<a-list :data-source="getGroupRobots(robots)">
<template #header>
<a-space align="center" :size="8">
<a-checkbox
v-if="editable"
:checked="checkGroupSelected(robots)"
@change="selectGroup(robots, $event.target.checked)"
@click.stop
/>
<span>{{ label }}</span>
</a-space>
</template>
<a-list rowKey="id" :data-source="getGroupRobots(robots)">
<template #renderItem="{ item }">
<a-list-item
class="ph-16"
@ -132,8 +203,15 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
style="height: 36px"
@click="emit('change', item.id)"
>
<template #actions> </template>
<a-typography-text>{{ item.label }}</a-typography-text>
<a-space align="center" :size="8">
<a-checkbox
v-if="editable"
:checked="selected.has(item.id)"
@change="selectRobot(item.id, $event.target.checked)"
@click.stop
/>
<a-typography-text type="secondary">{{ item.label }}</a-typography-text>
</a-space>
</a-list-item>
</template>
</a-list>
@ -146,9 +224,3 @@ const toDeleteGroup = (id: RobotGroup['id']) =>
</a-button>
</a-flex>
</template>
<style scoped lang="scss">
.btn {
justify-self: flex-end;
}
</style>

21
src/components/test-2.vue Normal file
View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { EditorService } from '@core/editor.service';
import sTheme from '@core/theme.service';
import { inject, type InjectionKey, type ShallowRef } from 'vue';
type Props = {
token: InjectionKey<ShallowRef<EditorService>>;
editable?: boolean;
current?: string;
};
const props = defineProps<Props>();
const editor = inject(props.token)!;
</script>
<template>
<a-row v-if="true"></a-row>
<a-empty v-else :image="sTheme.empty" />
</template>
<!-- <style scoped lang="scss"></style> -->
<!-- <style scoped lang="scss"></style> -->

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { getSceneById } from '@api/scene';
import { EditorService } from '@core/editor.service';
import { watch } from 'vue';
import { computed, nextTick, watch } from 'vue';
import { ref } from 'vue';
import { onMounted, provide, shallowRef } from 'vue';
@ -37,14 +37,38 @@ onMounted(() => {
const editable = ref<boolean>(false);
watch(editable, (v) => editor.value?.setState(v));
const current = ref<string>();
const show = ref<boolean>(true);
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
watch(
() => editor.value?.selected.value[0],
(v) => {
if (v) {
const [pen] = editor.value?.find(v) ?? [];
if (pen?.id) {
current.value = { type: <'point' | 'line' | 'area'>pen.name, id: pen.id };
return;
}
}
if (current.value?.type === 'robot') return;
current.value = undefined;
},
);
const isRobot = computed(() => current.value?.type === 'robot');
const isPoint = computed(() => current.value?.type === 'point');
const isRoute = computed(() => current.value?.type === 'line');
const isArea = computed(() => current.value?.type === 'area');
const selectRobot = (id: string) => {
current.value = { type: 'robot', id };
editor.value?.inactive();
};
</script>
<template>
<a-layout class="full">
<a-layout-header class="p-16" style="height: 64px">
<a-flex justify="space-between" align="center">
<a-typography-text strong>{{ title }}</a-typography-text>
<a-typography-text class="title">{{ title }}</a-typography-text>
<a-space align="center">
<a-button v-if="editable" class="warning" @click="editable = false">
<i class="icon exit size-18 mr-8" />
@ -56,25 +80,27 @@ const current = ref<string>();
</a-button>
<a-button>{{ $t('推送') }}</a-button>
<a-button>{{ $t('导入') }}</a-button>
<a-button>{{ $t('导出') }}</a-button>
<a-button @click="editor?.export()">{{ $t('导出') }}</a-button>
</a-space>
</a-flex>
</a-layout-header>
<a-layout class="p-16">
<a-layout-sider :width="320">
<a-tabs class="full" type="card">
<a-tabs type="card">
<a-tab-pane key="1" :tab="$t('机器人')">
<RobotGroups
v-if="editor"
:token="EDITOR_KEY"
:editable="editable"
:current="current"
@change="current = $event"
:current="current?.id"
@change="selectRobot"
/>
</a-tab-pane>
<a-tab-pane key="2" :tab="$t('库区')">Content of Tab Pane 2</a-tab-pane>
<a-tab-pane key="3" :tab="$t('高级组')">Content of Tab Pane 3</a-tab-pane>
<a-tab-pane key="3" :tab="$t('高级组')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" />
</a-tab-pane>
</a-tabs>
</a-layout-sider>
<a-layout-content>
@ -82,10 +108,45 @@ const current = ref<string>();
</a-layout-content>
</a-layout>
</a-layout>
<div v-if="editable" class="toolbar-container">
<EditorToolbar v-if="editor" :token="EDITOR_KEY" />
</div>
<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>
</a-float-button>
<div v-if="show" class="card-container">
<RobotDetailCard v-if="isRobot" :token="EDITOR_KEY" :current="current.id" />
<template v-if="isPoint"> </template>
</div>
</template>
</template>
<style scoped lang="scss">
.editor-container {
background-color: transparent !important;
}
.toolbar-container {
position: fixed;
right: 50%;
bottom: 40px;
left: 50%;
z-index: 8888;
}
.card-container {
position: fixed;
top: 80px;
right: 64px;
z-index: 8888;
width: 320px;
}
.ant-typography.title {
font: 500 16px/22px SourceHanSansSC;
}
</style>

View File

@ -2,28 +2,35 @@ import { EDITOR_CONFIG, MapAreaType, type MapPen, MapPointType } from '@api/map'
import type { RobotGroup, RobotInfo } from '@api/robot';
import type { SceneData } from '@api/scene';
import sTheme from '@core/theme.service';
import { CanvasLayer, EditType, LockState, Meta2d, s8 } from '@meta2d/core';
import { CanvasLayer, EditType, LockState, Meta2d, type Pen, s8 } from '@meta2d/core';
import { useObservable } from '@vueuse/rxjs';
import { clone, cloneDeep, get, isNil, pick, remove, some } from 'lodash-es';
import { clone, cloneDeep, get, isNil, isString, pick, remove, some } from 'lodash-es';
import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs';
import { watch } from 'vue';
import { reactive, watch } from 'vue';
export type Point = Record<'x' | 'y', number>;
export class EditorService extends Meta2d {
public load(map?: string, readonly = false): void {
public async load(map?: string, editable = false): Promise<void> {
const data = map ? JSON.parse(map) : undefined;
this.open(data);
this.setState(readonly);
this.setState(editable);
}
public save(): string {
const data = this.data();
const map = JSON.stringify(data);
return map;
return JSON.stringify(data);
}
public export(): string {
const png = this.toPng(10);
return png;
public export(): void {
const json = this.save();
console.log(json);
}
public setState(editable?: boolean): void {
this.lock(editable ? LockState.None : LockState.Disable);
}
public override data(): SceneData {
return super.data();
}
readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>();
@ -46,19 +53,8 @@ export class EditorService extends Meta2d {
),
);
public override data(): SceneData {
return super.data();
}
public override find(target: string): MapPen[] {
return super.find(target);
}
public setState(readonly?: boolean): void {
this.lock(readonly ? LockState.Disable : LockState.None);
}
//#region 机器人
readonly #robotMap = new Map<RobotInfo['id'], RobotInfo>();
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
public get robots(): RobotInfo[] {
return Array.from(this.#robotMap.values());
}
@ -81,6 +77,22 @@ export class EditorService extends Meta2d {
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
public removeRobots(ids: RobotInfo['id'][]): void {
ids?.forEach((v) => this.#robotMap.delete(v));
const groups = clone(this.#robotGroups$$.value);
groups.forEach(({ robots }) => remove(robots ?? [], (v) => !this.#robotMap.has(v)));
this.#robotGroups$$.next(groups);
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
(<SceneData>this.store.data).robotGroups = this.#robotGroups$$.value;
}
public updateRobots(ids: RobotInfo['id'][], values: Partial<RobotInfo>): void {
ids?.forEach((v) => {
const robot = this.#robotMap.get(v);
if (isNil(robot)) return;
this.#robotMap.set(v, { ...robot, ...values });
});
(<SceneData>this.store.data).robots = [...this.#robotMap.values()];
}
readonly #robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
public readonly robotGroups = useObservable<RobotGroup[]>(this.#robotGroups$$.pipe(debounceTime(300)));
@ -113,7 +125,47 @@ export class EditorService extends Meta2d {
}
//#endregion
readonly #change$$ = new Subject<boolean>();
public readonly selected = useObservable<string[], string[]>(
this.#change$$.pipe(
filter((v) => !v),
debounceTime(100),
map(() => this.store.active?.map(({ id }) => id).filter((v) => !isNil(v)) ?? []),
),
{ initialValue: new Array<string>() },
);
public readonly pens = useObservable<MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
debounceTime(100),
map(() => this.data().pens),
),
);
public override find(target: string): MapPen[] {
return super.find(target);
}
public override active(target: string | Pen[], emit?: boolean): void {
const pens = isString(target) ? this.find(target) : target;
super.active(pens, emit);
this.render();
}
public override inactive(): void {
super.inactive();
this.render();
}
//#region 点位
public readonly points = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
debounceTime(100),
map(() => this.find('point')),
),
{ initialValue: new Array<MapPen>() },
);
public async addPoint(p: Point, type = MapPointType.): Promise<void> {
const id = s8();
const pen: MapPen = {
@ -130,7 +182,7 @@ export class EditorService extends Meta2d {
pen.x = x - width / 2;
pen.y = y - height / 2;
await this.addPen(pen, false, true, true);
this.pushHistory({ type: EditType.Add, pens: [cloneDeep(pen)] });
// this.pushHistory({ type: EditType.Add, pens: [cloneDeep(pen)] });
}
#mapPoint(type: MapPointType): Required<Pick<MapPen, 'width' | 'height' | 'lineWidth' | 'iconSize'>> {
@ -186,10 +238,8 @@ export class EditorService extends Meta2d {
);
}
#load(theme?: string): void {
if (theme) {
this.setTheme(theme);
}
#load(theme: string): void {
this.setTheme(theme);
const { robots, robotGroups } = this.data();
this.#robotMap.clear();
@ -208,9 +258,50 @@ export class EditorService extends Meta2d {
#listen(e: unknown, v: any) {
switch (e) {
case 'opened':
this.#load();
this.#load(sTheme.theme);
this.#change$$.next(true);
break;
case 'add':
case 'delete':
case 'update':
case 'valueUpdate':
this.#change$$.next(true);
break;
case 'active':
case 'inactive':
this.#change$$.next(false);
break;
// case 'undo':
// case 'redo':
// {
// const { type, pens = [], initPens = [] } = data ?? {};
// switch (type) {
// case EditType.Add:
// pens?.forEach((pen: SceneMapPen) => {
// if (pen.name === 'point') {
// const { image } = this.#mapPointType(pen.point!.type);
// const rect = this.#editor?.getPenRect(pen);
// this.#editor?.setValue({ id: pen.id, image, ...rect }, { render: true, history: false });
// }
// });
// break;
// case EditType.Update:
// (event === 'undo' ? initPens : pens)?.forEach((pen: SceneMapPen) => {
// if (pen.name === 'point') {
// const { image } = this.#mapPointType(pen.point!.type);
// this.#editor?.setValue({ id: pen.id, image }, { render: true, history: false });
// }
// });
// break;
// default:
// break;
// }
// }
// break;
case 'click':
case 'mousedown':
case 'mouseup':
@ -280,6 +371,7 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
case MapPointType.:
case MapPointType.:
case MapPointType.:
case MapPointType.:
ctx.roundRect(x, y, w, h, r);
ctx.strokeStyle = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? '';
ctx.stroke();

View File

@ -41,6 +41,11 @@ class ThemeService {
return THEME_MAP[`editor-${this.#theme.value}`] ?? {};
}
public get empty(): string {
const { href } = new URL(`../assets/images/empty-${this.#theme.value}.png`, import.meta.url);
return href;
}
constructor() {
watch(this.#theme, (v) => this.#load(v), { immediate: true });
}