aifastdb-devplan 1.2.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,7 +98,7 @@ function getVisualizationHTML(projectName) {
98
98
  .panel-header.module { background: linear-gradient(135deg, #059669, #10b981); }
99
99
  .panel-header.main-task { background: linear-gradient(135deg, #4f46e5, #6366f1); }
100
100
  .panel-header.sub-task { background: linear-gradient(135deg, #7c3aed, #8b5cf6); }
101
- .panel-header.document { background: linear-gradient(135deg, #7c3aed, #a78bfa); }
101
+ .panel-header.document { background: linear-gradient(135deg, #1d4ed8, #3b82f6); }
102
102
  .panel-title { font-weight: 600; font-size: 14px; color: #fff; pointer-events: none; }
103
103
  .panel-close { background: rgba(255,255,255,0.2); border: none; color: #fff; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
104
104
  .panel-close:hover { background: rgba(255,255,255,0.3); }
@@ -156,13 +156,14 @@ function getVisualizationHTML(projectName) {
156
156
  .legend-icon.diamond { background: #10b981; clip-path: polygon(50% 0%,100% 50%,50% 100%,0% 50%); }
157
157
  .legend-icon.circle { background: #6366f1; border-radius: 50%; }
158
158
  .legend-icon.dot { background: #8b5cf6; border-radius: 50%; width: 8px; height: 8px; }
159
- .legend-icon.square { background: #a78bfa; border-radius: 2px; width: 10px; height: 10px; }
159
+ .legend-icon.square { background: #3b82f6; border-radius: 2px; width: 10px; height: 10px; }
160
160
  .legend-line { width: 24px; height: 2px; }
161
161
  .legend-line.solid { background: #6b7280; }
162
162
  .legend-line.thin { background: #6b7280; height: 1px; }
163
163
  .legend-line.dashed { border-top: 2px dashed #6b7280; background: none; height: 0; }
164
164
  .legend-line.dotted { border-top: 2px dotted #10b981; background: none; height: 0; }
165
- .legend-line.task-doc { border-top: 2px dashed #b45309; background: none; height: 0; }
165
+ .legend-line.task-doc { border-top: 2px dashed #6b7280; background: none; height: 0; }
166
+ .legend-line.doc-child { border-top: 2px dashed #6b7280; background: none; height: 0; }
166
167
 
167
168
  /* Document Content in Panel */
168
169
  .doc-section { margin-top: 12px; border-top: 1px solid #374151; padding-top: 10px; }
@@ -313,6 +314,14 @@ function getVisualizationHTML(projectName) {
313
314
  .docs-item-icon { font-size: 14px; flex-shrink: 0; opacity: 0.7; }
314
315
  .docs-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
315
316
  .docs-item-sub { font-size: 10px; color: #6b7280; flex-shrink: 0; }
317
+ .docs-item-toggle { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 4px; background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3); color: #818cf8; font-size: 12px; font-weight: 700; cursor: pointer; flex-shrink: 0; transition: all 0.15s; line-height: 1; }
318
+ .docs-item-toggle:hover { background: rgba(99,102,241,0.3); color: #a5b4fc; }
319
+ .docs-children { overflow: hidden; transition: max-height 0.25s ease; }
320
+ .docs-children.collapsed { max-height: 0 !important; }
321
+ .docs-children .docs-item { padding-left: 44px; font-size: 12px; opacity: 0.85; }
322
+ .docs-children .docs-children .docs-item { padding-left: 60px; font-size: 11px; opacity: 0.75; }
323
+ .docs-child-line { position: absolute; left: 35px; top: 0; bottom: 0; width: 1px; background: #374151; }
324
+ .docs-item-wrapper { position: relative; }
316
325
 
317
326
  .docs-content { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
318
327
  .docs-content-header { padding: 16px 28px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
@@ -465,6 +474,7 @@ function getVisualizationHTML(projectName) {
465
474
  <div class="legend-item"><div class="legend-line dashed"></div> 文档</div>
466
475
  <div class="legend-item"><div class="legend-line dotted"></div> 模块关联</div>
467
476
  <div class="legend-item"><div class="legend-line task-doc"></div> 任务-文档</div>
477
+ <div class="legend-item"><div class="legend-line doc-child"></div> 文档层级</div>
468
478
  </div>
469
479
  </div>
470
480
 
@@ -650,6 +660,393 @@ var ctrlPressed = false;
650
660
  var INCLUDE_NODE_DEGREE = true;
651
661
  var ENABLE_BACKEND_DEGREE_FALLBACK = true;
652
662
 
663
+ // ========== 边高亮:选中节点时关联边变色,取消选中时恢复灰色 ==========
664
+ function highlightConnectedEdges(nodeId) {
665
+ if (!edgesDataSet || !network) return;
666
+ var connectedEdgeIds = network.getConnectedEdges(nodeId);
667
+ var connectedSet = {};
668
+ for (var i = 0; i < connectedEdgeIds.length; i++) connectedSet[connectedEdgeIds[i]] = true;
669
+ var updates = [];
670
+ edgesDataSet.forEach(function(edge) {
671
+ if (connectedSet[edge.id]) {
672
+ // 关联边 → 使用高亮色
673
+ updates.push({ id: edge.id, color: { color: edge._highlightColor || '#9ca3af', highlight: edge._highlightColor || '#9ca3af', hover: edge._highlightColor || '#9ca3af' }, width: (edge._origWidth || 1) < 2 ? 2 : (edge._origWidth || edge.width || 1) });
674
+ } else {
675
+ // 非关联边 → 变淡
676
+ updates.push({ id: edge.id, color: { color: 'rgba(75,85,99,0.15)', highlight: edge._highlightColor || '#9ca3af', hover: edge._highlightColor || '#9ca3af' }, width: edge._origWidth || edge.width || 1 });
677
+ }
678
+ });
679
+ edgesDataSet.update(updates);
680
+ }
681
+
682
+ function resetAllEdgeColors() {
683
+ if (!edgesDataSet) return;
684
+ var updates = [];
685
+ edgesDataSet.forEach(function(edge) {
686
+ updates.push({ id: edge.id, color: { color: EDGE_GRAY, highlight: edge._highlightColor || '#9ca3af', hover: edge._highlightColor || '#9ca3af' }, width: edge._origWidth || edge.width || 1 });
687
+ });
688
+ edgesDataSet.update(updates);
689
+ }
690
+
691
+ // ========== 文档节点展开/收起 ==========
692
+ /** 记录哪些父文档节点处于收起状态(nodeId → true 表示收起) */
693
+ var collapsedDocNodes = {};
694
+ /** 收起时被重定向的边信息: { edgeId: { origFrom, origTo } } */
695
+ var redirectedEdges = {};
696
+ /** 记录各父文档 +/- 按钮在 canvas 坐标系中的位置,用于点击检测 */
697
+ var docToggleBtnPositions = {};
698
+ /** 收起前保存子文档节点的位置: { nodeId: { x, y } } */
699
+ var savedChildPositions = {};
700
+
701
+ /** 获取节点 ID 对应的子文档节点 ID 列表(仅直接子文档) */
702
+ function getChildDocNodeIds(parentNodeId) {
703
+ var childIds = [];
704
+ for (var i = 0; i < allEdges.length; i++) {
705
+ if (allEdges[i].from === parentNodeId && allEdges[i].label === 'doc_has_child') {
706
+ childIds.push(allEdges[i].to);
707
+ }
708
+ }
709
+ return childIds;
710
+ }
711
+
712
+ /** 递归获取所有后代文档节点 ID(含多层子文档) */
713
+ function getAllDescendantDocNodeIds(parentNodeId) {
714
+ var result = [];
715
+ var queue = [parentNodeId];
716
+ while (queue.length > 0) {
717
+ var current = queue.shift();
718
+ var children = getChildDocNodeIds(current);
719
+ for (var i = 0; i < children.length; i++) {
720
+ result.push(children[i]);
721
+ queue.push(children[i]);
722
+ }
723
+ }
724
+ return result;
725
+ }
726
+
727
+ /** 检查节点是否为父文档(有子文档的文档节点) */
728
+ function isParentDocNode(node) {
729
+ if (node.type !== 'document') return false;
730
+ var props = node.properties || {};
731
+ var childDocs = props.childDocs || [];
732
+ if (childDocs.length > 0) return true;
733
+ for (var i = 0; i < allEdges.length; i++) {
734
+ if (allEdges[i].from === node.id && allEdges[i].label === 'doc_has_child') return true;
735
+ }
736
+ return false;
737
+ }
738
+
739
+ /** 通过 nodeId 在 allNodes 中查找节点数据 */
740
+ function findAllNode(nodeId) {
741
+ for (var i = 0; i < allNodes.length; i++) {
742
+ if (allNodes[i].id === nodeId) return allNodes[i];
743
+ }
744
+ return null;
745
+ }
746
+
747
+ /** 检查节点是否应被隐藏(因为其祖先父文档处于收起状态) */
748
+ function isNodeCollapsedByParent(nodeId) {
749
+ for (var i = 0; i < allEdges.length; i++) {
750
+ var e = allEdges[i];
751
+ if (e.to === nodeId && e.label === 'doc_has_child') {
752
+ if (collapsedDocNodes[e.from]) return true;
753
+ if (isNodeCollapsedByParent(e.from)) return true;
754
+ }
755
+ }
756
+ return false;
757
+ }
758
+
759
+ /** 切换父文档节点的展开/收起状态 */
760
+ function toggleDocNodeExpand(nodeId) {
761
+ collapsedDocNodes[nodeId] = !collapsedDocNodes[nodeId];
762
+ var childIds = getAllDescendantDocNodeIds(nodeId);
763
+ var isCollapsed = collapsedDocNodes[nodeId];
764
+
765
+ if (isCollapsed) {
766
+ // ---- 收起 ----
767
+ var removeNodeIds = {};
768
+ for (var i = 0; i < childIds.length; i++) removeNodeIds[childIds[i]] = true;
769
+
770
+ // 0) 保存子文档节点当前位置
771
+ var childPositions = network.getPositions(childIds);
772
+ for (var i = 0; i < childIds.length; i++) {
773
+ if (childPositions[childIds[i]]) {
774
+ savedChildPositions[childIds[i]] = { x: childPositions[childIds[i]].x, y: childPositions[childIds[i]].y };
775
+ }
776
+ }
777
+
778
+ // 1) 将连接到子文档的非 doc_has_child 边重定向到父文档
779
+ var edgesToRedirect = [];
780
+ var edgesToRemove = [];
781
+ edgesDataSet.forEach(function(edge) {
782
+ var touchesChild = removeNodeIds[edge.from] || removeNodeIds[edge.to];
783
+ if (!touchesChild) return;
784
+ if (edge._label === 'doc_has_child') {
785
+ // doc_has_child 边直接移除
786
+ edgesToRemove.push(edge.id);
787
+ } else {
788
+ // 其他边(如 task_has_doc)重定向到父文档
789
+ edgesToRedirect.push(edge);
790
+ }
791
+ });
792
+
793
+ // 移除 doc_has_child 边
794
+ if (edgesToRemove.length > 0) edgesDataSet.remove(edgesToRemove);
795
+
796
+ // 重定向其他边到父文档
797
+ for (var i = 0; i < edgesToRedirect.length; i++) {
798
+ var edge = edgesToRedirect[i];
799
+ var newFrom = removeNodeIds[edge.from] ? nodeId : edge.from;
800
+ var newTo = removeNodeIds[edge.to] ? nodeId : edge.to;
801
+ // 检查是否已存在相同的重定向边(避免重复)
802
+ var duplicate = false;
803
+ edgesDataSet.forEach(function(existing) {
804
+ if (existing.from === newFrom && existing.to === newTo && existing._label === edge._label) duplicate = true;
805
+ });
806
+ if (newFrom === newTo) { duplicate = true; } // 不自连
807
+ if (!duplicate) {
808
+ redirectedEdges[edge.id] = { origFrom: edge.from, origTo: edge.to };
809
+ edgesDataSet.update({ id: edge.id, from: newFrom, to: newTo });
810
+ } else {
811
+ // 重复则移除
812
+ redirectedEdges[edge.id] = { origFrom: edge.from, origTo: edge.to };
813
+ edgesDataSet.remove([edge.id]);
814
+ }
815
+ }
816
+
817
+ // 2) 移除子文档节点
818
+ nodesDataSet.remove(childIds);
819
+
820
+ // 3) 更新父节点标签(加左侧留白和收起数量提示)
821
+ var parentNode = nodesDataSet.get(nodeId);
822
+ if (parentNode) {
823
+ var origLabel = parentNode._origLabel || parentNode.label;
824
+ var pad = ' ';
825
+ nodesDataSet.update({ id: nodeId, label: pad + origLabel + ' [' + childIds.length + ']', _origLabel: origLabel });
826
+ }
827
+ log('收起文档: 隐藏 ' + childIds.length + ' 个子文档, 重定向 ' + edgesToRedirect.length + ' 条边', true);
828
+
829
+ } else {
830
+ // ---- 展开 ----
831
+ // 1) 恢复被重定向的边
832
+ var restoreEdgeIds = [];
833
+ for (var eid in redirectedEdges) {
834
+ var info = redirectedEdges[eid];
835
+ // 检查 origFrom 或 origTo 是否属于此父文档的子孙
836
+ var isRelated = false;
837
+ for (var ci = 0; ci < childIds.length; ci++) {
838
+ if (info.origFrom === childIds[ci] || info.origTo === childIds[ci]) { isRelated = true; break; }
839
+ }
840
+ if (!isRelated) continue;
841
+ restoreEdgeIds.push(eid);
842
+ // 恢复原始 from/to 或重新添加
843
+ var existing = edgesDataSet.get(eid);
844
+ if (existing) {
845
+ edgesDataSet.update({ id: eid, from: info.origFrom, to: info.origTo });
846
+ } else {
847
+ // 边已被移除(因重复),需重新添加
848
+ // 在 allEdges 中找到此边原始数据
849
+ for (var ai = 0; ai < allEdges.length; ai++) {
850
+ var ae = allEdges[ai];
851
+ if (ae.from === info.origFrom && ae.to === info.origTo) {
852
+ var es = edgeStyle(ae);
853
+ edgesDataSet.add({ id: eid, from: ae.from, to: ae.to, width: es.width, _origWidth: es.width, color: es.color, dashes: es.dashes, arrows: es.arrows, _label: ae.label, _highlightColor: es._highlightColor || '#9ca3af' });
854
+ break;
855
+ }
856
+ }
857
+ }
858
+ }
859
+ for (var ri = 0; ri < restoreEdgeIds.length; ri++) {
860
+ delete redirectedEdges[restoreEdgeIds[ri]];
861
+ }
862
+
863
+ // 2) 重新添加子文档节点(使用保存的位置或思维导图排列)
864
+ var parentPos = network.getPositions([nodeId])[nodeId];
865
+ var addNodes = [];
866
+ var visibleChildIds = [];
867
+ for (var ni = 0; ni < allNodes.length; ni++) {
868
+ var n = allNodes[ni];
869
+ for (var ci = 0; ci < childIds.length; ci++) {
870
+ if (n.id === childIds[ci] && !isNodeCollapsedByParent(n.id)) {
871
+ var deg = getNodeDegree(n);
872
+ var s = nodeStyle(n, deg);
873
+ var nodeData = { id: n.id, label: n.label, _origLabel: n.label, title: n.label + ' (连接: ' + deg + ')', shape: s.shape, size: s.size, color: s.color, font: s.font, borderWidth: s.borderWidth, _type: n.type, _props: n.properties || {} };
874
+ // 使用保存的位置
875
+ if (savedChildPositions[n.id]) {
876
+ nodeData.x = savedChildPositions[n.id].x;
877
+ nodeData.y = savedChildPositions[n.id].y;
878
+ }
879
+ addNodes.push(nodeData);
880
+ visibleChildIds.push(n.id);
881
+ break;
882
+ }
883
+ }
884
+ }
885
+ if (addNodes.length > 0) {
886
+ nodesDataSet.add(addNodes);
887
+ // 如果没有保存位置,按思维导图方式排列
888
+ var needArrange = false;
889
+ for (var i = 0; i < visibleChildIds.length; i++) {
890
+ if (!savedChildPositions[visibleChildIds[i]]) { needArrange = true; break; }
891
+ }
892
+ if (needArrange && parentPos) {
893
+ arrangeDocMindMap(nodeId, visibleChildIds);
894
+ }
895
+ }
896
+
897
+ // 3) 重新添加 doc_has_child 边
898
+ var addedNodeIds = {};
899
+ nodesDataSet.forEach(function(n) { addedNodeIds[n.id] = true; });
900
+ var addEdges = [];
901
+ for (var ei = 0; ei < allEdges.length; ei++) {
902
+ var e = allEdges[ei];
903
+ if (!addedNodeIds[e.from] || !addedNodeIds[e.to]) continue;
904
+ if (e.label !== 'doc_has_child') continue;
905
+ var exists = false;
906
+ edgesDataSet.forEach(function(existing) {
907
+ if (existing.from === e.from && existing.to === e.to && existing._label === e.label) exists = true;
908
+ });
909
+ if (!exists) {
910
+ var es = edgeStyle(e);
911
+ addEdges.push({ id: 'e_expand_' + ei, from: e.from, to: e.to, width: es.width, _origWidth: es.width, color: es.color, dashes: es.dashes, arrows: es.arrows, _label: e.label, _highlightColor: es._highlightColor || '#9ca3af' });
912
+ }
913
+ }
914
+ if (addEdges.length > 0) edgesDataSet.add(addEdges);
915
+
916
+ // 4) 恢复父节点标签(保留左侧留白)
917
+ var parentNode = nodesDataSet.get(nodeId);
918
+ if (parentNode && parentNode._origLabel) {
919
+ var pad = ' ';
920
+ nodesDataSet.update({ id: nodeId, label: pad + parentNode._origLabel });
921
+ }
922
+ log('展开文档: 显示 ' + addNodes.length + ' 个子文档', true);
923
+ }
924
+ }
925
+
926
+ /** 在 afterDrawing 中绘制父文档节点的 +/- 按钮 */
927
+ function drawDocToggleButtons(ctx) {
928
+ docToggleBtnPositions = {};
929
+ nodesDataSet.forEach(function(node) {
930
+ if (node._type !== 'document') return;
931
+ var allNode = findAllNode(node.id);
932
+ if (!allNode || !isParentDocNode(allNode)) return;
933
+ var pos = network.getPositions([node.id])[node.id];
934
+ if (!pos) return;
935
+ var isCollapsed = !!collapsedDocNodes[node.id];
936
+ var btnRadius = 9;
937
+
938
+ // 使用 getBoundingBox 获取节点精确边界,按钮放在节点内左侧留白区域中心
939
+ var bbox = network.getBoundingBox(node.id);
940
+ var btnX, btnY;
941
+ if (bbox) {
942
+ btnX = bbox.left + btnRadius + 1; // 按钮完全在节点内,左侧留白区域居中
943
+ btnY = (bbox.top + bbox.bottom) / 2; // 垂直居中
944
+ } else {
945
+ btnX = pos.x;
946
+ btnY = pos.y;
947
+ }
948
+
949
+ // 记录位置(canvas 坐标)
950
+ docToggleBtnPositions[node.id] = { x: btnX, y: btnY, r: btnRadius };
951
+
952
+ // 绘制圆形按钮背景(蓝色系配色)
953
+ ctx.beginPath();
954
+ ctx.arc(btnX, btnY, btnRadius, 0, Math.PI * 2);
955
+ ctx.fillStyle = isCollapsed ? '#3b82f6' : '#1e40af'; // 收起:亮蓝 展开:深蓝
956
+ ctx.fill();
957
+ ctx.strokeStyle = '#ffffff'; // 白色描边
958
+ ctx.lineWidth = 1.5;
959
+ ctx.stroke();
960
+ ctx.closePath();
961
+
962
+ // 绘制 + 或 - 符号
963
+ ctx.fillStyle = '#ffffff';
964
+ ctx.font = 'bold 13px sans-serif';
965
+ ctx.textAlign = 'center';
966
+ ctx.textBaseline = 'middle';
967
+ ctx.fillText(isCollapsed ? '+' : '−', btnX, btnY + 0.5);
968
+ });
969
+ }
970
+
971
+ /** 检查 canvas 坐标是否点击了某个 +/- 按钮,返回 nodeId 或 null */
972
+ function hitTestDocToggleBtn(canvasX, canvasY) {
973
+ for (var nodeId in docToggleBtnPositions) {
974
+ var btn = docToggleBtnPositions[nodeId];
975
+ var dx = canvasX - btn.x;
976
+ var dy = canvasY - btn.y;
977
+ if (dx * dx + dy * dy <= (btn.r + 4) * (btn.r + 4)) {
978
+ return nodeId;
979
+ }
980
+ }
981
+ return null;
982
+ }
983
+
984
+ /**
985
+ * 将父文档及其子文档按思维导图方式排列:
986
+ * 父文档在左,子文档在右侧垂直等距、左边缘对齐
987
+ */
988
+ function arrangeDocMindMap(parentNodeId, childNodeIds) {
989
+ if (!network || childNodeIds.length === 0) return;
990
+ var parentPos = network.getPositions([parentNodeId])[parentNodeId];
991
+ if (!parentPos) return;
992
+
993
+ var parentBbox = network.getBoundingBox(parentNodeId);
994
+ var parentRight = parentBbox ? parentBbox.right : (parentPos.x + 80);
995
+ var leftEdgeX = parentRight + 40; // 子节点左边缘的目标 X
996
+ var vGap = 45;
997
+ var count = childNodeIds.length;
998
+ var totalHeight = (count - 1) * vGap;
999
+ var startY = parentPos.y - totalHeight / 2;
1000
+
1001
+ // 先读取每个子节点当前的宽度(移动前 bbox 有效)
1002
+ var halfLefts = [];
1003
+ for (var i = 0; i < count; i++) {
1004
+ var cid = childNodeIds[i];
1005
+ var bbox = network.getBoundingBox(cid);
1006
+ var cpos = network.getPositions([cid])[cid];
1007
+ if (bbox && cpos) {
1008
+ halfLefts.push(cpos.x - bbox.left); // 节点中心到左边缘的距离(即半宽)
1009
+ } else {
1010
+ halfLefts.push(100); // 默认估算
1011
+ }
1012
+ }
1013
+
1014
+ // 一次性移动所有子节点:左边缘对齐到 leftEdgeX
1015
+ for (var i = 0; i < count; i++) {
1016
+ var cx = leftEdgeX + halfLefts[i];
1017
+ var cy = startY + i * vGap;
1018
+ network.moveNode(childNodeIds[i], cx, cy);
1019
+ savedChildPositions[childNodeIds[i]] = { x: cx, y: cy };
1020
+ }
1021
+ }
1022
+
1023
+ /** 初始化时将所有父文档-子文档按思维导图方式排列 */
1024
+ function arrangeAllDocMindMaps() {
1025
+ // 找到所有父文档节点
1026
+ var parentDocIds = [];
1027
+ for (var i = 0; i < allNodes.length; i++) {
1028
+ var n = allNodes[i];
1029
+ if (isParentDocNode(n)) {
1030
+ // 检查该节点在当前可见节点集中
1031
+ var visible = nodesDataSet.get(n.id);
1032
+ if (visible) parentDocIds.push(n.id);
1033
+ }
1034
+ }
1035
+ for (var pi = 0; pi < parentDocIds.length; pi++) {
1036
+ var pid = parentDocIds[pi];
1037
+ var childIds = getChildDocNodeIds(pid);
1038
+ // 只排列当前可见的子节点
1039
+ var visibleChildIds = [];
1040
+ for (var ci = 0; ci < childIds.length; ci++) {
1041
+ if (nodesDataSet.get(childIds[ci])) visibleChildIds.push(childIds[ci]);
1042
+ }
1043
+ if (visibleChildIds.length > 0) {
1044
+ arrangeDocMindMap(pid, visibleChildIds);
1045
+ }
1046
+ }
1047
+ log('思维导图排列: ' + parentDocIds.length + ' 个父文档已排列', true);
1048
+ }
1049
+
653
1050
  // ========== 呼吸灯动画 (in_progress 主任务) ==========
654
1051
  var breathAnimId = null; // requestAnimationFrame ID
655
1052
  var breathPhase = 0; // 动画相位 [0, 2π)
@@ -696,7 +1093,7 @@ window.addEventListener('blur', function() { ctrlPressed = false; });
696
1093
  // ========== Node Styles ==========
697
1094
  var STATUS_COLORS = {
698
1095
  completed: { bg: '#059669', border: '#047857', font: '#d1fae5' },
699
- in_progress: { bg: '#2563eb', border: '#1d4ed8', font: '#dbeafe' },
1096
+ in_progress: { bg: '#7c3aed', border: '#6d28d9', font: '#ddd6fe' },
700
1097
  pending: { bg: '#4b5563', border: '#374151', font: '#d1d5db' },
701
1098
  cancelled: { bg: '#92400e', border: '#78350f', font: '#fde68a' }
702
1099
  };
@@ -751,19 +1148,23 @@ function nodeStyle(node, degree) {
751
1148
  return { shape: 'dot', size: ns.size, color: { background: sc.bg, border: sc.border, highlight: { background: sc.bg, border: '#fff' } }, font: { size: ns.fontSize, color: sc.font }, borderWidth: 1 };
752
1149
  }
753
1150
  if (t === 'document') {
754
- return { shape: 'box', size: ns.size, color: { background: '#7c3aed', border: '#6d28d9', highlight: { background: '#8b5cf6', border: '#fff' } }, font: { size: ns.fontSize, color: '#ddd6fe' }, borderWidth: 1 };
1151
+ return { shape: 'box', size: ns.size, color: { background: '#2563eb', border: '#1d4ed8', highlight: { background: '#3b82f6', border: '#fff' } }, font: { size: ns.fontSize, color: '#dbeafe' }, borderWidth: 1 };
755
1152
  }
756
1153
  return { shape: 'dot', size: ns.size, color: { background: '#6b7280', border: '#4b5563' }, font: { size: ns.fontSize, color: '#9ca3af' } };
757
1154
  }
758
1155
 
1156
+ // 默认灰色 + 选中时高亮色(per-type)
1157
+ var EDGE_GRAY = '#4b5563';
1158
+
759
1159
  function edgeStyle(edge) {
760
1160
  var label = edge.label || '';
761
- if (label === 'has_main_task') return { width: 2, color: { color: '#6b7280', highlight: '#93c5fd' }, dashes: false, arrows: { to: { enabled: true, scaleFactor: 0.6 } } };
762
- if (label === 'has_sub_task') return { width: 1, color: { color: '#4b5563', highlight: '#818cf8' }, dashes: false, arrows: { to: { enabled: true, scaleFactor: 0.4 } } };
763
- if (label === 'has_document') return { width: 1, color: { color: '#4b5563', highlight: '#a78bfa' }, dashes: [5, 5], arrows: { to: { enabled: true, scaleFactor: 0.4 } } };
764
- if (label === 'module_has_task') return { width: 1.5, color: { color: '#065f46', highlight: '#34d399' }, dashes: [2, 4], arrows: { to: { enabled: true, scaleFactor: 0.5 } } };
765
- if (label === 'task_has_doc') return { width: 1.5, color: { color: '#b45309', highlight: '#f59e0b' }, dashes: [4, 3], arrows: { to: { enabled: true, scaleFactor: 0.5 } } };
766
- return { width: 1, color: { color: '#374151' }, dashes: false };
1161
+ if (label === 'has_main_task') return { width: 2, color: { color: EDGE_GRAY, highlight: '#93c5fd', hover: '#93c5fd' }, dashes: false, arrows: { to: { enabled: true, scaleFactor: 0.6 } }, _highlightColor: '#93c5fd' };
1162
+ if (label === 'has_sub_task') return { width: 1, color: { color: EDGE_GRAY, highlight: '#818cf8', hover: '#818cf8' }, dashes: false, arrows: { to: { enabled: true, scaleFactor: 0.4 } }, _highlightColor: '#818cf8' };
1163
+ if (label === 'has_document') return { width: 1, color: { color: EDGE_GRAY, highlight: '#60a5fa', hover: '#60a5fa' }, dashes: [5, 5], arrows: { to: { enabled: true, scaleFactor: 0.4 } }, _highlightColor: '#60a5fa' };
1164
+ if (label === 'module_has_task') return { width: 1.5, color: { color: EDGE_GRAY, highlight: '#34d399', hover: '#34d399' }, dashes: [2, 4], arrows: { to: { enabled: true, scaleFactor: 0.5 } }, _highlightColor: '#34d399' };
1165
+ if (label === 'task_has_doc') return { width: 1.5, color: { color: EDGE_GRAY, highlight: '#f59e0b', hover: '#f59e0b' }, dashes: [4, 3], arrows: { to: { enabled: true, scaleFactor: 0.5 } }, _highlightColor: '#f59e0b' };
1166
+ if (label === 'doc_has_child') return { width: 1.5, color: { color: EDGE_GRAY, highlight: '#c084fc', hover: '#c084fc' }, dashes: [6, 3], arrows: { to: { enabled: true, scaleFactor: 0.5 } }, _highlightColor: '#c084fc' };
1167
+ return { width: 1, color: { color: EDGE_GRAY, highlight: '#9ca3af', hover: '#9ca3af' }, dashes: false, _highlightColor: '#9ca3af' };
767
1168
  }
768
1169
 
769
1170
  // ========== Data Loading ==========
@@ -794,13 +1195,16 @@ function renderStats(progress, graph) {
794
1195
  var bar = document.getElementById('statsBar');
795
1196
  var pct = progress.overallPercent || 0;
796
1197
  var moduleCount = 0;
1198
+ var docCount = 0;
797
1199
  for (var i = 0; i < graph.nodes.length; i++) {
798
1200
  if (graph.nodes[i].type === 'module') moduleCount++;
1201
+ if (graph.nodes[i].type === 'document') docCount++;
799
1202
  }
800
1203
  bar.innerHTML =
801
1204
  '<div class="stat clickable" onclick="showStatsModal(\\x27module\\x27)" title="查看所有模块"><span class="num amber">' + moduleCount + '</span> 模块</div>' +
802
1205
  '<div class="stat clickable" onclick="showStatsModal(\\x27main-task\\x27)" title="查看所有主任务"><span class="num blue">' + progress.mainTaskCount + '</span> 主任务</div>' +
803
1206
  '<div class="stat clickable" onclick="showStatsModal(\\x27sub-task\\x27)" title="查看所有子任务"><span class="num purple">' + progress.subTaskCount + '</span> 子任务</div>' +
1207
+ '<div class="stat clickable" onclick="showStatsModal(\\x27document\\x27)" title="查看所有文档"><span class="num" style="color:#3b82f6;">📄 ' + docCount + '</span> 文档</div>' +
804
1208
  '<div class="stat"><span class="num green">' + progress.completedSubTasks + '/' + progress.subTaskCount + '</span> 已完成</div>' +
805
1209
  '<div class="stat"><div class="progress-bar"><div class="progress-fill" style="width:' + pct + '%"></div></div><span>' + pct + '%</span></div>';
806
1210
  }
@@ -819,12 +1223,26 @@ function renderGraph() {
819
1223
  }
820
1224
 
821
1225
  var visibleNodes = [];
1226
+ var DOC_BTN_PAD = ' '; // 父文档标签左侧留白,为 +/- 按钮腾出空间
822
1227
  for (var i = 0; i < allNodes.length; i++) {
823
1228
  var n = allNodes[i];
824
1229
  if (hiddenTypes[n.type]) continue;
1230
+ // 跳过被收起的子文档节点
1231
+ if (isNodeCollapsedByParent(n.id)) continue;
825
1232
  var deg = getNodeDegree(n);
826
1233
  var s = nodeStyle(n, deg);
827
- visibleNodes.push({ id: n.id, label: n.label, title: n.label + ' (连接: ' + deg + ')', shape: s.shape, size: s.size, color: s.color, font: s.font, borderWidth: s.borderWidth, _type: n.type, _props: n.properties || {} });
1234
+ var label = n.label;
1235
+ var isParentDoc = isParentDocNode(n);
1236
+ if (isParentDoc) {
1237
+ // 父文档标签左侧加空格,为按钮腾位
1238
+ if (collapsedDocNodes[n.id]) {
1239
+ var childCount = getAllDescendantDocNodeIds(n.id).length;
1240
+ label = DOC_BTN_PAD + label + ' [' + childCount + ']';
1241
+ } else {
1242
+ label = DOC_BTN_PAD + label;
1243
+ }
1244
+ }
1245
+ visibleNodes.push({ id: n.id, label: label, _origLabel: n.label, title: n.label + ' (连接: ' + deg + ')', shape: s.shape, size: s.size, color: s.color, font: s.font, borderWidth: s.borderWidth, _type: n.type, _props: n.properties || {}, _isParentDoc: isParentDoc });
828
1246
  }
829
1247
 
830
1248
  var visibleIds = {};
@@ -835,7 +1253,7 @@ function renderGraph() {
835
1253
  var e = allEdges[i];
836
1254
  if (!visibleIds[e.from] || !visibleIds[e.to]) continue;
837
1255
  var es = edgeStyle(e);
838
- visibleEdges.push({ id: 'e' + i, from: e.from, to: e.to, width: es.width, color: es.color, dashes: es.dashes, arrows: es.arrows, _label: e.label });
1256
+ visibleEdges.push({ id: 'e' + i, from: e.from, to: e.to, width: es.width, _origWidth: es.width, color: es.color, dashes: es.dashes, arrows: es.arrows, _label: e.label, _highlightColor: es._highlightColor || '#9ca3af' });
839
1257
  }
840
1258
 
841
1259
  log('可见节点: ' + visibleNodes.length + ', 可见边: ' + visibleEdges.length, true);
@@ -890,16 +1308,28 @@ function renderGraph() {
890
1308
  network.setOptions({ physics: { enabled: false } });
891
1309
  document.getElementById('loading').style.display = 'none';
892
1310
  log('图谱渲染完成! ' + visibleNodes.length + ' 节点, ' + visibleEdges.length + ' 边', true);
1311
+ // 稳定后将父文档-子文档按思维导图方式整齐排列
1312
+ arrangeAllDocMindMaps();
893
1313
  network.fit({ animation: { duration: 800, easingFunction: 'easeInOutQuad' } });
894
1314
  });
895
1315
 
896
1316
  network.on('click', function(params) {
1317
+ // 先检查是否点击了 +/- 按钮
1318
+ if (params.pointer && params.pointer.canvas) {
1319
+ var hitNodeId = hitTestDocToggleBtn(params.pointer.canvas.x, params.pointer.canvas.y);
1320
+ if (hitNodeId) {
1321
+ toggleDocNodeExpand(hitNodeId);
1322
+ return; // 消费此次点击,不触发节点选择
1323
+ }
1324
+ }
897
1325
  if (params.nodes.length > 0) {
898
1326
  // 直接点击图谱节点 → 清空历史栈,重新开始导航
899
1327
  panelHistory = [];
900
1328
  currentPanelNodeId = null;
1329
+ highlightConnectedEdges(params.nodes[0]);
901
1330
  showPanel(params.nodes[0]);
902
1331
  } else {
1332
+ resetAllEdgeColors();
903
1333
  closePanel();
904
1334
  }
905
1335
  });
@@ -955,8 +1385,11 @@ function renderGraph() {
955
1385
  }
956
1386
  });
957
1387
 
958
- // ========== 呼吸灯: afterDrawing 绘制脉冲光环 ==========
1388
+ // ========== afterDrawing: 呼吸灯 + 文档展开/收起按钮 ==========
959
1389
  network.on('afterDrawing', function(ctx) {
1390
+ // 绘制父文档的 +/- 按钮
1391
+ drawDocToggleButtons(ctx);
1392
+
960
1393
  var ids = getInProgressMainTaskIds();
961
1394
  if (ids.length === 0) return;
962
1395
 
@@ -974,23 +1407,46 @@ function renderGraph() {
974
1407
  // 再通过 DOMtoCanvas 获取正确的 canvas 上下文坐标
975
1408
  // vis-network 的 afterDrawing ctx 已经在正确的坐标系中,直接用 pos 即可
976
1409
 
977
- // 外圈脉冲光环
978
- var maxExpand = baseSize * 1.2;
979
- var ringRadius = baseSize + 4 + breath * maxExpand;
980
- var ringAlpha = 0.35 * (1 - breath * 0.7); // 越大越淡
1410
+ // 外层大范围弥散光晕(营造醒目的辉光感)
1411
+ var outerGlowRadius = baseSize + 20 + breath * baseSize * 2.5;
1412
+ var outerGrad = ctx.createRadialGradient(pos.x, pos.y, baseSize, pos.x, pos.y, outerGlowRadius);
1413
+ outerGrad.addColorStop(0, 'rgba(124, 58, 237, ' + (0.18 + breath * 0.12) + ')');
1414
+ outerGrad.addColorStop(0.5, 'rgba(139, 92, 246, ' + (0.08 + breath * 0.06) + ')');
1415
+ outerGrad.addColorStop(1, 'rgba(139, 92, 246, 0)');
1416
+ ctx.beginPath();
1417
+ ctx.arc(pos.x, pos.y, outerGlowRadius, 0, Math.PI * 2);
1418
+ ctx.fillStyle = outerGrad;
1419
+ ctx.fill();
1420
+ ctx.closePath();
1421
+
1422
+ // 外圈脉冲光环(更粗、扩展范围更大)
1423
+ var maxExpand = baseSize * 2.2;
1424
+ var ringRadius = baseSize + 8 + breath * maxExpand;
1425
+ var ringAlpha = 0.55 * (1 - breath * 0.5);
981
1426
 
982
1427
  ctx.beginPath();
983
1428
  ctx.arc(pos.x, pos.y, ringRadius, 0, Math.PI * 2);
984
- ctx.strokeStyle = 'rgba(59, 130, 246, ' + ringAlpha + ')';
985
- ctx.lineWidth = 2 + breath * 1.5;
1429
+ ctx.strokeStyle = 'rgba(139, 92, 246, ' + ringAlpha + ')';
1430
+ ctx.lineWidth = 3.5 + breath * 3;
986
1431
  ctx.stroke();
987
1432
  ctx.closePath();
988
1433
 
989
- // 内圈柔光
990
- var glowRadius = baseSize + 6 + breath * 8;
991
- var gradient = ctx.createRadialGradient(pos.x, pos.y, baseSize * 0.5, pos.x, pos.y, glowRadius);
992
- gradient.addColorStop(0, 'rgba(59, 130, 246, ' + (0.15 + breath * 0.1) + ')');
993
- gradient.addColorStop(1, 'rgba(59, 130, 246, 0)');
1434
+ // 中圈脉冲光环(第二道更紧凑的环)
1435
+ var midRingRadius = baseSize + 4 + breath * baseSize * 1.2;
1436
+ var midRingAlpha = 0.4 * (1 - breath * 0.4);
1437
+ ctx.beginPath();
1438
+ ctx.arc(pos.x, pos.y, midRingRadius, 0, Math.PI * 2);
1439
+ ctx.strokeStyle = 'rgba(167, 139, 250, ' + midRingAlpha + ')';
1440
+ ctx.lineWidth = 2.5 + breath * 2;
1441
+ ctx.stroke();
1442
+ ctx.closePath();
1443
+
1444
+ // 内圈柔光(更大范围的径向渐变)
1445
+ var glowRadius = baseSize + 10 + breath * 16;
1446
+ var gradient = ctx.createRadialGradient(pos.x, pos.y, baseSize * 0.3, pos.x, pos.y, glowRadius);
1447
+ gradient.addColorStop(0, 'rgba(124, 58, 237, ' + (0.25 + breath * 0.15) + ')');
1448
+ gradient.addColorStop(0.6, 'rgba(139, 92, 246, ' + (0.10 + breath * 0.08) + ')');
1449
+ gradient.addColorStop(1, 'rgba(139, 92, 246, 0)');
994
1450
  ctx.beginPath();
995
1451
  ctx.arc(pos.x, pos.y, glowRadius, 0, Math.PI * 2);
996
1452
  ctx.fillStyle = gradient;
@@ -1035,6 +1491,7 @@ function navigateToPanel(nodeId) {
1035
1491
  panelHistory.push(currentPanelNodeId);
1036
1492
  }
1037
1493
  network.selectNodes([nodeId]);
1494
+ highlightConnectedEdges(nodeId);
1038
1495
  showPanel(nodeId);
1039
1496
  }
1040
1497
 
@@ -1043,6 +1500,7 @@ function panelGoBack() {
1043
1500
  if (panelHistory.length === 0) return;
1044
1501
  var prevNodeId = panelHistory.pop();
1045
1502
  network.selectNodes([prevNodeId]);
1503
+ highlightConnectedEdges(prevNodeId);
1046
1504
  showPanel(prevNodeId);
1047
1505
  }
1048
1506
 
@@ -1251,6 +1709,7 @@ function closePanel() {
1251
1709
  panelHistory = [];
1252
1710
  currentPanelNodeId = null;
1253
1711
  updateBackButton();
1712
+ resetAllEdgeColors();
1254
1713
  }
1255
1714
 
1256
1715
  // ========== Panel Resize ==========
@@ -1523,7 +1982,16 @@ function toggleFilter(type) {
1523
1982
  }
1524
1983
 
1525
1984
  // ========== Stats Modal ==========
1985
+ /** 记录文档弹层中各文档的折叠状态(docKey → true 表示已展开) */
1986
+ var docModalExpandedState = {};
1987
+
1526
1988
  function showStatsModal(nodeType) {
1989
+ // 文档类型使用专用渲染
1990
+ if (nodeType === 'document') {
1991
+ showDocModal();
1992
+ return;
1993
+ }
1994
+
1527
1995
  var titleMap = { 'module': '功能模块', 'main-task': '主任务', 'sub-task': '子任务' };
1528
1996
  var iconMap = { 'module': '◆', 'main-task': '●', 'sub-task': '·' };
1529
1997
  var items = [];
@@ -1577,6 +2045,161 @@ function showStatsModal(nodeType) {
1577
2045
  document.getElementById('statsModalOverlay').classList.add('active');
1578
2046
  }
1579
2047
 
2048
+ /** 获取文档节点的 docKey (section|subSection) */
2049
+ function getDocNodeKey(node) {
2050
+ var p = node.properties || {};
2051
+ return p.section + (p.subSection ? '|' + p.subSection : '');
2052
+ }
2053
+
2054
+ /** 构建文档层级树:{ node, children: [...] } */
2055
+ function buildDocTree() {
2056
+ var docNodes = [];
2057
+ for (var i = 0; i < allNodes.length; i++) {
2058
+ if (allNodes[i].type === 'document') docNodes.push(allNodes[i]);
2059
+ }
2060
+
2061
+ // 建立 parentDoc → children 映射
2062
+ var childrenMap = {}; // parentDocKey → [nodes]
2063
+ var childKeySet = {}; // 属于子文档的 nodeId 集合
2064
+ for (var i = 0; i < docNodes.length; i++) {
2065
+ var p = docNodes[i].properties || {};
2066
+ if (p.parentDoc) {
2067
+ if (!childrenMap[p.parentDoc]) childrenMap[p.parentDoc] = [];
2068
+ childrenMap[p.parentDoc].push(docNodes[i]);
2069
+ childKeySet[docNodes[i].id] = true;
2070
+ }
2071
+ }
2072
+
2073
+ // 按 section 分组顶级文档
2074
+ var groups = {};
2075
+ var groupOrder = [];
2076
+ for (var i = 0; i < docNodes.length; i++) {
2077
+ if (childKeySet[docNodes[i].id]) continue;
2078
+ var sec = (docNodes[i].properties || {}).section || 'custom';
2079
+ if (!groups[sec]) { groups[sec] = []; groupOrder.push(sec); }
2080
+ groups[sec].push(docNodes[i]);
2081
+ }
2082
+
2083
+ return { groups: groups, groupOrder: groupOrder, childrenMap: childrenMap };
2084
+ }
2085
+
2086
+ /** 显示文档弹层(左侧列表) */
2087
+ function showDocModal() {
2088
+ var docNodes = [];
2089
+ for (var i = 0; i < allNodes.length; i++) {
2090
+ if (allNodes[i].type === 'document') docNodes.push(allNodes[i]);
2091
+ }
2092
+
2093
+ document.getElementById('statsModalTitle').textContent = '📄 文档列表';
2094
+ document.getElementById('statsModalCount').textContent = '(' + docNodes.length + ')';
2095
+
2096
+ var tree = buildDocTree();
2097
+ var html = renderDocTreeHTML(tree);
2098
+
2099
+ if (docNodes.length === 0) {
2100
+ html = '<div style="text-align:center;padding:40px;color:#6b7280;">暂无文档</div>';
2101
+ }
2102
+
2103
+ document.getElementById('statsModalBody').innerHTML = html;
2104
+ // 根据侧边栏状态调整弹层位置
2105
+ updateStatsModalPosition();
2106
+ document.getElementById('statsModalOverlay').classList.add('active');
2107
+ }
2108
+
2109
+ /** 渲染文档层级树 HTML */
2110
+ function renderDocTreeHTML(tree) {
2111
+ var SECTION_NAMES_MODAL = {
2112
+ overview: '概述', core_concepts: '核心概念', api_design: 'API 设计',
2113
+ file_structure: '文件结构', config: '配置', examples: '使用示例',
2114
+ technical_notes: '技术笔记', api_endpoints: 'API 端点',
2115
+ milestones: '里程碑', changelog: '变更记录', custom: '自定义'
2116
+ };
2117
+ var SECTION_ICONS_MODAL = {
2118
+ overview: '📋', core_concepts: '🧠', api_design: '🔌',
2119
+ file_structure: '📁', config: '⚙️', examples: '💡',
2120
+ technical_notes: '🔬', api_endpoints: '🌐',
2121
+ milestones: '🏁', changelog: '📝', custom: '📎'
2122
+ };
2123
+
2124
+ var html = '';
2125
+ for (var gi = 0; gi < tree.groupOrder.length; gi++) {
2126
+ var sec = tree.groupOrder[gi];
2127
+ var items = tree.groups[sec];
2128
+ var secName = SECTION_NAMES_MODAL[sec] || sec;
2129
+ var secIcon = SECTION_ICONS_MODAL[sec] || '📄';
2130
+
2131
+ html += '<div style="margin-bottom:4px;">';
2132
+ html += '<div style="padding:8px 20px;font-size:11px;font-weight:700;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;display:flex;align-items:center;gap:6px;">';
2133
+ html += '<span>' + secIcon + '</span><span>' + secName + '</span>';
2134
+ html += '<span style="margin-left:auto;font-size:10px;color:#4b5563;">' + items.length + '</span>';
2135
+ html += '</div>';
2136
+
2137
+ for (var ii = 0; ii < items.length; ii++) {
2138
+ html += renderDocTreeItem(items[ii], tree.childrenMap, 0);
2139
+ }
2140
+ html += '</div>';
2141
+ }
2142
+ return html;
2143
+ }
2144
+
2145
+ /** 递归渲染单个文档节点及其子文档 */
2146
+ function renderDocTreeItem(node, childrenMap, depth) {
2147
+ var docKey = getDocNodeKey(node);
2148
+ var p = node.properties || {};
2149
+ var children = childrenMap[docKey] || [];
2150
+ var hasChildren = children.length > 0;
2151
+ var isExpanded = docModalExpandedState[docKey] === true;
2152
+ var paddingLeft = 20 + depth * 20;
2153
+
2154
+ var html = '';
2155
+
2156
+ // 文档项
2157
+ html += '<div class="stats-modal-item" style="padding-left:' + paddingLeft + 'px;gap:6px;" onclick="docModalSelectDoc(\\x27' + escHtml(docKey).replace(/'/g, "\\\\'") + '\\x27,\\x27' + escHtml(node.id).replace(/'/g, "\\\\'") + '\\x27)">';
2158
+
2159
+ // 展开/折叠按钮
2160
+ if (hasChildren) {
2161
+ html += '<span class="doc-modal-toggle" onclick="event.stopPropagation();toggleDocModalExpand(\\x27' + escHtml(docKey).replace(/'/g, "\\\\'") + '\\x27)" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;background:rgba(99,102,241,0.15);border:1px solid rgba(99,102,241,0.3);color:#818cf8;font-size:12px;font-weight:700;cursor:pointer;flex-shrink:0;transition:all 0.15s;line-height:1;">' + (isExpanded ? '−' : '+') + '</span>';
2162
+ } else {
2163
+ html += '<span style="width:18px;flex-shrink:0;"></span>';
2164
+ }
2165
+
2166
+ html += '<span class="stats-modal-item-icon" style="font-size:13px;">📄</span>';
2167
+ html += '<span class="stats-modal-item-name" title="' + escHtml(node.label) + '" style="font-size:' + (depth > 0 ? '12' : '13') + 'px;' + (depth > 0 ? 'opacity:0.85;' : '') + '">' + escHtml(node.label) + '</span>';
2168
+
2169
+ if (hasChildren) {
2170
+ html += '<span style="font-size:10px;color:#818cf8;flex-shrink:0;">' + children.length + '</span>';
2171
+ }
2172
+ if (p.subSection) {
2173
+ html += '<span style="font-size:10px;color:#6b7280;flex-shrink:0;font-family:monospace;">' + escHtml(p.subSection) + '</span>';
2174
+ }
2175
+
2176
+ html += '</div>';
2177
+
2178
+ // 子文档列表(仅展开时显示)
2179
+ if (hasChildren && isExpanded) {
2180
+ for (var ci = 0; ci < children.length; ci++) {
2181
+ html += renderDocTreeItem(children[ci], childrenMap, depth + 1);
2182
+ }
2183
+ }
2184
+
2185
+ return html;
2186
+ }
2187
+
2188
+ /** 展开/折叠文档弹层中的子文档 */
2189
+ function toggleDocModalExpand(docKey) {
2190
+ docModalExpandedState[docKey] = !docModalExpandedState[docKey];
2191
+ // 重新渲染文档列表
2192
+ var tree = buildDocTree();
2193
+ var html = renderDocTreeHTML(tree);
2194
+ document.getElementById('statsModalBody').innerHTML = html;
2195
+ }
2196
+
2197
+ /** 在文档弹层中选中文档 — 复用右侧图谱详情面板显示内容 */
2198
+ function docModalSelectDoc(docKey, nodeId) {
2199
+ // 直接复用 statsModalGoToNode,聚焦图谱节点并打开已有的右侧详情面板
2200
+ statsModalGoToNode(nodeId);
2201
+ }
2202
+
1580
2203
  function closeStatsModal() {
1581
2204
  document.getElementById('statsModalOverlay').classList.remove('active');
1582
2205
  }
@@ -1584,6 +2207,7 @@ function closeStatsModal() {
1584
2207
  function statsModalGoToNode(nodeId) {
1585
2208
  if (network && nodesDataSet && nodesDataSet.get(nodeId)) {
1586
2209
  network.selectNodes([nodeId]);
2210
+ highlightConnectedEdges(nodeId);
1587
2211
  network.focus(nodeId, { scale: 1.2, animation: { duration: 400, easingFunction: 'easeInOutQuad' } });
1588
2212
  panelHistory = [];
1589
2213
  currentPanelNodeId = null;
@@ -1723,6 +2347,16 @@ var docsLoaded = false;
1723
2347
  var docsData = []; // 全部文档列表
1724
2348
  var currentDocKey = ''; // 当前选中文档的 key (section|subSection)
1725
2349
 
2350
+ /** 根据 docKey 从 docsData 中查找文档标题 */
2351
+ function findDocTitle(docKey) {
2352
+ for (var i = 0; i < docsData.length; i++) {
2353
+ var d = docsData[i];
2354
+ var key = d.section + (d.subSection ? '|' + d.subSection : '');
2355
+ if (key === docKey) return d.title;
2356
+ }
2357
+ return null;
2358
+ }
2359
+
1726
2360
  /** Section 类型的中文名称映射 */
1727
2361
  var SECTION_NAMES = {
1728
2362
  overview: '概述', core_concepts: '核心概念', api_design: 'API 设计',
@@ -1753,16 +2387,38 @@ function loadDocsPage() {
1753
2387
  });
1754
2388
  }
1755
2389
 
1756
- /** 将文档列表按 section 分组渲染 */
2390
+ /** 获取文档的 key(唯一标识) */
2391
+ function docItemKey(item) {
2392
+ return item.section + (item.subSection ? '|' + item.subSection : '');
2393
+ }
2394
+
2395
+ /** 记录哪些父文档处于折叠状态(key → true 表示折叠) */
2396
+ var docsCollapsedState = {};
2397
+
2398
+ /** 将文档列表按 section 分组渲染,支持 parentDoc 层级 */
1757
2399
  function renderDocsList(docs) {
1758
2400
  var list = document.getElementById('docsGroupList');
1759
2401
  if (!list) return;
1760
2402
 
1761
- // section 分组
2403
+ // 建立 parentDoc → children 映射,区分顶级和子文档
2404
+ var childrenMap = {}; // parentDocKey → [child items]
2405
+ var childKeySet = {}; // 属于子文档的 key 集合
2406
+ for (var i = 0; i < docs.length; i++) {
2407
+ var d = docs[i];
2408
+ if (d.parentDoc) {
2409
+ if (!childrenMap[d.parentDoc]) childrenMap[d.parentDoc] = [];
2410
+ childrenMap[d.parentDoc].push(d);
2411
+ childKeySet[docItemKey(d)] = true;
2412
+ }
2413
+ }
2414
+
2415
+ // 按 section 分组(只放顶级文档)
1762
2416
  var groups = {};
1763
2417
  var groupOrder = [];
1764
2418
  for (var i = 0; i < docs.length; i++) {
1765
2419
  var d = docs[i];
2420
+ var key = docItemKey(d);
2421
+ if (childKeySet[key]) continue; // 跳过子文档(由父文档渲染)
1766
2422
  var sec = d.section;
1767
2423
  if (!groups[sec]) {
1768
2424
  groups[sec] = [];
@@ -1783,25 +2439,22 @@ function renderDocsList(docs) {
1783
2439
  var secName = SECTION_NAMES[sec] || sec;
1784
2440
  var secIcon = SECTION_ICONS[sec] || '📄';
1785
2441
 
2442
+ // 计算此分组下文档总数(含子文档)
2443
+ var totalCount = 0;
2444
+ for (var ci = 0; ci < docs.length; ci++) {
2445
+ if (docs[ci].section === sec) totalCount++;
2446
+ }
2447
+
1786
2448
  html += '<div class="docs-group" data-section="' + sec + '">';
1787
2449
  html += '<div class="docs-group-title" onclick="toggleDocsGroup(this)">';
1788
2450
  html += '<span class="docs-group-arrow">▼</span>';
1789
2451
  html += '<span>' + secIcon + ' ' + secName + '</span>';
1790
- html += '<span class="docs-group-count">' + items.length + '</span>';
2452
+ html += '<span class="docs-group-count">' + totalCount + '</span>';
1791
2453
  html += '</div>';
1792
2454
  html += '<div class="docs-group-items">';
1793
2455
 
1794
2456
  for (var ii = 0; ii < items.length; ii++) {
1795
- var item = items[ii];
1796
- var docKey = item.section + (item.subSection ? '|' + item.subSection : '');
1797
- var isActive = docKey === currentDocKey ? ' active' : '';
1798
- html += '<div class="docs-item' + isActive + '" data-key="' + escHtml(docKey) + '" onclick="selectDoc(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)">';
1799
- html += '<span class="docs-item-icon">' + secIcon + '</span>';
1800
- html += '<span class="docs-item-text" title="' + escHtml(item.title) + '">' + escHtml(item.title) + '</span>';
1801
- if (item.subSection) {
1802
- html += '<span class="docs-item-sub">' + escHtml(item.subSection) + '</span>';
1803
- }
1804
- html += '</div>';
2457
+ html += renderDocItemWithChildren(items[ii], childrenMap, secIcon);
1805
2458
  }
1806
2459
 
1807
2460
  html += '</div></div>';
@@ -1810,6 +2463,63 @@ function renderDocsList(docs) {
1810
2463
  list.innerHTML = html;
1811
2464
  }
1812
2465
 
2466
+ /** 递归渲染文档项及其子文档 */
2467
+ function renderDocItemWithChildren(item, childrenMap, secIcon) {
2468
+ var docKey = docItemKey(item);
2469
+ var isActive = docKey === currentDocKey ? ' active' : '';
2470
+ var children = childrenMap[docKey] || [];
2471
+ var hasChildren = children.length > 0;
2472
+ var isCollapsed = docsCollapsedState[docKey] === true;
2473
+
2474
+ var html = '<div class="docs-item-wrapper">';
2475
+
2476
+ // 文档项本身
2477
+ html += '<div class="docs-item' + isActive + '" data-key="' + escHtml(docKey) + '" onclick="selectDoc(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)">';
2478
+
2479
+ if (hasChildren) {
2480
+ var toggleIcon = isCollapsed ? '+' : '−';
2481
+ html += '<span class="docs-item-toggle" onclick="event.stopPropagation();toggleDocChildren(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)" title="' + (isCollapsed ? '展开子文档' : '收起子文档') + '">' + toggleIcon + '</span>';
2482
+ }
2483
+
2484
+ html += '<span class="docs-item-icon">' + secIcon + '</span>';
2485
+ html += '<span class="docs-item-text" title="' + escHtml(item.title) + '">' + escHtml(item.title) + '</span>';
2486
+ if (hasChildren) {
2487
+ html += '<span class="docs-item-sub" style="color:#818cf8;">' + children.length + ' 子文档</span>';
2488
+ } else if (item.subSection) {
2489
+ html += '<span class="docs-item-sub">' + escHtml(item.subSection) + '</span>';
2490
+ }
2491
+ html += '</div>';
2492
+
2493
+ // 子文档列表
2494
+ if (hasChildren) {
2495
+ html += '<div class="docs-children' + (isCollapsed ? ' collapsed' : '') + '" data-parent="' + escHtml(docKey) + '">';
2496
+ for (var ci = 0; ci < children.length; ci++) {
2497
+ html += renderDocItemWithChildren(children[ci], childrenMap, secIcon);
2498
+ }
2499
+ html += '</div>';
2500
+ }
2501
+
2502
+ html += '</div>';
2503
+ return html;
2504
+ }
2505
+
2506
+ /** 展开/折叠子文档 */
2507
+ function toggleDocChildren(docKey) {
2508
+ docsCollapsedState[docKey] = !docsCollapsedState[docKey];
2509
+ var container = document.querySelector('.docs-children[data-parent="' + docKey + '"]');
2510
+ if (!container) return;
2511
+ container.classList.toggle('collapsed');
2512
+ // 更新切换按钮图标
2513
+ var wrapper = container.previousElementSibling;
2514
+ if (wrapper) {
2515
+ var toggle = wrapper.querySelector('.docs-item-toggle');
2516
+ if (toggle) {
2517
+ toggle.textContent = docsCollapsedState[docKey] ? '+' : '−';
2518
+ toggle.title = docsCollapsedState[docKey] ? '展开子文档' : '收起子文档';
2519
+ }
2520
+ }
2521
+ }
2522
+
1813
2523
  /** 展开/折叠文档分组 */
1814
2524
  function toggleDocsGroup(el) {
1815
2525
  var group = el.closest('.docs-group');
@@ -1905,6 +2615,35 @@ function renderDocContent(doc, section, subSection) {
1905
2615
  contentHtml = '<div style="text-align:center;padding:40px;color:#6b7280;">文档内容为空</div>';
1906
2616
  }
1907
2617
 
2618
+ // 父文档链接
2619
+ if (doc.parentDoc) {
2620
+ var parentTitle = findDocTitle(doc.parentDoc);
2621
+ contentHtml += '<div class="docs-related" style="margin-top: 12px;">';
2622
+ contentHtml += '<div class="docs-related-title">⬆️ 父文档</div>';
2623
+ contentHtml += '<div class="docs-related-item" style="cursor:pointer;" onclick="selectDoc(\\x27' + doc.parentDoc.replace(/'/g, "\\\\'") + '\\x27)">';
2624
+ contentHtml += '<span class="rel-icon" style="background:#1e3a5f;color:#93c5fd;">📄</span>';
2625
+ contentHtml += '<span style="flex:1;color:#818cf8;">' + escHtml(parentTitle || doc.parentDoc) + '</span>';
2626
+ contentHtml += '<span style="font-size:10px;color:#6b7280;font-family:monospace;">' + escHtml(doc.parentDoc) + '</span>';
2627
+ contentHtml += '</div></div>';
2628
+ }
2629
+
2630
+ // 子文档列表
2631
+ var childDocs = doc.childDocs || [];
2632
+ if (childDocs.length > 0) {
2633
+ contentHtml += '<div class="docs-related" style="margin-top: 12px;">';
2634
+ contentHtml += '<div class="docs-related-title">⬇️ 子文档 (' + childDocs.length + ')</div>';
2635
+ for (var ci = 0; ci < childDocs.length; ci++) {
2636
+ var childKey = childDocs[ci];
2637
+ var childTitle = findDocTitle(childKey);
2638
+ contentHtml += '<div class="docs-related-item" style="cursor:pointer;" onclick="selectDoc(\\x27' + childKey.replace(/'/g, "\\\\'") + '\\x27)">';
2639
+ contentHtml += '<span class="rel-icon" style="background:#1e1b4b;color:#c084fc;">📄</span>';
2640
+ contentHtml += '<span style="flex:1;color:#c084fc;">' + escHtml(childTitle || childKey) + '</span>';
2641
+ contentHtml += '<span style="font-size:10px;color:#6b7280;font-family:monospace;">' + escHtml(childKey) + '</span>';
2642
+ contentHtml += '</div>';
2643
+ }
2644
+ contentHtml += '</div>';
2645
+ }
2646
+
1908
2647
  // 关联任务
1909
2648
  var relatedTasks = doc.relatedTasks || [];
1910
2649
  if (relatedTasks.length > 0) {