фиксы редактора свойств, добавлен компонент treeview

This commit is contained in:
kirillius 2026-02-03 10:42:00 +03:00
parent db88b2cf28
commit c20e725cb3
14 changed files with 927 additions and 37 deletions

View File

@ -4,10 +4,19 @@ import ru.kirillius.XCP.Commons.GenerateApiSpec;
import ru.kirillius.XCP.Persistence.PersistenceEntity;
import ru.kirillius.XCP.Properties.Constraint;
import ru.kirillius.XCP.Properties.Constraints;
import ru.kirillius.XCP.Properties.PropertyTarget;
import ru.kirillius.XCP.Properties.PropertyType;
import java.util.Set;
@GenerateApiSpec
public interface PropertyDescriptor extends PersistenceEntity {
@GenerateApiSpec(type = PropertyTarget[].class, alias = "targets")
Set<PropertyTarget> getPropertyTargets();
void setPropertyTargets(Set<PropertyTarget> propertyTargets);
@GenerateApiSpec(type = Constraint[].class)
Constraints getConstraints();

View File

@ -0,0 +1,7 @@
package ru.kirillius.XCP.Properties;
public enum PropertyTarget {
Users,
Data,
Any
}

View File

@ -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]?)$"
}
],
"targets": [
"Data"
],
"array": false,
"propertyType": "Text",
"name": "host.ip",
"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"
}
}
]

View File

@ -12,13 +12,16 @@ import ru.kirillius.XCP.Persistence.EntityImplementation;
import ru.kirillius.XCP.Persistence.RepositoryServiceImpl;
import ru.kirillius.XCP.Properties.Constraint;
import ru.kirillius.XCP.Properties.Constraints;
import ru.kirillius.XCP.Properties.PropertyTarget;
import ru.kirillius.XCP.Properties.PropertyType;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@EntityImplementation(PropertyDescriptorRepositoryImpl.PropertyDescriptorEntity.class)
public class PropertyDescriptorRepositoryImpl extends AbstractRepository<PropertyDescriptor> implements PropertyDescriptorRepository {
@ -49,10 +52,16 @@ public class PropertyDescriptorRepositoryImpl extends AbstractRepository<Propert
@JsonProperty
private String name = "";
@Getter
@Setter
@Column(nullable = false)
@JsonProperty("targets")
private Set<PropertyTarget> propertyTargets = Collections.emptySet();
@Getter
@Setter
@JsonIgnore
@Column(nullable = false, name = "constraints_set")
@Column(nullable = false, name = "constraints_set", columnDefinition = "TEXT")
@Convert(converter = ConstraintsConverter.class)
private Constraints constraints = new ConstraintsImpl();

View File

@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import ru.kirillius.XCP.Persistence.Entities.PropertyDescriptor;
import ru.kirillius.XCP.Persistence.PersistenceException;
import ru.kirillius.XCP.Properties.NumberConstraint;
import ru.kirillius.XCP.Properties.PropertyTarget;
import ru.kirillius.XCP.Properties.StringConstraint;
import ru.kirillius.XCP.Services.RepositoryService;
import java.io.IOException;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@ -64,5 +66,6 @@ class PropertyDescriptorRepositoryImplTest extends GenericRepositoryTest<Propert
entity.setName("test" + UUID.randomUUID());
entity.getConstraints().add(NumberConstraint.builder().build());
entity.getConstraints().add(StringConstraint.builder().build());
entity.setPropertyTargets(Set.of(PropertyTarget.Users, PropertyTarget.Data));
}
}

View File

@ -99,7 +99,8 @@ const menuItems = computed(() => [
path: '/profile'
},
{ 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()

View File

@ -13,7 +13,16 @@
</thead>
<tbody>
<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>
</td>
<td>
@ -62,16 +71,6 @@
</div>
</template>
</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>
<!-- Add new property row -->
@ -81,8 +80,8 @@
v-model="propertyInput"
:items="availableForCombobox"
:error-messages="propertyInputError"
item-title="displayName"
item-value="name"
item-title="title"
item-value="value"
placeholder="Имя нового свойства"
hide-details
density="compact"
@ -105,6 +104,11 @@
</td>
</tr>
</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-alert v-if="isLoading" type="info" variant="tonal" class="mt-4">
@ -127,11 +131,13 @@ import type {
ValueListConstraint,
StringConstraint
} from '@/generated/RpcClient'
import { PropertyTarget } from '@/generated/RpcClient'
import PropertyWidget from '@/components/PropertyWidget.vue'
interface Props {
modelValue: Record<string, any>
disabled?: boolean
targetType?: string
}
interface Emits {
@ -148,22 +154,29 @@ const error = ref<string | null>(null)
const descriptors = ref<PropertyDescriptor[]>([])
const propertyInput = ref<string>('')
const propertyInputError = ref<string>('')
const hasPropertyInputError = computed(() => {
return !!propertyInputError.value
})
const availableForCombobox = computed(() => {
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 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
console.log('Debug button:', {
text: propertyInputText.value,
text: inputText,
hasText: hasText,
error: propertyInputError.value,
hasError: hasError,
@ -181,11 +194,14 @@ const availableDescriptors = computed(() => {
const existingKeys = Object.keys(props.modelValue || {})
return descriptors.value
.filter((d) => !existingKeys.includes(d.name))
.map((d) => ({
...d,
displayName: d.name
}))
.sort((a, b) => a.name.localeCompare(b.name))
.filter((d) => {
// Filter by target type - allow Any or specific target type
const targetType = props.targetType
if (!targetType) return true
return (
d.targets.includes(PropertyTarget.Any) || d.targets.some((target) => target === targetType)
)
})
})
const getInputValue = (): string => {

View File

@ -13,10 +13,22 @@
<!-- Array type - multiple widgets -->
<div v-else-if="descriptor?.array">
<div
v-for="(item, index) in modelValue || []"
v-for="(item, index) in (modelValue || []).filter(
(item: any) => item !== undefined && item !== null
)"
:key="String(index)"
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
:model-value="item"
:descriptor="{ ...descriptor, array: false }"
@ -41,16 +53,6 @@
@click="moveDown(Number(index))"
/>
</div>
<v-btn
icon="mdi-close"
size="x-small"
variant="text"
color="error"
:disabled="disabled"
class="ml-1"
@click="removeItem(Number(index))"
/>
</div>
<v-btn

View File

@ -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>

View File

@ -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>

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import ProfileView from '../views/ProfileView.vue'
import SettingsView from '../views/SettingsView.vue'
import DashboardView from '../views/DashboardView.vue'
import TreeDemoView from '../views/TreeDemoView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -24,6 +25,11 @@ const router = createRouter({
path: '/dashboard',
name: 'dashboard',
component: DashboardView
},
{
path: '/tree-demo',
name: 'tree-demo',
component: TreeDemoView
}
]
})

View File

@ -131,7 +131,11 @@
<!-- Редактор свойств пользователя -->
<v-card elevation="2" class="mt-4">
<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>
</v-col>
@ -145,6 +149,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { useNotificationStore } from '@/stores/notification'
import PropertyEditor from '@/components/PropertyEditor.vue'
import { PropertyTarget } from '@/generated/RpcClient'
const appStore = useAppStore()
const { showSuccess, showError } = useNotificationStore()

View File

@ -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