| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- <script setup lang="ts">
- import type { CategoryNode } from '../types/category'
- const props = defineProps<{
- node: CategoryNode
- depth: number
- expandDepth: number
- expandedMap: Record<number, true>
- collapsedMap: Record<number, true>
- }>()
- const emit = defineEmits<{
- toggle: [id: number]
- }>()
- const hasChildren = () => props.node.children.length > 0
- function isExpanded(): boolean {
- if (!hasChildren()) return false
- if (props.collapsedMap[props.node.id]) return false
- if (props.expandedMap[props.node.id]) return true
- return props.depth < props.expandDepth
- }
- </script>
- <template>
- <div class="tree-node">
- <div class="node-card" :class="{ leaf: !hasChildren(), open: isExpanded() }">
- <button
- v-if="hasChildren()"
- class="toggle"
- type="button"
- :aria-expanded="isExpanded()"
- :title="isExpanded() ? '收起' : '展开'"
- @click="emit('toggle', node.id)"
- >
- {{ isExpanded() ? '−' : '+' }}
- </button>
- <span v-else class="toggle-spacer" />
- <div class="node-name">{{ node.name || '(未命名)' }}</div>
- </div>
- <ul v-if="hasChildren() && isExpanded()" class="children">
- <li
- v-for="child in node.children"
- :key="child.id"
- class="child"
- >
- <TreeNode
- :node="child"
- :depth="depth + 1"
- :expand-depth="expandDepth"
- :expanded-map="expandedMap"
- :collapsed-map="collapsedMap"
- @toggle="emit('toggle', $event)"
- />
- </li>
- </ul>
- </div>
- </template>
- <style scoped>
- /* 节点卡片垂直中线,用于对齐连线 */
- .tree-node {
- --line-y: 20px;
- --line-color: #cbd5e1;
- --gap: var(--col-gap, 36px);
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- }
- .node-card {
- display: flex;
- align-items: center;
- gap: 8px;
- width: var(--col-width, 180px);
- min-height: 40px;
- padding: 8px 10px;
- border: 1px solid #d8dee6;
- border-radius: 8px;
- background: #fff;
- box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
- flex-shrink: 0;
- box-sizing: border-box;
- }
- .node-card.open {
- border-color: #9eb6d4;
- background: #f7faff;
- }
- .node-card.leaf {
- border-style: dashed;
- background: #fafbfc;
- }
- .toggle {
- width: 22px;
- height: 22px;
- border: 1px solid #c5ced9;
- border-radius: 4px;
- background: #fff;
- color: #334155;
- font-size: 14px;
- line-height: 1;
- cursor: pointer;
- flex-shrink: 0;
- padding: 0;
- }
- .toggle:hover {
- border-color: #64748b;
- background: #f1f5f9;
- }
- .toggle-spacer {
- width: 22px;
- flex-shrink: 0;
- }
- .node-name {
- flex: 1;
- min-width: 0;
- font-size: 14px;
- font-weight: 600;
- color: #0f172a;
- line-height: 1.35;
- word-break: break-word;
- }
- .children {
- --half-gap: calc(var(--gap) / 2);
- --sibling-gap: 10px;
- display: flex;
- flex-direction: column;
- /* 不用 flex gap:空隙不在子项盒模型内,竖线会断开 */
- list-style: none;
- margin: 0;
- padding: 0 0 0 var(--gap);
- position: relative;
- }
- /* 父节点 → 竖脊:左半段水平线 */
- .children::before {
- content: '';
- position: absolute;
- left: 0;
- top: var(--line-y);
- width: var(--half-gap);
- border-top: 2px solid var(--line-color);
- }
- .child {
- position: relative;
- }
- /* 间距放进 padding,让竖脊 ::after 能连续穿过 */
- .child:not(:last-child) {
- padding-bottom: var(--sibling-gap);
- }
- /* 竖脊 → 子节点:右半段水平线 */
- .child::before {
- content: '';
- position: absolute;
- top: var(--line-y);
- left: 0;
- width: var(--half-gap);
- margin-left: calc(-1 * var(--half-gap));
- border-top: 2px solid var(--line-color);
- }
- /* 兄弟之间的竖脊(位于 gap 中点) */
- .child::after {
- content: '';
- position: absolute;
- left: 0;
- margin-left: calc(-1 * var(--half-gap));
- border-left: 2px solid var(--line-color);
- }
- /* 中间兄弟:通栏竖线(含底部 padding) */
- .child:not(:first-child):not(:last-child)::after {
- top: 0;
- bottom: 0;
- }
- /* 第一个兄弟:从中线画到自身底部(含 padding) */
- .child:first-child:not(:last-child)::after {
- top: var(--line-y);
- bottom: 0;
- }
- /* 最后一个兄弟:从顶部画到中线 */
- .child:last-child:not(:first-child)::after {
- top: 0;
- height: var(--line-y);
- }
- /* 唯一子节点:无需竖脊(水平线已贯通) */
- .child:only-child::after {
- display: none;
- }
- </style>
|