Files
xk-admin/apps/web-antd/src/components/oa-template-card-editor/Canvas.vue

301 lines
7.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>