新增Blackboard

This commit is contained in:
YHH
2025-06-18 23:31:53 +08:00
parent f48ebb65ba
commit 802ee25621
9 changed files with 2628 additions and 135 deletions

View File

@@ -9,6 +9,7 @@ import { useConnectionManager } from './useConnectionManager';
import { useCanvasManager } from './useCanvasManager';
import { useNodeDisplay } from './useNodeDisplay';
import { useConditionAttachment } from './useConditionAttachment';
import { useBlackboard } from './useBlackboard';
import { validateTree as validateTreeStructure } from '../utils/nodeUtils';
/**
@@ -77,6 +78,15 @@ export function useBehaviorTreeEditor() {
appState.isInstalling
);
// Blackboard功能
const blackboard = useBlackboard();
// Blackboard常驻侧边面板状态
const blackboardSidebarState = reactive({
collapsed: false,
transparent: true
});
const connectionState = reactive({
isConnecting: false,
startNodeId: null as string | null,
@@ -134,6 +144,65 @@ export function useBehaviorTreeEditor() {
updateCounter: 0
});
// Blackboard拖拽相关功能
const isBlackboardDroppable = (prop: any): boolean => {
return prop && (prop.type === 'string' || prop.type === 'number' || prop.type === 'boolean');
};
const isBlackboardReference = (value: string): boolean => {
return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}');
};
const handleBlackboardDrop = (event: DragEvent, propertyKey: string) => {
event.preventDefault();
event.stopPropagation();
try {
const blackboardData = event.dataTransfer?.getData('application/blackboard-variable');
if (!blackboardData) return;
const variable = JSON.parse(blackboardData);
const activeNode = computedProps.activeNode.value;
if (!activeNode || !activeNode.properties) return;
const property = activeNode.properties[propertyKey];
if (!property) return;
// 设置Blackboard引用
const referenceValue = `{{${variable.name}}}`;
nodeOps.updateNodeProperty(`properties.${propertyKey}.value`, referenceValue);
// 移除拖拽样式
const element = event.currentTarget as HTMLElement;
element.classList.remove('drag-over');
} catch (error) {
console.error('处理Blackboard拖拽失败:', error);
}
};
const handleBlackboardDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
const hasBlackboardData = event.dataTransfer?.types.includes('application/blackboard-variable');
if (hasBlackboardData) {
event.dataTransfer!.dropEffect = 'copy';
const element = event.currentTarget as HTMLElement;
element.classList.add('drag-over');
}
};
const handleBlackboardDragLeave = (event: DragEvent) => {
const element = event.currentTarget as HTMLElement;
element.classList.remove('drag-over');
};
const clearBlackboardReference = (propertyKey: string) => {
nodeOps.updateNodeProperty(`properties.${propertyKey}.value`, '');
};
const startNodeDrag = (event: MouseEvent, node: any) => {
event.stopPropagation();
event.preventDefault();
@@ -448,8 +517,6 @@ export function useBehaviorTreeEditor() {
}, 3000);
};
onMounted(() => {
// 自动检查安装状态
installation.checkInstallStatus();
@@ -551,6 +618,7 @@ export function useBehaviorTreeEditor() {
...fileOps,
...codeGen,
...installation,
...blackboard,
handleInstall,
connectionState,
...connectionManager,
@@ -656,6 +724,23 @@ export function useBehaviorTreeEditor() {
if (node.type === 'conditional-decorator') {
conditionAttachment.handleDecoratorDragLeave(node);
}
}
},
// Blackboard拖拽相关功能
isBlackboardDroppable,
isBlackboardReference,
handleBlackboardDrop,
handleBlackboardDragOver,
handleBlackboardDragLeave,
clearBlackboardReference,
// Blackboard常驻侧边面板功能
blackboardCollapsed: computed({
get: () => blackboardSidebarState.collapsed,
set: (value: boolean) => blackboardSidebarState.collapsed = value
}),
blackboardTransparent: computed({
get: () => blackboardSidebarState.transparent,
set: (value: boolean) => blackboardSidebarState.transparent = value
})
};
}

View File

@@ -0,0 +1,575 @@
import { ref, computed, reactive } from 'vue';
export interface BlackboardVariable {
name: string;
type: 'string' | 'number' | 'boolean' | 'vector2' | 'vector3' | 'object' | 'array';
value: any;
defaultValue: any;
description?: string;
group?: string;
readOnly?: boolean;
constraints?: {
min?: number;
max?: number;
step?: number;
allowedValues?: string[];
};
}
export interface BlackboardModalData {
name: string;
type: 'string' | 'number' | 'boolean' | 'vector2' | 'vector3' | 'object' | 'array';
defaultValue: any;
description: string;
group: string;
readOnly: boolean;
constraints: {
min?: number;
max?: number;
step?: number;
};
useAllowedValues: boolean;
allowedValuesText: string;
}
export function useBlackboard() {
const blackboardVariables = ref<Map<string, BlackboardVariable>>(new Map());
const expandedGroups = ref<Set<string>>(new Set(['未分组']));
const selectedVariable = ref<BlackboardVariable | null>(null);
const showBlackboardModal = ref(false);
const editingBlackboardVariable = ref<BlackboardVariable | null>(null);
const blackboardModalData = reactive<BlackboardModalData>({
name: '',
type: 'string',
defaultValue: '',
description: '',
group: '',
readOnly: false,
constraints: {},
useAllowedValues: false,
allowedValuesText: ''
});
const showAddVariableDialog = ref(false);
const editingVariable = ref<BlackboardVariable | null>(null);
const newVariable = reactive({
name: '',
type: 'string' as any,
defaultValue: '' as any,
defaultValueText: '',
description: '',
group: '',
readonly: false,
min: undefined as number | undefined,
max: undefined as number | undefined,
optionsText: ''
});
const showImportExportDialog = ref(false);
const activeTab = ref('export');
const exportData = computed(() => {
const data = Array.from(blackboardVariables.value.values());
return JSON.stringify(data, null, 2);
});
const importData = ref('');
const clearBeforeImport = ref(false);
const blackboardCollapsed = ref(false);
const blackboardTransparent = ref(true);
const blackboardVariablesArray = computed(() => {
return Array.from(blackboardVariables.value.values());
});
const blackboardVariableGroups = computed(() => {
const groups: Record<string, BlackboardVariable[]> = {};
blackboardVariables.value.forEach(variable => {
const groupName = variable.group || '未分组';
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(variable);
});
const sortedGroups: Record<string, BlackboardVariable[]> = {};
const groupNames = Object.keys(groups).sort((a, b) => {
if (a === '未分组') return -1;
if (b === '未分组') return 1;
return a.localeCompare(b);
});
groupNames.forEach(groupName => {
groups[groupName].sort((a, b) => a.name.localeCompare(b.name));
sortedGroups[groupName] = groups[groupName];
});
return sortedGroups;
});
const groups = computed(() => {
const groupSet = new Set<string>();
blackboardVariables.value.forEach(variable => {
groupSet.add(variable.group || '未分组');
});
return Array.from(groupSet);
});
const isValidVariable = computed(() => {
return newVariable.name.trim().length > 0;
});
const groupedBlackboardVariables = () => {
return Object.entries(blackboardVariableGroups.value);
};
const isGroupExpanded = (groupName: string): boolean => {
return expandedGroups.value.has(groupName);
};
const toggleGroup = (groupName: string) => {
if (expandedGroups.value.has(groupName)) {
expandedGroups.value.delete(groupName);
} else {
expandedGroups.value.add(groupName);
}
};
const getVariableTypeIcon = (type: string): string => {
const iconMap: Record<string, string> = {
string: '📝',
number: '🔢',
boolean: '☑️',
vector2: '📐',
vector3: '🧊',
object: '📦',
array: '📋'
};
return iconMap[type] || '❓';
};
const formatBlackboardValue = (variable: BlackboardVariable): string => {
if (variable.value === null || variable.value === undefined) {
return 'null';
}
switch (variable.type) {
case 'boolean':
return variable.value ? 'true' : 'false';
case 'string':
return `"${variable.value}"`;
case 'number':
return variable.value.toString();
default:
return String(variable.value);
}
};
const hasVisibleConstraints = (variable: BlackboardVariable): boolean => {
if (!variable.constraints) return false;
return !!(
variable.constraints.min !== undefined ||
variable.constraints.max !== undefined ||
variable.constraints.allowedValues?.length
);
};
const formatConstraints = (constraints: BlackboardVariable['constraints']): string => {
const parts: string[] = [];
if (constraints?.min !== undefined) {
parts.push(`最小: ${constraints.min}`);
}
if (constraints?.max !== undefined) {
parts.push(`最大: ${constraints.max}`);
}
if (constraints?.allowedValues?.length) {
parts.push(`可选: ${constraints.allowedValues.join(', ')}`);
}
return parts.join(', ');
};
const getTypeDisplayName = (type: string): string => {
const typeMap: Record<string, string> = {
string: 'STR',
number: 'NUM',
boolean: 'BOOL',
vector2: 'VEC2',
vector3: 'VEC3',
object: 'OBJ',
array: 'ARR'
};
return typeMap[type] || type.toUpperCase();
};
const getDisplayValue = (variable: BlackboardVariable): string => {
if (variable.value === null || variable.value === undefined) {
return 'null';
}
switch (variable.type) {
case 'string':
return String(variable.value);
case 'number':
return variable.value.toString();
case 'boolean':
return variable.value ? 'true' : 'false';
case 'vector2':
if (typeof variable.value === 'object' && variable.value.x !== undefined && variable.value.y !== undefined) {
return `(${variable.value.x}, ${variable.value.y})`;
}
return String(variable.value);
case 'vector3':
if (typeof variable.value === 'object' && variable.value.x !== undefined && variable.value.y !== undefined && variable.value.z !== undefined) {
return `(${variable.value.x}, ${variable.value.y}, ${variable.value.z})`;
}
return String(variable.value);
case 'object':
case 'array':
try {
const jsonStr = JSON.stringify(variable.value);
return jsonStr.length > 20 ? jsonStr.substring(0, 17) + '...' : jsonStr;
} catch {
return String(variable.value);
}
default:
return String(variable.value);
}
};
const onTypeChange = () => {
switch (newVariable.type) {
case 'string':
newVariable.defaultValue = '';
break;
case 'number':
newVariable.defaultValue = 0;
break;
case 'boolean':
newVariable.defaultValue = false;
break;
case 'vector2':
newVariable.defaultValue = { x: 0, y: 0 };
break;
case 'vector3':
newVariable.defaultValue = { x: 0, y: 0, z: 0 };
break;
case 'object':
case 'array':
newVariable.defaultValue = '';
newVariable.defaultValueText = '';
break;
}
};
const saveBlackboardVariable = () => {
if (!blackboardModalData.name.trim()) {
alert('请输入变量名称');
return;
}
let finalValue = blackboardModalData.defaultValue;
// 处理对象和数组类型的JSON格式
if (blackboardModalData.type === 'object' || blackboardModalData.type === 'array') {
try {
if (typeof blackboardModalData.defaultValue === 'string') {
finalValue = blackboardModalData.defaultValue ? JSON.parse(blackboardModalData.defaultValue) : (blackboardModalData.type === 'array' ? [] : {});
}
} catch (error) {
alert('JSON格式错误请检查输入');
return;
}
}
const constraints: BlackboardVariable['constraints'] = {};
if (blackboardModalData.constraints.min !== undefined) constraints.min = blackboardModalData.constraints.min;
if (blackboardModalData.constraints.max !== undefined) constraints.max = blackboardModalData.constraints.max;
if (blackboardModalData.constraints.step !== undefined) constraints.step = blackboardModalData.constraints.step;
// 处理字符串的可选值列表
if (blackboardModalData.useAllowedValues && blackboardModalData.allowedValuesText.trim()) {
constraints.allowedValues = blackboardModalData.allowedValuesText
.split('\n')
.map(val => val.trim())
.filter(val => val.length > 0);
}
const variable: BlackboardVariable = {
name: blackboardModalData.name,
type: blackboardModalData.type,
value: finalValue,
defaultValue: finalValue,
description: blackboardModalData.description,
group: blackboardModalData.group || undefined,
readOnly: blackboardModalData.readOnly,
constraints: Object.keys(constraints).length > 0 ? constraints : undefined
};
blackboardVariables.value.set(variable.name, variable);
const groupName = variable.group || '未分组';
expandedGroups.value.add(groupName);
showBlackboardModal.value = false;
editingBlackboardVariable.value = null;
// 重置模态框数据
Object.assign(blackboardModalData, {
name: '',
type: 'string',
defaultValue: '',
description: '',
group: '',
readOnly: false,
constraints: {},
useAllowedValues: false,
allowedValuesText: ''
});
};
const deleteBlackboardVariable = (variableName: string) => {
if (confirm(`确定要删除变量 "${variableName}" 吗?`)) {
blackboardVariables.value.delete(variableName);
}
};
const updateBlackboardVariable = (variableName: string, newValue: any) => {
const variable = blackboardVariables.value.get(variableName);
if (!variable) return;
if (variable.readOnly) {
alert('该变量为只读,无法修改');
return;
}
const updatedVariable = { ...variable, value: newValue };
blackboardVariables.value.set(variableName, updatedVariable);
};
const selectVariable = (variable: BlackboardVariable) => {
selectedVariable.value = variable;
};
const clearBlackboard = () => {
if (confirm('确定要清空所有变量吗?此操作不可恢复。')) {
blackboardVariables.value.clear();
selectedVariable.value = null;
}
};
const exportBlackboard = () => {
const data = Array.from(blackboardVariables.value.values());
const json = JSON.stringify(data, null, 2);
try {
navigator.clipboard.writeText(json);
alert('Blackboard配置已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
}
};
const importBlackboard = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
if (!Array.isArray(data)) {
throw new Error('格式错误:期望数组格式');
}
let importCount = 0;
data.forEach(varData => {
if (varData.name && varData.type) {
blackboardVariables.value.set(varData.name, varData);
importCount++;
}
});
alert(`成功导入 ${importCount} 个变量`);
} catch (error) {
alert('导入失败:' + (error as Error).message);
}
};
reader.readAsText(file);
};
input.click();
};
const onVariableDragStart = (event: DragEvent, variable: BlackboardVariable) => {
if (!event.dataTransfer) return;
event.dataTransfer.setData('application/blackboard-variable', JSON.stringify({
name: variable.name,
type: variable.type,
value: variable.value
}));
event.dataTransfer.effectAllowed = 'copy';
};
const removeBlackboardVariable = (variableName: string) => {
deleteBlackboardVariable(variableName);
};
const editVariable = (variable: BlackboardVariable) => {
editingBlackboardVariable.value = variable;
Object.assign(blackboardModalData, {
name: variable.name,
type: variable.type,
defaultValue: (variable.type === 'object' || variable.type === 'array') ? JSON.stringify(variable.value, null, 2) : variable.value,
description: variable.description || '',
group: variable.group || '',
readOnly: variable.readOnly || false,
constraints: {
min: variable.constraints?.min,
max: variable.constraints?.max,
step: variable.constraints?.step
},
useAllowedValues: !!(variable.constraints?.allowedValues?.length),
allowedValuesText: variable.constraints?.allowedValues?.join('\n') || ''
});
showBlackboardModal.value = true;
};
const onBlackboardDragStart = (event: DragEvent, variable: BlackboardVariable) => {
onVariableDragStart(event, variable);
};
const closeAddVariableDialog = () => {
showAddVariableDialog.value = false;
editingVariable.value = null;
};
const saveVariable = () => {
saveBlackboardVariable();
};
const closeImportExportDialog = () => {
showImportExportDialog.value = false;
};
const copyExportData = () => {
navigator.clipboard.writeText(exportData.value);
alert('已复制到剪贴板');
};
const importVariables = () => {
try {
const data = JSON.parse(importData.value);
if (!Array.isArray(data)) {
throw new Error('格式错误:期望数组格式');
}
if (clearBeforeImport.value) {
blackboardVariables.value.clear();
}
let importCount = 0;
data.forEach((varData: any) => {
if (varData.name && varData.type) {
blackboardVariables.value.set(varData.name, varData);
importCount++;
}
});
alert(`成功导入 ${importCount} 个变量`);
showImportExportDialog.value = false;
importData.value = '';
} catch (error) {
alert('导入失败:' + (error as Error).message);
}
};
const addBlackboardVariable = () => {
Object.assign(newVariable, {
name: '',
type: 'string',
defaultValue: '',
defaultValueText: '',
description: '',
group: '',
readonly: false,
min: undefined,
max: undefined,
optionsText: ''
});
editingVariable.value = null;
showAddVariableDialog.value = true;
};
return {
blackboardVariables: blackboardVariablesArray,
selectedVariable,
showBlackboardModal,
editingBlackboardVariable,
blackboardModalData,
expandedGroups,
blackboardVariableGroups,
showAddVariableDialog,
editingVariable,
newVariable,
groups,
showImportExportDialog,
activeTab,
exportData,
importData,
clearBeforeImport,
isValidVariable,
blackboardCollapsed,
blackboardTransparent,
groupedBlackboardVariables,
isGroupExpanded,
toggleGroup,
getVariableTypeIcon,
formatBlackboardValue,
hasVisibleConstraints,
formatConstraints,
getTypeDisplayName,
getDisplayValue,
addBlackboardVariable,
saveBlackboardVariable,
deleteBlackboardVariable,
removeBlackboardVariable,
updateBlackboardVariable,
editVariable,
selectVariable,
clearBlackboard,
closeAddVariableDialog,
saveVariable,
onTypeChange,
closeImportExportDialog,
copyExportData,
importVariables,
exportBlackboard,
importBlackboard,
onBlackboardDragStart,
editBlackboardVariable: editVariable,
onBlackboardValueChange: (variable: BlackboardVariable) => {
updateBlackboardVariable(variable.name, variable.value);
}
};
}

View File

@@ -702,5 +702,370 @@ export const nodeTemplates: NodeTemplate[] = [
maxChildren: 0,
className: 'DestroyEntityAction',
namespace: 'ecs-integration/behaviors'
},
// 黑板相关节点 - 动作节点
{
type: 'set-blackboard-value',
name: '设置黑板变量',
icon: '📝',
category: 'action',
description: '设置黑板变量的值',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'SetBlackboardValue',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '黑板变量名',
required: true
},
value: {
name: '设置值',
type: 'string',
value: '',
description: '要设置的值(留空则使用源变量)',
required: false
},
sourceVariable: {
name: '源变量名',
type: 'string',
value: '',
description: '从另一个黑板变量复制值',
required: false
},
force: {
name: '强制设置',
type: 'boolean',
value: false,
description: '是否忽略只读限制',
required: false
}
}
},
{
type: 'add-blackboard-value',
name: '增加数值变量',
icon: '',
category: 'action',
description: '增加数值型黑板变量的值',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'AddToBlackboardValue',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '数值型黑板变量名',
required: true
},
increment: {
name: '增量',
type: 'number',
value: 1,
description: '增加的数值',
required: true
},
incrementVariable: {
name: '增量变量名',
type: 'string',
value: '',
description: '从另一个变量获取增量值',
required: false
}
}
},
{
type: 'toggle-blackboard-bool',
name: '切换布尔变量',
icon: '🔄',
category: 'action',
description: '切换布尔型黑板变量的值',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'ToggleBlackboardBool',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '布尔型黑板变量名',
required: true
}
}
},
{
type: 'reset-blackboard-variable',
name: '重置变量',
icon: '🔄',
category: 'action',
description: '重置黑板变量到默认值',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'ResetBlackboardVariable',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要重置的黑板变量名',
required: true
}
}
},
{
type: 'wait-blackboard-condition',
name: '等待黑板条件',
icon: '⏳',
category: 'action',
description: '等待黑板变量满足指定条件',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'WaitForBlackboardCondition',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要监听的黑板变量名',
required: true
},
expectedValue: {
name: '期望值',
type: 'string',
value: '',
description: '期望的变量值',
required: true
}
}
},
{
type: 'log-blackboard-value',
name: '记录黑板变量',
icon: '📊',
category: 'action',
description: '将黑板变量值记录到控制台',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'LogBlackboardValue',
namespace: 'behaviourTree/actions',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要记录的黑板变量名',
required: true
},
prefix: {
name: '日志前缀',
type: 'string',
value: '[Blackboard]',
description: '日志消息的前缀',
required: false
}
}
},
{
type: 'math-blackboard-operation',
name: '数学运算',
icon: '🧮',
category: 'action',
description: '对黑板变量执行数学运算',
canHaveChildren: false,
canHaveParent: true,
maxChildren: 0,
className: 'MathBlackboardOperation',
namespace: 'behaviourTree/actions',
properties: {
targetVariable: {
name: '目标变量',
type: 'string',
value: '',
description: '存储结果的变量名',
required: true
},
operand1Variable: {
name: '操作数1变量',
type: 'string',
value: '',
description: '第一个操作数的变量名',
required: true
},
operand2: {
name: '操作数2',
type: 'string',
value: '',
description: '第二个操作数(数值或变量名)',
required: true
},
operation: {
name: '运算类型',
type: 'select',
value: 'add',
options: ['add', 'subtract', 'multiply', 'divide', 'modulo', 'power', 'min', 'max'],
description: '要执行的数学运算',
required: true
}
}
},
// 黑板相关节点 - 条件节点
{
type: 'blackboard-value-comparison',
name: '黑板值比较',
icon: '⚖️',
category: 'condition',
description: '比较黑板变量与指定值或另一个变量 (拖拽到条件装饰器上使用)',
canHaveChildren: false,
canHaveParent: false,
maxChildren: 0,
isDraggableCondition: true,
attachableToDecorator: true,
className: 'BlackboardValueComparison',
namespace: 'behaviourTree/conditionals',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要比较的黑板变量名',
required: true
},
operator: {
name: '比较操作符',
type: 'select',
value: 'equal',
options: ['equal', 'notEqual', 'greater', 'greaterOrEqual', 'less', 'lessOrEqual', 'contains', 'notContains'],
description: '比较操作类型',
required: true
},
compareValue: {
name: '比较值',
type: 'string',
value: '',
description: '用于比较的值(留空则使用比较变量)',
required: false
},
compareVariable: {
name: '比较变量名',
type: 'string',
value: '',
description: '用于比较的另一个黑板变量名',
required: false
}
}
},
{
type: 'blackboard-variable-exists',
name: '黑板变量存在',
icon: '✅',
category: 'condition',
description: '检查黑板变量是否存在且有效 (拖拽到条件装饰器上使用)',
canHaveChildren: false,
canHaveParent: false,
maxChildren: 0,
isDraggableCondition: true,
attachableToDecorator: true,
className: 'BlackboardVariableExists',
namespace: 'behaviourTree/conditionals',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要检查的黑板变量名',
required: true
},
invert: {
name: '反转结果',
type: 'boolean',
value: false,
description: '是否反转检查结果',
required: false
}
}
},
{
type: 'blackboard-variable-type-check',
name: '黑板变量类型检查',
icon: '🔍',
category: 'condition',
description: '检查黑板变量是否为指定类型 (拖拽到条件装饰器上使用)',
canHaveChildren: false,
canHaveParent: false,
maxChildren: 0,
isDraggableCondition: true,
attachableToDecorator: true,
className: 'BlackboardVariableTypeCheck',
namespace: 'behaviourTree/conditionals',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要检查的黑板变量名',
required: true
},
expectedType: {
name: '期望类型',
type: 'select',
value: 'string',
options: ['string', 'number', 'boolean', 'vector2', 'vector3', 'object', 'array'],
description: '期望的变量类型',
required: true
}
}
},
{
type: 'blackboard-variable-range-check',
name: '黑板变量范围检查',
icon: '📏',
category: 'condition',
description: '检查数值型黑板变量是否在指定范围内 (拖拽到条件装饰器上使用)',
canHaveChildren: false,
canHaveParent: false,
maxChildren: 0,
isDraggableCondition: true,
attachableToDecorator: true,
className: 'BlackboardVariableRangeCheck',
namespace: 'behaviourTree/conditionals',
properties: {
variableName: {
name: '变量名',
type: 'string',
value: '',
description: '要检查的数值型黑板变量名',
required: true
},
minValue: {
name: '最小值',
type: 'number',
value: 0,
description: '范围的最小值(包含)',
required: true
},
maxValue: {
name: '最大值',
type: 'number',
value: 100,
description: '范围的最大值(包含)',
required: true
}
}
}
];

View File

@@ -45,16 +45,50 @@
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
/* 画布容器 */
.canvas-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #2d3748;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.properties-panel-container {
width: 280px;
}
.blackboard-sidebar {
width: 260px;
right: 280px;
}
.blackboard-sidebar.collapsed {
right: 232px;
}
}
@media (max-width: 1200px) {
.nodes-panel {
width: 240px;
}
.properties-panel {
width: 280px;
.properties-panel-container {
width: 260px;
}
.blackboard-sidebar {
width: 240px;
right: 260px;
}
.blackboard-sidebar.collapsed {
right: 212px;
}
}
@@ -63,14 +97,33 @@
flex-direction: column;
}
.nodes-panel, .properties-panel {
.nodes-panel {
width: 100%;
height: 160px;
flex: none;
}
.properties-panel-container {
width: 100%;
height: 200px;
flex: none;
}
.blackboard-sidebar {
width: 250px;
max-height: 300px;
top: 380px;
right: 20px;
border-radius: 8px;
}
.blackboard-sidebar.collapsed {
right: 20px;
}
.canvas-container {
flex: 1;
min-height: 400px;
min-height: 300px;
}
}
@@ -199,13 +252,10 @@
}
.overlay-note {
padding: 12px;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
margin-top: 16px;
}
.overlay-note small {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}

View File

@@ -32,52 +32,112 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: modalFadeIn 0.2s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(4px);
}
}
.modal-content {
background: #2d3748;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border-radius: 12px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
border: 1px solid #4a5568;
animation: modalSlideIn 0.3s ease-out;
overflow: hidden;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
padding: 20px 24px;
border-bottom: 1px solid #4a5568;
background: #4a5568;
border-radius: 8px 8px 0 0;
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
position: relative;
}
.modal-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
}
.modal-header h3 {
margin: 0;
color: #e2e8f0;
color: #ffffff;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.modal-header h3::before {
content: '✨';
font-size: 14px;
}
.modal-header button {
background: none;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #a0aec0;
color: #cbd5e0;
cursor: pointer;
font-size: 18px;
font-size: 16px;
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.modal-header button:hover {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
transform: scale(1.05);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: 24px;
background: #2d3748;
}
.export-options {
@@ -112,22 +172,260 @@
.modal-footer {
display: flex;
gap: 12px;
padding: 16px 20px;
padding: 20px 24px;
border-top: 1px solid #4a5568;
justify-content: flex-end;
background: #374151;
}
.modal-footer button {
padding: 8px 16px;
background: #667eea;
padding: 12px 20px;
border: none;
border-radius: 4px;
border-radius: 8px;
color: white;
cursor: pointer;
font-size: 12px;
transition: background 0.3s ease;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
overflow: hidden;
}
.modal-footer button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s ease;
}
.modal-footer button:hover::before {
left: 100%;
}
.modal-footer button:hover {
background: #5a67d8;
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.modal-footer button:active {
transform: translateY(0);
}
.modal-footer .save-btn {
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
}
.modal-footer .save-btn:hover {
background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
}
.modal-footer .cancel-btn {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
}
.modal-footer .cancel-btn:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
}
/* Blackboard模态框特定样式 */
.blackboard-modal {
width: 520px;
max-width: 90vw;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #e2e8f0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.form-group label::before {
content: '';
width: 3px;
height: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 12px 16px;
background: #1a202c;
border: 2px solid #4a5568;
border-radius: 8px;
color: white;
font-size: 13px;
font-family: inherit;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: #667eea;
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
transform: translateY(-1px);
}
.form-input:hover,
.form-select:hover,
.form-textarea:hover {
border-color: #718096;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: #718096;
font-style: italic;
}
.constraint-inputs {
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid #4a5568;
border-radius: 8px;
padding: 16px;
margin-top: 8px;
}
.constraint-row {
display: flex;
align-items: center;
gap: 12px;
}
.constraint-row label {
min-width: 60px;
font-size: 12px;
color: #a0aec0;
margin: 0;
text-transform: none;
font-weight: 500;
}
.constraint-row label::before {
display: none;
}
/* 自定义复选框样式 */
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
border: 1px solid #4a5568;
cursor: pointer;
transition: all 0.2s ease;
}
.checkbox-group:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #667eea;
}
.checkbox-group input[type="checkbox"] {
appearance: none;
width: 18px;
height: 18px;
border: 2px solid #4a5568;
border-radius: 4px;
background: #1a202c;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.checkbox-group input[type="checkbox"]:checked {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
}
.checkbox-group input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 12px;
font-weight: bold;
}
.checkbox-group label {
margin: 0;
color: #e2e8f0;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-transform: none;
display: block;
}
.checkbox-group label::before {
display: none;
}
.constraint-row label {
min-width: 60px;
margin-bottom: 0;
font-size: 11px;
}
.constraint-row input {
flex: 1;
}
.allowed-values textarea {
margin-top: 6px;
resize: vertical;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
}
.export-code {
background: #1a202c;
border: 1px solid #4a5568;
border-radius: 4px;
padding: 12px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #e2e8f0;
resize: none;
min-height: 300px;
}

View File

@@ -11,15 +11,53 @@
}
.panel-header {
padding: 16px;
padding: 12px 16px;
background: #4a5568;
border-bottom: 1px solid #718096;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 48px;
}
.panel-header h3 {
margin: 0 0 12px 0;
font-size: 16px;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
flex: 1;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: visible;
text-overflow: ellipsis;
min-width: 0;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tool-btn.small {
padding: 6px 10px;
background: #1a202c;
border: 1px solid #4a5568;
border-radius: 4px;
color: #e2e8f0;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.tool-btn.small:hover {
background: #2d3748;
border-color: #667eea;
transform: translateY(-1px);
}
.search-input {
@@ -49,10 +87,36 @@
.category-title {
margin: 0 0 8px 0;
padding: 8px 12px;
background: #4a5568;
border-radius: 4px;
font-size: 14px;
color: #e2e8f0;
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: #cbd5e0;
letter-spacing: 0.3px;
border-left: 3px solid #667eea;
position: relative;
overflow: hidden;
}
.category-title::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 0.5s ease;
}
.category-title:hover::before {
left: 100%;
}
.category-title:hover {
background: linear-gradient(135deg, #667eea 0%, #4a5568 100%);
color: #ffffff;
transform: translateX(2px);
}
.node-list {
@@ -105,8 +169,8 @@
text-overflow: ellipsis;
}
/* 右侧属性面板 */
.properties-panel {
/* 右侧属性面板容器 */
.properties-panel-container {
width: 320px;
background: #2d3748;
border-left: 1px solid #4a5568;
@@ -115,6 +179,19 @@
overflow: hidden;
}
/* 属性面板 */
.properties-panel {
flex: 1;
background: #2d3748;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
}
.node-properties {
flex: 1;
overflow-y: auto;
@@ -126,13 +203,19 @@
}
.property-section h4 {
margin: 0 0 12px 0;
margin: 0 0 10px 0;
color: #e2e8f0;
font-size: 14px;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid #4a5568;
padding-bottom: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.property-item {
margin-bottom: 12px;
position: relative;
}
.property-item label {
@@ -143,6 +226,10 @@
font-weight: 500;
}
.property-input-container {
position: relative;
}
.property-item input,
.property-item textarea,
.property-item select {
@@ -154,6 +241,7 @@
color: white;
font-size: 12px;
font-family: inherit;
box-sizing: border-box;
}
.property-item input:focus,
@@ -178,7 +266,52 @@
line-height: 1.4;
}
.code-preview {
.blackboard-droppable {
border: 2px dashed transparent;
transition: border-color 0.2s ease;
}
.blackboard-droppable.drag-over {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.blackboard-reference {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 8px;
background: #4a5568;
border-radius: 3px;
font-size: 11px;
}
.ref-icon {
color: #667eea;
}
.ref-text {
color: #e2e8f0;
flex: 1;
}
.clear-ref {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
padding: 0 4px;
border-radius: 2px;
font-size: 12px;
}
.clear-ref:hover {
background: #ef4444;
color: white;
}
.config-preview {
background: #1a202c;
border: 1px solid #4a5568;
border-radius: 4px;
@@ -248,4 +381,543 @@
.tree-node-type {
font-size: 9px;
color: #718096;
}
}
/* Blackboard悬浮窗口样式 */
.blackboard-sidebar {
position: fixed;
top: 160px;
right: 320px;
width: 280px;
max-height: calc(100vh - 180px);
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 8px 0 0 8px;
z-index: 999;
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
}
.blackboard-sidebar.collapsed {
width: 48px;
right: 272px;
}
.blackboard-sidebar.transparent {
opacity: 0.7;
background: rgba(45, 55, 72, 0.85);
}
.blackboard-sidebar.transparent:not(.collapsed) {
transform: translateX(-20px);
}
.blackboard-toggle {
position: absolute;
left: -24px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 48px;
background: #667eea;
border: none;
border-radius: 8px 0 0 8px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.3s ease;
z-index: 1001;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
}
.blackboard-toggle:hover {
background: #5a6acf;
transform: translateY(-50%) translateX(-2px);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
}
.blackboard-toggle span {
transition: transform 0.3s ease;
}
.blackboard-sidebar.collapsed .blackboard-toggle span {
transform: rotate(180deg);
}
.blackboard-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.blackboard-header {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 12px;
position: relative;
min-height: 48px;
display: flex;
align-items: center;
}
.blackboard-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
}
.blackboard-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
}
.blackboard-scroll-area {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px 12px;
}
.blackboard-sidebar .blackboard-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
padding: 12px;
border-bottom: 1px solid #4a5568;
}
.blackboard-sidebar .blackboard-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.blackboard-sidebar .add-var-btn {
background: #48bb78;
color: white;
}
.blackboard-sidebar .add-var-btn:hover {
background: #38a169;
transform: translateY(-1px);
}
.blackboard-sidebar .clear-btn {
background: #f56565;
color: white;
}
.blackboard-sidebar .clear-btn:hover {
background: #e53e3e;
transform: translateY(-1px);
}
.blackboard-sidebar .blackboard-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.blackboard-sidebar .variable-group {
background: rgba(255, 255, 255, 0.02);
border: 1px solid #4a5568;
border-radius: 6px;
overflow: hidden;
}
.blackboard-sidebar .group-title {
margin: 0;
padding: 8px 12px;
background: #1a202c;
color: #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #4a5568;
}
.blackboard-sidebar .variable-list {
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.blackboard-sidebar .variable-item {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border: 1px solid #4a5568;
border-radius: 8px;
padding: 12px 16px;
cursor: grab;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
display: flex;
align-items: center;
gap: 12px;
border-left-width: 0;
min-height: 44px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
overflow: hidden;
}
.blackboard-sidebar .variable-item::before {
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1), 0 2px 4px rgba(0, 0, 0, 0.3);
}
.blackboard-sidebar .variable-item:hover {
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
border-color: #667eea;
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(102, 126, 234, 0.3);
}
.blackboard-sidebar .variable-item:hover::before {
transform: translateY(-50%) scale(1.2);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2), 0 0 12px currentColor, 0 4px 8px rgba(0, 0, 0, 0.4);
}
.blackboard-sidebar .variable-item:active {
cursor: grabbing;
transform: scale(0.98);
}
.blackboard-sidebar .variable-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 1;
min-width: 0;
margin-left: 20px;
}
.blackboard-sidebar .variable-info {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.blackboard-sidebar .variable-name {
font-weight: 600;
color: #f7fafc;
font-size: 13px;
flex-shrink: 0;
letter-spacing: 0.3px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.blackboard-sidebar .value-separator {
color: #64748b;
font-weight: 500;
flex-shrink: 0;
opacity: 0.7;
}
.blackboard-sidebar .variable-type {
font-size: 8px;
padding: 4px 8px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
line-height: 1;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.blackboard-sidebar .variable-item.string::before {
background: #48bb78;
}
.blackboard-sidebar .variable-item.string .variable-type {
background: #48bb78;
color: white;
}
.blackboard-sidebar .variable-item.number::before {
background: #3182ce;
}
.blackboard-sidebar .variable-item.number .variable-type {
background: #3182ce;
color: white;
}
.blackboard-sidebar .variable-item.boolean::before {
background: #d69e2e;
}
.blackboard-sidebar .variable-item.boolean .variable-type {
background: #d69e2e;
color: white;
}
.blackboard-sidebar .variable-item.vector2::before,
.blackboard-sidebar .variable-item.vector3::before {
background: #9f7aea;
}
.blackboard-sidebar .variable-item.vector2 .variable-type,
.blackboard-sidebar .variable-item.vector3 .variable-type {
background: #9f7aea;
color: white;
}
.blackboard-sidebar .variable-item.object::before,
.blackboard-sidebar .variable-item.array::before {
background: #ed8936;
}
.blackboard-sidebar .variable-item.object .variable-type,
.blackboard-sidebar .variable-item.array .variable-type {
background: #ed8936;
color: white;
}
.blackboard-sidebar .variable-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: all 0.3s ease;
flex-shrink: 0;
transform: translateX(8px);
}
.blackboard-sidebar .variable-item:hover .variable-actions {
opacity: 1;
transform: translateX(0);
}
.blackboard-sidebar .variable-actions button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
font-size: 10px;
cursor: pointer;
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
color: #cbd5e0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.blackboard-sidebar .edit-btn:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
transform: translateY(-1px) scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
color: white;
}
.blackboard-sidebar .remove-btn:hover {
background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
border-color: #f56565;
transform: translateY(-1px) scale(1.05);
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);
color: white;
}
.blackboard-sidebar .value-display {
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #10b981;
background: none;
border: none;
padding: 0;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
/* 不同类型的值颜色 */
.blackboard-sidebar .variable-item.string .value-display {
color: #f59e0b; /* 琥珀色 - 字符串 */
}
.blackboard-sidebar .variable-item.number .value-display {
color: #3b82f6; /* 蓝色 - 数字 */
}
.blackboard-sidebar .variable-item.boolean .value-display {
color: #8b5cf6; /* 紫色 - 布尔值 */
}
.blackboard-sidebar .variable-item.vector2 .value-display,
.blackboard-sidebar .variable-item.vector3 .value-display {
color: #ec4899; /* 粉色 - 向量 */
}
.blackboard-sidebar .variable-item.object .value-display,
.blackboard-sidebar .variable-item.array .value-display {
color: #10b981; /* 绿色 - 对象/数组 */
}
.blackboard-sidebar .variable-constraints {
margin-top: 4px;
font-size: 10px;
color: #718096;
font-style: italic;
}
.blackboard-sidebar .empty-blackboard {
text-align: center;
padding: 24px 16px;
color: #718096;
}
.blackboard-sidebar .empty-icon {
font-size: 32px;
margin-bottom: 12px;
opacity: 0.5;
}
.blackboard-sidebar .empty-blackboard p {
margin: 0 0 16px 0;
font-size: 13px;
}
.blackboard-sidebar .add-first-var {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.blackboard-sidebar .add-first-var:hover {
background: #5a67d8;
transform: translateY(-1px);
}
/* 只读变量样式 */
.blackboard-sidebar .variable-item.readonly {
opacity: 0.8;
background: linear-gradient(135deg, #374151 0%, #1f2937 100%) !important;
border-color: #6b7280;
}
.blackboard-sidebar .variable-item.readonly::before {
background: #6b7280 !important;
}
.blackboard-sidebar .variable-item.readonly .variable-name {
color: #9ca3af;
}
.blackboard-sidebar .variable-item.readonly .variable-type {
background: #6b7280 !important;
color: #e5e7eb !important;
}
/* 拖拽状态样式 */
.blackboard-sidebar .variable-item:active {
cursor: grabbing !important;
transform: scale(0.95) rotate(2deg);
opacity: 0.8;
z-index: 1000;
}
/* 响应式断点调整 */
@media (max-width: 1400px) {
.blackboard-sidebar .variable-item {
padding: 10px 14px;
gap: 10px;
}
.blackboard-sidebar .variable-name {
font-size: 12px;
}
.blackboard-sidebar .value-display {
font-size: 10px;
}
.blackboard-sidebar .variable-type {
font-size: 7px;
padding: 3px 6px;
}
.blackboard-sidebar .variable-actions button {
width: 20px;
height: 20px;
font-size: 9px;
}
}
@media (max-width: 1200px) {
.blackboard-sidebar .variable-item {
padding: 8px 12px;
gap: 8px;
min-height: 38px;
}
.blackboard-sidebar .variable-header {
margin-left: 16px;
gap: 8px;
}
.blackboard-sidebar .variable-name {
font-size: 11px;
}
.blackboard-sidebar .value-display {
font-size: 9px;
}
.blackboard-sidebar .variable-item::before {
left: 10px;
width: 6px;
height: 6px;
}
.blackboard-sidebar .variable-actions button {
width: 18px;
height: 18px;
font-size: 8px;
padding: 4px;
}
}

View File

@@ -17,15 +17,16 @@
.toolbar-left h2 {
margin: 0;
font-size: 20px;
font-size: 16px;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
letter-spacing: 0.5px;
}
.current-file {
color: #a0aec0;
font-weight: 400;
font-size: 16px;
font-size: 13px;
margin-left: 8px;
}
@@ -64,6 +65,12 @@
transform: translateY(-1px);
}
.tool-btn.active {
background: rgba(102, 126, 234, 0.3);
border-color: #667eea;
color: #e2e8f0;
}
.tool-btn.has-changes {

View File

@@ -21,6 +21,7 @@
<button class="tool-btn" @click="exportConfig" title="导出配置">
<span></span> 导出配置
</button>
</div>
</div>
<div class="toolbar-right">
@@ -395,104 +396,123 @@
</div>
<!-- 右侧属性面板 -->
<div class="properties-panel">
<div class="panel-header">
<h3>⚙️ 属性面板</h3>
</div>
<div v-if="activeNode" class="node-properties">
<div class="property-section">
<h4>基本信息</h4>
<div class="property-item">
<label>节点名称:</label>
<input
type="text"
:value="activeNode.name"
@input="updateNodeProperty('name', $event.target.value)"
:key="activeNode.id + '_name'"
:disabled="activeNode.isConditionNode"
>
</div>
<div class="property-item">
<label>描述:</label>
<textarea
:value="activeNode.description"
@input="updateNodeProperty('description', $event.target.value)"
:key="activeNode.id + '_description'"
:disabled="activeNode.isConditionNode"
></textarea>
</div>
<div class="properties-panel-container">
<!-- 属性面板 -->
<div class="properties-panel">
<div class="panel-header">
<h3>⚙️ 属性面板</h3>
</div>
<div class="property-section" v-if="activeNode.properties">
<h4>节点属性</h4>
<div
v-for="(prop, key) in activeNode.properties"
:key="key"
class="property-item"
>
<label>{{ prop.name }}:</label>
<input
v-if="prop.type === 'string'"
type="text"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="activeNode.id + '_' + key + '_string'"
>
<input
v-else-if="prop.type === 'number'"
type="number"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value) || 0)"
:key="activeNode.id + '_' + key + '_number'"
>
<input
v-else-if="prop.type === 'boolean'"
type="checkbox"
:checked="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
:key="activeNode.id + '_' + key + '_boolean'"
>
<textarea
v-else-if="prop.type === 'code'"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
rows="6"
class="code-input"
placeholder="请输入代码..."
:key="activeNode.id + '_' + key + '_code'"
></textarea>
<select
v-else-if="prop.type === 'select'"
:value="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="activeNode.id + '_' + key + '_select_' + prop.value"
>
<option
v-for="option in prop.options"
:key="option"
:value="option"
:selected="option === prop.value"
<div v-if="activeNode" class="node-properties">
<div class="property-section">
<h4>基本信息</h4>
<div class="property-item">
<label>节点名称:</label>
<input
type="text"
:value="activeNode.name"
@input="updateNodeProperty('name', $event.target.value)"
:key="activeNode.id + '_name'"
:disabled="activeNode.isConditionNode"
>
{{ option }}
</option>
</select>
<p v-if="prop.description" class="property-help">{{ prop.description }}</p>
</div>
<div class="property-item">
<label>描述:</label>
<textarea
:value="activeNode.description"
@input="updateNodeProperty('description', $event.target.value)"
:key="activeNode.id + '_description'"
:disabled="activeNode.isConditionNode"
></textarea>
</div>
</div>
<div class="property-section" v-if="activeNode.properties">
<h4>节点属性</h4>
<div
v-for="(prop, key) in activeNode.properties"
:key="key"
class="property-item"
:class="{ 'blackboard-droppable': isBlackboardDroppable(prop) }"
@drop="handleBlackboardDrop($event, key)"
@dragover="handleBlackboardDragOver"
@dragleave="handleBlackboardDragLeave"
>
<label>{{ prop.name }}:</label>
<div class="property-input-container">
<input
v-if="prop.type === 'string'"
type="text"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="activeNode.id + '_' + key + '_string'"
:placeholder="'拖拽Blackboard变量或直接输入'"
>
<input
v-else-if="prop.type === 'number'"
type="number"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value) || 0)"
:key="activeNode.id + '_' + key + '_number'"
:placeholder="'拖拽Blackboard变量或直接输入'"
>
<input
v-else-if="prop.type === 'boolean'"
type="checkbox"
:checked="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
:key="activeNode.id + '_' + key + '_boolean'"
>
<textarea
v-else-if="prop.type === 'code'"
:value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
rows="6"
class="code-input"
placeholder="请输入代码..."
:key="activeNode.id + '_' + key + '_code'"
></textarea>
<select
v-else-if="prop.type === 'select'"
:value="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="activeNode.id + '_' + key + '_select_' + prop.value"
>
<option
v-for="option in prop.options"
:key="option"
:value="option"
:selected="option === prop.value"
>
{{ option }}
</option>
</select>
<!-- Blackboard引用指示器 -->
<div v-if="isBlackboardReference(prop.value)" class="blackboard-reference">
<span class="ref-icon">🔗</span>
<span class="ref-text">{{ prop.value }}</span>
<button class="clear-ref" @click="clearBlackboardReference(key)" title="清除引用">×</button>
</div>
</div>
<p v-if="prop.description" class="property-help">{{ prop.description }}</p>
</div>
</div>
<div class="property-section">
<h4>节点配置</h4>
<pre class="config-preview">{{ activeNode ? JSON.stringify(activeNode, null, 2) : '{}' }}</pre>
</div>
</div>
<div class="property-section">
<h4>节点配置</h4>
<pre class="config-preview">{{ activeNode ? JSON.stringify(activeNode, null, 2) : '{}' }}</pre>
<div v-else class="no-selection">
<p>请选择一个节点查看属性</p>
</div>
</div>
<div v-else class="no-selection">
<p>请选择一个节点查看属性</p>
</div>
</div>
</div>
<!-- 行为树结构面板 -->
<div class="tree-structure-panel" v-if="rootNode()">
<div class="panel-header">
@@ -514,6 +534,233 @@
<span>{{ validationResult().message }}</span>
</div>
<!-- Blackboard变量编辑模态框 -->
<div v-if="showBlackboardModal" class="modal-overlay" @click="showBlackboardModal = false">
<div class="modal-content blackboard-modal" @click.stop>
<div class="modal-header">
<h3>{{ editingBlackboardVariable ? '编辑变量' : '添加变量' }}</h3>
<button @click="showBlackboardModal = false">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>变量名称:</label>
<input
type="text"
v-model="blackboardModalData.name"
placeholder="例如playerHealth、enemyCount..."
:disabled="editingBlackboardVariable"
class="form-input"
>
</div>
<div class="form-group">
<label>变量类型:</label>
<select v-model="blackboardModalData.type" class="form-select">
<option value="string">📝 字符串 (string)</option>
<option value="number">🔢 数字 (number)</option>
<option value="boolean">☑️ 布尔值 (boolean)</option>
<option value="vector2">📐 二维向量 (vector2)</option>
<option value="vector3">📏 三维向量 (vector3)</option>
<option value="object">📦 对象 (object)</option>
<option value="array">📋 数组 (array)</option>
</select>
</div>
<div class="form-group">
<label>默认值:</label>
<input
v-if="blackboardModalData.type === 'string'"
type="text"
v-model="blackboardModalData.defaultValue"
placeholder="输入默认字符串值..."
class="form-input"
>
<input
v-else-if="blackboardModalData.type === 'number'"
type="number"
v-model="blackboardModalData.defaultValue"
placeholder="输入默认数值..."
class="form-input"
>
<div v-else-if="blackboardModalData.type === 'boolean'" class="checkbox-group">
<input
type="checkbox"
v-model="blackboardModalData.defaultValue"
id="defaultBoolValue"
>
<label for="defaultBoolValue">默认为 True</label>
</div>
<textarea
v-else
v-model="blackboardModalData.defaultValue"
placeholder="请输入JSON格式的默认值例如{&quot;x&quot;: 0, &quot;y&quot;: 0}"
rows="3"
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<label>描述:</label>
<textarea
v-model="blackboardModalData.description"
placeholder="描述这个变量的用途和作用..."
rows="2"
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<label>分组:</label>
<input
type="text"
v-model="blackboardModalData.group"
placeholder="例如Player、AI、Environment、Game..."
class="form-input"
>
</div>
<!-- 约束设置 -->
<div v-if="blackboardModalData.type === 'number'" class="form-group">
<label>数值约束:</label>
<div class="constraint-inputs">
<div class="constraint-row">
<label>最小值:</label>
<input type="number" v-model="blackboardModalData.constraints.min" placeholder="不限制" class="form-input">
</div>
<div class="constraint-row">
<label>最大值:</label>
<input type="number" v-model="blackboardModalData.constraints.max" placeholder="不限制" class="form-input">
</div>
<div class="constraint-row">
<label>步长:</label>
<input type="number" v-model="blackboardModalData.constraints.step" placeholder="1" class="form-input">
</div>
</div>
</div>
<div v-if="blackboardModalData.type === 'string'" class="form-group">
<div class="checkbox-group">
<input type="checkbox" v-model="blackboardModalData.useAllowedValues" id="useAllowedValues">
<label for="useAllowedValues">限制可选值</label>
</div>
<div v-if="blackboardModalData.useAllowedValues" class="allowed-values">
<textarea
v-model="blackboardModalData.allowedValuesText"
placeholder="每行输入一个可选值&#10;例如:&#10;idle&#10;running&#10;jumping&#10;attacking"
rows="4"
class="form-textarea"
></textarea>
</div>
</div>
<div class="checkbox-group">
<input type="checkbox" v-model="blackboardModalData.readOnly" id="readOnlyVar">
<label for="readOnlyVar">只读变量</label>
</div>
</div>
<div class="modal-footer">
<button @click="saveBlackboardVariable" class="save-btn">保存</button>
<button @click="showBlackboardModal = false" class="cancel-btn">取消</button>
</div>
</div>
</div>
<!-- Blackboard常驻侧边面板 -->
<div class="blackboard-sidebar"
:class="{
'collapsed': blackboardCollapsed,
'transparent': blackboardTransparent
}"
@mouseenter="blackboardTransparent = false"
@mouseleave="blackboardTransparent = true">
<!-- 收缩/展开按钮 -->
<button class="blackboard-toggle"
@click="blackboardCollapsed = !blackboardCollapsed"
:title="blackboardCollapsed ? '展开 Blackboard' : '收缩 Blackboard'">
<span v-if="blackboardCollapsed">📋</span>
<span v-else></span>
</button>
<!-- 面板内容 -->
<div class="blackboard-content" v-show="!blackboardCollapsed">
<div class="blackboard-header">
<h3>📋 Blackboard</h3>
</div>
<!-- Blackboard操作按钮 -->
<div class="blackboard-actions">
<button @click="showBlackboardModal = true" class="add-var-btn">+ 添加变量</button>
<button @click="clearBlackboard" v-if="blackboardVariables.length > 0" class="clear-btn">清空</button>
</div>
<!-- Blackboard变量列表 -->
<div class="blackboard-scroll-area">
<div v-if="blackboardVariables.length > 0" class="blackboard-groups">
<div
v-for="[groupName, variables] in groupedBlackboardVariables()"
:key="groupName"
class="variable-group"
>
<h4 class="group-title">{{ groupName }}</h4>
<div class="variable-list">
<div
v-for="variable in variables"
:key="variable.name"
class="variable-item"
:class="variable.type"
:draggable="true"
@dragstart="onBlackboardDragStart($event, variable)"
:title="variable.description"
>
<div class="variable-header">
<span class="variable-name">{{ variable.name }}</span>
<span class="variable-type">{{ getTypeDisplayName(variable.type) }}</span>
<div class="variable-actions">
<button @click="editBlackboardVariable(variable)" class="edit-btn">✏️</button>
<button @click="removeBlackboardVariable(variable.name)" class="remove-btn">🗑️</button>
</div>
</div>
<div class="variable-value">
<select
v-if="variable.constraints && variable.constraints.allowedValues"
v-model="variable.value"
@change="onBlackboardValueChange(variable)"
:disabled="variable.readOnly"
>
<option
v-for="option in variable.constraints.allowedValues"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<span v-else class="value-display">{{ getDisplayValue(variable) }}</span>
</div>
<!-- 约束显示 -->
<div v-if="variable.constraints && hasVisibleConstraints(variable)" class="variable-constraints">
<small>{{ formatConstraints(variable.constraints) }}</small>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-blackboard">
<div class="empty-icon">📋</div>
<p>还没有定义任何变量</p>
<button class="add-first-var" @click="showBlackboardModal = true">添加第一个变量</button>
</div>
</div>
</div>
</div>
<!-- 导出模态框 -->
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
<div class="modal-content" @click.stop>

View File

@@ -0,0 +1,194 @@
<div class="blackboard-panel">
<div class="panel-header">
<h3>🎯 黑板变量</h3>
<div class="header-actions">
<button class="btn-add-variable" @click="showAddVariableDialog = true" title="添加新变量">
添加变量
</button>
<button class="btn-import-export" @click="showImportExportDialog = true" title="导入/导出变量">
📤 导入/导出
</button>
</div>
</div>
<div class="variables-container">
<!-- 按分组显示变量 -->
<div v-for="group in groups" :key="group" class="variable-group">
<div class="group-header" @click="toggleGroup(group)">
<span class="group-icon">{{ expandedGroups.has(group) ? '📂' : '📁' }}</span>
<span class="group-name">{{ group }}</span>
<span class="group-count">({{ groupedBlackboardVariables()[group]?.length || 0 }})</span>
</div>
<div v-if="expandedGroups.has(group)" class="group-variables">
<div v-for="variable in groupedBlackboardVariables()[group]" :key="variable.name" class="variable-item"
:class="[variable.type, { 'readonly': variable.readonly }]" :draggable="true"
@dragstart="onBlackboardDragStart($event, variable)">
<div class="variable-header">
<div class="variable-info">
<span class="variable-name">{{ variable.name }}</span>
<span class="value-separator">:</span>
<span class="value-display">{{ getDisplayValue(variable) }}</span>
</div>
<span class="variable-type">{{ getTypeDisplayName(variable.type) }}</span>
</div>
<div class="variable-actions">
<button @click="editVariable(variable)" title="编辑" class="edit-btn"></button>
<button @click="removeBlackboardVariable(variable.name)" title="删除"
class="remove-btn">×</button>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="groups.length === 0" class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-text">还没有黑板变量</div>
<div class="empty-hint">点击"添加变量"来创建第一个变量</div>
</div>
</div>
<!-- 添加变量对话框 -->
<div v-if="showAddVariableDialog" class="dialog-overlay" @click="closeAddVariableDialog">
<div class="dialog" @click.stop>
<div class="dialog-header">
<h4>{{ editingVariable ? '编辑变量' : '添加变量' }}</h4>
<button @click="closeAddVariableDialog" class="dialog-close"></button>
</div>
<div class="dialog-content">
<div class="form-group">
<label>变量名</label>
<input v-model="newVariable.name" :disabled="editingVariable" type="text" placeholder="请输入变量名"
class="form-input">
</div>
<div class="form-group">
<label>变量类型</label>
<select v-model="newVariable.type" class="form-select" @change="onTypeChange">
<option value="string">字符串</option>
<option value="number">数字</option>
<option value="boolean">布尔</option>
<option value="vector2">Vector2</option>
<option value="vector3">Vector3</option>
<option value="object">对象</option>
<option value="array">数组</option>
</select>
</div>
<div class="form-group">
<label>默认值</label>
<!-- 根据类型显示不同的输入控件 -->
<input v-if="newVariable.type === 'string'" v-model="newVariable.defaultValue" type="text"
class="form-input">
<input v-else-if="newVariable.type === 'number'" v-model.number="newVariable.defaultValue" type="number"
class="form-input">
<label v-else-if="newVariable.type === 'boolean'" class="checkbox-label">
<input type="checkbox" v-model="newVariable.defaultValue">
<span>{{ newVariable.defaultValue ? '真' : '假' }}</span>
</label>
<div v-else-if="newVariable.type === 'vector2'" class="vector-input">
<input v-model.number="newVariable.defaultValue.x" type="number" placeholder="X">
<input v-model.number="newVariable.defaultValue.y" type="number" placeholder="Y">
</div>
<div v-else-if="newVariable.type === 'vector3'" class="vector-input">
<input v-model.number="newVariable.defaultValue.x" type="number" placeholder="X">
<input v-model.number="newVariable.defaultValue.y" type="number" placeholder="Y">
<input v-model.number="newVariable.defaultValue.z" type="number" placeholder="Z">
</div>
<textarea v-else-if="newVariable.type === 'object' || newVariable.type === 'array'"
v-model="newVariable.defaultValueText" class="form-textarea" rows="3"
placeholder="JSON格式"></textarea>
</div>
<div class="form-group">
<label>描述</label>
<input v-model="newVariable.description" type="text" placeholder="变量的用途描述" class="form-input">
</div>
<div class="form-group">
<label>分组</label>
<input v-model="newVariable.group" type="text" placeholder="变量分组Player、Enemy等" class="form-input">
</div>
<div class="form-row">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="newVariable.readonly">
<span>只读</span>
</label>
</div>
</div>
<!-- 数字类型的约束 -->
<div v-if="newVariable.type === 'number'" class="form-row">
<div class="form-group">
<label>最小值</label>
<input v-model.number="newVariable.min" type="number" class="form-input">
</div>
<div class="form-group">
<label>最大值</label>
<input v-model.number="newVariable.max" type="number" class="form-input">
</div>
</div>
<!-- 可选值列表 -->
<div class="form-group">
<label>可选值列表(用逗号分隔)</label>
<input v-model="newVariable.optionsText" type="text" placeholder="例如: small,medium,large"
class="form-input">
</div>
</div>
<div class="dialog-actions">
<button @click="closeAddVariableDialog" class="btn-cancel">取消</button>
<button @click="saveVariable" class="btn-save" :disabled="!isValidVariable">
{{ editingVariable ? '保存' : '添加' }}
</button>
</div>
</div>
</div>
<!-- 导入/导出对话框 -->
<div v-if="showImportExportDialog" class="dialog-overlay" @click="closeImportExportDialog">
<div class="dialog large" @click.stop>
<div class="dialog-header">
<h4>导入/导出黑板变量</h4>
<button @click="closeImportExportDialog" class="dialog-close"></button>
</div>
<div class="dialog-content">
<div class="tab-container">
<div class="tabs">
<button :class="{ active: activeTab === 'export' }" @click="activeTab = 'export'">导出</button>
<button :class="{ active: activeTab === 'import' }" @click="activeTab = 'import'">导入</button>
</div>
<div v-if="activeTab === 'export'" class="tab-content">
<p>以下是当前黑板变量的JSON数据可以复制保存</p>
<textarea v-model="exportData" readonly class="export-textarea" rows="15"></textarea>
<button @click="copyExportData" class="btn-copy">📋 复制到剪贴板</button>
</div>
<div v-if="activeTab === 'import'" class="tab-content">
<p>粘贴黑板变量的JSON数据进行导入</p>
<textarea v-model="importData" class="import-textarea" rows="15"
placeholder="粘贴JSON数据..."></textarea>
<div class="import-options">
<label class="checkbox-label">
<input type="checkbox" v-model="clearBeforeImport">
<span>导入前清空现有变量</span>
</label>
</div>
<button @click="importVariables" class="btn-import" :disabled="!importData.trim()">
📥 导入变量
</button>
</div>
</div>
</div>
</div>
</div>
</div>