属性面板拆分

This commit is contained in:
2025-12-26 11:19:28 +08:00
parent aa585af76e
commit 56ca6be482
6 changed files with 368 additions and 304 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { useDialogs } from '../../ts/useDialogs';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
type FitMode = 'contain' | 'cover' | 'fill';
import { computed } from 'vue';
import ShikigamiPanel from './panels/ShikigamiPanel.vue';
import YuhunPanel from './panels/YuhunPanel.vue';
import PropertyRulePanel from './panels/PropertyRulePanel.vue';
import ImagePanel from './panels/ImagePanel.vue';
import TextPanel from './panels/TextPanel.vue';
const props = defineProps({
height: {
@@ -16,10 +17,7 @@ const props = defineProps({
}
});
const { openDialog } = useDialogs();
const selectedNode = computed(() => props.node);
const hasNodeSelected = computed(() => !!selectedNode.value);
const nodeType = computed(() => {
@@ -27,132 +25,15 @@ const nodeType = computed(() => {
return selectedNode.value.type || 'default';
});
type ImageForm = {
url: string;
fit: FitMode;
width: number;
height: number;
const panelMap: Record<string, any> = {
shikigamiSelect: ShikigamiPanel,
yuhunSelect: YuhunPanel,
propertySelect: PropertyRulePanel,
imageNode: ImagePanel,
textNode: TextPanel
};
const imageForm = reactive<ImageForm>({
url: '',
fit: 'contain',
width: 180,
height: 120
});
const parseNumber = (value: any, fallback: number) => {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const getImageProps = (node?: any): ImageForm => {
const props = node?.properties ?? {};
const style = props.style ?? {};
return {
url: props.image?.url ?? props.url ?? '',
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
width: parseNumber(props.width ?? style.width ?? node?.width, 180),
height: parseNumber(props.height ?? style.height ?? node?.height, 120)
};
};
watch(
() => selectedNode.value,
(node) => {
if (!node) {
imageForm.url = '';
imageForm.fit = 'contain';
imageForm.width = 180;
imageForm.height = 120;
return;
}
const next = getImageProps(node);
imageForm.url = next.url || '';
imageForm.fit = next.fit;
imageForm.width = next.width;
imageForm.height = next.height;
},
{ immediate: true, deep: true }
);
// 通用的弹窗处理方法
const handleOpenDialog = (type: 'shikigami' | 'yuhun' | 'property') => {
const lf = getLogicFlowInstance();
if (selectedNode.value && lf) {
const node = selectedNode.value;
const currentData = node.properties && node.properties[type] ? node.properties[type] : undefined;
openDialog(
type,
currentData,
node,
(updatedData) => {
lf.setProperties(node.id, {
...node.properties,
[type]: updatedData
});
}
);
}
};
const applyImageChanges = (partial: Partial<ImageForm>) => {
const lf = getLogicFlowInstance();
const node = selectedNode.value;
if (!lf || !node) return;
const baseProps = node.properties || {};
const merged = { ...getImageProps(node), ...partial };
const nextProps = {
...baseProps,
...merged,
width: merged.width,
height: merged.height,
style: {
...(baseProps.style || {}),
width: merged.width,
height: merged.height
},
image: {
...(baseProps.image || {}),
url: merged.url,
fit: merged.fit
},
url: merged.url
};
Object.assign(imageForm, merged);
lf.setProperties(node.id, nextProps);
};
const handleImageUpload = (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const result = evt.target?.result as string;
if (result) {
applyImageChanges({ url: result });
}
if (input) input.value = '';
};
reader.readAsDataURL(file);
};
const handleImageUrlChange = () => {
applyImageChanges({ url: imageForm.url });
};
const handleSizeChange = () => {
applyImageChanges({ width: imageForm.width, height: imageForm.height });
};
const handleFitChange = (val: FitMode) => {
applyImageChanges({ fit: val });
};
const panelComponent = computed(() => panelMap[nodeType.value] || null);
</script>
<template>
@@ -178,133 +59,11 @@ const handleFitChange = (val: FitMode) => {
</div>
</div>
<!-- 式神选择节点的特定属性 -->
<div v-if="nodeType === 'shikigamiSelect'" class="property-section">
<div class="section-header">式神属性</div>
<component v-if="panelComponent" :is="panelComponent" :node="selectedNode" />
<div v-else class="property-section">
<div class="section-header">暂无特定属性</div>
<div class="property-item">
<span>当前选择式神{{ selectedNode.properties?.shikigami?.name || '未选择' }}</span>
<el-button
type="primary"
@click="handleOpenDialog('shikigami')"
style="width: 100%"
>
选择式神
</el-button>
</div>
</div>
<!-- 御魂选择节点的特定属性 -->
<div v-if="nodeType === 'yuhunSelect'" class="property-section">
<div class="section-header">御魂属性</div>
<div class="property-item">
<el-button
type="primary"
@click="handleOpenDialog('yuhun')"
style="width: 100%"
>
选择御魂
</el-button>
</div>
</div>
<!-- 属性选择节点的特定属性 -->
<div v-if="nodeType === 'propertySelect'" class="property-section">
<div class="section-header">属性设置</div>
<div class="property-item">
<el-button
type="primary"
@click="handleOpenDialog('property')"
style="width: 100%"
>
设置属性
</el-button>
</div>
</div>
<!-- 图片节点属性 -->
<div v-if="nodeType === 'imageNode'" class="property-section">
<div class="section-header">图片设置</div>
<div class="property-item">
<div class="property-label">图片 URL</div>
<div class="property-value">
<el-input
v-model="imageForm.url"
size="small"
placeholder="输入图片链接或上传文件"
style="width: 100%;"
@change="handleImageUrlChange"
/>
</div>
</div>
<div class="property-item">
<div class="property-label">上传文件</div>
<div class="property-value upload-row">
<input class="upload-input" type="file" accept="image/*" @change="handleImageUpload" />
<span class="upload-hint">本地上传将以 base64 保存</span>
</div>
</div>
<div class="property-item">
<div class="property-label">显示模式</div>
<div class="property-value">
<el-select
v-model="imageForm.fit"
size="small"
style="width: 100%;"
@change="handleFitChange"
>
<el-option label="自适应" value="contain" />
<el-option label="填充" value="cover" />
<el-option label="拉伸" value="fill" />
</el-select>
</div>
</div>
<div class="property-item size-item">
<div class="property-label"> / </div>
<div class="property-value size-inputs">
<el-input-number
v-model="imageForm.width"
:min="40"
:max="1000"
size="small"
style="width: 120px;"
@change="handleSizeChange"
/>
<span class="size-divider">×</span>
<el-input-number
v-model="imageForm.height"
:min="40"
:max="1000"
size="small"
style="width: 120px;"
@change="handleSizeChange"
/>
</div>
</div>
<div v-if="imageForm.url" class="property-item">
<div class="property-label">预览</div>
<div class="property-value image-preview">
<img :src="imageForm.url" alt="预览" />
</div>
</div>
</div>
<!-- 文本节点属性 -->
<div v-if="nodeType === 'textNode'" class="property-section">
<div class="section-header">文本编辑</div>
<div class="property-item">
<!-- <QuillEditor-->
<!-- v-model:content="selectedNode.value.properties.html"-->
<!-- contentType="html"-->
<!-- :toolbar="quillToolbar"-->
<!-- theme="snow"-->
<!-- style="height:120px;"-->
<!-- @update:content="val => updateNodeData('html', val)"-->
<!-- />-->
<div class="property-value">当前节点类型无需额外配置</div>
</div>
</div>
</div>
@@ -382,50 +141,4 @@ const handleFitChange = (val: FitMode) => {
font-size: 14px;
word-break: break-all;
}
.property-value.upload-row {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.upload-input {
flex: 1;
}
.upload-hint {
color: #909399;
font-size: 12px;
}
.size-item .property-value {
text-align: left;
}
.size-inputs {
display: flex;
align-items: center;
gap: 6px;
}
.size-divider {
color: #909399;
font-size: 12px;
}
.image-preview {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 6px;
background: #fafafa;
text-align: center;
}
.image-preview img {
max-width: 100%;
max-height: 140px;
display: block;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
type FitMode = 'contain' | 'cover' | 'fill';
const props = defineProps<{
node: any;
}>();
type ImageForm = {
url: string;
fit: FitMode;
width: number;
height: number;
};
const imageForm = reactive<ImageForm>({
url: '',
fit: 'contain',
width: 180,
height: 120
});
const parseNumber = (value: any, fallback: number) => {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const getImageProps = (node?: any): ImageForm => {
const props = node?.properties ?? {};
const style = props.style ?? {};
return {
url: props.image?.url ?? props.url ?? '',
fit: (props.image?.fit ?? props.fit ?? 'contain') as FitMode,
width: parseNumber(props.width ?? style.width ?? node?.width, 180),
height: parseNumber(props.height ?? style.height ?? node?.height, 120)
};
};
watch(
() => props.node,
(node) => {
if (!node) {
imageForm.url = '';
imageForm.fit = 'contain';
imageForm.width = 180;
imageForm.height = 120;
return;
}
const next = getImageProps(node);
imageForm.url = next.url || '';
imageForm.fit = next.fit;
imageForm.width = next.width;
imageForm.height = next.height;
},
{ immediate: true, deep: true }
);
const applyImageChanges = (partial: Partial<ImageForm>) => {
const lf = getLogicFlowInstance();
const node = props.node;
if (!lf || !node) return;
const baseProps = node.properties || {};
const merged = { ...getImageProps(node), ...partial };
const nextProps = {
...baseProps,
...merged,
width: merged.width,
height: merged.height,
style: {
...(baseProps.style || {}),
width: merged.width,
height: merged.height
},
image: {
...(baseProps.image || {}),
url: merged.url,
fit: merged.fit
},
url: merged.url
};
Object.assign(imageForm, merged);
lf.setProperties(node.id, nextProps);
};
const handleImageUpload = (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const result = evt.target?.result as string;
if (result) {
applyImageChanges({ url: result });
}
if (input) input.value = '';
};
reader.readAsDataURL(file);
};
const handleImageUrlChange = () => {
applyImageChanges({ url: imageForm.url });
};
const handleSizeChange = () => {
applyImageChanges({ width: imageForm.width, height: imageForm.height });
};
const handleFitChange = (val: FitMode) => {
applyImageChanges({ fit: val });
};
</script>
<template>
<div class="property-section">
<div class="section-header">图片设置</div>
<div class="property-item">
<div class="property-label">图片 URL</div>
<div class="property-value">
<el-input
v-model="imageForm.url"
size="small"
placeholder="输入图片链接或上传文件"
style="width: 100%;"
@change="handleImageUrlChange"
/>
</div>
</div>
<div class="property-item">
<div class="property-label">上传文件</div>
<div class="property-value upload-row">
<input class="upload-input" type="file" accept="image/*" @change="handleImageUpload" />
<span class="upload-hint">本地上传将以 base64 保存</span>
</div>
</div>
<div class="property-item">
<div class="property-label">显示模式</div>
<div class="property-value">
<el-select
v-model="imageForm.fit"
size="small"
style="width: 100%;"
@change="handleFitChange"
>
<el-option label="自适应" value="contain" />
<el-option label="填充" value="cover" />
<el-option label="拉伸" value="fill" />
</el-select>
</div>
</div>
<div class="property-item size-item">
<div class="property-label"> / </div>
<div class="property-value size-inputs">
<el-input-number
v-model="imageForm.width"
:min="40"
:max="1000"
size="small"
style="width: 120px;"
@change="handleSizeChange"
/>
<span class="size-divider">×</span>
<el-input-number
v-model="imageForm.height"
:min="40"
:max="1000"
size="small"
style="width: 120px;"
@change="handleSizeChange"
/>
</div>
</div>
<div v-if="imageForm.url" class="property-item">
<div class="property-label">预览</div>
<div class="property-value image-preview">
<img :src="imageForm.url" alt="预览" />
</div>
</div>
</div>
</template>
<style scoped>
.property-value.upload-row {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.upload-input {
flex: 1;
}
.upload-hint {
color: #909399;
font-size: 12px;
}
.size-item .property-value {
text-align: left;
}
.size-inputs {
display: flex;
align-items: center;
gap: 6px;
}
.size-divider {
color: #909399;
font-size: 12px;
}
.image-preview {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 6px;
background: #fafafa;
text-align: center;
}
.image-preview img {
max-width: 100%;
max-height: 140px;
display: block;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useDialogs } from '@/ts/useDialogs';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps<{
node: any;
}>();
const { openDialog } = useDialogs();
const handleOpenDialog = () => {
const lf = getLogicFlowInstance();
const node = props.node;
if (!lf || !node) return;
const currentData = node.properties?.property;
openDialog('property', currentData, node, (updatedData) => {
lf.setProperties(node.id, {
...node.properties,
property: updatedData
});
});
};
</script>
<template>
<div class="property-section">
<div class="section-header">属性设置</div>
<div class="property-item">
<el-button type="primary" @click="handleOpenDialog" style="width: 100%">设置属性</el-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useDialogs } from '@/ts/useDialogs';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps<{
node: any;
}>();
const { openDialog } = useDialogs();
const handleOpenDialog = () => {
const lf = getLogicFlowInstance();
const node = props.node;
if (!lf || !node) return;
const currentData = node.properties?.shikigami;
openDialog('shikigami', currentData, node, (updatedData) => {
lf.setProperties(node.id, {
...node.properties,
shikigami: updatedData
});
});
};
</script>
<template>
<div class="property-section">
<div class="section-header">式神属性</div>
<div class="property-item">
<span>当前选择式神{{ node.properties?.shikigami?.name || '未选择' }}</span>
<el-button type="primary" @click="handleOpenDialog" style="width: 100%">选择式神</el-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
const props = defineProps<{
node: any;
}>();
</script>
<template>
<div class="property-section">
<div class="section-header">文本节点</div>
<div class="property-item">
<div class="property-value">文本编辑器待实现当前节点内容{{ props.node?.properties?.text?.content || '未设置' }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useDialogs } from '@/ts/useDialogs';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps<{
node: any;
}>();
const { openDialog } = useDialogs();
const handleOpenDialog = () => {
const lf = getLogicFlowInstance();
const node = props.node;
if (!lf || !node) return;
const currentData = node.properties?.yuhun;
openDialog('yuhun', currentData, node, (updatedData) => {
lf.setProperties(node.id, {
...node.properties,
yuhun: updatedData
});
});
};
</script>
<template>
<div class="property-section">
<div class="section-header">御魂属性</div>
<div class="property-item">
<el-button type="primary" @click="handleOpenDialog" style="width: 100%">选择御魂</el-button>
</div>
</div>
</template>