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>
This commit is contained in:
316
frontend/src/components/partsTree/PartsTreeNode.vue
Normal file
316
frontend/src/components/partsTree/PartsTreeNode.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user