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
@@ -74,10 +74,10 @@
| 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d | ✅ 已完成 | | 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d | ✅ 已完成 |
| 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d | ✅ 已完成 | | 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d | ✅ 已完成 |
| | **结构树** | | | | | | **结构树** | | | |
| 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d | ⏳ 未完成 | | 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d | ✅ 已完成 |
| 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d | ⏳ 未完成 | | 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d | ✅ 已完成 |
| 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d | ⏳ 未完成 | | 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d | ✅ 已完成 |
| 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d | ⏳ 未完成 | | 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d | ✅ 已完成 |
| | **配置面板** | | | | | | **配置面板** | | | |
| 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d | ⏳ 未完成 | | 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d | ⏳ 未完成 |
| 059 | 区域类型 select | 前端 | 0.25d | ⏳ 未完成 | | 059 | 区域类型 select | 前端 | 0.25d | ⏳ 未完成 |
+13
View File
@@ -108,3 +108,16 @@
7. 增加 70%、100%、150% 缩放控制。 7. 增加 70%、100%、150% 缩放控制。
8. 执行前端构建和浏览器 DOM/点击选中检查。 8. 执行前端构建和浏览器 DOM/点击选中检查。
- **执行结果**: 完成任务 047-053,Word 预览区支持渲染、选中、标签、颜色和缩放。 - **执行结果**: 完成任务 047-053,Word 预览区支持渲染、选中、标签、颜色和缩放。
## 会话 ID: 20260701-structure-tree
- [2026-07-01 20:41:24]
- **执行原因**: 按任务清单继续完成结构树 054-057。
- **执行过程**:
1. 新增 `StructureTreeNode.vue` 递归树节点组件。
2. 将结构树改为层级数据绑定,支持 children 递归渲染。
3. 增加展开/折叠按钮和箭头旋转状态。
4. 增加已配置、待审核、已禁用、未配置四类状态圆点。
5. 点击树节点后更新高亮,并滚动到预览区对应 `data-block-id` 元素。
6. 增加全局 `tree-flash` 动画用于预览区闪烁提示。
7. 执行前端构建和浏览器 DOM 检查。
- **执行结果**: 完成任务 054-057,结构树支持递归展示、状态、折叠和预览定位。
@@ -1,9 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
const nodes = [ import { reactive, ref } from "vue";
{ id: "block_001", name: "企业经营分析报告", status: "done" },
{ id: "block_003", name: "一、经营概况", status: "pending" }, import StructureTreeNode, { type StructureNode } from "./StructureTreeNode.vue";
{ id: "block_005", name: "经营指标表", status: "empty" },
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> </script>
<template> <template>
@@ -12,14 +50,16 @@ const nodes = [
<span>文档结构</span> <span>文档结构</span>
<span class="count">{{ nodes.length }}</span> <span class="count">{{ nodes.length }}</span>
</header> </header>
<ul> <ul class="tree-list">
<li v-for="node in nodes" :key="node.id"> <StructureTreeNode
<button type="button"> v-for="node in nodes"
<span class="dot" :class="node.status"></span> :key="node.id"
<span class="node-name">{{ node.name }}</span> :node="node"
<span class="node-id">{{ node.id }}</span> :active-id="activeId"
</button> :expanded-keys="expandedKeys"
</li> @select="selectNode"
@toggle="toggleNode"
/>
</ul> </ul>
</aside> </aside>
</template> </template>
@@ -53,7 +93,7 @@ header {
line-height: 18px; line-height: 18px;
} }
ul { .tree-list {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
margin: 0; margin: 0;
@@ -61,48 +101,4 @@ ul {
overflow: auto; overflow: auto;
list-style: none; 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> </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 { select {
font: inherit; 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);
}
}