фиксы редактора свойств, добавлен компонент treeview
This commit is contained in:
parent
db88b2cf28
commit
c20e725cb3
|
|
@ -4,10 +4,19 @@ import ru.kirillius.XCP.Commons.GenerateApiSpec;
|
||||||
import ru.kirillius.XCP.Persistence.PersistenceEntity;
|
import ru.kirillius.XCP.Persistence.PersistenceEntity;
|
||||||
import ru.kirillius.XCP.Properties.Constraint;
|
import ru.kirillius.XCP.Properties.Constraint;
|
||||||
import ru.kirillius.XCP.Properties.Constraints;
|
import ru.kirillius.XCP.Properties.Constraints;
|
||||||
|
import ru.kirillius.XCP.Properties.PropertyTarget;
|
||||||
import ru.kirillius.XCP.Properties.PropertyType;
|
import ru.kirillius.XCP.Properties.PropertyType;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@GenerateApiSpec
|
@GenerateApiSpec
|
||||||
public interface PropertyDescriptor extends PersistenceEntity {
|
public interface PropertyDescriptor extends PersistenceEntity {
|
||||||
|
|
||||||
|
@GenerateApiSpec(type = PropertyTarget[].class, alias = "targets")
|
||||||
|
Set<PropertyTarget> getPropertyTargets();
|
||||||
|
|
||||||
|
void setPropertyTargets(Set<PropertyTarget> propertyTargets);
|
||||||
|
|
||||||
@GenerateApiSpec(type = Constraint[].class)
|
@GenerateApiSpec(type = Constraint[].class)
|
||||||
Constraints getConstraints();
|
Constraints getConstraints();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package ru.kirillius.XCP.Properties;
|
||||||
|
|
||||||
|
public enum PropertyTarget {
|
||||||
|
Users,
|
||||||
|
Data,
|
||||||
|
Any
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,27 @@
|
||||||
"regexp": "^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
"regexp": "^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"targets": [
|
||||||
|
"Data"
|
||||||
|
],
|
||||||
"array": false,
|
"array": false,
|
||||||
"propertyType": "Text",
|
"propertyType": "Text",
|
||||||
"name": "host.ip",
|
"name": "host.ip",
|
||||||
"uuid": "00bd0f8a-e9d8-4789-83e5-d2f2eb94f876"
|
"uuid": "00bd0f8a-e9d8-4789-83e5-d2f2eb94f876"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "PropertyDescriptor",
|
||||||
|
"entity": {
|
||||||
|
"constraints": [
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"array": true,
|
||||||
|
"propertyType": "Number",
|
||||||
|
"name": "user.test.prop",
|
||||||
|
"uuid": "00bd0f8a-e9d8-4789-83e5-d2f2eb94f877"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -12,13 +12,16 @@ import ru.kirillius.XCP.Persistence.EntityImplementation;
|
||||||
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
|
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
|
||||||
import ru.kirillius.XCP.Properties.Constraint;
|
import ru.kirillius.XCP.Properties.Constraint;
|
||||||
import ru.kirillius.XCP.Properties.Constraints;
|
import ru.kirillius.XCP.Properties.Constraints;
|
||||||
|
import ru.kirillius.XCP.Properties.PropertyTarget;
|
||||||
import ru.kirillius.XCP.Properties.PropertyType;
|
import ru.kirillius.XCP.Properties.PropertyType;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import tools.jackson.databind.node.ArrayNode;
|
import tools.jackson.databind.node.ArrayNode;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@EntityImplementation(PropertyDescriptorRepositoryImpl.PropertyDescriptorEntity.class)
|
@EntityImplementation(PropertyDescriptorRepositoryImpl.PropertyDescriptorEntity.class)
|
||||||
public class PropertyDescriptorRepositoryImpl extends AbstractRepository<PropertyDescriptor> implements PropertyDescriptorRepository {
|
public class PropertyDescriptorRepositoryImpl extends AbstractRepository<PropertyDescriptor> implements PropertyDescriptorRepository {
|
||||||
|
|
@ -49,10 +52,16 @@ public class PropertyDescriptorRepositoryImpl extends AbstractRepository<Propert
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
private String name = "";
|
private String name = "";
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Column(nullable = false)
|
||||||
|
@JsonProperty("targets")
|
||||||
|
private Set<PropertyTarget> propertyTargets = Collections.emptySet();
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@Column(nullable = false, name = "constraints_set")
|
@Column(nullable = false, name = "constraints_set", columnDefinition = "TEXT")
|
||||||
@Convert(converter = ConstraintsConverter.class)
|
@Convert(converter = ConstraintsConverter.class)
|
||||||
private Constraints constraints = new ConstraintsImpl();
|
private Constraints constraints = new ConstraintsImpl();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
|
||||||
import ru.kirillius.XCP.Persistence.Entities.PropertyDescriptor;
|
import ru.kirillius.XCP.Persistence.Entities.PropertyDescriptor;
|
||||||
import ru.kirillius.XCP.Persistence.PersistenceException;
|
import ru.kirillius.XCP.Persistence.PersistenceException;
|
||||||
import ru.kirillius.XCP.Properties.NumberConstraint;
|
import ru.kirillius.XCP.Properties.NumberConstraint;
|
||||||
|
import ru.kirillius.XCP.Properties.PropertyTarget;
|
||||||
import ru.kirillius.XCP.Properties.StringConstraint;
|
import ru.kirillius.XCP.Properties.StringConstraint;
|
||||||
import ru.kirillius.XCP.Services.RepositoryService;
|
import ru.kirillius.XCP.Services.RepositoryService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
@ -64,5 +66,6 @@ class PropertyDescriptorRepositoryImplTest extends GenericRepositoryTest<Propert
|
||||||
entity.setName("test" + UUID.randomUUID());
|
entity.setName("test" + UUID.randomUUID());
|
||||||
entity.getConstraints().add(NumberConstraint.builder().build());
|
entity.getConstraints().add(NumberConstraint.builder().build());
|
||||||
entity.getConstraints().add(StringConstraint.builder().build());
|
entity.getConstraints().add(StringConstraint.builder().build());
|
||||||
|
entity.setPropertyTargets(Set.of(PropertyTarget.Users, PropertyTarget.Data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +99,8 @@ const menuItems = computed(() => [
|
||||||
path: '/profile'
|
path: '/profile'
|
||||||
},
|
},
|
||||||
{ title: 'Настройки', icon: 'mdi-cog', path: '/settings' },
|
{ title: 'Настройки', icon: 'mdi-cog', path: '/settings' },
|
||||||
{ title: 'Панель управления', icon: 'mdi-view-dashboard', path: '/dashboard' }
|
{ title: 'Панель управления', icon: 'mdi-view-dashboard', path: '/dashboard' },
|
||||||
|
{ title: 'Tree Demo', icon: 'mdi-tree', path: '/tree-demo' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,16 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(value, key) in modelValue" :key="key">
|
<tr v-for="(value, key) in modelValue" :key="key">
|
||||||
<td class="align-middle">
|
<td class="align-middle d-flex align-center">
|
||||||
|
<v-btn
|
||||||
|
v-if="!disabled"
|
||||||
|
icon="mdi-close"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
class="mr-2"
|
||||||
|
@click="removeProperty(key)"
|
||||||
|
/>
|
||||||
<div class="font-weight-medium">{{ key }}</div>
|
<div class="font-weight-medium">{{ key }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -62,16 +71,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
|
||||||
<v-btn
|
|
||||||
v-if="!disabled"
|
|
||||||
icon="mdi-close"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
@click="removeProperty(key)"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Add new property row -->
|
<!-- Add new property row -->
|
||||||
|
|
@ -81,8 +80,8 @@
|
||||||
v-model="propertyInput"
|
v-model="propertyInput"
|
||||||
:items="availableForCombobox"
|
:items="availableForCombobox"
|
||||||
:error-messages="propertyInputError"
|
:error-messages="propertyInputError"
|
||||||
item-title="displayName"
|
item-title="title"
|
||||||
item-value="name"
|
item-value="value"
|
||||||
placeholder="Имя нового свойства"
|
placeholder="Имя нового свойства"
|
||||||
hide-details
|
hide-details
|
||||||
density="compact"
|
density="compact"
|
||||||
|
|
@ -105,6 +104,11 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot v-if="!modelValue || Object.keys(modelValue).length === 0">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center pa-4 text-medium-emphasis">Свойства не заданы</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</v-table>
|
</v-table>
|
||||||
|
|
||||||
<v-alert v-if="isLoading" type="info" variant="tonal" class="mt-4">
|
<v-alert v-if="isLoading" type="info" variant="tonal" class="mt-4">
|
||||||
|
|
@ -127,11 +131,13 @@ import type {
|
||||||
ValueListConstraint,
|
ValueListConstraint,
|
||||||
StringConstraint
|
StringConstraint
|
||||||
} from '@/generated/RpcClient'
|
} from '@/generated/RpcClient'
|
||||||
|
import { PropertyTarget } from '@/generated/RpcClient'
|
||||||
import PropertyWidget from '@/components/PropertyWidget.vue'
|
import PropertyWidget from '@/components/PropertyWidget.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: Record<string, any>
|
modelValue: Record<string, any>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
targetType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
|
|
@ -148,22 +154,29 @@ const error = ref<string | null>(null)
|
||||||
const descriptors = ref<PropertyDescriptor[]>([])
|
const descriptors = ref<PropertyDescriptor[]>([])
|
||||||
const propertyInput = ref<string>('')
|
const propertyInput = ref<string>('')
|
||||||
const propertyInputError = ref<string>('')
|
const propertyInputError = ref<string>('')
|
||||||
|
|
||||||
const hasPropertyInputError = computed(() => {
|
const hasPropertyInputError = computed(() => {
|
||||||
return !!propertyInputError.value
|
return !!propertyInputError.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const availableForCombobox = computed(() => {
|
const availableForCombobox = computed(() => {
|
||||||
const existingKeys = Object.keys(props.modelValue || {})
|
const existingKeys = Object.keys(props.modelValue || {})
|
||||||
return availableDescriptors.value.filter((d) => !existingKeys.includes(d.name))
|
return availableDescriptors.value
|
||||||
|
.filter((d) => !existingKeys.includes(d.name))
|
||||||
|
.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
title: d.name,
|
||||||
|
value: d.name
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonDisabled = computed(() => {
|
const buttonDisabled = computed(() => {
|
||||||
const hasText = propertyInputText.value && propertyInputText.value.trim().length > 0
|
const inputText = getInputValue()
|
||||||
|
const hasText = inputText && inputText.trim().length > 0
|
||||||
const hasError = !!propertyInputError.value && propertyInputError.value.trim().length > 0
|
const hasError = !!propertyInputError.value && propertyInputError.value.trim().length > 0
|
||||||
|
|
||||||
console.log('Debug button:', {
|
console.log('Debug button:', {
|
||||||
text: propertyInputText.value,
|
text: inputText,
|
||||||
hasText: hasText,
|
hasText: hasText,
|
||||||
error: propertyInputError.value,
|
error: propertyInputError.value,
|
||||||
hasError: hasError,
|
hasError: hasError,
|
||||||
|
|
@ -181,11 +194,14 @@ const availableDescriptors = computed(() => {
|
||||||
const existingKeys = Object.keys(props.modelValue || {})
|
const existingKeys = Object.keys(props.modelValue || {})
|
||||||
return descriptors.value
|
return descriptors.value
|
||||||
.filter((d) => !existingKeys.includes(d.name))
|
.filter((d) => !existingKeys.includes(d.name))
|
||||||
.map((d) => ({
|
.filter((d) => {
|
||||||
...d,
|
// Filter by target type - allow Any or specific target type
|
||||||
displayName: d.name
|
const targetType = props.targetType
|
||||||
}))
|
if (!targetType) return true
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
return (
|
||||||
|
d.targets.includes(PropertyTarget.Any) || d.targets.some((target) => target === targetType)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const getInputValue = (): string => {
|
const getInputValue = (): string => {
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,22 @@
|
||||||
<!-- Array type - multiple widgets -->
|
<!-- Array type - multiple widgets -->
|
||||||
<div v-else-if="descriptor?.array">
|
<div v-else-if="descriptor?.array">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in modelValue || []"
|
v-for="(item, index) in (modelValue || []).filter(
|
||||||
|
(item: any) => item !== undefined && item !== null
|
||||||
|
)"
|
||||||
:key="String(index)"
|
:key="String(index)"
|
||||||
class="d-flex align-center mb-2"
|
class="d-flex align-center mb-2"
|
||||||
>
|
>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="mr-2"
|
||||||
|
@click="removeItem(Number(index))"
|
||||||
|
/>
|
||||||
|
|
||||||
<PropertyWidget
|
<PropertyWidget
|
||||||
:model-value="item"
|
:model-value="item"
|
||||||
:descriptor="{ ...descriptor, array: false }"
|
:descriptor="{ ...descriptor, array: false }"
|
||||||
|
|
@ -41,16 +53,6 @@
|
||||||
@click="moveDown(Number(index))"
|
@click="moveDown(Number(index))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-close"
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
:disabled="disabled"
|
|
||||||
class="ml-1"
|
|
||||||
@click="removeItem(Number(index))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
<template>
|
||||||
|
<div class="tree-node" :class="{ 'mobile-node': mobile }">
|
||||||
|
<!-- Desktop view -->
|
||||||
|
<div v-if="!mobile" class="node-content desktop-node">
|
||||||
|
<div
|
||||||
|
class="node-row"
|
||||||
|
:style="{ paddingLeft: `${level * 20}px` }"
|
||||||
|
:class="{ selected: isSelected }"
|
||||||
|
@click="handleClick"
|
||||||
|
@click.ctrl.exact="handleCtrlClick"
|
||||||
|
>
|
||||||
|
<!-- Expand/collapse icon -->
|
||||||
|
<v-icon
|
||||||
|
v-if="node.hasChildren"
|
||||||
|
:icon="node.expanded ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||||
|
size="20"
|
||||||
|
class="expand-icon"
|
||||||
|
@click.stop="toggleExpand"
|
||||||
|
/>
|
||||||
|
<div v-else style="width: 20px" />
|
||||||
|
|
||||||
|
<!-- Node icon -->
|
||||||
|
<v-icon :icon="node.icon" size="20" class="node-icon" />
|
||||||
|
|
||||||
|
<!-- Node label -->
|
||||||
|
<span class="node-label">{{ node.label }}</span>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="node.loading"
|
||||||
|
indeterminate
|
||||||
|
size="16"
|
||||||
|
width="2"
|
||||||
|
class="loading-indicator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<div class="add-button" :style="{ paddingLeft: `${(level + 1) * 20}px` }" @click="handleAdd">
|
||||||
|
<v-icon icon="mdi-plus" size="16" />
|
||||||
|
<span class="add-label">Добавить</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children -->
|
||||||
|
<div v-if="node.expanded && node.children" class="children">
|
||||||
|
<tree-node
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.id"
|
||||||
|
:node="child"
|
||||||
|
:level="level + 1"
|
||||||
|
:selected-nodes="selectedNodes"
|
||||||
|
:multi-select-mode="multiSelectMode"
|
||||||
|
@select="(node, ctrlKey) => $emit('select', node, ctrlKey)"
|
||||||
|
@expand="$emit('expand', $event)"
|
||||||
|
@add="$emit('add', $event)"
|
||||||
|
@load-children="$emit('load-children', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile view -->
|
||||||
|
<div v-else class="node-content mobile-node-content">
|
||||||
|
<div class="mobile-node-row" :class="{ selected: isSelected }" @click="handleClick">
|
||||||
|
<!-- Expand/collapse icon (left side) -->
|
||||||
|
<v-icon
|
||||||
|
v-if="node.hasChildren"
|
||||||
|
:icon="node.expanded ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||||
|
size="20"
|
||||||
|
class="mobile-expand-icon"
|
||||||
|
@click.stop="toggleExpand"
|
||||||
|
/>
|
||||||
|
<div v-else style="width: 20px" />
|
||||||
|
|
||||||
|
<!-- Node icon -->
|
||||||
|
<v-icon :icon="node.icon" size="20" class="mobile-node-icon" />
|
||||||
|
|
||||||
|
<!-- Node label -->
|
||||||
|
<span class="mobile-node-label">{{ node.label }}</span>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="node.loading"
|
||||||
|
indeterminate
|
||||||
|
size="16"
|
||||||
|
width="2"
|
||||||
|
class="mobile-loading-indicator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children (shown below parent in mobile) -->
|
||||||
|
<div v-if="node.expanded && node.children" class="mobile-children">
|
||||||
|
<tree-node
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.id"
|
||||||
|
:node="child"
|
||||||
|
:level="level + 1"
|
||||||
|
:selected-nodes="selectedNodes"
|
||||||
|
:multi-select-mode="false"
|
||||||
|
mobile
|
||||||
|
@select="$emit('select', $event, false)"
|
||||||
|
@expand="$emit('expand', $event)"
|
||||||
|
@add="$emit('add', $event)"
|
||||||
|
@load-children="$emit('load-children', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button (last element at this level) -->
|
||||||
|
<div class="mobile-add-button" @click="handleAdd">
|
||||||
|
<v-icon icon="mdi-plus" size="16" />
|
||||||
|
<span class="mobile-add-label">Добавить</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { TreeNodeData } from './TreeView.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: TreeNodeData
|
||||||
|
level: number
|
||||||
|
selectedNodes: Set<string>
|
||||||
|
multiSelectMode: boolean
|
||||||
|
mobile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
mobile: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [node: TreeNodeData, ctrlKey?: boolean]
|
||||||
|
expand: [node: TreeNodeData]
|
||||||
|
add: [parentId: string]
|
||||||
|
'load-children': [nodeId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isSelected = computed(() => props.selectedNodes.has(props.node.id))
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
emit('select', props.node, event.ctrlKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCtrlClick = () => {
|
||||||
|
emit('select', props.node, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
emit('expand', props.node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
emit('add', props.node.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-node {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop styles */
|
||||||
|
.desktop-node {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row:hover {
|
||||||
|
background-color: rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row.selected {
|
||||||
|
background-color: rgb(var(--v-theme-primary-container));
|
||||||
|
color: rgb(var(--v-theme-on-primary-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon:hover {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
background-color: rgb(var(--v-theme-primary-container));
|
||||||
|
color: rgb(var(--v-theme-on-primary-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-label {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile styles */
|
||||||
|
.mobile-node-content {
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-node-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-height: 44px; /* Larger touch target */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-node-row:hover {
|
||||||
|
background-color: rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-node-row.selected {
|
||||||
|
background-color: rgb(var(--v-theme-primary-container));
|
||||||
|
color: rgb(var(--v-theme-on-primary-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-expand-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-node-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-node-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px; /* Larger font for mobile */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-loading-indicator {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-children {
|
||||||
|
/* Children appear naturally below parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-add-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px 8px 44px; /* Extra left padding to align with children */
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-height: 44px; /* Larger touch target */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-add-button:hover {
|
||||||
|
background-color: rgb(var(--v-theme-primary-container));
|
||||||
|
color: rgb(var(--v-theme-on-primary-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-add-label {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
<template>
|
||||||
|
<div class="tree-view">
|
||||||
|
<!-- Action bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-delete"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
@click="handleDelete"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
@click="handleCopy"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-arrow-right-bold"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
@click="handleMove"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
v-if="isMobile"
|
||||||
|
icon="mdi-dots-vertical"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
@click="handleMobileMenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tree content -->
|
||||||
|
<div class="tree-content" :class="{ 'mobile-view': isMobile }">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="loader-container">
|
||||||
|
<v-progress-circular indeterminate size="24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop view -->
|
||||||
|
<div v-if="!isMobile && !loading" class="desktop-tree">
|
||||||
|
<tree-node
|
||||||
|
v-for="node in rootNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:node="node"
|
||||||
|
:level="0"
|
||||||
|
:selected-nodes="selectedNodes"
|
||||||
|
:multi-select-mode="multiSelectMode"
|
||||||
|
@select="handleNodeSelect"
|
||||||
|
@expand="handleNodeExpand"
|
||||||
|
@add="handleAdd"
|
||||||
|
@load-children="loadChildren"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile view -->
|
||||||
|
<div v-if="isMobile && !loading" class="mobile-tree">
|
||||||
|
<tree-node
|
||||||
|
v-for="node in visibleMobileNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:node="node"
|
||||||
|
:level="node.level"
|
||||||
|
:selected-nodes="selectedNodes"
|
||||||
|
:multi-select-mode="false"
|
||||||
|
mobile
|
||||||
|
@select="handleNodeSelect"
|
||||||
|
@expand="handleNodeExpand"
|
||||||
|
@add="handleAdd"
|
||||||
|
@load-children="loadChildren"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import TreeNode from './TreeNode.vue'
|
||||||
|
|
||||||
|
export interface TreeNodeData {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
hasChildren?: boolean
|
||||||
|
children?: TreeNodeData[]
|
||||||
|
loading?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rootNode?: TreeNodeData[]
|
||||||
|
loadFunction?: (nodeId: string) => Promise<TreeNodeData[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
rootNode: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'delete', 'copy', 'move', 'add'])
|
||||||
|
|
||||||
|
const rootNodes = ref<TreeNodeData[]>([...props.rootNode])
|
||||||
|
const selectedNodes = ref<Set<string>>(new Set())
|
||||||
|
const loading = ref(false)
|
||||||
|
const isMobile = ref(window.innerWidth < 768)
|
||||||
|
const multiSelectMode = ref(false)
|
||||||
|
|
||||||
|
const hasSelection = computed(() => selectedNodes.value.size > 0)
|
||||||
|
|
||||||
|
const visibleMobileNodes = computed(() => {
|
||||||
|
const result: Array<TreeNodeData & { level: number }> = []
|
||||||
|
|
||||||
|
const flattenNodes = (nodes: TreeNodeData[], level: number = 0) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({ ...node, level })
|
||||||
|
if (node.expanded && node.children) {
|
||||||
|
flattenNodes(node.children, level + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenNodes(rootNodes.value)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleNodeSelect = (node: TreeNodeData, multiSelect: boolean = false) => {
|
||||||
|
if (!isMobile.value) {
|
||||||
|
if (multiSelect) {
|
||||||
|
if (selectedNodes.value.has(node.id)) {
|
||||||
|
selectedNodes.value.delete(node.id)
|
||||||
|
} else {
|
||||||
|
selectedNodes.value.add(node.id)
|
||||||
|
}
|
||||||
|
const selected = rootNodes.value.filter((n) => selectedNodes.value.has(n.id))
|
||||||
|
emit('select', selected)
|
||||||
|
} else {
|
||||||
|
selectedNodes.value.clear()
|
||||||
|
selectedNodes.value.add(node.id)
|
||||||
|
emit('select', node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeExpand = (node: TreeNodeData) => {
|
||||||
|
node.expanded = !node.expanded
|
||||||
|
if (node.expanded && node.hasChildren && (!node.children || node.children.length === 0)) {
|
||||||
|
loadChildren(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = (parentId: string) => {
|
||||||
|
emit('add', parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
const selected = rootNodes.value.filter((n) => selectedNodes.value.has(n.id))
|
||||||
|
emit('delete', selected.length > 1 ? selected : selected[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
const selected = rootNodes.value.filter((n) => selectedNodes.value.has(n.id))
|
||||||
|
emit('copy', selected.length > 1 ? selected : selected[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = () => {
|
||||||
|
const selected = rootNodes.value.filter((n) => selectedNodes.value.has(n.id))
|
||||||
|
emit('move', selected.length > 1 ? selected : selected[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMobileMenu = () => {
|
||||||
|
const selected = rootNodes.value.filter((n) => selectedNodes.value.has(n.id))
|
||||||
|
emit('select', selected.length > 1 ? selected : selected[0] || null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChildren = async (nodeId: string) => {
|
||||||
|
if (!props.loadFunction) return
|
||||||
|
|
||||||
|
const findNode = (nodes: TreeNodeData[]): TreeNodeData | undefined => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === nodeId) return node
|
||||||
|
if (node.children) {
|
||||||
|
const found = findNode(node.children)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = findNode(rootNodes.value)
|
||||||
|
if (node) {
|
||||||
|
node.loading = true
|
||||||
|
try {
|
||||||
|
const children = await props.loadFunction(nodeId)
|
||||||
|
node.children = children
|
||||||
|
node.loading = false
|
||||||
|
} catch (error) {
|
||||||
|
node.loading = false
|
||||||
|
console.error('Failed to load children:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
if (rootNodes.value.length === 0 && props.loadFunction) {
|
||||||
|
loading.value = true
|
||||||
|
props
|
||||||
|
.loadFunction('')
|
||||||
|
.then((nodes) => {
|
||||||
|
rootNodes.value = nodes
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-outline));
|
||||||
|
background: rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-tree {
|
||||||
|
/* Desktop specific styles */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tree {
|
||||||
|
/* Mobile specific styles */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-view .tree-content {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import ProfileView from '../views/ProfileView.vue'
|
import ProfileView from '../views/ProfileView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
import DashboardView from '../views/DashboardView.vue'
|
import DashboardView from '../views/DashboardView.vue'
|
||||||
|
import TreeDemoView from '../views/TreeDemoView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -24,6 +25,11 @@ const router = createRouter({
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: DashboardView
|
component: DashboardView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tree-demo',
|
||||||
|
name: 'tree-demo',
|
||||||
|
component: TreeDemoView
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,11 @@
|
||||||
<!-- Редактор свойств пользователя -->
|
<!-- Редактор свойств пользователя -->
|
||||||
<v-card elevation="2" class="mt-4">
|
<v-card elevation="2" class="mt-4">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<property-editor v-model="userValues" :disabled="!isEditing" />
|
<property-editor
|
||||||
|
v-model="userValues"
|
||||||
|
:disabled="!isEditing"
|
||||||
|
:target-type="PropertyTarget.Users"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
@ -145,6 +149,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useNotificationStore } from '@/stores/notification'
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import PropertyEditor from '@/components/PropertyEditor.vue'
|
import PropertyEditor from '@/components/PropertyEditor.vue'
|
||||||
|
import { PropertyTarget } from '@/generated/RpcClient'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const { showSuccess, showError } = useNotificationStore()
|
const { showSuccess, showError } = useNotificationStore()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
<template>
|
||||||
|
<div class="tree-demo">
|
||||||
|
<v-container fluid>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1>Tree View Demo</h1>
|
||||||
|
<p>This is a demonstration of the TreeView component with all required features.</p>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Tree View Component</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="tree-container">
|
||||||
|
<TreeView
|
||||||
|
:root-node="sampleData"
|
||||||
|
:load-function="loadChildren"
|
||||||
|
@select="handleSelect"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@copy="handleCopy"
|
||||||
|
@move="handleMove"
|
||||||
|
@add="handleAdd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Event Log</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="event-log">
|
||||||
|
<div v-for="(event, index) in eventLog" :key="index" class="event-item">
|
||||||
|
<strong>{{ event.type }}:</strong> {{ event.message }}
|
||||||
|
</div>
|
||||||
|
<div v-if="eventLog.length === 0" class="no-events">
|
||||||
|
No events yet. Interact with the tree to see events here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import TreeView from '@/components/TreeView.vue'
|
||||||
|
import type { TreeNodeData } from '@/components/TreeView.vue'
|
||||||
|
|
||||||
|
// Sample data for demonstration
|
||||||
|
const sampleData = ref<TreeNodeData[]>([
|
||||||
|
{
|
||||||
|
id: 'root',
|
||||||
|
label: 'Root',
|
||||||
|
icon: 'mdi-folder',
|
||||||
|
hasChildren: true,
|
||||||
|
expanded: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'leaf1',
|
||||||
|
label: 'Leaf 1',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: true,
|
||||||
|
expanded: false,
|
||||||
|
children: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaf2',
|
||||||
|
label: 'Leaf 2',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: true,
|
||||||
|
expanded: false,
|
||||||
|
children: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaf3',
|
||||||
|
label: 'Leaf 3',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const eventLog = ref<Array<{ type: string; message: string }>>([])
|
||||||
|
|
||||||
|
// Simulate async loading of children
|
||||||
|
const loadChildren = async (nodeId: string): Promise<TreeNodeData[]> => {
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// Return mock data based on nodeId
|
||||||
|
if (nodeId === 'root') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'leaf1',
|
||||||
|
label: 'Leaf 1',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: true,
|
||||||
|
expanded: false,
|
||||||
|
children: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaf2',
|
||||||
|
label: 'Leaf 2',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: true,
|
||||||
|
expanded: false,
|
||||||
|
children: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaf3',
|
||||||
|
label: 'Leaf 3',
|
||||||
|
icon: 'mdi-file',
|
||||||
|
hasChildren: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} else if (nodeId === 'leaf1') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'leaf1.1',
|
||||||
|
label: 'Leaf 1.1',
|
||||||
|
icon: 'mdi-file-document',
|
||||||
|
hasChildren: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leaf1.2',
|
||||||
|
label: 'Leaf 1.2',
|
||||||
|
icon: 'mdi-file-document',
|
||||||
|
hasChildren: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} else if (nodeId === 'leaf2') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'leaf2.1',
|
||||||
|
label: 'Leaf 2.1',
|
||||||
|
icon: 'mdi-file-document',
|
||||||
|
hasChildren: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEvent = (type: string, message: string) => {
|
||||||
|
eventLog.value.unshift({ type, message })
|
||||||
|
// Keep only last 10 events
|
||||||
|
if (eventLog.value.length > 10) {
|
||||||
|
eventLog.value = eventLog.value.slice(0, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (items: TreeNodeData | TreeNodeData[]) => {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
addEvent('SELECT', `Selected ${items.length} items: ${items.map((i) => i.label).join(', ')}`)
|
||||||
|
} else {
|
||||||
|
addEvent('SELECT', `Selected: ${items.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (items: TreeNodeData | TreeNodeData[]) => {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
addEvent('DELETE', `Delete ${items.length} items: ${items.map((i) => i.label).join(', ')}`)
|
||||||
|
} else {
|
||||||
|
addEvent('DELETE', `Delete: ${items.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (items: TreeNodeData | TreeNodeData[]) => {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
addEvent('COPY', `Copy ${items.length} items: ${items.map((i) => i.label).join(', ')}`)
|
||||||
|
} else {
|
||||||
|
addEvent('COPY', `Copy: ${items.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (items: TreeNodeData | TreeNodeData[]) => {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
addEvent('MOVE', `Move ${items.length} items: ${items.map((i) => i.label).join(', ')}`)
|
||||||
|
} else {
|
||||||
|
addEvent('MOVE', `Move: ${items.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = (parentId: string) => {
|
||||||
|
addEvent('ADD', `Add new item under parent: ${parentId}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-demo {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
height: 500px;
|
||||||
|
border: 1px solid rgb(var(--v-theme-outline));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgb(var(--v-theme-outline));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-events {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue