Files
3Dviewer/frontend/src/components/partsTree/PartsTreeNode.vue
likegears 7af9c323f6 Initial commit: 3D Viewer application
Features:
- Vue 3 frontend with Three.js/Online3DViewer
- Node.js API with PostgreSQL and Redis
- Python worker for model conversion
- Docker Compose for deployment
- ViewCube navigation with drag rotation and 90° snap
- Cross-section, exploded view, and render settings
- Parts tree with visibility controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:00:17 +08:00

317 lines
8.3 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FlatTreeNode } from '@/types/partsTree'
import { usePartsTreeStore } from '@/stores/partsTree'
import { getExplodeService } from '@/services/explodeService'
import { useViewerStore } from '@/stores/viewer'
import ColorPicker from './ColorPicker.vue'
const props = defineProps<{
node: FlatTreeNode
searchQuery?: string
}>()
const partsTreeStore = usePartsTreeStore()
const viewerStore = useViewerStore()
const isHovered = computed(() => partsTreeStore.hoveredNodeId === props.node.id)
const isExploded = ref(false)
const indentStyle = computed(() => ({
paddingLeft: `${props.node.depth * 16 + 8}px`
}))
function handleToggleExpand() {
if (props.node.hasChildren) {
partsTreeStore.toggleExpanded(props.node.id)
}
}
function handleToggleVisible(event: Event) {
event.stopPropagation()
partsTreeStore.toggleVisible(props.node.id)
}
function handleToggleExplode(event: Event) {
event.stopPropagation()
const service = getExplodeService()
const nodeUuid = props.node.object.uuid
if (isExploded.value) {
service.animateResetPart(nodeUuid, 300)
isExploded.value = false
} else {
service.animateExplodePart(nodeUuid, 100, 300)
isExploded.value = true
}
viewerStore.forceRender()
}
function handleMouseEnter() {
partsTreeStore.highlightNode(props.node.id)
}
function handleMouseLeave() {
partsTreeStore.highlightNode(null)
}
function handleColorChanged() {
viewerStore.forceRender()
}
// Highlight matching text in search
function highlightText(text: string, query: string): string {
if (!query) return text
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
const displayName = computed(() => {
if (props.searchQuery) {
return highlightText(props.node.name, props.searchQuery)
}
return props.node.name
})
</script>
<template>
<div
class="tree-node"
:class="{ 'is-hovered': isHovered, 'is-hidden': !node.visible }"
:style="indentStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleToggleExpand"
>
<span class="expand-icon" :class="{ 'has-children': node.hasChildren }">
<template v-if="node.hasChildren">
<svg v-if="node.isExpanded" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</template>
</span>
<span class="node-name" :title="node.name" v-html="displayName"></span>
<span v-if="node.childCount > 0" class="child-count">
({{ node.childCount }})
</span>
<button
class="explode-btn"
:class="{ 'is-exploded': isExploded }"
:title="isExploded ? '复位' : '爆炸'"
@click="handleToggleExplode"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path v-if="!isExploded" d="M10 2a1 1 0 011 1v4.586l2.293-2.293a1 1 0 111.414 1.414L11.414 10l3.293 3.293a1 1 0 01-1.414 1.414L11 12.414V17a1 1 0 11-2 0v-4.586l-2.293 2.293a1 1 0 01-1.414-1.414L8.586 10 5.293 6.707a1 1 0 011.414-1.414L9 7.586V3a1 1 0 011-1z"/>
<path v-else d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/>
</svg>
</button>
<ColorPicker
:uuid="node.object.uuid"
@color-changed="handleColorChanged"
/>
<button
class="visibility-btn"
:class="{ 'is-visible': node.visible }"
:title="node.visible ? '隐藏' : '显示'"
@click="handleToggleVisible"
>
<svg v-if="node.visible" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.742L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
</svg>
</button>
</div>
</template>
<style scoped>
.tree-node {
display: flex;
align-items: flex-start;
gap: var(--space-1);
padding: 6px 10px;
cursor: pointer;
border-radius: var(--radius-md);
user-select: none;
transition:
background-color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-spring);
}
.tree-node:hover {
background: var(--bg-tertiary);
}
.tree-node:active {
transform: scale(0.995);
}
.tree-node.is-hovered {
background: var(--primary-subtle);
box-shadow: inset 0 0 0 1px var(--primary-color);
}
.tree-node.is-hidden {
opacity: 0.4;
}
.tree-node.is-hidden .node-name {
text-decoration: line-through;
text-decoration-color: var(--text-tertiary);
}
.expand-icon {
width: 18px;
height: 18px;
margin-top: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.expand-icon svg {
width: 14px;
height: 14px;
color: var(--text-tertiary);
transition:
transform var(--duration-normal) var(--ease-spring),
color var(--duration-fast) var(--ease-default);
}
.expand-icon.has-children {
cursor: pointer;
}
.expand-icon.has-children:hover svg {
color: var(--text-primary);
transform: scale(1.1);
}
/* Chevron rotation when expanded */
.tree-node .expand-icon.has-children svg {
transform: rotate(-90deg);
}
.tree-node:has(.expand-icon.has-children) .expand-icon svg {
transform: rotate(-90deg);
}
.node-name {
flex: 1;
min-width: 60px;
font-size: 0.8125rem;
font-weight: 450;
line-height: 1.4;
/* 多行截断 - 最多显示2行 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
transition: color var(--duration-fast) var(--ease-default);
}
.tree-node:hover .node-name {
color: var(--text-primary);
}
.node-name :deep(mark) {
background: var(--warning-subtle);
color: var(--warning-color);
padding: 1px 4px;
border-radius: var(--radius-xs);
font-weight: 600;
}
.child-count {
font-size: 0.6875rem;
font-family: var(--font-mono);
color: var(--text-tertiary);
padding: 1px 5px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
margin-left: var(--space-1);
}
/* Action buttons base */
.visibility-btn,
.explode-btn {
width: 22px;
height: 22px;
padding: 3px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
flex-shrink: 0;
transition:
opacity var(--duration-fast) var(--ease-default),
background-color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-spring);
}
.tree-node:hover .visibility-btn,
.tree-node:hover .explode-btn {
opacity: 1;
}
.visibility-btn:hover,
.explode-btn:hover {
background: var(--bg-sunken);
transform: scale(1.1);
}
.visibility-btn:active,
.explode-btn:active {
transform: scale(0.95);
}
.visibility-btn svg,
.explode-btn svg {
width: 16px;
height: 16px;
color: var(--text-tertiary);
transition: color var(--duration-fast) var(--ease-default);
}
.visibility-btn:hover svg {
color: var(--primary-color);
}
.visibility-btn.is-visible svg {
color: var(--primary-color);
}
.explode-btn:hover svg {
color: var(--warning-color);
}
.explode-btn.is-exploded {
opacity: 1;
}
.explode-btn.is-exploded svg {
color: var(--warning-color);
animation: explode-pulse 0.5s ease-out;
}
@keyframes explode-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
</style>