301 lines
7.1 KiB
Vue
301 lines
7.1 KiB
Vue
<script setup lang="ts">
|
||
import type { BlockInstance, BlockSchema } from './types';
|
||
|
||
import { computed } from 'vue';
|
||
|
||
import MIcon from '#/components/icon/icon.vue';
|
||
|
||
/**
|
||
* 中间画布:按 targetArrayPath 分组渲染块实例
|
||
* 每个分组是一个 SortableJS 接收区,可拖入、排序、删除
|
||
*/
|
||
const props = defineProps<{
|
||
/** 所有块定义(按 type 查找对应 schema) */
|
||
blockSchemas: BlockSchema[];
|
||
/** 所有块实例(已拖入画布的) */
|
||
instances: BlockInstance[];
|
||
/** 当前选中的块实例 ID(高亮显示) */
|
||
selectedInstanceId?: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
/** 选中块实例(点击时触发) */
|
||
select: [instanceId: string];
|
||
/** 删除块实例 */
|
||
remove: [instanceId: string];
|
||
}>();
|
||
|
||
/**
|
||
* 按 targetArrayPath 分组的渲染结构
|
||
* 每个分组对应一个 SortableJS 接收区
|
||
*/
|
||
const groupedRender = computed(() => {
|
||
// 收集所有出现的 targetArrayPath,按 blockSchemas 顺序排序
|
||
const orderedPaths: string[] = [];
|
||
const labelMap: Record<string, { label: string; maxCount?: number }> = {};
|
||
for (const schema of props.blockSchemas) {
|
||
if (!orderedPaths.includes(schema.targetArrayPath)) {
|
||
orderedPaths.push(schema.targetArrayPath);
|
||
labelMap[schema.targetArrayPath] = {
|
||
label: schema.targetArrayPath,
|
||
maxCount: schema.maxCount,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 把 instances 按目标路径分组
|
||
const grouped: Record<string, BlockInstance[]> = {};
|
||
for (const inst of props.instances) {
|
||
const schema = props.blockSchemas.find((s) => s.type === inst.type);
|
||
if (!schema) continue;
|
||
const path = schema.targetArrayPath;
|
||
if (!grouped[path]) grouped[path] = [];
|
||
grouped[path].push(inst);
|
||
}
|
||
|
||
return orderedPaths.map((path) => ({
|
||
path,
|
||
label: labelMap[path]?.label ?? path,
|
||
maxCount: labelMap[path]?.maxCount,
|
||
items: grouped[path] ?? [],
|
||
}));
|
||
});
|
||
|
||
/**
|
||
* 获取块实例的 schema 定义
|
||
*/
|
||
function getSchema(type: string): BlockSchema | undefined {
|
||
return props.blockSchemas.find((s) => s.type === type);
|
||
}
|
||
|
||
/**
|
||
* 获取块实例的简要描述(属性面板编辑的字段值预览)
|
||
*/
|
||
function getInstanceSummary(inst: BlockInstance): string {
|
||
const schema = getSchema(inst.type);
|
||
if (!schema) return '';
|
||
const firstField = schema.fields[0];
|
||
if (!firstField) return '';
|
||
// 嵌套路径取最末级
|
||
const path = firstField.name.split('.');
|
||
let value: any = inst.data;
|
||
for (const key of path) {
|
||
value = value?.[key];
|
||
}
|
||
return value ? String(value) : '(未填写)';
|
||
}
|
||
|
||
function handleClick(instanceId: string) {
|
||
emit('select', instanceId);
|
||
}
|
||
|
||
function handleRemove(instanceId: string) {
|
||
emit('remove', instanceId);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="canvas">
|
||
<div class="panel-header">
|
||
<span class="title">画布</span>
|
||
<span class="hint">拖入 / 排序 / 点击编辑属性</span>
|
||
</div>
|
||
<div class="canvas-body">
|
||
<div
|
||
v-for="group in groupedRender"
|
||
:key="group.path"
|
||
class="canvas-group"
|
||
>
|
||
<div class="group-header">
|
||
<span class="group-label">{{ group.label }}</span>
|
||
<span class="group-count">
|
||
{{ group.items.length }}{{ group.maxCount ? `/${group.maxCount}` : '' }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="group-dropzone"
|
||
:data-target-array-path="group.path"
|
||
>
|
||
<div
|
||
v-for="inst in group.items"
|
||
:key="inst.id"
|
||
class="instance-item"
|
||
:class="{ selected: inst.id === selectedInstanceId }"
|
||
:data-instance-id="inst.id"
|
||
@click.stop="handleClick(inst.id)"
|
||
>
|
||
<div class="instance-info">
|
||
<MIcon
|
||
v-if="getSchema(inst.type)?.icon"
|
||
:icon="getSchema(inst.type)!.icon"
|
||
size="14"
|
||
/>
|
||
<div class="instance-text">
|
||
<div class="instance-label">{{ getSchema(inst.type)?.label }}</div>
|
||
<div class="instance-summary">{{ getInstanceSummary(inst) }}</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="instance-remove"
|
||
title="删除"
|
||
@click.stop="handleRemove(inst.id)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div v-if="group.items.length === 0" class="empty-dropzone">
|
||
拖入组件
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.canvas {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: var(--ant-color-bg-container, #fff);
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||
font-size: 13px;
|
||
|
||
.title {
|
||
font-weight: 600;
|
||
color: var(--ant-color-text, #1d2129);
|
||
}
|
||
|
||
.hint {
|
||
color: var(--ant-color-text-tertiary, #86909c);
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.canvas-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.canvas-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.group-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
|
||
.group-label {
|
||
color: var(--ant-color-text-secondary, #4e5969);
|
||
font-weight: 500;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.group-count {
|
||
color: var(--ant-color-text-tertiary, #86909c);
|
||
}
|
||
}
|
||
|
||
.group-dropzone {
|
||
min-height: 60px;
|
||
padding: 8px;
|
||
border: 2px dashed var(--ant-color-border, #d9d9d9);
|
||
border-radius: 6px;
|
||
background: var(--ant-color-fill-quaternary, #fafbfc);
|
||
}
|
||
|
||
.empty-dropzone {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 44px;
|
||
color: var(--ant-color-text-quaternary, #c9cdd4);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.instance-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
margin-bottom: 6px;
|
||
border: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||
border-radius: 4px;
|
||
background: var(--ant-color-bg-container, #fff);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: var(--ant-color-primary, #165dff);
|
||
}
|
||
|
||
&.selected {
|
||
border-color: var(--ant-color-primary, #165dff);
|
||
background: var(--ant-color-primary-bg, #f2f7ff);
|
||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
|
||
}
|
||
}
|
||
|
||
.instance-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.instance-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.instance-label {
|
||
font-size: 13px;
|
||
color: var(--ant-color-text, #1d2129);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.instance-summary {
|
||
font-size: 11px;
|
||
color: var(--ant-color-text-tertiary, #86909c);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.instance-remove {
|
||
width: 18px;
|
||
height: 18px;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
color: var(--ant-color-text-tertiary, #86909c);
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
|
||
&:hover {
|
||
background: var(--ant-color-error, #f53f3f);
|
||
color: #fff;
|
||
}
|
||
}
|
||
</style>
|