feat: add recursive structure tree

This commit is contained in:
zwt13703
2026-07-01 20:41:48 +08:00
parent 5da6f7f3d0
commit 7a1e069139
5 changed files with 233 additions and 61 deletions
@@ -1,9 +1,47 @@
<script setup lang="ts">
const nodes = [
{ id: "block_001", name: "企业经营分析报告", status: "done" },
{ id: "block_003", name: "一、经营概况", status: "pending" },
{ id: "block_005", name: "经营指标表", status: "empty" },
import { reactive, ref } from "vue";
import StructureTreeNode, { type StructureNode } from "./StructureTreeNode.vue";
const nodes: StructureNode[] = [
{
id: "block_001",
name: "企业经营分析报告",
status: "done",
children: [
{ id: "block_002", name: "报告说明", status: "review" },
{
id: "block_003",
name: "一、经营概况",
status: "done",
children: [
{ id: "block_004", name: "经营概况段落", status: "empty" },
{ id: "block_005", name: "经营指标表", status: "disabled" },
],
},
],
},
];
const activeId = ref("block_003");
const expandedKeys = reactive<Record<string, boolean>>({
block_001: true,
block_003: true,
});
function toggleNode(id: string) {
expandedKeys[id] = !expandedKeys[id];
}
function selectNode(node: StructureNode) {
activeId.value = node.id;
const target = document.querySelector<HTMLElement>(`[data-block-id="${node.id}"]`);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("tree-flash");
window.setTimeout(() => target.classList.remove("tree-flash"), 1600);
}
</script>
<template>
@@ -12,14 +50,16 @@ const nodes = [
<span>文档结构</span>
<span class="count">{{ nodes.length }}</span>
</header>
<ul>
<li v-for="node in nodes" :key="node.id">
<button type="button">
<span class="dot" :class="node.status"></span>
<span class="node-name">{{ node.name }}</span>
<span class="node-id">{{ node.id }}</span>
</button>
</li>
<ul class="tree-list">
<StructureTreeNode
v-for="node in nodes"
:key="node.id"
:node="node"
:active-id="activeId"
:expanded-keys="expandedKeys"
@select="selectNode"
@toggle="toggleNode"
/>
</ul>
</aside>
</template>
@@ -53,7 +93,7 @@ header {
line-height: 18px;
}
ul {
.tree-list {
flex: 1;
min-height: 0;
margin: 0;
@@ -61,48 +101,4 @@ ul {
overflow: auto;
list-style: none;
}
button {
display: grid;
width: 100%;
grid-template-columns: 8px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 7px 12px;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
button:hover {
background: var(--c-surface-soft);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-text-muted);
}
.dot.done {
background: var(--c-success);
}
.dot.pending {
background: var(--c-warn);
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-id {
grid-column: 2;
color: var(--c-text-muted);
font-size: 11px;
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { RightOutlined } from "@ant-design/icons-vue";
export type StructureNode = {
id: string;
name: string;
status: "done" | "review" | "disabled" | "empty";
children?: StructureNode[];
};
defineProps<{
node: StructureNode;
activeId: string;
expandedKeys: Record<string, boolean>;
level?: number;
}>();
const emit = defineEmits<{
select: [node: StructureNode];
toggle: [id: string];
}>();
</script>
<template>
<li>
<div
class="tree-node"
:class="{ active: activeId === node.id }"
:style="{ paddingLeft: `${10 + (level ?? 0) * 16}px` }"
>
<button
v-if="node.children?.length"
class="toggle-btn"
:class="{ expanded: expandedKeys[node.id] }"
type="button"
:aria-label="expandedKeys[node.id] ? '折叠' : '展开'"
@click.stop="emit('toggle', node.id)"
>
<RightOutlined />
</button>
<span v-else class="toggle-spacer"></span>
<button class="node-main" type="button" @click="emit('select', node)">
<span class="dot" :class="node.status"></span>
<span class="node-name">{{ node.name }}</span>
</button>
</div>
<ul v-if="node.children?.length" v-show="expandedKeys[node.id]" class="tree-children">
<StructureTreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:active-id="activeId"
:expanded-keys="expandedKeys"
:level="(level ?? 0) + 1"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
/>
</ul>
</li>
</template>
<style scoped>
li,
ul {
margin: 0;
padding: 0;
list-style: none;
}
.tree-node {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
padding: 3px 10px;
}
.tree-node.active {
background: var(--c-primary-soft);
color: var(--c-primary);
}
.toggle-btn,
.node-main {
border: 0;
background: transparent;
cursor: pointer;
}
.toggle-btn {
display: grid;
width: 18px;
height: 22px;
place-items: center;
color: var(--c-text-muted);
font-size: 10px;
transition: transform 0.15s ease;
}
.toggle-btn.expanded {
transform: rotate(90deg);
}
.toggle-spacer {
width: 18px;
}
.node-main {
display: grid;
min-width: 0;
grid-template-columns: 7px minmax(0, 1fr);
gap: 7px;
align-items: center;
padding: 3px 2px;
color: inherit;
text-align: left;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-text-muted);
}
.dot.done {
background: var(--c-success);
}
.dot.review {
background: var(--c-warn);
}
.dot.disabled {
background: #d33;
}
.dot.empty {
background: var(--c-border);
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+16
View File
@@ -21,3 +21,19 @@ textarea,
select {
font: inherit;
}
.doc-block.tree-flash {
animation: tree-flash 1.6s ease;
}
@keyframes tree-flash {
0%,
100% {
outline-color: var(--c-primary);
}
50% {
background: #fff7d6;
outline-color: var(--c-warn);
}
}