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

349 lines
10 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, 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>