фиксы редактора свойств, добавлен компонент 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.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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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]?)$"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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