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>
317 lines
8.3 KiB
Vue
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>
|