349 lines
10 KiB
Vue
349 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import type { BlockInstance, BlockSchema, FixedField, FormFieldSchema } from './types';
|
||
|
||
import { computed, watch } from 'vue';
|
||
|
||
import { Empty, Input, InputNumber, Radio, RadioGroup, Select, Textarea } from 'ant-design-vue';
|
||
|
||
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
|
||
|
||
/**
|
||
* 右侧属性面板:根据选中块的字段 schema 渲染表单
|
||
* - 顶层固定字段(fixedFields):渲染在顶部,不可删除
|
||
* - 选中块的 fields:渲染在下方
|
||
* - 图片类型字段用 GalleryPickLink(透传 acceptTypes)
|
||
*/
|
||
const props = defineProps<{
|
||
/** 顶层固定字段定义 */
|
||
fixedFields?: FixedField[];
|
||
/** 顶层固定字段当前值 */
|
||
fixedValues?: Record<string, any>;
|
||
/** 当前选中的块定义(可能为空) */
|
||
selectedBlockSchema?: BlockSchema;
|
||
/** 当前选中的块实例(可能为空) */
|
||
selectedInstance?: BlockInstance;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
/** 顶层固定字段值变化 */
|
||
'update:fixedValues': [value: Record<string, any>];
|
||
/** 选中块实例数据变化 */
|
||
'update:instance': [instance: BlockInstance];
|
||
}>();
|
||
|
||
/**
|
||
* 设置顶层固定字段的值
|
||
* 支持 a.b.c 嵌套路径
|
||
*/
|
||
function setFixedValue(path: string, value: any) {
|
||
const next = { ...(props.fixedValues || {}) };
|
||
setByPath(next, path, value);
|
||
emit('update:fixedValues', next);
|
||
}
|
||
|
||
/**
|
||
* 设置选中块实例的字段值
|
||
*/
|
||
function setInstanceValue(path: string, value: any) {
|
||
if (!props.selectedInstance) return;
|
||
const nextData = { ...props.selectedInstance.data };
|
||
setByPath(nextData, path, value);
|
||
emit('update:instance', {
|
||
...props.selectedInstance,
|
||
data: nextData,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 按 a.b.c 路径设置对象嵌套属性(不可变更新)
|
||
*/
|
||
function setByPath(obj: Record<string, any>, path: string, value: any) {
|
||
const keys = path.split('.');
|
||
let cursor: any = obj;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
const k = keys[i]!;
|
||
if (!cursor[k] || typeof cursor[k] !== 'object') {
|
||
cursor[k] = {};
|
||
} else {
|
||
cursor[k] = { ...cursor[k] };
|
||
}
|
||
cursor = cursor[k];
|
||
}
|
||
cursor[keys[keys.length - 1]!] = value;
|
||
}
|
||
|
||
/**
|
||
* 按 a.b.c 路径读取对象嵌套属性
|
||
*/
|
||
function getByPath(obj: Record<string, any> | undefined, path: string): any {
|
||
if (!obj) return undefined;
|
||
const keys = path.split('.');
|
||
let cursor: any = obj;
|
||
for (const k of keys) {
|
||
if (cursor == null) return undefined;
|
||
cursor = cursor[k];
|
||
}
|
||
return cursor;
|
||
}
|
||
|
||
const hasAnyField = computed(() => {
|
||
return (
|
||
(props.fixedFields && props.fixedFields.length > 0) ||
|
||
(props.selectedBlockSchema && props.selectedBlockSchema.fields.length > 0)
|
||
);
|
||
});
|
||
|
||
// 当选中的块变化时,触发 watch 让父级感知(用于属性面板切换的动画/数据初始化)
|
||
watch(
|
||
() => props.selectedInstance?.id,
|
||
() => {
|
||
// 仅做依赖追踪,实际更新在用户交互时通过 emit 触发
|
||
},
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="property-panel">
|
||
<div class="panel-header">
|
||
<span class="title">属性</span>
|
||
<span v-if="selectedBlockSchema" class="hint">{{ selectedBlockSchema.label }}</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<!-- 顶层固定字段 -->
|
||
<div v-if="fixedFields && fixedFields.length > 0" class="field-section">
|
||
<div class="section-title">基础信息</div>
|
||
<div
|
||
v-for="field in fixedFields"
|
||
:key="field.name"
|
||
class="form-row"
|
||
>
|
||
<label class="form-label">
|
||
{{ field.label }}
|
||
<span v-if="field.required" class="required">*</span>
|
||
</label>
|
||
<!-- 文本 -->
|
||
<Input
|
||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||
:value="getByPath(fixedValues, field.name)"
|
||
:placeholder="field.placeholder"
|
||
allow-clear
|
||
size="small"
|
||
@update:value="setFixedValue(field.name, $event)"
|
||
/>
|
||
<!-- 数字 -->
|
||
<InputNumber
|
||
v-else-if="field.type === 'number'"
|
||
:value="getByPath(fixedValues, field.name)"
|
||
:placeholder="field.placeholder"
|
||
size="small"
|
||
style="width: 100%"
|
||
@update:value="setFixedValue(field.name, $event)"
|
||
/>
|
||
<!-- 多行文本 -->
|
||
<Textarea
|
||
v-else-if="field.type === 'textarea'"
|
||
:value="getByPath(fixedValues, field.name)"
|
||
:placeholder="field.placeholder"
|
||
:rows="3"
|
||
size="small"
|
||
@update:value="setFixedValue(field.name, $event)"
|
||
/>
|
||
<!-- 下拉选择 -->
|
||
<Select
|
||
v-else-if="field.type === 'select'"
|
||
:value="getByPath(fixedValues, field.name)"
|
||
:options="field.options"
|
||
:placeholder="field.placeholder"
|
||
size="small"
|
||
@update:value="setFixedValue(field.name, $event)"
|
||
/>
|
||
<!-- 单选 -->
|
||
<RadioGroup
|
||
v-else-if="field.type === 'radio'"
|
||
:value="getByPath(fixedValues, field.name)"
|
||
size="small"
|
||
@update:value="setFixedValue(field.name, $event)"
|
||
>
|
||
<Radio
|
||
v-for="opt in field.options"
|
||
:key="String(opt.value)"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</Radio>
|
||
</RadioGroup>
|
||
<!-- 图片素材 -->
|
||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||
<Input
|
||
:value="getByPath(fixedValues, field.name)"
|
||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||
size="small"
|
||
read-only
|
||
/>
|
||
<GalleryPickLink
|
||
:accept-types="field.acceptTypes || [0]"
|
||
@select="(urls) => setFixedValue(field.name, urls[0] || '')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 选中块的属性字段 -->
|
||
<div v-if="selectedBlockSchema && selectedInstance" class="field-section">
|
||
<div class="section-title">{{ selectedBlockSchema.label }}属性</div>
|
||
<div
|
||
v-for="field in selectedBlockSchema.fields"
|
||
:key="field.name"
|
||
class="form-row"
|
||
>
|
||
<label class="form-label">
|
||
{{ field.label }}
|
||
<span v-if="field.required" class="required">*</span>
|
||
</label>
|
||
<Input
|
||
v-if="!field.type || field.type === 'string' || field.type === 'url'"
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
:placeholder="field.placeholder"
|
||
allow-clear
|
||
size="small"
|
||
@update:value="setInstanceValue(field.name, $event)"
|
||
/>
|
||
<InputNumber
|
||
v-else-if="field.type === 'number'"
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
:placeholder="field.placeholder"
|
||
size="small"
|
||
style="width: 100%"
|
||
@update:value="setInstanceValue(field.name, $event)"
|
||
/>
|
||
<Textarea
|
||
v-else-if="field.type === 'textarea'"
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
:placeholder="field.placeholder"
|
||
:rows="3"
|
||
size="small"
|
||
@update:value="setInstanceValue(field.name, $event)"
|
||
/>
|
||
<Select
|
||
v-else-if="field.type === 'select'"
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
:options="field.options"
|
||
:placeholder="field.placeholder"
|
||
size="small"
|
||
@update:value="setInstanceValue(field.name, $event)"
|
||
/>
|
||
<RadioGroup
|
||
v-else-if="field.type === 'radio'"
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
size="small"
|
||
@update:value="setInstanceValue(field.name, $event)"
|
||
>
|
||
<Radio
|
||
v-for="opt in field.options"
|
||
:key="String(opt.value)"
|
||
:value="opt.value"
|
||
>
|
||
{{ opt.label }}
|
||
</Radio>
|
||
</RadioGroup>
|
||
<div v-else-if="field.type === 'image'" class="image-picker-row">
|
||
<Input
|
||
:value="getByPath(selectedInstance.data, field.name)"
|
||
:placeholder="field.placeholder || '从素材库选择图片'"
|
||
size="small"
|
||
read-only
|
||
/>
|
||
<GalleryPickLink
|
||
:accept-types="field.acceptTypes || [0]"
|
||
@select="(urls) => setInstanceValue(field.name, urls[0] || '')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未选中任何块时的提示 -->
|
||
<Empty
|
||
v-if="!hasAnyField"
|
||
description="点击画布中的块以编辑属性"
|
||
:image-style="{ height: '60px' }"
|
||
style="margin-top: 60px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.property-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 280px;
|
||
height: 100%;
|
||
border-left: 1px solid var(--ant-color-border-secondary, #e5e6eb);
|
||
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;
|
||
max-width: 120px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.panel-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.field-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.section-title {
|
||
margin-bottom: 12px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--ant-color-split, #f2f3f5);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--ant-color-text-secondary, #4e5969);
|
||
}
|
||
|
||
.form-row {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
color: var(--ant-color-text-secondary, #4e5969);
|
||
|
||
.required {
|
||
margin-left: 2px;
|
||
color: var(--ant-color-error, #f53f3f);
|
||
}
|
||
}
|
||
|
||
.image-picker-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
</style>
|