aifastdb-devplan 1.6.1 → 1.6.2

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.
Files changed (89) hide show
  1. package/dist/dev-plan-document-store.d.ts +13 -1
  2. package/dist/dev-plan-document-store.d.ts.map +1 -1
  3. package/dist/dev-plan-document-store.js +119 -0
  4. package/dist/dev-plan-document-store.js.map +1 -1
  5. package/dist/dev-plan-factory.d.ts.map +1 -1
  6. package/dist/dev-plan-factory.js +2 -1
  7. package/dist/dev-plan-factory.js.map +1 -1
  8. package/dist/dev-plan-graph-store.d.ts +64 -1
  9. package/dist/dev-plan-graph-store.d.ts.map +1 -1
  10. package/dist/dev-plan-graph-store.js +364 -2
  11. package/dist/dev-plan-graph-store.js.map +1 -1
  12. package/dist/dev-plan-interface.d.ts +24 -1
  13. package/dist/dev-plan-interface.d.ts.map +1 -1
  14. package/dist/dev-plan-migrate.d.ts +1 -0
  15. package/dist/dev-plan-migrate.d.ts.map +1 -1
  16. package/dist/dev-plan-migrate.js +28 -2
  17. package/dist/dev-plan-migrate.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcp-server/index.js +119 -0
  22. package/dist/mcp-server/index.js.map +1 -1
  23. package/dist/types.d.ts +88 -1
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/types.js.map +1 -1
  26. package/dist/visualize/graph-canvas/api-compat.d.ts.map +1 -1
  27. package/dist/visualize/graph-canvas/api-compat.js +22 -12
  28. package/dist/visualize/graph-canvas/api-compat.js.map +1 -1
  29. package/dist/visualize/graph-canvas/core.d.ts.map +1 -1
  30. package/dist/visualize/graph-canvas/core.js +296 -4
  31. package/dist/visualize/graph-canvas/core.js.map +1 -1
  32. package/dist/visualize/graph-canvas/interaction.d.ts.map +1 -1
  33. package/dist/visualize/graph-canvas/interaction.js +11 -0
  34. package/dist/visualize/graph-canvas/interaction.js.map +1 -1
  35. package/dist/visualize/graph-canvas/layout-worker.d.ts.map +1 -1
  36. package/dist/visualize/graph-canvas/layout-worker.js +45 -9
  37. package/dist/visualize/graph-canvas/layout-worker.js.map +1 -1
  38. package/dist/visualize/graph-canvas/renderer.d.ts.map +1 -1
  39. package/dist/visualize/graph-canvas/renderer.js +164 -33
  40. package/dist/visualize/graph-canvas/renderer.js.map +1 -1
  41. package/dist/visualize/graph-canvas/styles.d.ts.map +1 -1
  42. package/dist/visualize/graph-canvas/styles.js +136 -121
  43. package/dist/visualize/graph-canvas/styles.js.map +1 -1
  44. package/dist/visualize/graph-canvas/viewport.d.ts.map +1 -1
  45. package/dist/visualize/graph-canvas/viewport.js +10 -0
  46. package/dist/visualize/graph-canvas/viewport.js.map +1 -1
  47. package/dist/visualize/server.js +149 -32
  48. package/dist/visualize/server.js.map +1 -1
  49. package/dist/visualize/template-core.d.ts +9 -0
  50. package/dist/visualize/template-core.d.ts.map +1 -0
  51. package/dist/visualize/template-core.js +714 -0
  52. package/dist/visualize/template-core.js.map +1 -0
  53. package/dist/visualize/template-data-loading.d.ts +7 -0
  54. package/dist/visualize/template-data-loading.d.ts.map +1 -0
  55. package/dist/visualize/template-data-loading.js +677 -0
  56. package/dist/visualize/template-data-loading.js.map +1 -0
  57. package/dist/visualize/template-detail-panel.d.ts +14 -0
  58. package/dist/visualize/template-detail-panel.d.ts.map +1 -0
  59. package/dist/visualize/template-detail-panel.js +553 -0
  60. package/dist/visualize/template-detail-panel.js.map +1 -0
  61. package/dist/visualize/template-graph-3d.d.ts +7 -0
  62. package/dist/visualize/template-graph-3d.d.ts.map +1 -0
  63. package/dist/visualize/template-graph-3d.js +1112 -0
  64. package/dist/visualize/template-graph-3d.js.map +1 -0
  65. package/dist/visualize/template-graph-vis.d.ts +8 -0
  66. package/dist/visualize/template-graph-vis.d.ts.map +1 -0
  67. package/dist/visualize/template-graph-vis.js +1204 -0
  68. package/dist/visualize/template-graph-vis.js.map +1 -0
  69. package/dist/visualize/template-html.d.ts +9 -0
  70. package/dist/visualize/template-html.d.ts.map +1 -0
  71. package/dist/visualize/template-html.js +484 -0
  72. package/dist/visualize/template-html.js.map +1 -0
  73. package/dist/visualize/template-pages.d.ts +7 -0
  74. package/dist/visualize/template-pages.d.ts.map +1 -0
  75. package/dist/visualize/template-pages.js +806 -0
  76. package/dist/visualize/template-pages.js.map +1 -0
  77. package/dist/visualize/template-stats-modal.d.ts +7 -0
  78. package/dist/visualize/template-stats-modal.d.ts.map +1 -0
  79. package/dist/visualize/template-stats-modal.js +406 -0
  80. package/dist/visualize/template-stats-modal.js.map +1 -0
  81. package/dist/visualize/template-styles.d.ts +9 -0
  82. package/dist/visualize/template-styles.d.ts.map +1 -0
  83. package/dist/visualize/template-styles.js +487 -0
  84. package/dist/visualize/template-styles.js.map +1 -0
  85. package/dist/visualize/template.d.ts +14 -3
  86. package/dist/visualize/template.d.ts.map +1 -1
  87. package/dist/visualize/template.js +38 -3475
  88. package/dist/visualize/template.js.map +1 -1
  89. package/package.json +1 -1
@@ -1,12 +1,32 @@
1
1
  "use strict";
2
2
  /**
3
- * DevPlan 图可视化 HTML 模板
3
+ * DevPlan 图可视化 HTML 模板 — 主入口
4
4
  *
5
- * 自包含的 HTML 页面,通过 CDN 引入 vis-network standalone 版本。
6
- * 支持 5 种节点类型和 4 种边类型的视觉映射,暗色主题。
5
+ * 自包含的 HTML 页面,通过 CDN 引入渲染引擎。
6
+ * 所有模块化代码通过 import 组合为完整页面。
7
+ *
8
+ * 模块结构:
9
+ * - template-styles.ts — CSS 样式
10
+ * - template-html.ts — HTML 结构
11
+ * - template-core.ts — 侧边栏、设置、引擎加载、状态
12
+ * - template-graph-vis.ts — vis-network 渲染、边高亮、文档展开、筛选
13
+ * - template-graph-3d.ts — 3D Force Graph 渲染
14
+ * - template-data-loading.ts — 数据加载
15
+ * - template-detail-panel.ts — 共享详情面板、Markdown 渲染
16
+ * - template-stats-modal.ts — 统计弹层、手动刷新
17
+ * - template-pages.ts — 文档浏览、RAG 聊天、统计仪表盘
7
18
  */
8
19
  Object.defineProperty(exports, "__esModule", { value: true });
9
20
  exports.getVisualizationHTML = getVisualizationHTML;
21
+ const template_styles_1 = require("./template-styles");
22
+ const template_html_1 = require("./template-html");
23
+ const template_core_1 = require("./template-core");
24
+ const template_graph_vis_1 = require("./template-graph-vis");
25
+ const template_data_loading_1 = require("./template-data-loading");
26
+ const template_graph_3d_1 = require("./template-graph-3d");
27
+ const template_detail_panel_1 = require("./template-detail-panel");
28
+ const template_stats_modal_1 = require("./template-stats-modal");
29
+ const template_pages_1 = require("./template-pages");
10
30
  function getVisualizationHTML(projectName) {
11
31
  return `<!DOCTYPE html>
12
32
  <html lang="zh-CN">
@@ -15,3486 +35,29 @@ function getVisualizationHTML(projectName) {
15
35
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
36
  <title>DevPlan - ${projectName}</title>
17
37
  <style>
18
- * { margin: 0; padding: 0; box-sizing: border-box; }
19
- body { background: #111827; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow: hidden; }
20
-
21
- /* App Layout */
22
- .app-layout { display: flex; height: 100vh; overflow: hidden; }
23
- .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
24
-
25
- /* Sidebar */
26
- .sidebar { width: 48px; background: #0f172a; border-right: 1px solid #1e293b; flex-shrink: 0; display: flex; flex-direction: column; transition: width 0.25s ease; overflow: hidden; z-index: 40; }
27
- .sidebar.expanded { width: 200px; }
28
- .sidebar-header { height: 56px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #1e293b; cursor: pointer; flex-shrink: 0; overflow: hidden; transition: all 0.2s; padding: 0 8px; }
29
- .sidebar-header:hover { background: #1e293b; }
30
- .sidebar-logo { font-size: 18px; font-weight: 900; background: linear-gradient(90deg, #38bdf8, #818cf8, #a78bfa, #f472b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; white-space: nowrap; line-height: 1; }
31
- .sidebar-menu-icon { display: flex; align-items: center; justify-content: center; color: #9ca3af; transition: color 0.2s; }
32
- .sidebar-header:hover .sidebar-menu-icon { color: #e2e8f0; }
33
- .sidebar-logo-full { display: none; }
34
- .sidebar-logo-short { display: block; }
35
- .sidebar.expanded .sidebar-header { justify-content: flex-start; padding: 0 16px; }
36
- .sidebar.expanded .sidebar-logo-full { display: block; }
37
- .sidebar.expanded .sidebar-logo-short { display: none; }
38
- .sidebar-nav { flex: 1; padding: 8px 0; display: flex; flex-direction: column; gap: 2px; }
39
- .sidebar-footer { padding: 8px 0; border-top: 1px solid #1e293b; }
40
- .nav-item { position: relative; display: flex; align-items: center; height: 40px; padding: 0 12px; cursor: pointer; color: #6b7280; transition: all 0.2s; white-space: nowrap; overflow: hidden; gap: 12px; border-left: 3px solid transparent; }
41
- .nav-item:hover { background: #1e293b; color: #d1d5db; }
42
- .nav-item.active { color: #a5b4fc; background: rgba(99,102,241,0.1); border-left-color: #6366f1; }
43
- .nav-item.disabled { cursor: default; opacity: 0.5; }
44
- .nav-item.disabled:hover { background: #1e293b; }
45
- .nav-item-icon { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
46
- .nav-item-text { font-size: 13px; font-weight: 500; opacity: 0; transition: opacity 0.2s; }
47
- .sidebar.expanded .nav-item-text { opacity: 1; }
48
- .nav-item-badge { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #374151; color: #6b7280; margin-left: auto; opacity: 0; transition: opacity 0.2s; }
49
- .sidebar.expanded .nav-item-badge { opacity: 1; }
50
-
51
- /* Sidebar tooltip (collapsed mode) */
52
- .nav-item .nav-tooltip { position: absolute; left: 52px; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; color: #e5e7eb; padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 50; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
53
- .sidebar:not(.expanded) .nav-item:hover .nav-tooltip { opacity: 1; }
54
-
55
- /* Header */
56
- .header { background: transparent; border-bottom: none; padding: 8px 24px; display: flex; align-items: center; justify-content: space-between; height: 44px; position: absolute; top: 0; left: 0; right: 0; z-index: 10; pointer-events: none; }
57
- .header * { pointer-events: auto; }
58
- .header h1 { font-size: 20px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
59
- .header h1 .icon { font-size: 24px; }
60
- .header .project-name { color: #818cf8; }
61
-
62
- /* Stats Bar */
63
- .stats-bar { display: flex; gap: 24px; align-items: center; }
64
- .stat { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #9ca3af; }
65
- .stat .num { font-weight: 700; font-size: 16px; }
66
- .stat .num.green { color: #10b981; }
67
- .stat .num.blue { color: #3b82f6; }
68
- .stat .num.purple { color: #8b5cf6; }
69
- .stat .num.amber { color: #f59e0b; }
70
- .stat.clickable { cursor: pointer; border-radius: 6px; padding: 2px 8px; margin: -2px -8px; transition: background 0.15s; }
71
- .stat.clickable:hover { background: rgba(99,102,241,0.12); }
72
- .progress-bar { width: 120px; height: 8px; background: #374151; border-radius: 4px; overflow: hidden; }
73
- .progress-fill { height: 100%; background: linear-gradient(90deg, #10b981, #3b82f6); border-radius: 4px; transition: width 0.5s; }
74
-
75
- /* Controls */
76
- .controls { display: none; }
77
- .filter-check { display: flex; align-items: center; gap: 4px; cursor: pointer; font-size: 12px; color: #9ca3af; user-select: none; }
78
- .filter-check input { accent-color: #6366f1; width: 13px; height: 13px; cursor: pointer; }
79
- .filter-check:hover { color: #d1d5db; }
80
-
81
- /* Graph — flex 自适应高度 */
82
- .graph-container { position: relative; flex: 1; background: #111827; min-height: 0; }
83
- #graph { width: 100%; height: 100%; }
84
-
85
- .loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(17,24,39,0.9); z-index: 20; }
86
- .spinner { width: 40px; height: 40px; border: 4px solid #4f46e5; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
87
- @keyframes spin { to { transform: rotate(360deg); } }
88
-
89
- /* Detail Panel */
90
- .panel { position: absolute; top: 12px; right: 12px; width: 340px; max-height: calc(100vh - 180px); background: #1f2937; border: 1px solid #374151; border-radius: 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.5); z-index: 10; display: none; overflow: hidden; min-width: 280px; max-width: calc(100vw - 40px); transition: none; }
91
- .panel.show { display: flex; flex-direction: column; }
92
- .panel-resize-handle { position: absolute; top: 0; left: -4px; width: 8px; height: 100%; cursor: col-resize; z-index: 15; background: transparent; }
93
- .panel-resize-handle:hover, .panel-resize-handle.active { background: linear-gradient(90deg, transparent, rgba(99,102,241,0.4), transparent); }
94
- .panel-resize-handle::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 3px; height: 32px; background: #4b5563; border-radius: 2px; opacity: 0; transition: opacity 0.2s; }
95
- .panel-resize-handle:hover::after, .panel-resize-handle.active::after { opacity: 1; background: #6366f1; }
96
- .panel-header { padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; cursor: default; user-select: none; }
97
- .panel-header.project { background: linear-gradient(135deg, #d97706, #f59e0b); }
98
- .panel-header.module { background: linear-gradient(135deg, #059669, #10b981); }
99
- .panel-header.main-task { background: linear-gradient(135deg, #4f46e5, #6366f1); }
100
- .panel-header.sub-task { background: linear-gradient(135deg, #7c3aed, #8b5cf6); }
101
- .panel-header.document { background: linear-gradient(135deg, #1d4ed8, #3b82f6); }
102
- .panel-title { font-weight: 600; font-size: 14px; color: #fff; pointer-events: none; }
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
- .panel-close:hover { background: rgba(255,255,255,0.3); }
105
- .panel-back { background: rgba(255,255,255,0.2); border: none; color: #fff; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; font-size: 16px; display: none; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; transition: background 0.15s, transform 0.15s; }
106
- .panel-back:hover { background: rgba(255,255,255,0.3); transform: translateX(-1px); }
107
- .panel-back.visible { display: flex; }
108
- .panel-header-left { display: flex; align-items: center; min-width: 0; flex: 1; }
109
- .panel-body { padding: 16px; overflow-y: auto; flex: 1; }
110
- .panel-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px solid #374151; }
111
- .panel-row:last-child { border-bottom: none; }
112
- .panel-label { color: #9ca3af; }
113
- .panel-value { color: #e5e7eb; font-weight: 500; }
114
- .status-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
115
- .status-completed { background: #064e3b; color: #6ee7b7; }
116
- .status-in_progress { background: #1e3a5f; color: #93c5fd; }
117
- .status-pending { background: #374151; color: #9ca3af; }
118
- .status-cancelled { background: #451a03; color: #fbbf24; }
119
- .priority-P0 { background: #7f1d1d; color: #fca5a5; }
120
- .priority-P1 { background: #78350f; color: #fde68a; }
121
- .priority-P2 { background: #1e3a5f; color: #93c5fd; }
122
- .panel-progress { margin-top: 8px; }
123
- .panel-progress-bar { width: 100%; height: 6px; background: #374151; border-radius: 3px; overflow: hidden; margin-top: 4px; }
124
- .panel-progress-fill { height: 100%; background: #10b981; border-radius: 3px; }
125
-
126
- /* Sub-task List in Panel */
127
- .subtask-section { margin-top: 12px; border-top: 1px solid #374151; padding-top: 10px; }
128
- .subtask-section-title { font-size: 12px; color: #9ca3af; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
129
- .subtask-list { list-style: none; padding: 0; margin: 0; }
130
- .subtask-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid rgba(55,65,81,0.5); font-size: 12px; }
131
- .subtask-item:last-child { border-bottom: none; }
132
- .subtask-icon { width: 16px; height: 16px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 10px; }
133
- .subtask-icon.completed { background: #064e3b; color: #6ee7b7; }
134
- .subtask-icon.in_progress { background: #1e3a5f; color: #93c5fd; }
135
- .subtask-icon.pending { background: #374151; color: #6b7280; }
136
- .subtask-icon.cancelled { background: #451a03; color: #fbbf24; }
137
- .subtask-name { color: #d1d5db; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
138
- .subtask-name.completed { color: #6ee7b7; text-decoration: line-through; text-decoration-color: rgba(110,231,183,0.3); }
139
- .subtask-name.cancelled { color: #9ca3af; text-decoration: line-through; }
140
- .subtask-id { color: #6b7280; font-size: 10px; flex-shrink: 0; font-family: monospace; }
141
- .subtask-time { color: #6ee7b7; font-size: 10px; flex-shrink: 0; opacity: 0.75; margin-left: auto; }
142
-
143
- /* Legend */
144
- .legend { background: #1f2937; border-top: 1px solid #374151; padding: 6px 24px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 20px; font-size: 12px; color: #9ca3af; }
145
- .legend-filters { display: flex; align-items: center; gap: 8px; }
146
- .legend-divider { width: 1px; height: 18px; background: #374151; }
147
- .legend-refresh-btn { display: flex; align-items: center; justify-content: center; background: none; border: 1px solid #374151; border-radius: 4px; padding: 3px 6px; cursor: pointer; color: #9ca3af; transition: color 0.2s, border-color 0.2s, background 0.2s; }
148
- .legend-refresh-btn:hover { color: #60a5fa; border-color: #60a5fa; background: rgba(96,165,250,0.08); }
149
- .legend-refresh-btn:active { color: #3b82f6; }
150
- .legend-refresh-btn.refreshing .legend-refresh-icon { animation: spin-refresh 0.8s linear infinite; }
151
- @keyframes spin-refresh { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
152
- .legend-sep { width: 100%; height: 0; }
153
- .legend-item { display: flex; align-items: center; gap: 6px; }
154
- .legend-icon { width: 12px; height: 12px; }
155
- .legend-icon.star { background: #f59e0b; clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%); }
156
- .legend-icon.diamond { background: #10b981; clip-path: polygon(50% 0%,100% 50%,50% 100%,0% 50%); }
157
- .legend-icon.circle { background: #6366f1; border-radius: 50%; }
158
- .legend-icon.dot { background: #8b5cf6; border-radius: 50%; width: 8px; height: 8px; }
159
- .legend-icon.square { background: #3b82f6; border-radius: 2px; width: 10px; height: 10px; }
160
- .legend-line { width: 24px; height: 2px; }
161
- .legend-line.solid { background: #6b7280; }
162
- .legend-line.thin { background: #6b7280; height: 1px; }
163
- .legend-line.dashed { border-top: 2px dashed #6b7280; background: none; height: 0; }
164
- .legend-line.dotted { border-top: 2px dotted #10b981; 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; }
167
-
168
- /* Document Content in Panel */
169
- .doc-section { margin-top: 12px; border-top: 1px solid #374151; padding-top: 10px; }
170
- .doc-section-title { font-size: 12px; color: #9ca3af; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; }
171
- .doc-content { background: #111827; border: 1px solid #374151; border-radius: 8px; padding: 12px; font-size: 12px; line-height: 1.7; color: #d1d5db; overflow-x: auto; }
172
- .doc-content h1, .doc-content h2, .doc-content h3, .doc-content h4 { color: #f3f4f6; margin: 12px 0 6px 0; }
173
- .doc-content h1 { font-size: 16px; border-bottom: 1px solid #374151; padding-bottom: 4px; }
174
- .doc-content h2 { font-size: 14px; border-bottom: 1px solid rgba(55,65,81,0.5); padding-bottom: 3px; }
175
- .doc-content h3 { font-size: 13px; }
176
- .doc-content h4 { font-size: 12px; color: #d1d5db; }
177
- .doc-content p { margin: 6px 0; }
178
- .doc-content code { background: #1e293b; color: #a5b4fc; padding: 1px 5px; border-radius: 3px; font-size: 11px; font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace; }
179
- .doc-content pre { background: #0f172a; border: 1px solid #1e293b; border-radius: 6px; padding: 10px; overflow-x: auto; margin: 8px 0; }
180
- .doc-content pre code { background: none; padding: 0; color: #e2e8f0; display: block; white-space: pre; }
181
- .doc-content ul, .doc-content ol { padding-left: 20px; margin: 6px 0; }
182
- .doc-content li { margin: 2px 0; }
183
- .doc-content blockquote { border-left: 3px solid #4f46e5; padding-left: 10px; color: #9ca3af; margin: 8px 0; font-style: italic; }
184
- .doc-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 11px; }
185
- .doc-content th { background: #1e293b; color: #a5b4fc; padding: 5px 8px; text-align: left; border: 1px solid #374151; font-weight: 600; }
186
- .doc-content td { padding: 4px 8px; border: 1px solid #374151; }
187
- .doc-content tr:nth-child(even) { background: rgba(30,41,59,0.3); }
188
- .doc-content a { color: #818cf8; text-decoration: none; }
189
- .doc-content a:hover { text-decoration: underline; }
190
- .doc-content hr { border: none; border-top: 1px solid #374151; margin: 10px 0; }
191
- .doc-content strong { color: #f3f4f6; }
192
- .doc-content em { color: #c4b5fd; }
193
- .doc-loading { text-align: center; color: #6b7280; padding: 16px; font-size: 12px; }
194
- .doc-error { text-align: center; color: #f87171; padding: 12px; font-size: 12px; }
195
- .doc-toggle { background: none; border: 1px solid #4b5563; color: #9ca3af; padding: 2px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; }
196
- .doc-toggle:hover { border-color: #6b7280; color: #d1d5db; }
197
-
198
- /* Page Views */
199
- .page-view { display: none; }
200
- .page-view.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
201
- .page-graph.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
202
-
203
- /* Stats Dashboard */
204
- .stats-page { padding: 24px; overflow-y: auto; background: #111827; flex: 1; }
205
- .stats-header { margin-bottom: 24px; }
206
- .stats-header h2 { font-size: 22px; font-weight: 700; color: #f3f4f6; margin-bottom: 4px; }
207
- .stats-header p { font-size: 13px; color: #6b7280; }
208
-
209
- .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
210
- .stat-card { background: #1f2937; border: 1px solid #374151; border-radius: 12px; padding: 20px; position: relative; overflow: hidden; }
211
- .stat-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
212
- .stat-card.purple::before { background: linear-gradient(90deg, #6366f1, #8b5cf6); }
213
- .stat-card.green::before { background: linear-gradient(90deg, #059669, #10b981); }
214
- .stat-card.blue::before { background: linear-gradient(90deg, #2563eb, #3b82f6); }
215
- .stat-card.amber::before { background: linear-gradient(90deg, #d97706, #f59e0b); }
216
- .stat-card.rose::before { background: linear-gradient(90deg, #e11d48, #f43f5e); }
217
- .stat-card-icon { font-size: 28px; margin-bottom: 8px; }
218
- .stat-card-value { font-size: 32px; font-weight: 800; color: #f3f4f6; line-height: 1; }
219
- .stat-card-label { font-size: 12px; color: #9ca3af; margin-top: 6px; }
220
- .stat-card-sub { font-size: 11px; color: #6b7280; margin-top: 4px; }
221
-
222
- .stats-section { margin-bottom: 28px; }
223
- .stats-section-title { font-size: 15px; font-weight: 600; color: #e5e7eb; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
224
- .stats-section-title .sec-icon { font-size: 18px; }
225
-
226
- /* Overall Progress Ring */
227
- .progress-ring-wrap { display: flex; align-items: center; gap: 24px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; padding: 24px; margin-bottom: 28px; }
228
- .progress-ring-info { flex: 1; }
229
- .progress-ring-info h3 { font-size: 18px; font-weight: 700; color: #f3f4f6; margin-bottom: 4px; }
230
- .progress-ring-info p { font-size: 13px; color: #9ca3af; margin-bottom: 12px; }
231
- .progress-ring-info .motivate { font-size: 14px; color: #fbbf24; font-weight: 600; }
232
- .ring-svg { flex-shrink: 0; }
233
-
234
- /* Priority Bars */
235
- .priority-bars { display: flex; flex-direction: column; gap: 12px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; padding: 20px; }
236
- .priority-row { display: flex; align-items: center; gap: 12px; }
237
- .priority-label { width: 32px; font-size: 12px; font-weight: 700; text-align: center; padding: 2px 0; border-radius: 4px; flex-shrink: 0; }
238
- .priority-label.P0 { background: #7f1d1d; color: #fca5a5; }
239
- .priority-label.P1 { background: #78350f; color: #fde68a; }
240
- .priority-label.P2 { background: #1e3a5f; color: #93c5fd; }
241
- .priority-bar-track { flex: 1; height: 10px; background: #374151; border-radius: 5px; overflow: hidden; }
242
- .priority-bar-fill { height: 100%; border-radius: 5px; transition: width 0.5s; }
243
- .priority-bar-fill.P0 { background: linear-gradient(90deg, #dc2626, #f87171); }
244
- .priority-bar-fill.P1 { background: linear-gradient(90deg, #d97706, #fbbf24); }
245
- .priority-bar-fill.P2 { background: linear-gradient(90deg, #2563eb, #60a5fa); }
246
- .priority-nums { font-size: 11px; color: #9ca3af; width: 70px; text-align: right; flex-shrink: 0; }
247
-
248
- /* Phase List */
249
- .phase-list { display: flex; flex-direction: column; gap: 8px; }
250
- .phase-item { background: #1f2937; border: 1px solid #374151; border-radius: 10px; padding: 14px 16px; display: flex; align-items: center; gap: 14px; }
251
- .phase-status-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
252
- .phase-status-icon.completed { background: #064e3b; color: #6ee7b7; }
253
- .phase-status-icon.in_progress { background: #1e3a5f; color: #93c5fd; }
254
- .phase-status-icon.pending { background: #374151; color: #6b7280; }
255
- .phase-info { flex: 1; min-width: 0; }
256
- .phase-info-title { font-size: 13px; font-weight: 600; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
257
- .phase-info-sub { font-size: 11px; color: #6b7280; margin-top: 2px; }
258
- .phase-bar-mini { width: 80px; height: 6px; background: #374151; border-radius: 3px; overflow: hidden; flex-shrink: 0; }
259
- .phase-bar-mini-fill { height: 100%; background: #10b981; border-radius: 3px; }
260
- .phase-pct { font-size: 12px; font-weight: 700; color: #9ca3af; width: 40px; text-align: right; flex-shrink: 0; }
261
- .phase-item-wrap { background: #1f2937; border: 1px solid #374151; border-radius: 10px; overflow: hidden; }
262
- .phase-item-main { display: flex; align-items: center; gap: 14px; padding: 14px 16px; cursor: pointer; transition: background 0.15s; }
263
- .phase-item-main:hover { background: rgba(55,65,81,0.3); }
264
- .phase-expand-icon { width: 16px; font-size: 10px; color: #6b7280; flex-shrink: 0; transition: transform 0.2s; text-align: center; }
265
- .phase-item-wrap.expanded .phase-expand-icon { transform: rotate(90deg); }
266
- .phase-subtasks { display: none; border-top: 1px solid #374151; padding: 6px 0; }
267
- .phase-item-wrap.expanded .phase-subtasks { display: block; }
268
- .phase-sub-item { display: flex; align-items: center; gap: 8px; padding: 4px 16px 4px 62px; font-size: 12px; }
269
- .phase-sub-icon { width: 14px; height: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; flex-shrink: 0; }
270
- .phase-sub-icon.completed { background: #064e3b; color: #6ee7b7; }
271
- .phase-sub-icon.in_progress { background: #1e3a5f; color: #93c5fd; }
272
- .phase-sub-icon.pending { background: #374151; color: #6b7280; }
273
- .phase-sub-icon.cancelled { background: #451a03; color: #fbbf24; }
274
- .phase-sub-name { color: #d1d5db; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
275
- .phase-sub-name.completed { color: #6ee7b7; text-decoration: line-through; text-decoration-color: rgba(110,231,183,0.3); }
276
- .phase-sub-id { color: #4b5563; font-size: 10px; font-family: monospace; flex-shrink: 0; }
277
- .phase-time { color: #6ee7b7; font-size: 10px; opacity: 0.8; }
278
- .phase-sub-time { color: #6ee7b7; font-size: 10px; opacity: 0.7; flex-shrink: 0; margin-left: auto; }
279
- .phase-time { color: #6ee7b7; font-size: 10px; }
280
- .phase-sub-time { color: #6ee7b7; font-size: 10px; flex-shrink: 0; margin-left: auto; }
281
-
282
- /* Module Cards */
283
- .module-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
284
- .module-card { background: #1f2937; border: 1px solid #374151; border-radius: 10px; padding: 16px; }
285
- .module-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
286
- .module-card-dot { width: 10px; height: 10px; border-radius: 50%; background: #10b981; flex-shrink: 0; }
287
- .module-card-name { font-size: 13px; font-weight: 600; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
288
- .module-card-bar { width: 100%; height: 6px; background: #374151; border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
289
- .module-card-bar-fill { height: 100%; background: linear-gradient(90deg, #059669, #10b981); border-radius: 3px; }
290
- .module-card-stats { display: flex; justify-content: space-between; font-size: 11px; color: #6b7280; }
291
-
292
- /* ===== Docs Browser Page ===== */
293
- .docs-page { display: flex; flex: 1; min-height: 0; overflow: hidden; background: #111827; }
294
- .docs-sidebar { width: 280px; background: #1f2937; border-right: 1px solid #374151; display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
295
- .docs-sidebar-header { padding: 16px 20px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; }
296
- .docs-sidebar-header h3 { font-size: 15px; font-weight: 700; color: #f3f4f6; margin-bottom: 8px; }
297
- .docs-search-wrap { position: relative; }
298
- .docs-search { width: 100%; background: #111827; border: 1px solid #374151; border-radius: 6px; padding: 7px 30px 7px 10px; color: #e5e7eb; font-size: 12px; outline: none; transition: border-color 0.2s; box-sizing: border-box; }
299
- .docs-search:focus { border-color: #6366f1; }
300
- .docs-search::placeholder { color: #6b7280; }
301
- .docs-search-clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 20px; height: 20px; border: none; background: none; color: #6b7280; font-size: 14px; cursor: pointer; display: none; align-items: center; justify-content: center; border-radius: 4px; padding: 0; line-height: 1; }
302
- .docs-search-clear:hover { color: #e5e7eb; background: #374151; }
303
- .docs-search-clear.show { display: flex; }
304
- .docs-group-list { overflow-y: auto; flex: 1; padding: 8px 0; scrollbar-width: thin; scrollbar-color: #374151 transparent; }
305
- .docs-group-list::-webkit-scrollbar { width: 6px; }
306
- .docs-group-list::-webkit-scrollbar-track { background: transparent; }
307
- .docs-group-list::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
308
- .docs-group { margin-bottom: 4px; }
309
- .docs-group-title { display: flex; align-items: center; gap: 8px; padding: 8px 20px; font-size: 11px; font-weight: 700; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; user-select: none; transition: color 0.15s; }
310
- .docs-group-title:hover { color: #d1d5db; }
311
- .docs-group-title .docs-group-arrow { font-size: 9px; transition: transform 0.2s; color: #6b7280; }
312
- .docs-group.collapsed .docs-group-arrow { transform: rotate(-90deg); }
313
- .docs-group.collapsed .docs-group-items { display: none; }
314
- .docs-group-count { font-size: 10px; color: #4b5563; font-weight: 500; margin-left: auto; }
315
- .docs-item { display: flex; align-items: center; gap: 8px; padding: 7px 20px 7px 28px; cursor: pointer; transition: background 0.15s; font-size: 13px; color: #d1d5db; border-left: 3px solid transparent; }
316
- .docs-item:hover { background: rgba(55,65,81,0.4); }
317
- .docs-item.active { background: rgba(99,102,241,0.12); border-left-color: #6366f1; color: #a5b4fc; }
318
- .docs-item-icon { font-size: 14px; flex-shrink: 0; opacity: 0.7; }
319
- .docs-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
320
- .docs-item-sub { font-size: 10px; color: #6b7280; flex-shrink: 0; }
321
- .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; }
322
- .docs-item-toggle:hover { background: rgba(99,102,241,0.3); color: #a5b4fc; }
323
- .docs-children { overflow: hidden; transition: max-height 0.25s ease; }
324
- .docs-children.collapsed { max-height: 0 !important; }
325
- .docs-children .docs-item { padding-left: 44px; font-size: 12px; opacity: 0.85; }
326
- .docs-children .docs-children .docs-item { padding-left: 60px; font-size: 11px; opacity: 0.75; }
327
- .docs-child-line { position: absolute; left: 35px; top: 0; bottom: 0; width: 1px; background: #374151; }
328
- .docs-item-wrapper { position: relative; }
329
-
330
- .docs-content { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
331
- .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; }
332
- .docs-content-title { font-size: 20px; font-weight: 700; color: #f3f4f6; }
333
- .docs-content-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
334
- .docs-content-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px; background: #374151; color: #9ca3af; }
335
- .docs-content-tag.section { background: rgba(99,102,241,0.15); color: #a5b4fc; }
336
- .docs-content-tag.version { background: rgba(16,185,129,0.15); color: #6ee7b7; }
337
- .docs-content-body { flex: 1; overflow-y: auto; padding: 20px 28px 40px; scrollbar-width: thin; scrollbar-color: #374151 transparent; }
338
- .docs-content-body::-webkit-scrollbar { width: 6px; }
339
- .docs-content-body::-webkit-scrollbar-track { background: transparent; }
340
- .docs-content-body::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
341
- .docs-content-empty { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
342
- .docs-content-empty .empty-icon { font-size: 48px; opacity: 0.4; }
343
-
344
- /* RAG Chat UI */
345
- .docs-chat-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
346
- .docs-chat-messages { flex: 1; overflow-y: auto; padding: 20px 28px; scrollbar-width: thin; scrollbar-color: #374151 transparent; }
347
- .docs-chat-messages::-webkit-scrollbar { width: 6px; }
348
- .docs-chat-messages::-webkit-scrollbar-track { background: transparent; }
349
- .docs-chat-messages::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
350
- .docs-chat-welcome { text-align: center; padding: 60px 40px 30px; color: #6b7280; }
351
- .docs-chat-welcome .welcome-icon { font-size: 48px; opacity: 0.4; margin-bottom: 12px; }
352
- .docs-chat-welcome .welcome-title { font-size: 16px; font-weight: 600; color: #9ca3af; margin-bottom: 6px; }
353
- .docs-chat-welcome .welcome-desc { font-size: 13px; color: #6b7280; line-height: 1.6; }
354
- .docs-chat-welcome .welcome-tips { margin-top: 20px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
355
- .docs-chat-welcome .tip-chip { font-size: 12px; padding: 6px 14px; border-radius: 16px; background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: #a5b4fc; cursor: pointer; transition: all 0.15s; }
356
- .docs-chat-welcome .tip-chip:hover { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.4); }
357
-
358
- .chat-bubble { margin-bottom: 16px; max-width: 90%; animation: chatFadeIn 0.25s ease; }
359
- .chat-bubble.user { margin-left: auto; }
360
- .chat-bubble.assistant { margin-right: auto; }
361
- .chat-bubble-inner { padding: 10px 16px; border-radius: 12px; font-size: 13px; line-height: 1.6; }
362
- .chat-bubble.user .chat-bubble-inner { background: rgba(99,102,241,0.2); color: #c7d2fe; border-bottom-right-radius: 4px; }
363
- .chat-bubble.assistant .chat-bubble-inner { background: #1f2937; color: #d1d5db; border: 1px solid #374151; border-bottom-left-radius: 4px; }
364
- .chat-result-card { margin-top: 8px; padding: 10px 14px; border-radius: 8px; background: rgba(55,65,81,0.4); border: 1px solid #374151; cursor: pointer; transition: all 0.15s; }
365
- .chat-result-card:hover { background: rgba(99,102,241,0.1); border-color: rgba(99,102,241,0.3); }
366
- .chat-result-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
367
- .chat-result-score { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(16,185,129,0.15); color: #6ee7b7; font-weight: 500; }
368
- .chat-result-snippet { font-size: 11px; color: #9ca3af; line-height: 1.5; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
369
- .chat-result-meta { font-size: 10px; color: #6b7280; margin-top: 4px; display: flex; gap: 8px; }
370
- .chat-no-result { color: #6b7280; font-size: 12px; margin-top: 8px; }
371
- .chat-typing { display: flex; gap: 4px; padding: 12px 16px; }
372
- .chat-typing-dot { width: 6px; height: 6px; border-radius: 50%; background: #6b7280; animation: chatTyping 1.2s infinite; }
373
- .chat-typing-dot:nth-child(2) { animation-delay: 0.2s; }
374
- .chat-typing-dot:nth-child(3) { animation-delay: 0.4s; }
375
- @keyframes chatTyping { 0%,60%,100% { opacity: 0.3; transform: scale(0.8); } 30% { opacity: 1; transform: scale(1); } }
376
- @keyframes chatFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
377
-
378
- .docs-chat-input-wrap { padding: 12px 20px 16px; border-top: 1px solid #374151; flex-shrink: 0; display: flex; gap: 8px; align-items: flex-end; background: #111827; }
379
- .docs-chat-input { flex: 1; background: #1f2937; border: 1px solid #374151; border-radius: 12px; padding: 10px 16px; color: #e5e7eb; font-size: 13px; outline: none; resize: none; min-height: 20px; max-height: 120px; line-height: 1.5; font-family: inherit; transition: border-color 0.2s; }
380
- .docs-chat-input:focus { border-color: #6366f1; }
381
- .docs-chat-input::placeholder { color: #6b7280; }
382
- .docs-chat-send { width: 36px; height: 36px; border-radius: 10px; border: none; background: #6366f1; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; font-size: 16px; }
383
- .docs-chat-send:hover { background: #818cf8; }
384
- .docs-chat-send:disabled { background: #374151; color: #6b7280; cursor: not-allowed; }
385
- .docs-related { margin-top: 20px; border-top: 1px solid #374151; padding-top: 16px; }
386
- .docs-related-title { font-size: 13px; font-weight: 600; color: #9ca3af; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
387
- .docs-related-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12px; color: #d1d5db; }
388
- .docs-related-item .rel-icon { width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; flex-shrink: 0; }
389
-
390
- /* Debug bar */
391
- .debug { position: absolute; bottom: 0; left: 12px; background: rgba(31,41,55,0.9); border: 1px solid #374151; border-radius: 8px 8px 0 0; padding: 8px 12px; font-size: 11px; color: #9ca3af; z-index: 30; max-width: 400px; }
392
- .debug .ok { color: #10b981; }
393
- .debug .err { color: #f87171; }
394
-
395
- /* Stats Modal — left side panel */
396
- .stats-modal-overlay { display: none; position: fixed; inset: 0; z-index: 200; pointer-events: none; }
397
- .stats-modal-overlay.active { display: block; }
398
- .stats-modal { position: fixed; top: 0; bottom: 0; left: 48px; width: 300px; background: #1f2937; border-right: 1px solid #374151; display: flex; flex-direction: column; box-shadow: 4px 0 24px rgba(0,0,0,0.4); animation: modal-slide-in 0.2s ease; z-index: 201; pointer-events: auto; transition: left 0.25s ease; }
399
- @keyframes modal-slide-in { from { opacity: 0; transform: translateX(-16px); } to { opacity: 1; transform: translateX(0); } }
400
- .stats-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #374151; }
401
- .stats-modal-title { font-size: 15px; font-weight: 700; color: #f3f4f6; }
402
- .stats-modal-count { font-size: 12px; color: #6b7280; margin-left: 8px; }
403
- .stats-modal-close { background: none; border: none; color: #6b7280; font-size: 18px; cursor: pointer; padding: 4px 8px; border-radius: 4px; line-height: 1; }
404
- .stats-modal-close:hover { background: #374151; color: #e5e7eb; }
405
- .stats-modal-body { overflow-y: auto; padding: 8px 0; flex: 1; min-height: 0; scrollbar-width: thin; scrollbar-color: #374151 transparent; }
406
- .stats-modal-body::-webkit-scrollbar { width: 6px; }
407
- .stats-modal-body::-webkit-scrollbar-track { background: transparent; }
408
- .stats-modal-body::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
409
- .stats-modal-body::-webkit-scrollbar-thumb:hover { background: #4b5563; }
410
- .stats-modal-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; transition: background 0.15s; }
411
- .stats-modal-item:hover { background: #283344; }
412
- .stats-modal-item-icon { font-size: 14px; flex-shrink: 0; width: 22px; text-align: center; }
413
- .stats-modal-item-name { flex: 1; font-size: 13px; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
414
- .stats-modal-item-badge { font-size: 11px; padding: 2px 8px; border-radius: 9999px; flex-shrink: 0; }
415
- .stats-modal-item-badge.completed { background: rgba(16,185,129,0.15); color: #6ee7b7; }
416
- .stats-modal-item-badge.in_progress { background: rgba(59,130,246,0.15); color: #93c5fd; }
417
- .stats-modal-item-badge.pending { background: rgba(107,114,128,0.15); color: #9ca3af; }
418
- .stats-modal-item-badge.cancelled { background: rgba(146,64,14,0.15); color: #fbbf24; }
419
- .stats-modal-item-badge.active { background: rgba(16,185,129,0.15); color: #6ee7b7; }
420
- .stats-modal-item-sub { font-size: 11px; color: #6b7280; flex-shrink: 0; font-family: monospace; }
38
+ ${(0, template_styles_1.getStyles)()}
421
39
  </style>
422
40
  </head>
423
- <body>
424
- <div class="app-layout">
425
- <!-- Sidebar -->
426
- <div class="sidebar" id="sidebar">
427
- <div class="sidebar-header" onclick="toggleSidebar()" title="展开/收起导航">
428
- <span class="sidebar-menu-icon sidebar-logo-short"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></span>
429
- <span class="sidebar-logo sidebar-logo-full">AiFastDb-DevPlan</span>
430
- </div>
431
- <div class="sidebar-nav">
432
- <div class="nav-item active" data-page="graph" onclick="navTo('graph')">
433
- <span class="nav-item-icon">🔗</span>
434
- <span class="nav-item-text">图谱可视化</span>
435
- <span class="nav-tooltip">图谱可视化</span>
436
- </div>
437
- <div class="nav-item disabled" data-page="tasks" onclick="navTo('tasks')">
438
- <span class="nav-item-icon">📋</span>
439
- <span class="nav-item-text">任务看板</span>
440
- <span class="nav-item-badge">即将推出</span>
441
- <span class="nav-tooltip">任务看板 (即将推出)</span>
442
- </div>
443
- <div class="nav-item" data-page="docs" onclick="navTo('docs')">
444
- <span class="nav-item-icon">📄</span>
445
- <span class="nav-item-text">文档浏览</span>
446
- <span class="nav-tooltip">文档浏览</span>
447
- </div>
448
- <div class="nav-item" data-page="stats" onclick="navTo('stats')">
449
- <span class="nav-item-icon">📊</span>
450
- <span class="nav-item-text">统计仪表盘</span>
451
- <span class="nav-tooltip">统计仪表盘</span>
452
- </div>
453
- </div>
454
- <div class="sidebar-footer">
455
- <div class="nav-item disabled" data-page="settings" onclick="navTo('settings')">
456
- <span class="nav-item-icon">⚙️</span>
457
- <span class="nav-item-text">项目设置</span>
458
- <span class="nav-item-badge">即将推出</span>
459
- <span class="nav-tooltip">项目设置 (即将推出)</span>
460
- </div>
461
- </div>
462
- </div>
463
-
464
- <!-- Main Content -->
465
- <div class="main-content">
466
-
467
- <!-- ===== PAGE: Graph ===== -->
468
- <div class="page-view page-graph active" id="pageGraph">
469
- <!-- Header -->
470
- <div class="header">
471
- <h1><span class="icon">🔗</span> DevPlan 图谱 <span class="project-name">${projectName}</span></h1>
472
- <div class="stats-bar" id="statsBar">
473
- <div class="stat"><span>加载中...</span></div>
474
- </div>
475
- </div>
476
-
477
- <!-- Graph -->
478
- <div class="graph-container">
479
- <div class="loading" id="loading"><div><div class="spinner"></div><p style="margin-top:12px;color:#9ca3af;">加载图谱数据...</p></div></div>
480
- <div id="graph"></div>
481
- <div class="panel" id="panel">
482
- <div class="panel-resize-handle" id="panelResizeHandle"></div>
483
- <div class="panel-header" id="panelHeader">
484
- <div class="panel-header-left">
485
- <button class="panel-back" id="panelBack" onclick="panelGoBack()" title="返回上一个详情">
486
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
487
- </button>
488
- <span class="panel-title" id="panelTitle">节点详情</span>
489
- </div>
490
- <button class="panel-close" onclick="closePanel()">✕</button>
491
- </div>
492
- <div class="panel-body" id="panelBody"></div>
493
- </div>
494
- <!-- Debug info -->
495
- <div class="debug" id="debug">状态: 正在加载 vis-network...</div>
496
- </div>
497
-
498
- <!-- Legend + Filters (merged) -->
499
- <div class="legend">
500
- <!-- 刷新按钮 -->
501
- <button class="legend-refresh-btn" id="legendRefreshBtn" onclick="manualRefresh()" title="刷新数据 (F5)">
502
- <svg class="legend-refresh-icon" id="legendRefreshIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
503
- </button>
504
- <div class="legend-divider"></div>
505
- <!-- 筛选复选框 -->
506
- <label class="filter-check"><input type="checkbox" checked data-type="module" onchange="toggleFilter('module')"> 模块</label>
507
- <label class="filter-check"><input type="checkbox" checked data-type="main-task" onchange="toggleFilter('main-task')"> 主任务</label>
508
- <label class="filter-check"><input type="checkbox" checked data-type="sub-task" onchange="toggleFilter('sub-task')"> 子任务</label>
509
- <label class="filter-check"><input type="checkbox" checked data-type="document" onchange="toggleFilter('document')"> 文档</label>
510
- <div class="legend-divider"></div>
511
- <!-- 图例 -->
512
- <div class="legend-item"><div class="legend-icon star"></div> 项目</div>
513
- <div class="legend-item"><div class="legend-icon diamond"></div> 模块</div>
514
- <div class="legend-item"><div class="legend-icon circle"></div> 主任务</div>
515
- <div class="legend-item"><div class="legend-icon dot"></div> 子任务</div>
516
- <div class="legend-item"><div class="legend-icon square"></div> 文档</div>
517
- <div class="legend-divider"></div>
518
- <div class="legend-item"><div class="legend-line solid"></div> 主任务</div>
519
- <div class="legend-item"><div class="legend-line thin"></div> 子任务</div>
520
- <div class="legend-item"><div class="legend-line dashed"></div> 文档</div>
521
- <div class="legend-item"><div class="legend-line dotted"></div> 模块关联</div>
522
- <div class="legend-item"><div class="legend-line task-doc"></div> 任务-文档</div>
523
- <div class="legend-item"><div class="legend-line doc-child"></div> 文档层级</div>
524
- </div>
525
- </div>
526
-
527
- <!-- ===== PAGE: Docs Browser ===== -->
528
- <div class="page-view" id="pageDocs">
529
- <div class="docs-page">
530
- <!-- Left: Document List -->
531
- <div class="docs-sidebar">
532
- <div class="docs-sidebar-header">
533
- <h3>📄 文档库</h3>
534
- <div class="docs-search-wrap">
535
- <input type="text" class="docs-search" id="docsSearch" placeholder="搜索文档标题..." oninput="filterDocs();toggleSearchClear()">
536
- <button class="docs-search-clear" id="docsSearchClear" onclick="clearDocsSearch()" title="清空搜索">✕</button>
537
- </div>
538
- </div>
539
- <div class="docs-group-list" id="docsGroupList">
540
- <div style="text-align:center;padding:40px;color:#6b7280;font-size:12px;">加载中...</div>
541
- </div>
542
- </div>
543
- <!-- Right: Document Content / Chat -->
544
- <div class="docs-content">
545
- <!-- RAG Chat (default view) -->
546
- <div class="docs-content-empty" id="docsEmptyState">
547
- <div class="docs-chat-container">
548
- <div class="docs-chat-messages" id="docsChatMessages">
549
- <div class="docs-chat-welcome" id="docsChatWelcome">
550
- <div class="welcome-icon">🔍</div>
551
- <div class="welcome-title">文档智能搜索</div>
552
- <div class="welcome-desc">输入问题,AI 将在文档库中搜索相关内容<br>支持语义搜索,理解你的意图而非仅匹配关键词</div>
553
- <div class="welcome-tips">
554
- <span class="tip-chip" onclick="chatSendTip(this)">有多少篇文档?</span>
555
- <span class="tip-chip" onclick="chatSendTip(this)">项目进度</span>
556
- <span class="tip-chip" onclick="chatSendTip(this)">有哪些阶段?</span>
557
- <span class="tip-chip" onclick="chatSendTip(this)">最近更新</span>
558
- <span class="tip-chip" onclick="chatSendTip(this)">帮助</span>
559
- </div>
560
- <div class="welcome-tips" style="margin-top:8px;">
561
- <span class="tip-chip" onclick="chatSendTip(this)">向量搜索</span>
562
- <span class="tip-chip" onclick="chatSendTip(this)">aifastdb vs LanceDB</span>
563
- <span class="tip-chip" onclick="chatSendTip(this)">GPU 加速</span>
564
- <span class="tip-chip" onclick="chatSendTip(this)">全文搜索</span>
565
- </div>
566
- </div>
567
- </div>
568
- <div class="docs-chat-input-wrap">
569
- <textarea class="docs-chat-input" id="docsChatInput" placeholder="发送消息搜索文档数据库..." rows="1" onkeydown="chatInputKeydown(event)" oninput="chatAutoResize(this)"></textarea>
570
- <button class="docs-chat-send" id="docsChatSend" onclick="chatSend()" title="发送">↑</button>
571
- </div>
572
- </div>
573
- </div>
574
- <!-- Document Content View -->
575
- <div id="docsContentView" style="display:none;flex-direction:column;flex:1;min-height:0;">
576
- <div class="docs-content-header">
577
- <div style="flex:1;min-width:0;">
578
- <div class="docs-content-title" id="docsContentTitle">文档标题</div>
579
- <div class="docs-content-meta" id="docsContentMeta"></div>
580
- </div>
581
- <button style="flex-shrink:0;background:none;border:1px solid #374151;border-radius:6px;padding:4px 10px;color:#9ca3af;font-size:11px;cursor:pointer;transition:all 0.15s;" onmouseover="this.style.borderColor='#6366f1';this.style.color='#a5b4fc'" onmouseout="this.style.borderColor='#374151';this.style.color='#9ca3af'" onclick="backToChat()" title="返回对话搜索">← 返回搜索</button>
582
- </div>
583
- <div class="docs-content-body" id="docsContentBody">
584
- <div class="doc-content" id="docsContentInner"></div>
585
- </div>
586
- </div>
587
- </div>
588
- </div>
589
- </div>
590
-
591
- <!-- ===== PAGE: Stats Dashboard ===== -->
592
- <div class="page-view" id="pageStats">
593
- <div class="stats-page" id="statsPageContent">
594
- <div class="stats-header">
595
- <h2>📊 项目仪表盘 — ${projectName}</h2>
596
- <p>项目开发进度总览与关键指标</p>
597
- </div>
598
- <!-- 内容由 JS 动态渲染 -->
599
- <div id="statsContent"><div style="text-align:center;padding:60px;color:#6b7280;">加载中...</div></div>
600
- </div>
601
- </div>
602
-
603
- </div>
604
- </div>
605
-
606
- <!-- Stats Modal -->
607
- <div class="stats-modal-overlay" id="statsModalOverlay">
608
- <div class="stats-modal">
609
- <div class="stats-modal-header">
610
- <div><span class="stats-modal-title" id="statsModalTitle">列表</span><span class="stats-modal-count" id="statsModalCount"></span></div>
611
- <button class="stats-modal-close" onclick="closeStatsModal()">&times;</button>
612
- </div>
613
- <div class="stats-modal-body" id="statsModalBody"></div>
614
- </div>
615
- </div>
616
-
41
+ ${(0, template_html_1.getHTML)(projectName)}
617
42
  <script>
618
- // ========== Sidebar ==========
619
- function toggleSidebar() {
620
- var sidebar = document.getElementById('sidebar');
621
- if (!sidebar) return;
622
- sidebar.classList.toggle('expanded');
623
- var isExpanded = sidebar.classList.contains('expanded');
624
- // 记住偏好
625
- try { localStorage.setItem('devplan_sidebar_expanded', isExpanded ? '1' : '0'); } catch(e) {}
626
- // 同步更新左侧弹层位置
627
- updateStatsModalPosition();
628
- // 通知 vis-network 重新适配尺寸
629
- setTimeout(function() { if (network) network.redraw(); }, 300);
630
- }
631
-
632
- /** 根据侧边栏状态更新左侧弹层位置 */
633
- function updateStatsModalPosition() {
634
- var modal = document.querySelector('.stats-modal');
635
- var sidebar = document.getElementById('sidebar');
636
- if (modal && sidebar) {
637
- modal.style.left = (sidebar.classList.contains('expanded') ? 200 : 48) + 'px';
638
- }
639
- }
640
-
641
- var currentPage = 'graph';
642
- var pageMap = { graph: 'pageGraph', stats: 'pageStats', docs: 'pageDocs' };
643
-
644
- function navTo(page) {
645
- // 仅支持已实现的页面
646
- if (!pageMap[page]) return;
647
- if (page === currentPage) return;
648
-
649
- // 切换页面视图
650
- var oldView = document.getElementById(pageMap[currentPage]);
651
- var newView = document.getElementById(pageMap[page]);
652
- if (oldView) oldView.classList.remove('active');
653
- if (newView) newView.classList.add('active');
654
-
655
- // 切换导航高亮
656
- var items = document.querySelectorAll('.nav-item[data-page]');
657
- for (var i = 0; i < items.length; i++) {
658
- items[i].classList.remove('active');
659
- if (items[i].getAttribute('data-page') === page) items[i].classList.add('active');
660
- }
661
-
662
- currentPage = page;
663
-
664
- // 离开图谱页面时关闭左侧弹层
665
- if (page !== 'graph') closeStatsModal();
666
-
667
- // 按需加载页面数据
668
- if (page === 'stats') loadStatsPage();
669
- if (page === 'docs') loadDocsPage();
670
- if (page === 'graph' && network) {
671
- setTimeout(function() { network.redraw(); network.fit(); }, 100);
672
- }
673
- }
674
-
675
- // 恢复 sidebar 偏好
676
- (function() {
677
- try {
678
- var saved = localStorage.getItem('devplan_sidebar_expanded');
679
- if (saved === '1') {
680
- var sidebar = document.getElementById('sidebar');
681
- if (sidebar) { sidebar.classList.add('expanded'); }
682
- // 同步弹层初始位置
683
- updateStatsModalPosition();
684
- }
685
- } catch(e) {}
686
- })();
687
-
688
- // ========== Debug ==========
689
- var dbg = document.getElementById('debug');
690
- function log(msg, ok) {
691
- console.log('[DevPlan]', msg);
692
- dbg.innerHTML = (ok ? '<span class="ok">✓</span> ' : '<span class="err">✗</span> ') + msg;
693
- }
694
-
695
- // ========== 渲染引擎选择: GraphCanvas (高性能) vs vis-network (兼容) ==========
696
- // URL 参数 ?renderer=vis 可强制使用 vis-network; 默认使用 GraphCanvas
697
- var RENDERER_ENGINE = 'auto'; // 'auto' | 'graphcanvas' | 'vis'
698
- (function() {
699
- var params = new URLSearchParams(window.location.search);
700
- var r = params.get('renderer');
701
- if (r === 'vis') RENDERER_ENGINE = 'vis';
702
- else if (r === 'graphcanvas' || r === 'gc') RENDERER_ENGINE = 'graphcanvas';
703
- })();
704
- var USE_GRAPH_CANVAS = false; // set after engine loads
705
-
706
- // ========== SimpleDataSet — vis.DataSet shim for GraphCanvas mode ==========
707
- function SimpleDataSet(items) {
708
- this._data = {};
709
- this._ids = [];
710
- if (items) {
711
- for (var i = 0; i < items.length; i++) {
712
- var item = items[i];
713
- this._data[item.id] = item;
714
- this._ids.push(item.id);
715
- }
716
- }
717
- }
718
- SimpleDataSet.prototype.get = function(id) {
719
- if (id === undefined || id === null) {
720
- // Return all items as array
721
- var result = [];
722
- for (var i = 0; i < this._ids.length; i++) result.push(this._data[this._ids[i]]);
723
- return result;
724
- }
725
- return this._data[id] || null;
726
- };
727
- SimpleDataSet.prototype.getIds = function() {
728
- return this._ids.slice();
729
- };
730
- SimpleDataSet.prototype.forEach = function(callback) {
731
- for (var i = 0; i < this._ids.length; i++) {
732
- callback(this._data[this._ids[i]], this._ids[i]);
733
- }
734
- };
735
- SimpleDataSet.prototype.update = function(itemOrArray) {
736
- var items = Array.isArray(itemOrArray) ? itemOrArray : [itemOrArray];
737
- for (var i = 0; i < items.length; i++) {
738
- var item = items[i];
739
- if (this._data[item.id]) {
740
- for (var key in item) {
741
- if (item.hasOwnProperty(key)) this._data[item.id][key] = item[key];
742
- }
743
- }
744
- }
745
- };
746
- SimpleDataSet.prototype.add = function(itemOrArray) {
747
- var items = Array.isArray(itemOrArray) ? itemOrArray : [itemOrArray];
748
- for (var i = 0; i < items.length; i++) {
749
- var item = items[i];
750
- this._data[item.id] = item;
751
- if (this._ids.indexOf(item.id) === -1) this._ids.push(item.id);
752
- }
753
- };
754
- SimpleDataSet.prototype.remove = function(idOrArray) {
755
- var ids = Array.isArray(idOrArray) ? idOrArray : [idOrArray];
756
- for (var i = 0; i < ids.length; i++) {
757
- var id = typeof ids[i] === 'object' ? ids[i].id : ids[i];
758
- delete this._data[id];
759
- var idx = this._ids.indexOf(id);
760
- if (idx >= 0) this._ids.splice(idx, 1);
761
- }
762
- };
763
- // ========== 动态加载渲染引擎 ==========
764
- function loadRenderEngine() {
765
- if (RENDERER_ENGINE === 'vis') {
766
- log('强制使用 vis-network 渲染器 (?renderer=vis)', true);
767
- loadVisNetwork(0);
768
- return;
769
- }
770
-
771
- // Try loading GraphCanvas first (from local server)
772
- log('正在加载 GraphCanvas 高性能渲染引擎...', true);
773
- var s = document.createElement('script');
774
- s.src = '/graph-canvas.js';
775
- s.onload = function() {
776
- if (typeof GraphCanvas !== 'undefined' && typeof DevPlanGraph !== 'undefined') {
777
- log('GraphCanvas 引擎加载成功 ✓', true);
778
- USE_GRAPH_CANVAS = true;
779
- startApp();
780
- } else {
781
- log('GraphCanvas 加载但对象不完整, 回退到 vis-network', false);
782
- loadVisNetwork(0);
783
- }
784
- };
785
- s.onerror = function() {
786
- log('GraphCanvas 加载失败, 回退到 vis-network', false);
787
- loadVisNetwork(0);
788
- };
789
- document.head.appendChild(s);
790
- }
791
-
792
- var VIS_URLS = [
793
- 'https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js',
794
- 'https://cdn.jsdelivr.net/npm/vis-network@9.1.9/standalone/umd/vis-network.min.js',
795
- 'https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/standalone/umd/vis-network.min.js'
796
- ];
797
-
798
- function loadVisNetwork(index) {
799
- if (index >= VIS_URLS.length) {
800
- log('所有 CDN 均加载失败,请检查网络连接', false);
801
- document.getElementById('loading').innerHTML = '<div style="text-align:center"><div style="font-size:48px;margin-bottom:16px;">⚠️</div><p style="color:#f87171;">渲染引擎加载失败</p><p style="color:#9ca3af;margin-top:8px;font-size:13px;">GraphCanvas 和 vis-network CDN 均不可用</p><button class="refresh-btn" onclick="location.reload()" style="margin-top:12px;">刷新页面</button></div>';
802
- return;
803
- }
804
- var url = VIS_URLS[index];
805
- log('尝试加载 vis-network CDN #' + (index+1) + ': ' + url.split('/')[2], true);
806
- var s = document.createElement('script');
807
- s.src = url;
808
- s.onload = function() {
809
- if (typeof vis !== 'undefined' && vis.Network && vis.DataSet) {
810
- log('vis-network 加载成功 (CDN #' + (index+1) + ')', true);
811
- USE_GRAPH_CANVAS = false;
812
- startApp();
813
- } else {
814
- log('CDN #' + (index+1) + ' 加载但 vis 对象不完整, 尝试下一个', false);
815
- loadVisNetwork(index + 1);
816
- }
817
- };
818
- s.onerror = function() {
819
- log('CDN #' + (index+1) + ' 加载失败, 尝试下一个', false);
820
- loadVisNetwork(index + 1);
821
- };
822
- document.head.appendChild(s);
823
- }
824
-
825
- // ========== State ==========
826
- var network = null;
827
- var allNodes = [];
828
- var allEdges = [];
829
- var nodesDataSet = null;
830
- var edgesDataSet = null;
831
- var hiddenTypes = {};
832
- var ctrlPressed = false;
833
- var INCLUDE_NODE_DEGREE = true;
834
- var ENABLE_BACKEND_DEGREE_FALLBACK = true;
835
-
836
- // ========== 边高亮:选中节点时关联边变色,取消选中时恢复灰色 ==========
837
- function highlightConnectedEdges(nodeId) {
838
- if (!edgesDataSet || !network) return;
839
- var connectedEdgeIds = network.getConnectedEdges(nodeId);
840
- var connectedSet = {};
841
- for (var i = 0; i < connectedEdgeIds.length; i++) connectedSet[connectedEdgeIds[i]] = true;
842
- var updates = [];
843
- edgesDataSet.forEach(function(edge) {
844
- if (connectedSet[edge.id]) {
845
- // 关联边 → 使用高亮色
846
- 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) });
847
- } else {
848
- // 非关联边 → 变淡
849
- 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 });
850
- }
851
- });
852
- edgesDataSet.update(updates);
853
- }
854
-
855
- function resetAllEdgeColors() {
856
- if (!edgesDataSet) return;
857
- var updates = [];
858
- edgesDataSet.forEach(function(edge) {
859
- updates.push({ id: edge.id, color: { color: EDGE_GRAY, highlight: edge._highlightColor || '#9ca3af', hover: edge._highlightColor || '#9ca3af' }, width: edge._origWidth || edge.width || 1 });
860
- });
861
- edgesDataSet.update(updates);
862
- }
863
-
864
- // ========== 文档节点展开/收起 ==========
865
- /** 记录哪些父文档节点处于收起状态(nodeId → true 表示收起) */
866
- var collapsedDocNodes = {};
867
- /** 收起时被重定向的边信息: { edgeId: { origFrom, origTo } } */
868
- var redirectedEdges = {};
869
- /** 记录各父文档 +/- 按钮在 canvas 坐标系中的位置,用于点击检测 */
870
- var docToggleBtnPositions = {};
871
- /** 收起前保存子文档节点的位置: { nodeId: { x, y } } */
872
- var savedChildPositions = {};
873
-
874
- /** 获取节点 ID 对应的子文档节点 ID 列表(仅直接子文档) */
875
- function getChildDocNodeIds(parentNodeId) {
876
- var childIds = [];
877
- for (var i = 0; i < allEdges.length; i++) {
878
- if (allEdges[i].from === parentNodeId && allEdges[i].label === 'doc_has_child') {
879
- childIds.push(allEdges[i].to);
880
- }
881
- }
882
- return childIds;
883
- }
884
-
885
- /** 递归获取所有后代文档节点 ID(含多层子文档) */
886
- function getAllDescendantDocNodeIds(parentNodeId) {
887
- var result = [];
888
- var queue = [parentNodeId];
889
- while (queue.length > 0) {
890
- var current = queue.shift();
891
- var children = getChildDocNodeIds(current);
892
- for (var i = 0; i < children.length; i++) {
893
- result.push(children[i]);
894
- queue.push(children[i]);
895
- }
896
- }
897
- return result;
898
- }
899
-
900
- /** 检查节点是否为父文档(有子文档的文档节点) */
901
- function isParentDocNode(node) {
902
- if (node.type !== 'document') return false;
903
- var props = node.properties || {};
904
- var childDocs = props.childDocs || [];
905
- if (childDocs.length > 0) return true;
906
- for (var i = 0; i < allEdges.length; i++) {
907
- if (allEdges[i].from === node.id && allEdges[i].label === 'doc_has_child') return true;
908
- }
909
- return false;
910
- }
911
-
912
- /** 通过 nodeId 在 allNodes 中查找节点数据 */
913
- function findAllNode(nodeId) {
914
- for (var i = 0; i < allNodes.length; i++) {
915
- if (allNodes[i].id === nodeId) return allNodes[i];
916
- }
917
- return null;
918
- }
919
-
920
- /** 检查节点是否应被隐藏(因为其祖先父文档处于收起状态) */
921
- function isNodeCollapsedByParent(nodeId) {
922
- for (var i = 0; i < allEdges.length; i++) {
923
- var e = allEdges[i];
924
- if (e.to === nodeId && e.label === 'doc_has_child') {
925
- if (collapsedDocNodes[e.from]) return true;
926
- if (isNodeCollapsedByParent(e.from)) return true;
927
- }
928
- }
929
- return false;
930
- }
931
-
932
- /** 切换父文档节点的展开/收起状态 */
933
- function toggleDocNodeExpand(nodeId) {
934
- collapsedDocNodes[nodeId] = !collapsedDocNodes[nodeId];
935
- var childIds = getAllDescendantDocNodeIds(nodeId);
936
- var isCollapsed = collapsedDocNodes[nodeId];
937
-
938
- if (isCollapsed) {
939
- // ---- 收起 ----
940
- var removeNodeIds = {};
941
- for (var i = 0; i < childIds.length; i++) removeNodeIds[childIds[i]] = true;
942
-
943
- // 0) 保存子文档节点当前位置
944
- var childPositions = network.getPositions(childIds);
945
- for (var i = 0; i < childIds.length; i++) {
946
- if (childPositions[childIds[i]]) {
947
- savedChildPositions[childIds[i]] = { x: childPositions[childIds[i]].x, y: childPositions[childIds[i]].y };
948
- }
949
- }
950
-
951
- // 1) 将连接到子文档的非 doc_has_child 边重定向到父文档
952
- var edgesToRedirect = [];
953
- var edgesToRemove = [];
954
- edgesDataSet.forEach(function(edge) {
955
- var touchesChild = removeNodeIds[edge.from] || removeNodeIds[edge.to];
956
- if (!touchesChild) return;
957
- if (edge._label === 'doc_has_child') {
958
- // doc_has_child 边直接移除
959
- edgesToRemove.push(edge.id);
960
- } else {
961
- // 其他边(如 task_has_doc)重定向到父文档
962
- edgesToRedirect.push(edge);
963
- }
964
- });
965
-
966
- // 移除 doc_has_child 边
967
- if (edgesToRemove.length > 0) edgesDataSet.remove(edgesToRemove);
968
-
969
- // 重定向其他边到父文档
970
- for (var i = 0; i < edgesToRedirect.length; i++) {
971
- var edge = edgesToRedirect[i];
972
- var newFrom = removeNodeIds[edge.from] ? nodeId : edge.from;
973
- var newTo = removeNodeIds[edge.to] ? nodeId : edge.to;
974
- // 检查是否已存在相同的重定向边(避免重复)
975
- var duplicate = false;
976
- edgesDataSet.forEach(function(existing) {
977
- if (existing.from === newFrom && existing.to === newTo && existing._label === edge._label) duplicate = true;
978
- });
979
- if (newFrom === newTo) { duplicate = true; } // 不自连
980
- if (!duplicate) {
981
- redirectedEdges[edge.id] = { origFrom: edge.from, origTo: edge.to };
982
- edgesDataSet.update({ id: edge.id, from: newFrom, to: newTo });
983
- } else {
984
- // 重复则移除
985
- redirectedEdges[edge.id] = { origFrom: edge.from, origTo: edge.to };
986
- edgesDataSet.remove([edge.id]);
987
- }
988
- }
989
-
990
- // 2) 移除子文档节点
991
- nodesDataSet.remove(childIds);
992
-
993
- // 3) 更新父节点标签(加左侧留白和收起数量提示)
994
- var parentNode = nodesDataSet.get(nodeId);
995
- if (parentNode) {
996
- var origLabel = parentNode._origLabel || parentNode.label;
997
- var pad = ' ';
998
- nodesDataSet.update({ id: nodeId, label: pad + origLabel + ' [' + childIds.length + ']', _origLabel: origLabel });
999
- }
1000
- log('收起文档: 隐藏 ' + childIds.length + ' 个子文档, 重定向 ' + edgesToRedirect.length + ' 条边', true);
1001
-
1002
- } else {
1003
- // ---- 展开 ----
1004
- // 1) 恢复被重定向的边
1005
- var restoreEdgeIds = [];
1006
- for (var eid in redirectedEdges) {
1007
- var info = redirectedEdges[eid];
1008
- // 检查 origFrom 或 origTo 是否属于此父文档的子孙
1009
- var isRelated = false;
1010
- for (var ci = 0; ci < childIds.length; ci++) {
1011
- if (info.origFrom === childIds[ci] || info.origTo === childIds[ci]) { isRelated = true; break; }
1012
- }
1013
- if (!isRelated) continue;
1014
- restoreEdgeIds.push(eid);
1015
- // 恢复原始 from/to 或重新添加
1016
- var existing = edgesDataSet.get(eid);
1017
- if (existing) {
1018
- edgesDataSet.update({ id: eid, from: info.origFrom, to: info.origTo });
1019
- } else {
1020
- // 边已被移除(因重复),需重新添加
1021
- // 在 allEdges 中找到此边原始数据
1022
- for (var ai = 0; ai < allEdges.length; ai++) {
1023
- var ae = allEdges[ai];
1024
- if (ae.from === info.origFrom && ae.to === info.origTo) {
1025
- var es = edgeStyle(ae);
1026
- 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' });
1027
- break;
1028
- }
1029
- }
1030
- }
1031
- }
1032
- for (var ri = 0; ri < restoreEdgeIds.length; ri++) {
1033
- delete redirectedEdges[restoreEdgeIds[ri]];
1034
- }
1035
-
1036
- // 2) 重新添加子文档节点(使用保存的位置或思维导图排列)
1037
- var parentPos = network.getPositions([nodeId])[nodeId];
1038
- var addNodes = [];
1039
- var visibleChildIds = [];
1040
- for (var ni = 0; ni < allNodes.length; ni++) {
1041
- var n = allNodes[ni];
1042
- for (var ci = 0; ci < childIds.length; ci++) {
1043
- if (n.id === childIds[ci] && !isNodeCollapsedByParent(n.id)) {
1044
- var deg = getNodeDegree(n);
1045
- var s = nodeStyle(n, deg);
1046
- 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 || {} };
1047
- // 使用保存的位置
1048
- if (savedChildPositions[n.id]) {
1049
- nodeData.x = savedChildPositions[n.id].x;
1050
- nodeData.y = savedChildPositions[n.id].y;
1051
- }
1052
- addNodes.push(nodeData);
1053
- visibleChildIds.push(n.id);
1054
- break;
1055
- }
1056
- }
1057
- }
1058
- if (addNodes.length > 0) {
1059
- nodesDataSet.add(addNodes);
1060
- // 如果没有保存位置,按思维导图方式排列
1061
- var needArrange = false;
1062
- for (var i = 0; i < visibleChildIds.length; i++) {
1063
- if (!savedChildPositions[visibleChildIds[i]]) { needArrange = true; break; }
1064
- }
1065
- if (needArrange && parentPos) {
1066
- arrangeDocMindMap(nodeId, visibleChildIds);
1067
- }
1068
- }
1069
-
1070
- // 3) 重新添加 doc_has_child 边
1071
- var addedNodeIds = {};
1072
- nodesDataSet.forEach(function(n) { addedNodeIds[n.id] = true; });
1073
- var addEdges = [];
1074
- for (var ei = 0; ei < allEdges.length; ei++) {
1075
- var e = allEdges[ei];
1076
- if (!addedNodeIds[e.from] || !addedNodeIds[e.to]) continue;
1077
- if (e.label !== 'doc_has_child') continue;
1078
- var exists = false;
1079
- edgesDataSet.forEach(function(existing) {
1080
- if (existing.from === e.from && existing.to === e.to && existing._label === e.label) exists = true;
1081
- });
1082
- if (!exists) {
1083
- var es = edgeStyle(e);
1084
- 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' });
1085
- }
1086
- }
1087
- if (addEdges.length > 0) edgesDataSet.add(addEdges);
1088
-
1089
- // 4) 恢复父节点标签(保留左侧留白)
1090
- var parentNode = nodesDataSet.get(nodeId);
1091
- if (parentNode && parentNode._origLabel) {
1092
- var pad = ' ';
1093
- nodesDataSet.update({ id: nodeId, label: pad + parentNode._origLabel });
1094
- }
1095
- log('展开文档: 显示 ' + addNodes.length + ' 个子文档', true);
1096
- }
1097
- }
1098
-
1099
- /** 在 afterDrawing 中绘制父文档节点的 +/- 按钮 */
1100
- function drawDocToggleButtons(ctx) {
1101
- docToggleBtnPositions = {};
1102
- nodesDataSet.forEach(function(node) {
1103
- if (node._type !== 'document') return;
1104
- var allNode = findAllNode(node.id);
1105
- if (!allNode || !isParentDocNode(allNode)) return;
1106
- var pos = network.getPositions([node.id])[node.id];
1107
- if (!pos) return;
1108
- var isCollapsed = !!collapsedDocNodes[node.id];
1109
- var btnRadius = 9;
1110
-
1111
- // 使用 getBoundingBox 获取节点精确边界,按钮放在节点内左侧留白区域中心
1112
- var bbox = network.getBoundingBox(node.id);
1113
- var btnX, btnY;
1114
- if (bbox) {
1115
- btnX = bbox.left + btnRadius + 1; // 按钮完全在节点内,左侧留白区域居中
1116
- btnY = (bbox.top + bbox.bottom) / 2; // 垂直居中
1117
- } else {
1118
- btnX = pos.x;
1119
- btnY = pos.y;
1120
- }
1121
-
1122
- // 记录位置(canvas 坐标)
1123
- docToggleBtnPositions[node.id] = { x: btnX, y: btnY, r: btnRadius };
1124
-
1125
- // 绘制圆形按钮背景(蓝色系配色)
1126
- ctx.beginPath();
1127
- ctx.arc(btnX, btnY, btnRadius, 0, Math.PI * 2);
1128
- ctx.fillStyle = isCollapsed ? '#3b82f6' : '#1e40af'; // 收起:亮蓝 展开:深蓝
1129
- ctx.fill();
1130
- ctx.strokeStyle = '#ffffff'; // 白色描边
1131
- ctx.lineWidth = 1.5;
1132
- ctx.stroke();
1133
- ctx.closePath();
1134
-
1135
- // 绘制 + 或 - 符号
1136
- ctx.fillStyle = '#ffffff';
1137
- ctx.font = 'bold 13px sans-serif';
1138
- ctx.textAlign = 'center';
1139
- ctx.textBaseline = 'middle';
1140
- ctx.fillText(isCollapsed ? '+' : '−', btnX, btnY + 0.5);
1141
- });
1142
- }
1143
-
1144
- /** 检查 canvas 坐标是否点击了某个 +/- 按钮,返回 nodeId 或 null */
1145
- function hitTestDocToggleBtn(canvasX, canvasY) {
1146
- for (var nodeId in docToggleBtnPositions) {
1147
- var btn = docToggleBtnPositions[nodeId];
1148
- var dx = canvasX - btn.x;
1149
- var dy = canvasY - btn.y;
1150
- if (dx * dx + dy * dy <= (btn.r + 4) * (btn.r + 4)) {
1151
- return nodeId;
1152
- }
1153
- }
1154
- return null;
1155
- }
1156
-
1157
- /**
1158
- * 将父文档及其子文档按思维导图方式排列:
1159
- * 父文档在左,子文档在右侧垂直等距、左边缘对齐
1160
- */
1161
- function arrangeDocMindMap(parentNodeId, childNodeIds) {
1162
- if (!network || childNodeIds.length === 0) return;
1163
- var parentPos = network.getPositions([parentNodeId])[parentNodeId];
1164
- if (!parentPos) return;
1165
-
1166
- var parentBbox = network.getBoundingBox(parentNodeId);
1167
- var parentRight = parentBbox ? parentBbox.right : (parentPos.x + 80);
1168
- var leftEdgeX = parentRight + 40; // 子节点左边缘的目标 X
1169
- var vGap = 45;
1170
- var count = childNodeIds.length;
1171
- var totalHeight = (count - 1) * vGap;
1172
- var startY = parentPos.y - totalHeight / 2;
1173
-
1174
- // 先读取每个子节点当前的宽度(移动前 bbox 有效)
1175
- var halfLefts = [];
1176
- for (var i = 0; i < count; i++) {
1177
- var cid = childNodeIds[i];
1178
- var bbox = network.getBoundingBox(cid);
1179
- var cpos = network.getPositions([cid])[cid];
1180
- if (bbox && cpos) {
1181
- halfLefts.push(cpos.x - bbox.left); // 节点中心到左边缘的距离(即半宽)
1182
- } else {
1183
- halfLefts.push(100); // 默认估算
1184
- }
1185
- }
1186
-
1187
- // 一次性移动所有子节点:左边缘对齐到 leftEdgeX
1188
- for (var i = 0; i < count; i++) {
1189
- var cx = leftEdgeX + halfLefts[i];
1190
- var cy = startY + i * vGap;
1191
- network.moveNode(childNodeIds[i], cx, cy);
1192
- savedChildPositions[childNodeIds[i]] = { x: cx, y: cy };
1193
- }
1194
- }
1195
-
1196
- /** 初始化时将所有父文档-子文档按思维导图方式排列 */
1197
- function arrangeAllDocMindMaps() {
1198
- // 找到所有父文档节点
1199
- var parentDocIds = [];
1200
- for (var i = 0; i < allNodes.length; i++) {
1201
- var n = allNodes[i];
1202
- if (isParentDocNode(n)) {
1203
- // 检查该节点在当前可见节点集中
1204
- var visible = nodesDataSet.get(n.id);
1205
- if (visible) parentDocIds.push(n.id);
1206
- }
1207
- }
1208
- for (var pi = 0; pi < parentDocIds.length; pi++) {
1209
- var pid = parentDocIds[pi];
1210
- var childIds = getChildDocNodeIds(pid);
1211
- // 只排列当前可见的子节点
1212
- var visibleChildIds = [];
1213
- for (var ci = 0; ci < childIds.length; ci++) {
1214
- if (nodesDataSet.get(childIds[ci])) visibleChildIds.push(childIds[ci]);
1215
- }
1216
- if (visibleChildIds.length > 0) {
1217
- arrangeDocMindMap(pid, visibleChildIds);
1218
- }
1219
- }
1220
- log('思维导图排列: ' + parentDocIds.length + ' 个父文档已排列', true);
1221
- }
43
+ ${(0, template_core_1.getCoreScript)()}
44
+ ${(0, template_graph_vis_1.getGraphVisScript)()}
45
+ ${(0, template_data_loading_1.getDataLoadingScript)()}
46
+ ${(0, template_graph_3d_1.getGraph3DScript)()}
47
+ ${(0, template_detail_panel_1.getDetailPanelScript)()}
48
+ ${(0, template_stats_modal_1.getStatsModalScript)()}
49
+ ${(0, template_pages_1.getPagesScript)()}
1222
50
 
1223
- // ========== 呼吸灯动画 (in_progress 主任务) ==========
1224
- var breathAnimId = null; // requestAnimationFrame ID
1225
- var breathPhase = 0; // 动画相位 [0, 2π)
1226
-
1227
- /** 启动呼吸灯动画循环 */
1228
- function startBreathAnimation() {
1229
- if (breathAnimId) return; // 已在运行
1230
- function tick() {
1231
- breathPhase += 0.03; // 控制呼吸速度
1232
- if (breathPhase > Math.PI * 2) breathPhase -= Math.PI * 2;
1233
- if (network) network.redraw();
1234
- breathAnimId = requestAnimationFrame(tick);
1235
- }
1236
- breathAnimId = requestAnimationFrame(tick);
1237
- }
1238
-
1239
- /** 停止呼吸灯动画循环 */
1240
- function stopBreathAnimation() {
1241
- if (breathAnimId) {
1242
- cancelAnimationFrame(breathAnimId);
1243
- breathAnimId = null;
1244
- }
1245
- }
1246
-
1247
- /** 获取所有 in_progress 的主任务节点 ID 列表 */
1248
- function getInProgressMainTaskIds() {
1249
- var ids = [];
1250
- if (!nodesDataSet) return ids;
1251
- var all = nodesDataSet.get();
1252
- for (var i = 0; i < all.length; i++) {
1253
- var n = all[i];
1254
- if (n._type === 'main-task' && n._props && n._props.status === 'in_progress') {
1255
- ids.push(n.id);
1256
- }
1257
- }
1258
- return ids;
1259
- }
1260
-
1261
- // 监听 Ctrl 按键状态
1262
- document.addEventListener('keydown', function(e) { if (e.key === 'Control') ctrlPressed = true; });
1263
- document.addEventListener('keyup', function(e) { if (e.key === 'Control') ctrlPressed = false; });
1264
- window.addEventListener('blur', function() { ctrlPressed = false; });
1265
-
1266
- // ========== Node Styles ==========
1267
- var STATUS_COLORS = {
1268
- completed: { bg: '#059669', border: '#047857', font: '#d1fae5' },
1269
- in_progress: { bg: '#7c3aed', border: '#6d28d9', font: '#ddd6fe' },
1270
- pending: { bg: '#4b5563', border: '#374151', font: '#d1d5db' },
1271
- cancelled: { bg: '#92400e', border: '#78350f', font: '#fde68a' }
1272
- };
1273
-
1274
- // ========== 节点动态大小规则 ==========
1275
- // 根据节点的连接数(度数)动态调整大小,连接越多节点越大
1276
- // min: 最小尺寸, max: 最大尺寸, baseFont: 基础字号, maxFont: 最大字号
1277
- // scale: 缩放系数 (越大增长越快)
1278
- var NODE_SIZE_RULES = {
1279
- 'project': { min: 35, max: 65, baseFont: 16, maxFont: 22, scale: 3.5 },
1280
- 'module': { min: 20, max: 45, baseFont: 12, maxFont: 16, scale: 2.8 },
1281
- 'main-task': { min: 14, max: 38, baseFont: 11, maxFont: 15, scale: 2.2 },
1282
- 'sub-task': { min: 7, max: 18, baseFont: 8, maxFont: 11, scale: 1.5 },
1283
- 'document': { min: 12, max: 30, baseFont: 9, maxFont: 13, scale: 1.8 }
1284
- };
1285
-
1286
- /** 获取节点度数:纯后端下发,缺失视为 0 */
1287
- function getNodeDegree(node) {
1288
- if (typeof node.degree === 'number' && !isNaN(node.degree)) return node.degree;
1289
- return 0;
1290
- }
1291
-
1292
- /** 根据类型和度数计算节点尺寸与字号 */
1293
- function calcNodeSize(type, degree) {
1294
- var rule = NODE_SIZE_RULES[type] || { min: 10, max: 22, baseFont: 10, maxFont: 13, scale: 1.0 };
1295
- // 使用 sqrt 曲线:低度数时增长快,高度数时增长变缓
1296
- var size = rule.min + rule.scale * Math.sqrt(degree);
1297
- size = Math.max(rule.min, Math.min(size, rule.max));
1298
- // 字号随尺寸线性插值
1299
- var sizeRatio = (size - rule.min) / (rule.max - rule.min || 1);
1300
- var fontSize = Math.round(rule.baseFont + sizeRatio * (rule.maxFont - rule.baseFont));
1301
- return { size: Math.round(size), fontSize: fontSize };
1302
- }
1303
-
1304
- function nodeStyle(node, degree) {
1305
- var t = node.type;
1306
- var p = node.properties || {};
1307
- var status = p.status || 'pending';
1308
- var sc = STATUS_COLORS[status] || STATUS_COLORS.pending;
1309
- var ns = calcNodeSize(t, degree || 0);
1310
-
1311
- if (t === 'project') {
1312
- return { shape: 'star', size: ns.size, color: { background: '#f59e0b', border: '#d97706', highlight: { background: '#fbbf24', border: '#fff' } }, font: { size: ns.fontSize, color: '#fff' }, borderWidth: 3 };
1313
- }
1314
- if (t === 'module') {
1315
- return { shape: 'diamond', size: ns.size, color: { background: '#059669', border: '#047857', highlight: { background: '#10b981', border: '#fff' } }, font: { size: ns.fontSize, color: '#d1fae5' }, borderWidth: 2 };
1316
- }
1317
- if (t === 'main-task') {
1318
- 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: 2 };
1319
- }
1320
- if (t === 'sub-task') {
1321
- 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 };
1322
- }
1323
- if (t === 'document') {
1324
- return { shape: 'box', size: ns.size, color: { background: '#2563eb', border: '#1d4ed8', highlight: { background: '#3b82f6', border: '#fff' } }, font: { size: ns.fontSize, color: '#dbeafe' }, borderWidth: 1 };
1325
- }
1326
- return { shape: 'dot', size: ns.size, color: { background: '#6b7280', border: '#4b5563' }, font: { size: ns.fontSize, color: '#9ca3af' } };
1327
- }
1328
-
1329
- // 默认灰色 + 选中时高亮色(per-type)
1330
- var EDGE_GRAY = '#4b5563';
1331
-
1332
- function edgeStyle(edge) {
1333
- var label = edge.label || '';
1334
- 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' };
1335
- 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' };
1336
- 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' };
1337
- 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' };
1338
- 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' };
1339
- 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' };
1340
- return { width: 1, color: { color: EDGE_GRAY, highlight: '#9ca3af', hover: '#9ca3af' }, dashes: false, _highlightColor: '#9ca3af' };
1341
- }
1342
-
1343
- // ========== Data Loading ==========
1344
- // ── Phase-8C: Chunked loading configuration ──
1345
- var CHUNK_SIZE = 5000; // nodes per page
1346
- var CHUNK_THRESHOLD = 3000; // use chunked loading if total > this
1347
-
1348
- function loadData() {
1349
- document.getElementById('loading').style.display = 'flex';
1350
- log('正在获取图谱数据...', true);
1351
- var graphApiUrl = '/api/graph?includeNodeDegree=' + (INCLUDE_NODE_DEGREE ? 'true' : 'false') +
1352
- '&enableBackendDegreeFallback=' + (ENABLE_BACKEND_DEGREE_FALLBACK ? 'true' : 'false');
1353
-
1354
- Promise.all([
1355
- fetch(graphApiUrl).then(function(r) { return r.json(); }),
1356
- fetch('/api/progress').then(function(r) { return r.json(); })
1357
- ]).then(function(results) {
1358
- var graphRes = results[0];
1359
- var progressRes = results[1];
1360
- allNodes = graphRes.nodes || [];
1361
- allEdges = graphRes.edges || [];
1362
- log('数据获取成功: ' + allNodes.length + ' 节点, ' + allEdges.length + ' 边', true);
1363
- renderStats(progressRes, graphRes);
1364
-
1365
- // Phase-8C: If data is large and GraphCanvas is active, use chunked loading
1366
- if (USE_GRAPH_CANVAS && allNodes.length > CHUNK_THRESHOLD) {
1367
- renderGraphChunked();
1368
- } else {
1369
- renderGraph();
1370
- }
1371
- }).catch(function(err) {
1372
- log('数据获取失败: ' + err.message, false);
1373
- document.getElementById('loading').innerHTML = '<div style="text-align:center"><div style="font-size:48px;margin-bottom:16px;">⚠️</div><p style="color:#f87171;">数据加载失败: ' + err.message + '</p><button class="refresh-btn" onclick="loadData()" style="margin-top:12px;">重试</button></div>';
1374
- });
1375
- }
1376
-
1377
- /**
1378
- * Phase-8C T8C.3+T8C.4: Chunked progressive rendering for large datasets.
1379
- * Renders the first CHUNK_SIZE nodes immediately, then loads remaining chunks
1380
- * in the background using addNodes/addEdges incremental API.
1381
- */
1382
- function renderGraphChunked() {
1383
- try {
1384
- var container = document.getElementById('graph');
1385
- var rect = container.getBoundingClientRect();
1386
- if (rect.height < 50) {
1387
- container.style.height = (window.innerHeight - 140) + 'px';
1388
- }
1389
-
1390
- // Sort nodes: center-priority (closest to centroid first)
1391
- var cx = 0, cy = 0;
1392
- for (var i = 0; i < allNodes.length; i++) {
1393
- cx += (allNodes[i].x || 0);
1394
- cy += (allNodes[i].y || 0);
1395
- }
1396
- if (allNodes.length > 0) { cx /= allNodes.length; cy /= allNodes.length; }
1397
- var sortedNodes = allNodes.slice().sort(function(a, b) {
1398
- var da = Math.pow(((a.x||0) - cx), 2) + Math.pow(((a.y||0) - cy), 2);
1399
- var db = Math.pow(((b.x||0) - cx), 2) + Math.pow(((b.y||0) - cy), 2);
1400
- return da - db;
1401
- });
1402
-
1403
- // First chunk
1404
- var firstChunkNodes = sortedNodes.slice(0, CHUNK_SIZE);
1405
- var firstChunkIds = {};
1406
- for (var i = 0; i < firstChunkNodes.length; i++) firstChunkIds[firstChunkNodes[i].id] = true;
1407
- var firstChunkEdges = [];
1408
- for (var i = 0; i < allEdges.length; i++) {
1409
- if (firstChunkIds[allEdges[i].from] && firstChunkIds[allEdges[i].to]) {
1410
- firstChunkEdges.push(allEdges[i]);
1411
- }
1412
- }
1413
-
1414
- // Prepare first chunk visible nodes/edges (same transform as renderGraph)
1415
- var visibleNodes = [];
1416
- for (var i = 0; i < firstChunkNodes.length; i++) {
1417
- var n = firstChunkNodes[i];
1418
- if (hiddenTypes[n.type]) continue;
1419
- if (isNodeCollapsedByParent(n.id)) continue;
1420
- var deg = getNodeDegree(n);
1421
- var s = nodeStyle(n, deg);
1422
- visibleNodes.push({
1423
- id: n.id, label: n.label, _origLabel: n.label,
1424
- title: n.label + ' (连接: ' + deg + ')',
1425
- shape: s.shape, size: s.size, color: s.color, font: s.font,
1426
- borderWidth: s.borderWidth, _type: n.type,
1427
- _props: n.properties || {}, _isParentDoc: isParentDocNode(n),
1428
- });
1429
- }
1430
- var visibleIds = {};
1431
- for (var i = 0; i < visibleNodes.length; i++) visibleIds[visibleNodes[i].id] = true;
1432
- var visibleEdges = [];
1433
- for (var i = 0; i < firstChunkEdges.length; i++) {
1434
- var e = firstChunkEdges[i];
1435
- if (!visibleIds[e.from] || !visibleIds[e.to]) continue;
1436
- var es = edgeStyle(e);
1437
- visibleEdges.push({
1438
- id: 'e' + i, from: e.from, to: e.to,
1439
- width: es.width, _origWidth: es.width,
1440
- color: es.color, dashes: es.dashes, arrows: es.arrows,
1441
- _label: e.label, _highlightColor: es._highlightColor || '#9ca3af',
1442
- });
1443
- }
1444
-
1445
- log('分块加载: 首批 ' + visibleNodes.length + '/' + allNodes.length + ' 节点', true);
1446
-
1447
- if (network) { network.destroy(); network = null; }
1448
-
1449
- // Create network with first chunk
1450
- nodesDataSet = new SimpleDataSet(visibleNodes);
1451
- edgesDataSet = new SimpleDataSet(visibleEdges);
1452
-
1453
- var networkOptions = {
1454
- nodes: { borderWidth: 2, shadow: { enabled: true, color: 'rgba(0,0,0,0.3)', size: 5, x: 0, y: 2 } },
1455
- edges: { smooth: { enabled: true, type: 'continuous', roundness: 0.5 }, shadow: false },
1456
- physics: { enabled: true, solver: 'forceAtlas2Based',
1457
- forceAtlas2Based: { gravitationalConstant: -80, centralGravity: 0.015, springLength: 150, springConstant: 0.05, damping: 0.4, avoidOverlap: 0.8 },
1458
- stabilization: { enabled: true, iterations: 200, updateInterval: 25 }
1459
- },
1460
- interaction: { hover: true, tooltipDelay: 100, navigationButtons: false, keyboard: false, zoomView: true, dragView: true },
1461
- layout: { improvedLayout: false, hierarchical: false }
1462
- };
1463
-
1464
- network = new DevPlanGraph(container, { nodes: visibleNodes, edges: visibleEdges }, networkOptions);
1465
-
1466
- // Show loading indicator with progress
1467
- document.getElementById('loading').style.display = 'none';
1468
- log('首批数据已渲染,后台加载剩余 ' + (sortedNodes.length - CHUNK_SIZE) + ' 节点...', true);
1469
-
1470
- // ── Progressive background loading ──
1471
- var loadedNodeIds = Object.assign({}, firstChunkIds);
1472
- var chunkIndex = 1;
1473
- var totalChunks = Math.ceil(sortedNodes.length / CHUNK_SIZE);
1474
-
1475
- function loadNextChunk() {
1476
- var start = chunkIndex * CHUNK_SIZE;
1477
- var end = Math.min(start + CHUNK_SIZE, sortedNodes.length);
1478
- if (start >= sortedNodes.length) {
1479
- log('✅ 全部数据加载完成: ' + allNodes.length + ' 节点, ' + allEdges.length + ' 边', true);
1480
- return;
1481
- }
1482
-
1483
- var chunkNodes = [];
1484
- for (var i = start; i < end; i++) {
1485
- var n = sortedNodes[i];
1486
- if (hiddenTypes[n.type]) continue;
1487
- if (isNodeCollapsedByParent(n.id)) continue;
1488
- var deg = getNodeDegree(n);
1489
- var s = nodeStyle(n, deg);
1490
- chunkNodes.push({
1491
- id: n.id, label: n.label, _origLabel: n.label,
1492
- title: n.label, shape: s.shape, size: s.size,
1493
- color: s.color, font: s.font, borderWidth: s.borderWidth,
1494
- _type: n.type, _props: n.properties || {},
1495
- x: n.x || 0, y: n.y || 0,
1496
- });
1497
- loadedNodeIds[n.id] = true;
1498
- }
1499
-
1500
- // Edges for this chunk (both endpoints must be loaded)
1501
- var chunkEdges = [];
1502
- for (var i = 0; i < allEdges.length; i++) {
1503
- var e = allEdges[i];
1504
- if (loadedNodeIds[e.from] && loadedNodeIds[e.to]) {
1505
- var es = edgeStyle(e);
1506
- chunkEdges.push({
1507
- id: 'ec' + chunkIndex + '_' + i, from: e.from, to: e.to,
1508
- width: es.width, _origWidth: es.width,
1509
- color: es.color, dashes: es.dashes, arrows: es.arrows,
1510
- _label: e.label, _highlightColor: es._highlightColor || '#9ca3af',
1511
- });
1512
- }
1513
- }
1514
-
1515
- // Use incremental API (Phase-8C T8C.5)
1516
- if (network && network._gc) {
1517
- network._gc.addNodes(chunkNodes);
1518
- network._gc.addEdges(chunkEdges);
1519
- }
1520
-
1521
- chunkIndex++;
1522
- var pct = Math.min(100, Math.round(chunkIndex / totalChunks * 100));
1523
- log('加载进度: ' + pct + '% (' + (chunkIndex * CHUNK_SIZE) + '/' + sortedNodes.length + ')', true);
1524
-
1525
- // Schedule next chunk (yield to main thread for rendering)
1526
- if (chunkIndex < totalChunks) {
1527
- setTimeout(loadNextChunk, 50);
1528
- } else {
1529
- log('✅ 全部数据加载完成: ' + Object.keys(loadedNodeIds).length + ' 节点', true);
1530
- }
1531
- }
1532
-
1533
- // Start loading remaining chunks after first render stabilizes
1534
- network.on('stabilizationIterationsDone', function() {
1535
- network.setOptions({ physics: { enabled: false } });
1536
- log('首批渲染稳定,开始后台增量加载...', true);
1537
- setTimeout(loadNextChunk, 100);
1538
- });
1539
-
1540
- // Wire up click handler (same as renderGraph)
1541
- network.on('click', function(params) {
1542
- if (params.pointer && params.pointer.canvas) {
1543
- var hitNodeId = hitTestDocToggleBtn(params.pointer.canvas.x, params.pointer.canvas.y);
1544
- if (hitNodeId) { toggleDocNodeExpand(hitNodeId); return; }
1545
- }
1546
- if (params.nodes.length > 0) {
1547
- panelHistory = [];
1548
- currentPanelNodeId = null;
1549
- highlightConnectedEdges(params.nodes[0]);
1550
- showPanel(params.nodes[0]);
1551
- } else {
1552
- resetAllEdgeColors();
1553
- closePanel();
1554
- }
1555
- });
1556
-
1557
- } catch (e) {
1558
- log('分块渲染失败: ' + e.message, false);
1559
- log('回退到标准渲染模式', true);
1560
- renderGraph();
1561
- }
1562
- }
1563
-
1564
- function renderStats(progress, graph) {
1565
- var bar = document.getElementById('statsBar');
1566
- var pct = progress.overallPercent || 0;
1567
- var moduleCount = 0;
1568
- var docCount = 0;
1569
- for (var i = 0; i < graph.nodes.length; i++) {
1570
- if (graph.nodes[i].type === 'module') moduleCount++;
1571
- if (graph.nodes[i].type === 'document') docCount++;
1572
- }
1573
- bar.innerHTML =
1574
- '<div class="stat clickable" onclick="showStatsModal(\\x27module\\x27)" title="查看所有模块"><span class="num amber">' + moduleCount + '</span> 模块</div>' +
1575
- '<div class="stat clickable" onclick="showStatsModal(\\x27main-task\\x27)" title="查看所有主任务"><span class="num blue">' + progress.mainTaskCount + '</span> 主任务</div>' +
1576
- '<div class="stat clickable" onclick="showStatsModal(\\x27sub-task\\x27)" title="查看所有子任务"><span class="num purple">' + progress.subTaskCount + '</span> 子任务</div>' +
1577
- '<div class="stat clickable" onclick="showStatsModal(\\x27document\\x27)" title="查看所有文档"><span class="num" style="color:#3b82f6;">📄 ' + docCount + '</span> 文档</div>' +
1578
- '<div class="stat"><span class="num green">' + progress.completedSubTasks + '/' + progress.subTaskCount + '</span> 已完成</div>' +
1579
- '<div class="stat"><div class="progress-bar"><div class="progress-fill" style="width:' + pct + '%"></div></div><span>' + pct + '%</span></div>';
1580
- }
1581
-
1582
- // ========== Graph Rendering ==========
1583
- function renderGraph() {
1584
- try {
1585
- var container = document.getElementById('graph');
1586
- var rect = container.getBoundingClientRect();
1587
- log('容器尺寸: ' + Math.round(rect.width) + 'x' + Math.round(rect.height) + ', 渲染中...', true);
1588
-
1589
- if (rect.height < 50) {
1590
- container.style.height = (window.innerHeight - 140) + 'px';
1591
- rect = container.getBoundingClientRect();
1592
- log('容器高度修正为: ' + Math.round(rect.height) + 'px', true);
1593
- }
1594
-
1595
- var visibleNodes = [];
1596
- var DOC_BTN_PAD = ' '; // 父文档标签左侧留白,为 +/- 按钮腾出空间
1597
- for (var i = 0; i < allNodes.length; i++) {
1598
- var n = allNodes[i];
1599
- if (hiddenTypes[n.type]) continue;
1600
- // 跳过被收起的子文档节点
1601
- if (isNodeCollapsedByParent(n.id)) continue;
1602
- var deg = getNodeDegree(n);
1603
- var s = nodeStyle(n, deg);
1604
- var label = n.label;
1605
- var isParentDoc = isParentDocNode(n);
1606
- if (isParentDoc) {
1607
- // 父文档标签左侧加空格,为按钮腾位
1608
- if (collapsedDocNodes[n.id]) {
1609
- var childCount = getAllDescendantDocNodeIds(n.id).length;
1610
- label = DOC_BTN_PAD + label + ' [' + childCount + ']';
1611
- } else {
1612
- label = DOC_BTN_PAD + label;
1613
- }
1614
- }
1615
- 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 });
1616
- }
1617
-
1618
- var visibleIds = {};
1619
- for (var i = 0; i < visibleNodes.length; i++) visibleIds[visibleNodes[i].id] = true;
1620
-
1621
- var visibleEdges = [];
1622
- for (var i = 0; i < allEdges.length; i++) {
1623
- var e = allEdges[i];
1624
- if (!visibleIds[e.from] || !visibleIds[e.to]) continue;
1625
- var es = edgeStyle(e);
1626
- 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' });
1627
- }
1628
-
1629
- log('可见节点: ' + visibleNodes.length + ', 可见边: ' + visibleEdges.length, true);
1630
-
1631
- if (USE_GRAPH_CANVAS) {
1632
- nodesDataSet = new SimpleDataSet(visibleNodes);
1633
- edgesDataSet = new SimpleDataSet(visibleEdges);
1634
- } else {
1635
- nodesDataSet = new vis.DataSet(visibleNodes);
1636
- edgesDataSet = new vis.DataSet(visibleEdges);
1637
- }
1638
-
1639
- if (network) {
1640
- network.destroy();
1641
- network = null;
1642
- }
1643
-
1644
- var networkOptions = {
1645
- nodes: {
1646
- borderWidth: 2,
1647
- shadow: { enabled: true, color: 'rgba(0,0,0,0.3)', size: 5, x: 0, y: 2 }
1648
- },
1649
- edges: {
1650
- smooth: { enabled: true, type: 'continuous', roundness: 0.5 },
1651
- shadow: false
1652
- },
1653
- physics: {
1654
- enabled: true,
1655
- solver: 'forceAtlas2Based',
1656
- forceAtlas2Based: {
1657
- gravitationalConstant: -80,
1658
- centralGravity: 0.015,
1659
- springLength: 150,
1660
- springConstant: 0.05,
1661
- damping: 0.4,
1662
- avoidOverlap: 0.8
1663
- },
1664
- stabilization: { enabled: true, iterations: 200, updateInterval: 25 }
1665
- },
1666
- interaction: {
1667
- hover: true,
1668
- tooltipDelay: 100,
1669
- navigationButtons: false,
1670
- keyboard: false,
1671
- zoomView: true,
1672
- dragView: true
1673
- },
1674
- layout: {
1675
- improvedLayout: false,
1676
- hierarchical: false
1677
- }
1678
- };
1679
-
1680
- if (USE_GRAPH_CANVAS) {
1681
- network = new DevPlanGraph(container,
1682
- { nodes: visibleNodes, edges: visibleEdges },
1683
- networkOptions
1684
- );
1685
- } else {
1686
- network = new vis.Network(container,
1687
- { nodes: nodesDataSet, edges: edgesDataSet },
1688
- networkOptions
1689
- );
1690
- }
1691
-
1692
- log('Network 实例已创建, 等待物理稳定化...', true);
1693
-
1694
- network.on('stabilizationIterationsDone', function() {
1695
- network.setOptions({ physics: { enabled: false } });
1696
- document.getElementById('loading').style.display = 'none';
1697
- log('图谱渲染完成! ' + visibleNodes.length + ' 节点, ' + visibleEdges.length + ' 边', true);
1698
- // 稳定后将父文档-子文档按思维导图方式整齐排列
1699
- arrangeAllDocMindMaps();
1700
- network.fit({ animation: { duration: 800, easingFunction: 'easeInOutQuad' } });
1701
- });
1702
-
1703
- network.on('click', function(params) {
1704
- // 先检查是否点击了 +/- 按钮
1705
- if (params.pointer && params.pointer.canvas) {
1706
- var hitNodeId = hitTestDocToggleBtn(params.pointer.canvas.x, params.pointer.canvas.y);
1707
- if (hitNodeId) {
1708
- toggleDocNodeExpand(hitNodeId);
1709
- return; // 消费此次点击,不触发节点选择
1710
- }
1711
- }
1712
- if (params.nodes.length > 0) {
1713
- // 直接点击图谱节点 → 清空历史栈,重新开始导航
1714
- panelHistory = [];
1715
- currentPanelNodeId = null;
1716
- highlightConnectedEdges(params.nodes[0]);
1717
- showPanel(params.nodes[0]);
1718
- } else {
1719
- resetAllEdgeColors();
1720
- closePanel();
1721
- }
1722
- });
1723
-
1724
- // ========== Ctrl+拖拽整体移动关联节点 ==========
1725
- var groupDrag = { active: false, nodeId: null, connectedIds: [], startPositions: {} };
1726
-
1727
- network.on('dragStart', function(params) {
1728
- if (!ctrlPressed || params.nodes.length === 0) {
1729
- groupDrag.active = false;
1730
- return;
1731
- }
1732
- var draggedId = params.nodes[0];
1733
- // 获取所有直接关联的节点
1734
- var connected = network.getConnectedNodes(draggedId);
1735
- groupDrag.active = true;
1736
- groupDrag.nodeId = draggedId;
1737
- groupDrag.connectedIds = connected;
1738
- // 记录所有关联节点的初始位置
1739
- groupDrag.startPositions = {};
1740
- var positions = network.getPositions([draggedId].concat(connected));
1741
- groupDrag.startPositions = positions;
1742
- groupDrag.dragStartPos = positions[draggedId];
1743
- log('Ctrl+拖拽: 整体移动 ' + (connected.length + 1) + ' 个节点', true);
1744
- });
1745
-
1746
- network.on('dragging', function(params) {
1747
- if (!groupDrag.active || params.nodes.length === 0) return;
1748
- var draggedId = groupDrag.nodeId;
1749
- // 获取当前被拖拽节点的位置
1750
- var currentPos = network.getPositions([draggedId])[draggedId];
1751
- if (!currentPos || !groupDrag.dragStartPos) return;
1752
- // 计算位移差
1753
- var dx = currentPos.x - groupDrag.dragStartPos.x;
1754
- var dy = currentPos.y - groupDrag.dragStartPos.y;
1755
- // 移动所有关联节点
1756
- for (var i = 0; i < groupDrag.connectedIds.length; i++) {
1757
- var cid = groupDrag.connectedIds[i];
1758
- var startPos = groupDrag.startPositions[cid];
1759
- if (startPos) {
1760
- network.moveNode(cid, startPos.x + dx, startPos.y + dy);
1761
- }
1762
- }
1763
- });
1764
-
1765
- network.on('dragEnd', function(params) {
1766
- if (groupDrag.active) {
1767
- log('整体移动完成', true);
1768
- groupDrag.active = false;
1769
- groupDrag.nodeId = null;
1770
- groupDrag.connectedIds = [];
1771
- groupDrag.startPositions = {};
1772
- }
1773
- });
1774
-
1775
- // ========== afterDrawing: 呼吸灯 + 文档展开/收起按钮 ==========
1776
- network.on('afterDrawing', function(ctx) {
1777
- // 绘制父文档的 +/- 按钮
1778
- drawDocToggleButtons(ctx);
1779
-
1780
- var ids = getInProgressMainTaskIds();
1781
- if (ids.length === 0) return;
1782
-
1783
- // 呼吸因子: 0 → 1 → 0 平滑循环
1784
- var breath = (Math.sin(breathPhase) + 1) / 2; // [0, 1]
1785
-
1786
- for (var i = 0; i < ids.length; i++) {
1787
- var pos = network.getPositions([ids[i]])[ids[i]];
1788
- if (!pos) continue;
1789
- var nodeData = nodesDataSet.get(ids[i]);
1790
- var baseSize = (nodeData && nodeData.size) || 14;
1791
-
1792
- // 将网络坐标转换为 canvas 坐标
1793
- var canvasPos = network.canvasToDOM(pos);
1794
- // 再通过 DOMtoCanvas 获取正确的 canvas 上下文坐标
1795
- // vis-network 的 afterDrawing ctx 已经在正确的坐标系中,直接用 pos 即可
1796
-
1797
- // 外层大范围弥散光晕(营造醒目的辉光感)
1798
- var outerGlowRadius = baseSize + 20 + breath * baseSize * 2.5;
1799
- var outerGrad = ctx.createRadialGradient(pos.x, pos.y, baseSize, pos.x, pos.y, outerGlowRadius);
1800
- outerGrad.addColorStop(0, 'rgba(124, 58, 237, ' + (0.18 + breath * 0.12) + ')');
1801
- outerGrad.addColorStop(0.5, 'rgba(139, 92, 246, ' + (0.08 + breath * 0.06) + ')');
1802
- outerGrad.addColorStop(1, 'rgba(139, 92, 246, 0)');
1803
- ctx.beginPath();
1804
- ctx.arc(pos.x, pos.y, outerGlowRadius, 0, Math.PI * 2);
1805
- ctx.fillStyle = outerGrad;
1806
- ctx.fill();
1807
- ctx.closePath();
1808
-
1809
- // 外圈脉冲光环(更粗、扩展范围更大)
1810
- var maxExpand = baseSize * 2.2;
1811
- var ringRadius = baseSize + 8 + breath * maxExpand;
1812
- var ringAlpha = 0.55 * (1 - breath * 0.5);
1813
-
1814
- ctx.beginPath();
1815
- ctx.arc(pos.x, pos.y, ringRadius, 0, Math.PI * 2);
1816
- ctx.strokeStyle = 'rgba(139, 92, 246, ' + ringAlpha + ')';
1817
- ctx.lineWidth = 3.5 + breath * 3;
1818
- ctx.stroke();
1819
- ctx.closePath();
1820
-
1821
- // 中圈脉冲光环(第二道更紧凑的环)
1822
- var midRingRadius = baseSize + 4 + breath * baseSize * 1.2;
1823
- var midRingAlpha = 0.4 * (1 - breath * 0.4);
1824
- ctx.beginPath();
1825
- ctx.arc(pos.x, pos.y, midRingRadius, 0, Math.PI * 2);
1826
- ctx.strokeStyle = 'rgba(167, 139, 250, ' + midRingAlpha + ')';
1827
- ctx.lineWidth = 2.5 + breath * 2;
1828
- ctx.stroke();
1829
- ctx.closePath();
1830
-
1831
- // 内圈柔光(更大范围的径向渐变)
1832
- var glowRadius = baseSize + 10 + breath * 16;
1833
- var gradient = ctx.createRadialGradient(pos.x, pos.y, baseSize * 0.3, pos.x, pos.y, glowRadius);
1834
- gradient.addColorStop(0, 'rgba(124, 58, 237, ' + (0.25 + breath * 0.15) + ')');
1835
- gradient.addColorStop(0.6, 'rgba(139, 92, 246, ' + (0.10 + breath * 0.08) + ')');
1836
- gradient.addColorStop(1, 'rgba(139, 92, 246, 0)');
1837
- ctx.beginPath();
1838
- ctx.arc(pos.x, pos.y, glowRadius, 0, Math.PI * 2);
1839
- ctx.fillStyle = gradient;
1840
- ctx.fill();
1841
- ctx.closePath();
1842
- }
1843
- });
1844
-
1845
- // 检查是否有 in_progress 主任务,有则启动动画
1846
- stopBreathAnimation();
1847
- var inProgIds = getInProgressMainTaskIds();
1848
- if (inProgIds.length > 0) {
1849
- startBreathAnimation();
1850
- log('呼吸灯: 检测到 ' + inProgIds.length + ' 个进行中主任务', true);
1851
- }
1852
-
1853
- // 超时回退
1854
- setTimeout(function() {
1855
- if (document.getElementById('loading').style.display !== 'none') {
1856
- document.getElementById('loading').style.display = 'none';
1857
- log('稳定化超时, 强制显示图谱', true);
1858
- if (network) network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
1859
- }
1860
- }, 8000);
1861
-
1862
- } catch (err) {
1863
- log('渲染错误: ' + err.message, false);
1864
- console.error('[DevPlan] renderGraph error:', err);
1865
- document.getElementById('loading').innerHTML = '<div style="text-align:center"><div style="font-size:48px;margin-bottom:16px;">⚠️</div><p style="color:#f87171;">渲染失败: ' + err.message + '</p><button class="refresh-btn" onclick="loadData()" style="margin-top:12px;">重试</button></div>';
1866
- }
1867
- }
1868
-
1869
- // ========== Detail Panel ==========
1870
-
1871
- /** 面板导航历史栈:存储节点 ID,用于"返回"功能 */
1872
- var panelHistory = [];
1873
- var currentPanelNodeId = null;
1874
-
1875
- /** 从关联链接跳转到新面板(将当前节点压入历史栈) */
1876
- function navigateToPanel(nodeId) {
1877
- if (currentPanelNodeId) {
1878
- panelHistory.push(currentPanelNodeId);
1879
- }
1880
- network.selectNodes([nodeId]);
1881
- highlightConnectedEdges(nodeId);
1882
- showPanel(nodeId);
1883
- }
1884
-
1885
- /** 返回上一个面板 */
1886
- function panelGoBack() {
1887
- if (panelHistory.length === 0) return;
1888
- var prevNodeId = panelHistory.pop();
1889
- network.selectNodes([prevNodeId]);
1890
- highlightConnectedEdges(prevNodeId);
1891
- showPanel(prevNodeId);
1892
- }
1893
-
1894
- /** 更新返回按钮的可见性 */
1895
- function updateBackButton() {
1896
- var btn = document.getElementById('panelBack');
1897
- if (!btn) return;
1898
- if (panelHistory.length > 0) {
1899
- btn.classList.add('visible');
51
+ // ========== App Start ==========
52
+ function startApp() {
53
+ if (USE_3D) {
54
+ log('3D Force Graph 引擎就绪 (Three.js WebGL), 开始加载数据...', true);
1900
55
  } else {
1901
- btn.classList.remove('visible');
1902
- }
1903
- }
1904
-
1905
- /** 根据主任务节点 ID,从 allNodes/allEdges 中查找其所有子任务节点 */
1906
- function getSubTasksForMainTask(mainTaskNodeId) {
1907
- var subTaskIds = [];
1908
- for (var i = 0; i < allEdges.length; i++) {
1909
- var e = allEdges[i];
1910
- if (e.from === mainTaskNodeId && e.label === 'has_sub_task') {
1911
- subTaskIds.push(e.to);
1912
- }
1913
- }
1914
- var subTasks = [];
1915
- var idSet = {};
1916
- for (var i = 0; i < subTaskIds.length; i++) idSet[subTaskIds[i]] = true;
1917
- for (var i = 0; i < allNodes.length; i++) {
1918
- if (idSet[allNodes[i].id]) {
1919
- subTasks.push(allNodes[i]);
1920
- }
1921
- }
1922
- return subTasks;
1923
- }
1924
-
1925
- function getRelatedDocsForTask(taskNodeId) {
1926
- var docIds = [];
1927
- for (var i = 0; i < allEdges.length; i++) {
1928
- var e = allEdges[i];
1929
- if (e.from === taskNodeId && e.label === 'task_has_doc') {
1930
- docIds.push(e.to);
1931
- }
1932
- }
1933
- var docs = [];
1934
- var idSet = {};
1935
- for (var i = 0; i < docIds.length; i++) idSet[docIds[i]] = true;
1936
- for (var i = 0; i < allNodes.length; i++) {
1937
- if (idSet[allNodes[i].id]) docs.push(allNodes[i]);
1938
- }
1939
- return docs;
1940
- }
1941
-
1942
- function getRelatedTasksForDoc(docNodeId) {
1943
- var taskIds = [];
1944
- for (var i = 0; i < allEdges.length; i++) {
1945
- var e = allEdges[i];
1946
- if (e.to === docNodeId && e.label === 'task_has_doc') {
1947
- taskIds.push(e.from);
1948
- }
1949
- }
1950
- var tasks = [];
1951
- var idSet = {};
1952
- for (var i = 0; i < taskIds.length; i++) idSet[taskIds[i]] = true;
1953
- for (var i = 0; i < allNodes.length; i++) {
1954
- if (idSet[allNodes[i].id]) tasks.push(allNodes[i]);
1955
- }
1956
- return tasks;
1957
- }
1958
-
1959
- function showPanel(nodeId) {
1960
- var node = nodesDataSet.get(nodeId);
1961
- if (!node) return;
1962
- var panel = document.getElementById('panel');
1963
- var header = document.getElementById('panelHeader');
1964
- var title = document.getElementById('panelTitle');
1965
- var body = document.getElementById('panelBody');
1966
-
1967
- header.className = 'panel-header ' + (node._type || '');
1968
- var typeNames = { project: '项目', module: '模块', 'main-task': '主任务', 'sub-task': '子任务', document: '文档' };
1969
- title.textContent = (typeNames[node._type] || '节点') + ' 详情';
1970
-
1971
- var p = node._props;
1972
- var html = '<div class="panel-row"><span class="panel-label">名称</span><span class="panel-value">' + escHtml(node.label) + '</span></div>';
1973
-
1974
- if (node._type === 'main-task') {
1975
- html += row('任务ID', p.taskId);
1976
- html += row('优先级', '<span class="status-badge priority-' + (p.priority || 'P2') + '">' + (p.priority || 'P2') + '</span>');
1977
- html += row('状态', statusBadge(p.status));
1978
- if (p.completedAt) { html += row('完成时间', '<span style="color:#6ee7b7;">' + fmtTime(p.completedAt) + '</span>'); }
1979
- if (p.totalSubtasks !== undefined) {
1980
- var pct = p.totalSubtasks > 0 ? Math.round((p.completedSubtasks || 0) / p.totalSubtasks * 100) : 0;
1981
- html += row('子任务', (p.completedSubtasks || 0) + '/' + p.totalSubtasks);
1982
- html += '<div class="panel-progress"><div class="panel-progress-bar"><div class="panel-progress-fill" style="width:' + pct + '%"></div></div></div>';
1983
- }
1984
-
1985
- // 查找并显示子任务列表
1986
- var subTasks = getSubTasksForMainTask(nodeId);
1987
- if (subTasks.length > 0) {
1988
- var completedCount = 0;
1989
- for (var si = 0; si < subTasks.length; si++) {
1990
- if ((subTasks[si].properties || {}).status === 'completed') completedCount++;
1991
- }
1992
- html += '<div class="subtask-section">';
1993
- html += '<div class="subtask-section-title"><span>子任务列表</span><span style="color:#6b7280;">' + completedCount + '/' + subTasks.length + '</span></div>';
1994
- html += '<ul class="subtask-list">';
1995
- // 排序:进行中 > 待开始 > 已完成 > 已取消
1996
- var statusOrder = { in_progress: 0, pending: 1, completed: 2, cancelled: 3 };
1997
- subTasks.sort(function(a, b) {
1998
- var sa = (a.properties || {}).status || 'pending';
1999
- var sb = (b.properties || {}).status || 'pending';
2000
- return (statusOrder[sa] || 1) - (statusOrder[sb] || 1);
2001
- });
2002
- for (var si = 0; si < subTasks.length; si++) {
2003
- var st = subTasks[si];
2004
- var stProps = st.properties || {};
2005
- var stStatus = stProps.status || 'pending';
2006
- var stIcon = stStatus === 'completed' ? '✓' : stStatus === 'in_progress' ? '▶' : stStatus === 'cancelled' ? '✗' : '○';
2007
- var stTime = stProps.completedAt ? fmtTime(stProps.completedAt) : '';
2008
- html += '<li class="subtask-item">';
2009
- html += '<span class="subtask-icon ' + stStatus + '">' + stIcon + '</span>';
2010
- html += '<span class="subtask-name ' + stStatus + '" title="' + escHtml(st.label) + '">' + escHtml(st.label) + '</span>';
2011
- if (stTime) { html += '<span class="subtask-time">' + stTime + '</span>'; }
2012
- html += '<span class="subtask-id">' + escHtml(stProps.taskId || '') + '</span>';
2013
- html += '</li>';
2014
- }
2015
- html += '</ul>';
2016
- html += '</div>';
2017
- }
2018
-
2019
- // 查找并显示关联文档
2020
- var relDocs = getRelatedDocsForTask(nodeId);
2021
- if (relDocs.length > 0) {
2022
- html += '<div class="subtask-section">';
2023
- html += '<div class="subtask-section-title"><span style="color:#f59e0b;">关联文档</span><span style="color:#6b7280;">' + relDocs.length + '</span></div>';
2024
- html += '<ul class="subtask-list">';
2025
- for (var di = 0; di < relDocs.length; di++) {
2026
- var doc = relDocs[di];
2027
- var docProps = doc.properties || {};
2028
- var docLabel = docProps.section || '';
2029
- if (docProps.subSection) docLabel += ' / ' + docProps.subSection;
2030
- html += '<li class="subtask-item" style="cursor:pointer;" onclick="navigateToPanel(\\x27' + doc.id + '\\x27)">';
2031
- html += '<span class="subtask-icon" style="color:#f59e0b;">&#x1F4C4;</span>';
2032
- html += '<span class="subtask-name" title="' + escHtml(doc.label) + '">' + escHtml(doc.label) + '</span>';
2033
- html += '<span class="subtask-id">' + escHtml(docLabel) + '</span>';
2034
- html += '</li>';
2035
- }
2036
- html += '</ul>';
2037
- html += '</div>';
2038
- }
2039
- } else if (node._type === 'sub-task') {
2040
- html += row('任务ID', p.taskId);
2041
- html += row('父任务', p.parentTaskId);
2042
- html += row('状态', statusBadge(p.status));
2043
- if (p.completedAt) { html += row('完成时间', '<span style="color:#6ee7b7;">' + fmtTime(p.completedAt) + '</span>'); }
2044
- } else if (node._type === 'module') {
2045
- html += row('模块ID', p.moduleId);
2046
- html += row('状态', statusBadge(p.status || 'active'));
2047
- html += row('主任务数', p.mainTaskCount);
2048
- } else if (node._type === 'document') {
2049
- html += row('类型', p.section);
2050
- if (p.subSection) html += row('子类型', p.subSection);
2051
- html += row('版本', p.version);
2052
-
2053
- // 查找并显示关联任务
2054
- var relTasks = getRelatedTasksForDoc(nodeId);
2055
- if (relTasks.length > 0) {
2056
- html += '<div class="subtask-section">';
2057
- html += '<div class="subtask-section-title"><span style="color:#6366f1;">关联任务</span><span style="color:#6b7280;">' + relTasks.length + '</span></div>';
2058
- html += '<ul class="subtask-list">';
2059
- for (var ti = 0; ti < relTasks.length; ti++) {
2060
- var task = relTasks[ti];
2061
- var tProps = task.properties || {};
2062
- var tStatus = tProps.status || 'pending';
2063
- var tIcon = tStatus === 'completed' ? '✓' : tStatus === 'in_progress' ? '▶' : '○';
2064
- html += '<li class="subtask-item" style="cursor:pointer;" onclick="navigateToPanel(\\x27' + task.id + '\\x27)">';
2065
- html += '<span class="subtask-icon ' + tStatus + '">' + tIcon + '</span>';
2066
- html += '<span class="subtask-name" title="' + escHtml(task.label) + '">' + escHtml(task.label) + '</span>';
2067
- html += '<span class="subtask-id">' + escHtml(tProps.taskId || '') + '</span>';
2068
- html += '</li>';
2069
- }
2070
- html += '</ul>';
2071
- html += '</div>';
2072
- }
2073
-
2074
- // 文档内容区域 — 先显示加载中,稍后异步填充
2075
- html += '<div class="doc-section">';
2076
- html += '<div class="doc-section-title"><span>文档内容</span><button class="doc-toggle" id="docToggleBtn" onclick="toggleDocContent()">收起</button></div>';
2077
- html += '<div id="docContentArea"><div class="doc-loading">加载中...</div></div>';
2078
- html += '</div>';
2079
- } else if (node._type === 'project') {
2080
- html += row('类型', '项目根节点');
2081
- }
2082
-
2083
- body.innerHTML = html;
2084
- panel.classList.add('show');
2085
- currentPanelNodeId = nodeId;
2086
- updateBackButton();
2087
-
2088
- // 如果是文档节点,异步加载内容
2089
- if (node._type === 'document') {
2090
- loadDocContent(p.section, p.subSection);
56
+ log('vis-network 就绪, 开始加载数据...', true);
2091
57
  }
58
+ loadData();
2092
59
  }
2093
60
 
2094
- function closePanel() {
2095
- document.getElementById('panel').classList.remove('show');
2096
- panelHistory = [];
2097
- currentPanelNodeId = null;
2098
- updateBackButton();
2099
- resetAllEdgeColors();
2100
- }
2101
-
2102
- // ========== Panel Resize ==========
2103
- var panelDefaultWidth = 340;
2104
- var panelExpandedWidth = 680;
2105
- var panelIsExpanded = false;
2106
- var panelResizing = false;
2107
-
2108
- // 双击标题栏切换宽度
2109
- (function() {
2110
- var header = document.getElementById('panelHeader');
2111
- if (!header) return;
2112
- header.addEventListener('dblclick', function(e) {
2113
- // 不要在关闭按钮上触发
2114
- if (e.target.closest && e.target.closest('.panel-close')) return;
2115
- var panel = document.getElementById('panel');
2116
- if (!panel) return;
2117
- panelIsExpanded = !panelIsExpanded;
2118
- var targetWidth = panelIsExpanded ? panelExpandedWidth : panelDefaultWidth;
2119
- panel.style.transition = 'width 0.25s ease';
2120
- panel.style.width = targetWidth + 'px';
2121
- setTimeout(function() { panel.style.transition = 'none'; }, 260);
2122
- });
2123
- })();
2124
-
2125
- // 拖拽左边线调整宽度
2126
- (function() {
2127
- var handle = document.getElementById('panelResizeHandle');
2128
- var panel = document.getElementById('panel');
2129
- if (!handle || !panel) return;
2130
-
2131
- var startX = 0;
2132
- var startWidth = 0;
2133
-
2134
- handle.addEventListener('mousedown', function(e) {
2135
- e.preventDefault();
2136
- e.stopPropagation();
2137
- panelResizing = true;
2138
- startX = e.clientX;
2139
- startWidth = panel.offsetWidth;
2140
- handle.classList.add('active');
2141
- document.body.style.cursor = 'col-resize';
2142
- document.body.style.userSelect = 'none';
2143
-
2144
- function onMouseMove(ev) {
2145
- if (!panelResizing) return;
2146
- // 面板在右侧,向左拖 = 增大宽度
2147
- var dx = startX - ev.clientX;
2148
- var newWidth = Math.max(280, Math.min(startWidth + dx, window.innerWidth - 40));
2149
- panel.style.width = newWidth + 'px';
2150
- panelIsExpanded = newWidth > (panelDefaultWidth + 50);
2151
- }
2152
-
2153
- function onMouseUp() {
2154
- panelResizing = false;
2155
- handle.classList.remove('active');
2156
- document.body.style.cursor = '';
2157
- document.body.style.userSelect = '';
2158
- document.removeEventListener('mousemove', onMouseMove);
2159
- document.removeEventListener('mouseup', onMouseUp);
2160
- }
2161
-
2162
- document.addEventListener('mousemove', onMouseMove);
2163
- document.addEventListener('mouseup', onMouseUp);
2164
- });
2165
- })();
2166
-
2167
- function row(label, value) { return '<div class="panel-row"><span class="panel-label">' + label + '</span><span class="panel-value">' + (value || '-') + '</span></div>'; }
2168
- function statusBadge(s) { return '<span class="status-badge status-' + (s || 'pending') + '">' + statusText(s) + '</span>'; }
2169
- function statusText(s) { var m = { completed: '已完成', in_progress: '进行中', pending: '待开始', cancelled: '已取消', active: '活跃', planning: '规划中', deprecated: '已废弃' }; return m[s] || s || '未知'; }
2170
- function escHtml(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
2171
-
2172
- // 格式化时间戳(毫秒)为可读日期时间,当年省略年份
2173
- function fmtTime(ts) {
2174
- if (!ts) return '';
2175
- var d = new Date(ts);
2176
- var m = String(d.getMonth() + 1).padStart(2, '0');
2177
- var day = String(d.getDate()).padStart(2, '0');
2178
- var h = String(d.getHours()).padStart(2, '0');
2179
- var min = String(d.getMinutes()).padStart(2, '0');
2180
- var time = m + '-' + day + ' ' + h + ':' + min;
2181
- if (d.getFullYear() !== new Date().getFullYear()) {
2182
- time = d.getFullYear() + '-' + time;
2183
- }
2184
- return time;
2185
- }
2186
-
2187
- /** 文档列表用的短日期格式:MM-DD 或 YYYY-MM-DD */
2188
- function fmtDateShort(ts) {
2189
- if (!ts) return '';
2190
- var d = new Date(ts);
2191
- var m = String(d.getMonth() + 1).padStart(2, '0');
2192
- var day = String(d.getDate()).padStart(2, '0');
2193
- if (d.getFullYear() !== new Date().getFullYear()) {
2194
- return d.getFullYear() + '-' + m + '-' + day;
2195
- }
2196
- return m + '-' + day;
2197
- }
2198
-
2199
- // ========== Phase Expand (Stats page) ==========
2200
- function togglePhaseExpand(el) {
2201
- var wrap = el.closest('.phase-item-wrap');
2202
- if (wrap) wrap.classList.toggle('expanded');
2203
- }
2204
-
2205
- // ========== Document Content ==========
2206
- var docContentVisible = true;
2207
-
2208
- function toggleDocContent() {
2209
- var area = document.getElementById('docContentArea');
2210
- var btn = document.getElementById('docToggleBtn');
2211
- if (!area) return;
2212
- docContentVisible = !docContentVisible;
2213
- area.style.display = docContentVisible ? 'block' : 'none';
2214
- if (btn) btn.textContent = docContentVisible ? '收起' : '展开';
2215
- }
2216
-
2217
- function loadDocContent(section, subSection) {
2218
- var area = document.getElementById('docContentArea');
2219
- if (!area) return;
2220
- var url = '/api/doc?section=' + encodeURIComponent(section || '');
2221
- if (subSection) url += '&subSection=' + encodeURIComponent(subSection);
2222
-
2223
- fetch(url).then(function(r) {
2224
- if (!r.ok) throw new Error('HTTP ' + r.status);
2225
- return r.json();
2226
- }).then(function(doc) {
2227
- if (!doc || !doc.content) {
2228
- area.innerHTML = '<div class="doc-error">文档内容为空</div>';
2229
- return;
2230
- }
2231
- area.innerHTML = '<div class="doc-content">' + renderMarkdown(doc.content) + '</div>';
2232
- docContentVisible = true;
2233
- var btn = document.getElementById('docToggleBtn');
2234
- if (btn) btn.textContent = '收起';
2235
- }).catch(function(err) {
2236
- area.innerHTML = '<div class="doc-error">加载失败: ' + escHtml(err.message) + '</div>';
2237
- });
2238
- }
2239
-
2240
- /** 简易 Markdown 渲染 — 支持标题、粗体、斜体、代码、列表、表格、引用、链接、分隔线 */
2241
- function renderMarkdown(md) {
2242
- if (!md) return '';
2243
-
2244
- // 先处理代码块(防止内部被其他规则干扰)
2245
- var codeBlocks = [];
2246
- md = md.replace(/\`\`\`(\\w*)?\\n([\\s\\S]*?)\`\`\`/g, function(m, lang, code) {
2247
- var idx = codeBlocks.length;
2248
- codeBlocks.push('<pre><code>' + escHtml(code.replace(/\\n$/, '')) + '</code></pre>');
2249
- return '%%CODEBLOCK_' + idx + '%%';
2250
- });
2251
-
2252
- // 按行处理
2253
- var lines = md.split('\\n');
2254
- var html = '';
2255
- var inTable = false;
2256
- var inList = false;
2257
- var listType = '';
2258
-
2259
- for (var i = 0; i < lines.length; i++) {
2260
- var line = lines[i];
2261
-
2262
- // 代码块占位符
2263
- var cbMatch = line.match(/^%%CODEBLOCK_(\\d+)%%$/);
2264
- if (cbMatch) {
2265
- if (inList) { html += '</' + listType + '>'; inList = false; }
2266
- if (inTable) { html += '</table>'; inTable = false; }
2267
- html += codeBlocks[parseInt(cbMatch[1])];
2268
- continue;
2269
- }
2270
-
2271
- // 表格行
2272
- if (line.match(/^\\|(.+)\\|\\s*$/)) {
2273
- if (inList) { html += '</' + listType + '>'; inList = false; }
2274
- // 跳过分隔行
2275
- if (line.match(/^\\|[\\s\\-:|]+\\|\\s*$/)) continue;
2276
- var cells = line.split('|').filter(function(c, idx, arr) { return idx > 0 && idx < arr.length - 1; });
2277
- if (!inTable) {
2278
- html += '<table>';
2279
- html += '<tr>' + cells.map(function(c) { return '<th>' + inlineFormat(c.trim()) + '</th>'; }).join('') + '</tr>';
2280
- inTable = true;
2281
- } else {
2282
- html += '<tr>' + cells.map(function(c) { return '<td>' + inlineFormat(c.trim()) + '</td>'; }).join('') + '</tr>';
2283
- }
2284
- continue;
2285
- } else if (inTable) {
2286
- html += '</table>';
2287
- inTable = false;
2288
- }
2289
-
2290
- // 空行
2291
- if (line.trim() === '') {
2292
- if (inList) { html += '</' + listType + '>'; inList = false; }
2293
- continue;
2294
- }
2295
-
2296
- // 标题
2297
- var hMatch = line.match(/^(#{1,4})\\s+(.+)$/);
2298
- if (hMatch) {
2299
- if (inList) { html += '</' + listType + '>'; inList = false; }
2300
- var level = hMatch[1].length;
2301
- html += '<h' + level + '>' + inlineFormat(hMatch[2]) + '</h' + level + '>';
2302
- continue;
2303
- }
2304
-
2305
- // 分隔线
2306
- if (line.match(/^(\\*{3,}|-{3,}|_{3,})\\s*$/)) {
2307
- if (inList) { html += '</' + listType + '>'; inList = false; }
2308
- html += '<hr>';
2309
- continue;
2310
- }
2311
-
2312
- // 引用
2313
- if (line.match(/^>\\s?/)) {
2314
- if (inList) { html += '</' + listType + '>'; inList = false; }
2315
- html += '<blockquote>' + inlineFormat(line.replace(/^>\\s?/, '')) + '</blockquote>';
2316
- continue;
2317
- }
2318
-
2319
- // 无序列表
2320
- var ulMatch = line.match(/^\\s*[-*+]\\s+(.+)$/);
2321
- if (ulMatch) {
2322
- if (!inList || listType !== 'ul') {
2323
- if (inList) html += '</' + listType + '>';
2324
- html += '<ul>';
2325
- inList = true;
2326
- listType = 'ul';
2327
- }
2328
- html += '<li>' + inlineFormat(ulMatch[1]) + '</li>';
2329
- continue;
2330
- }
2331
-
2332
- // 有序列表
2333
- var olMatch = line.match(/^\\s*\\d+\\.\\s+(.+)$/);
2334
- if (olMatch) {
2335
- if (!inList || listType !== 'ol') {
2336
- if (inList) html += '</' + listType + '>';
2337
- html += '<ol>';
2338
- inList = true;
2339
- listType = 'ol';
2340
- }
2341
- html += '<li>' + inlineFormat(olMatch[1]) + '</li>';
2342
- continue;
2343
- }
2344
-
2345
- // 普通段落
2346
- if (inList) { html += '</' + listType + '>'; inList = false; }
2347
- html += '<p>' + inlineFormat(line) + '</p>';
2348
- }
2349
-
2350
- if (inList) html += '</' + listType + '>';
2351
- if (inTable) html += '</table>';
2352
-
2353
- return html;
2354
- }
2355
-
2356
- /** 行内格式化:粗体、斜体、行内代码、链接 */
2357
- function inlineFormat(text) {
2358
- if (!text) return '';
2359
- // 行内代码
2360
- text = text.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
2361
- // 粗体
2362
- text = text.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
2363
- text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
2364
- // 斜体
2365
- text = text.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
2366
- text = text.replace(/_(.+?)_/g, '<em>$1</em>');
2367
- // 链接
2368
- text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
2369
- return text;
2370
- }
2371
-
2372
- // ========== Filters ==========
2373
- function toggleFilter(type) {
2374
- var cb = document.querySelector('.filter-check input[data-type="' + type + '"]');
2375
- if (cb && !cb.checked) {
2376
- hiddenTypes[type] = true;
2377
- } else {
2378
- delete hiddenTypes[type];
2379
- }
2380
- renderGraph();
2381
- }
2382
-
2383
- // ========== Stats Modal ==========
2384
- /** 记录文档弹层中各文档的折叠状态(docKey → true 表示已展开) */
2385
- var docModalExpandedState = {};
2386
-
2387
- function showStatsModal(nodeType) {
2388
- // 文档类型使用专用渲染
2389
- if (nodeType === 'document') {
2390
- showDocModal();
2391
- return;
2392
- }
2393
-
2394
- var titleMap = { 'module': '功能模块', 'main-task': '主任务', 'sub-task': '子任务' };
2395
- var iconMap = { 'module': '◆', 'main-task': '●', 'sub-task': '·' };
2396
- var items = [];
2397
- for (var i = 0; i < allNodes.length; i++) {
2398
- if (allNodes[i].type === nodeType) items.push(allNodes[i]);
2399
- }
2400
- // 排序:进行中 > 待开始 > 已完成 > 已取消
2401
- var statusOrder = { in_progress: 0, pending: 1, completed: 2, cancelled: 3, active: 1 };
2402
- items.sort(function(a, b) {
2403
- var sa = (a.properties || {}).status || 'pending';
2404
- var sb = (b.properties || {}).status || 'pending';
2405
- return (statusOrder[sa] !== undefined ? statusOrder[sa] : 5) - (statusOrder[sb] !== undefined ? statusOrder[sb] : 5);
2406
- });
2407
-
2408
- document.getElementById('statsModalTitle').textContent = titleMap[nodeType] || nodeType;
2409
- document.getElementById('statsModalCount').textContent = '(' + items.length + ')';
2410
-
2411
- var html = '';
2412
- for (var i = 0; i < items.length; i++) {
2413
- var n = items[i];
2414
- var p = n.properties || {};
2415
- var st = p.status || (nodeType === 'module' ? 'active' : 'pending');
2416
- var icon = iconMap[nodeType] || '●';
2417
- html += '<div class="stats-modal-item" onclick="statsModalGoToNode(\\x27' + n.id + '\\x27)">';
2418
- html += '<span class="stats-modal-item-icon">' + icon + '</span>';
2419
- html += '<span class="stats-modal-item-name" title="' + escHtml(n.label) + '">' + escHtml(n.label) + '</span>';
2420
- if (nodeType === 'main-task') {
2421
- var subCount = 0; var subDone = 0;
2422
- for (var j = 0; j < allNodes.length; j++) {
2423
- if (allNodes[j].type === 'sub-task' && (allNodes[j].properties || {}).parentTaskId === p.taskId) {
2424
- subCount++;
2425
- if ((allNodes[j].properties || {}).status === 'completed') subDone++;
2426
- }
2427
- }
2428
- if (subCount > 0) {
2429
- html += '<span class="stats-modal-item-sub">' + subDone + '/' + subCount + '</span>';
2430
- }
2431
- }
2432
- if (nodeType === 'module' && p.mainTaskCount !== undefined) {
2433
- html += '<span class="stats-modal-item-sub">' + p.mainTaskCount + ' 任务</span>';
2434
- }
2435
- html += '<span class="stats-modal-item-badge ' + st + '">' + statusText(st) + '</span>';
2436
- html += '</div>';
2437
- }
2438
- if (items.length === 0) {
2439
- html = '<div style="text-align:center;padding:40px;color:#6b7280;">暂无数据</div>';
2440
- }
2441
- document.getElementById('statsModalBody').innerHTML = html;
2442
- // 根据侧边栏状态调整弹层位置
2443
- updateStatsModalPosition();
2444
- document.getElementById('statsModalOverlay').classList.add('active');
2445
- }
2446
-
2447
- /** 获取文档节点的 docKey (section|subSection) */
2448
- function getDocNodeKey(node) {
2449
- var p = node.properties || {};
2450
- return p.section + (p.subSection ? '|' + p.subSection : '');
2451
- }
2452
-
2453
- /** 构建文档层级树:{ node, children: [...] } */
2454
- function buildDocTree() {
2455
- var docNodes = [];
2456
- for (var i = 0; i < allNodes.length; i++) {
2457
- if (allNodes[i].type === 'document') docNodes.push(allNodes[i]);
2458
- }
2459
-
2460
- // 建立 parentDoc → children 映射
2461
- var childrenMap = {}; // parentDocKey → [nodes]
2462
- var childKeySet = {}; // 属于子文档的 nodeId 集合
2463
- for (var i = 0; i < docNodes.length; i++) {
2464
- var p = docNodes[i].properties || {};
2465
- if (p.parentDoc) {
2466
- if (!childrenMap[p.parentDoc]) childrenMap[p.parentDoc] = [];
2467
- childrenMap[p.parentDoc].push(docNodes[i]);
2468
- childKeySet[docNodes[i].id] = true;
2469
- }
2470
- }
2471
-
2472
- // 按 section 分组顶级文档
2473
- var groups = {};
2474
- var groupOrder = [];
2475
- for (var i = 0; i < docNodes.length; i++) {
2476
- if (childKeySet[docNodes[i].id]) continue;
2477
- var sec = (docNodes[i].properties || {}).section || 'custom';
2478
- if (!groups[sec]) { groups[sec] = []; groupOrder.push(sec); }
2479
- groups[sec].push(docNodes[i]);
2480
- }
2481
-
2482
- return { groups: groups, groupOrder: groupOrder, childrenMap: childrenMap };
2483
- }
2484
-
2485
- /** 显示文档弹层(左侧列表) */
2486
- function showDocModal() {
2487
- var docNodes = [];
2488
- for (var i = 0; i < allNodes.length; i++) {
2489
- if (allNodes[i].type === 'document') docNodes.push(allNodes[i]);
2490
- }
2491
-
2492
- document.getElementById('statsModalTitle').textContent = '📄 文档列表';
2493
- document.getElementById('statsModalCount').textContent = '(' + docNodes.length + ')';
2494
-
2495
- var tree = buildDocTree();
2496
- var html = renderDocTreeHTML(tree);
2497
-
2498
- if (docNodes.length === 0) {
2499
- html = '<div style="text-align:center;padding:40px;color:#6b7280;">暂无文档</div>';
2500
- }
2501
-
2502
- document.getElementById('statsModalBody').innerHTML = html;
2503
- // 根据侧边栏状态调整弹层位置
2504
- updateStatsModalPosition();
2505
- document.getElementById('statsModalOverlay').classList.add('active');
2506
- }
2507
-
2508
- /** 渲染文档层级树 HTML */
2509
- function renderDocTreeHTML(tree) {
2510
- var SECTION_NAMES_MODAL = {
2511
- overview: '概述', core_concepts: '核心概念', api_design: 'API 设计',
2512
- file_structure: '文件结构', config: '配置', examples: '使用示例',
2513
- technical_notes: '技术笔记', api_endpoints: 'API 端点',
2514
- milestones: '里程碑', changelog: '变更记录', custom: '自定义'
2515
- };
2516
- var SECTION_ICONS_MODAL = {
2517
- overview: '▸', core_concepts: '▸', api_design: '▸',
2518
- file_structure: '▸', config: '▸', examples: '▸',
2519
- technical_notes: '▸', api_endpoints: '▸',
2520
- milestones: '▸', changelog: '▸', custom: '▸'
2521
- };
2522
-
2523
- var html = '';
2524
- for (var gi = 0; gi < tree.groupOrder.length; gi++) {
2525
- var sec = tree.groupOrder[gi];
2526
- var items = tree.groups[sec];
2527
- var secName = SECTION_NAMES_MODAL[sec] || sec;
2528
- var secIcon = SECTION_ICONS_MODAL[sec] || '▸';
2529
-
2530
- html += '<div style="margin-bottom:4px;">';
2531
- 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;">';
2532
- html += '<span>' + secName + '</span>';
2533
- html += '<span style="margin-left:auto;font-size:10px;color:#4b5563;">' + items.length + '</span>';
2534
- html += '</div>';
2535
-
2536
- for (var ii = 0; ii < items.length; ii++) {
2537
- html += renderDocTreeItem(items[ii], tree.childrenMap, 0);
2538
- }
2539
- html += '</div>';
2540
- }
2541
- return html;
2542
- }
2543
-
2544
- /** 递归渲染单个文档节点及其子文档 */
2545
- function renderDocTreeItem(node, childrenMap, depth) {
2546
- var docKey = getDocNodeKey(node);
2547
- var p = node.properties || {};
2548
- var children = childrenMap[docKey] || [];
2549
- var hasChildren = children.length > 0;
2550
- var isExpanded = docModalExpandedState[docKey] === true;
2551
- var paddingLeft = 20 + depth * 20;
2552
-
2553
- var html = '';
2554
-
2555
- // 文档项
2556
- 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)">';
2557
-
2558
- // 展开/折叠按钮
2559
- if (hasChildren) {
2560
- 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>';
2561
- } else {
2562
- html += '<span style="width:18px;flex-shrink:0;"></span>';
2563
- }
2564
-
2565
- html += '<span class="stats-modal-item-icon" style="font-size:13px;color:#6b7280;">▸</span>';
2566
- 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>';
2567
-
2568
- if (hasChildren) {
2569
- html += '<span style="font-size:10px;color:#818cf8;flex-shrink:0;">' + children.length + '</span>';
2570
- }
2571
- if (p.subSection) {
2572
- html += '<span style="font-size:10px;color:#6b7280;flex-shrink:0;font-family:monospace;">' + escHtml(p.subSection) + '</span>';
2573
- }
2574
-
2575
- html += '</div>';
2576
-
2577
- // 子文档列表(仅展开时显示)
2578
- if (hasChildren && isExpanded) {
2579
- for (var ci = 0; ci < children.length; ci++) {
2580
- html += renderDocTreeItem(children[ci], childrenMap, depth + 1);
2581
- }
2582
- }
2583
-
2584
- return html;
2585
- }
2586
-
2587
- /** 展开/折叠文档弹层中的子文档 */
2588
- function toggleDocModalExpand(docKey) {
2589
- docModalExpandedState[docKey] = !docModalExpandedState[docKey];
2590
- // 重新渲染文档列表
2591
- var tree = buildDocTree();
2592
- var html = renderDocTreeHTML(tree);
2593
- document.getElementById('statsModalBody').innerHTML = html;
2594
- }
2595
-
2596
- /** 在文档弹层中选中文档 — 复用右侧图谱详情面板显示内容 */
2597
- function docModalSelectDoc(docKey, nodeId) {
2598
- // 直接复用 statsModalGoToNode,聚焦图谱节点并打开已有的右侧详情面板
2599
- statsModalGoToNode(nodeId);
2600
- }
2601
-
2602
- function closeStatsModal() {
2603
- document.getElementById('statsModalOverlay').classList.remove('active');
2604
- }
2605
-
2606
- function statsModalGoToNode(nodeId) {
2607
- if (network && nodesDataSet && nodesDataSet.get(nodeId)) {
2608
- network.selectNodes([nodeId]);
2609
- highlightConnectedEdges(nodeId);
2610
- network.focus(nodeId, { scale: 1.2, animation: { duration: 400, easingFunction: 'easeInOutQuad' } });
2611
- panelHistory = [];
2612
- currentPanelNodeId = null;
2613
- showPanel(nodeId);
2614
- }
2615
- }
2616
-
2617
- // ========== Manual Refresh ==========
2618
- var _refreshing = false;
2619
-
2620
- /** 手动刷新:点击刷新按钮或按 F5 时触发(带旋转动画反馈) */
2621
- function manualRefresh() {
2622
- if (_refreshing) return;
2623
- _refreshing = true;
2624
- var btn = document.getElementById('legendRefreshBtn');
2625
- if (btn) btn.classList.add('refreshing');
2626
- log('手动刷新: 获取最新数据...', true);
2627
- silentRefresh(function() {
2628
- _refreshing = false;
2629
- if (btn) btn.classList.remove('refreshing');
2630
- });
2631
- }
2632
-
2633
- /** 静默刷新:只更新数据,不重建图谱(避免布局跳动)。onDone 回调在请求完成后触发。 */
2634
- function silentRefresh(onDone) {
2635
- var graphApiUrl = '/api/graph?includeNodeDegree=' + (INCLUDE_NODE_DEGREE ? 'true' : 'false') +
2636
- '&enableBackendDegreeFallback=' + (ENABLE_BACKEND_DEGREE_FALLBACK ? 'true' : 'false');
2637
- Promise.all([
2638
- fetch(graphApiUrl).then(function(r) { return r.json(); }),
2639
- fetch('/api/progress').then(function(r) { return r.json(); })
2640
- ]).then(function(results) {
2641
- var graphRes = results[0];
2642
- var progressRes = results[1];
2643
- var newNodes = graphRes.nodes || [];
2644
- var newEdges = graphRes.edges || [];
2645
-
2646
- // 检查数据是否有变化(通过节点数量和状态快照比较)
2647
- var changed = false;
2648
- if (newNodes.length !== allNodes.length || newEdges.length !== allEdges.length) {
2649
- changed = true;
2650
- } else {
2651
- // 比较每个节点的状态
2652
- var oldStatusMap = {};
2653
- for (var i = 0; i < allNodes.length; i++) {
2654
- var n = allNodes[i];
2655
- oldStatusMap[n.id] = (n.properties || {}).status || '';
2656
- }
2657
- for (var i = 0; i < newNodes.length; i++) {
2658
- var n = newNodes[i];
2659
- var oldStatus = oldStatusMap[n.id];
2660
- var newStatus = (n.properties || {}).status || '';
2661
- if (oldStatus !== newStatus) {
2662
- changed = true;
2663
- break;
2664
- }
2665
- }
2666
- }
2667
-
2668
- if (changed) {
2669
- log('检测到数据变化, 更新图谱...', true);
2670
- allNodes = newNodes;
2671
- allEdges = newEdges;
2672
- renderStats(progressRes, graphRes);
2673
- // 仅更新节点样式而非重建整个图谱,以保持当前布局
2674
- if (nodesDataSet && network) {
2675
- updateNodeStyles();
2676
- } else {
2677
- renderGraph();
2678
- }
2679
- } else {
2680
- log('数据无变化 (' + new Date().toLocaleTimeString() + ')', true);
2681
- }
2682
- if (typeof onDone === 'function') onDone();
2683
- }).catch(function(err) {
2684
- log('刷新失败: ' + err.message, false);
2685
- if (typeof onDone === 'function') onDone();
2686
- });
2687
- }
2688
-
2689
- /** 增量更新节点样式(不重建布局) */
2690
- function updateNodeStyles() {
2691
- try {
2692
- // 构建当前可见节点的 ID 和新数据映射
2693
- var newNodeMap = {};
2694
- for (var i = 0; i < allNodes.length; i++) {
2695
- newNodeMap[allNodes[i].id] = allNodes[i];
2696
- }
2697
-
2698
- // 更新已有节点的样式和大小
2699
- var currentIds = nodesDataSet.getIds();
2700
- for (var i = 0; i < currentIds.length; i++) {
2701
- var id = currentIds[i];
2702
- var newData = newNodeMap[id];
2703
- if (newData && !hiddenTypes[newData.type]) {
2704
- var deg = getNodeDegree(newData);
2705
- var s = nodeStyle(newData, deg);
2706
- nodesDataSet.update({
2707
- id: id,
2708
- label: newData.label,
2709
- size: s.size,
2710
- color: s.color,
2711
- font: s.font,
2712
- _props: newData.properties || {}
2713
- });
2714
- }
2715
- }
2716
-
2717
- // 处理新增/删除的节点 — 如果有结构变化,完整重建
2718
- var visibleNewNodes = allNodes.filter(function(n) { return !hiddenTypes[n.type]; });
2719
- if (visibleNewNodes.length !== currentIds.length) {
2720
- renderGraph();
2721
- }
2722
-
2723
- // 增量更新后重新检查呼吸灯
2724
- var updatedInProg = getInProgressMainTaskIds();
2725
- if (updatedInProg.length > 0 && !breathAnimId) {
2726
- startBreathAnimation();
2727
- } else if (updatedInProg.length === 0 && breathAnimId) {
2728
- stopBreathAnimation();
2729
- }
2730
-
2731
- log('节点样式已更新 (' + new Date().toLocaleTimeString() + ')', true);
2732
- } catch (err) {
2733
- log('增量更新失败, 完整重建: ' + err.message, false);
2734
- renderGraph();
2735
- }
2736
- }
2737
-
2738
- // ========== App Start ==========
2739
- function startApp() {
2740
- if (USE_GRAPH_CANVAS) {
2741
- log('GraphCanvas 引擎就绪, 开始加载数据...', true);
2742
- } else {
2743
- log('vis-network 就绪, 开始加载数据...', true);
2744
- }
2745
- loadData();
2746
- }
2747
-
2748
- // ========== Docs Browser ==========
2749
- var docsLoaded = false;
2750
- var docsData = []; // 全部文档列表
2751
- var currentDocKey = ''; // 当前选中文档的 key (section|subSection)
2752
-
2753
- /** 根据 docKey 从 docsData 中查找文档标题 */
2754
- function findDocTitle(docKey) {
2755
- for (var i = 0; i < docsData.length; i++) {
2756
- var d = docsData[i];
2757
- var key = d.section + (d.subSection ? '|' + d.subSection : '');
2758
- if (key === docKey) return d.title;
2759
- }
2760
- return null;
2761
- }
2762
-
2763
- /** Section 类型的中文名称映射 */
2764
- var SECTION_NAMES = {
2765
- overview: '概述', core_concepts: '核心概念', api_design: 'API 设计',
2766
- file_structure: '文件结构', config: '配置', examples: '使用示例',
2767
- technical_notes: '技术笔记', api_endpoints: 'API 端点',
2768
- milestones: '里程碑', changelog: '变更记录', custom: '自定义'
2769
- };
2770
-
2771
- /** Section 图标映射(使用简洁符号替代 emoji) */
2772
- var SECTION_ICONS = {
2773
- overview: '▸', core_concepts: '▸', api_design: '▸',
2774
- file_structure: '▸', config: '▸', examples: '▸',
2775
- technical_notes: '▸', api_endpoints: '▸',
2776
- milestones: '▸', changelog: '▸', custom: '▸'
2777
- };
2778
-
2779
- function loadDocsPage() {
2780
- if (docsLoaded && docsData.length > 0) return;
2781
- var list = document.getElementById('docsGroupList');
2782
- if (list) list.innerHTML = '<div style="text-align:center;padding:40px;color:#6b7280;font-size:12px;"><div class="spinner" style="margin:0 auto 12px;width:24px;height:24px;border-width:3px;"></div>加载文档列表...</div>';
2783
-
2784
- fetch('/api/docs').then(function(r) { return r.json(); }).then(function(data) {
2785
- docsData = data.docs || [];
2786
- docsLoaded = true;
2787
- renderDocsList(docsData);
2788
- }).catch(function(err) {
2789
- if (list) list.innerHTML = '<div style="text-align:center;padding:40px;color:#f87171;font-size:12px;">加载失败: ' + err.message + '<br><span style="cursor:pointer;color:#818cf8;text-decoration:underline;" onclick="docsLoaded=false;loadDocsPage();">重试</span></div>';
2790
- });
2791
- }
2792
-
2793
- /** 获取文档的 key(唯一标识) */
2794
- function docItemKey(item) {
2795
- return item.section + (item.subSection ? '|' + item.subSection : '');
2796
- }
2797
-
2798
- /** 记录哪些父文档处于折叠状态(key → true 表示折叠) */
2799
- var docsCollapsedState = {};
2800
-
2801
- /** 将文档列表按 section 分组渲染,支持 parentDoc 层级 */
2802
- function renderDocsList(docs) {
2803
- var list = document.getElementById('docsGroupList');
2804
- if (!list) return;
2805
-
2806
- // 建立 parentDoc → children 映射,区分顶级和子文档
2807
- var childrenMap = {}; // parentDocKey → [child items]
2808
- var childKeySet = {}; // 属于子文档的 key 集合
2809
- for (var i = 0; i < docs.length; i++) {
2810
- var d = docs[i];
2811
- if (d.parentDoc) {
2812
- if (!childrenMap[d.parentDoc]) childrenMap[d.parentDoc] = [];
2813
- childrenMap[d.parentDoc].push(d);
2814
- childKeySet[docItemKey(d)] = true;
2815
- }
2816
- }
2817
-
2818
- // 按 section 分组(只放顶级文档)
2819
- var groups = {};
2820
- var groupOrder = [];
2821
- for (var i = 0; i < docs.length; i++) {
2822
- var d = docs[i];
2823
- var key = docItemKey(d);
2824
- if (childKeySet[key]) continue; // 跳过子文档(由父文档渲染)
2825
- var sec = d.section;
2826
- if (!groups[sec]) {
2827
- groups[sec] = [];
2828
- groupOrder.push(sec);
2829
- }
2830
- groups[sec].push(d);
2831
- }
2832
-
2833
- // 每组内按 updatedAt 倒序排列(最新的在上方)
2834
- for (var gi = 0; gi < groupOrder.length; gi++) {
2835
- groups[groupOrder[gi]].sort(function(a, b) {
2836
- var ta = a.updatedAt || 0;
2837
- var tb = b.updatedAt || 0;
2838
- return tb - ta; // 降序
2839
- });
2840
- }
2841
-
2842
- // 子文档也按 updatedAt 倒序
2843
- var parentKeys = Object.keys(childrenMap);
2844
- for (var pi = 0; pi < parentKeys.length; pi++) {
2845
- childrenMap[parentKeys[pi]].sort(function(a, b) {
2846
- var ta = a.updatedAt || 0;
2847
- var tb = b.updatedAt || 0;
2848
- return tb - ta;
2849
- });
2850
- }
2851
-
2852
- // 分组按最新文档日期排序(最新的分组在上)
2853
- groupOrder.sort(function(secA, secB) {
2854
- var maxA = 0, maxB = 0;
2855
- var itemsA = groups[secA] || [];
2856
- var itemsB = groups[secB] || [];
2857
- for (var k = 0; k < itemsA.length; k++) {
2858
- if ((itemsA[k].updatedAt || 0) > maxA) maxA = itemsA[k].updatedAt || 0;
2859
- }
2860
- for (var k = 0; k < itemsB.length; k++) {
2861
- if ((itemsB[k].updatedAt || 0) > maxB) maxB = itemsB[k].updatedAt || 0;
2862
- }
2863
- return maxB - maxA;
2864
- });
2865
-
2866
- if (groupOrder.length === 0) {
2867
- list.innerHTML = '<div style="text-align:center;padding:40px;color:#6b7280;font-size:12px;">暂无文档</div>';
2868
- return;
2869
- }
2870
-
2871
- var html = '';
2872
- for (var gi = 0; gi < groupOrder.length; gi++) {
2873
- var sec = groupOrder[gi];
2874
- var items = groups[sec];
2875
- var secName = SECTION_NAMES[sec] || sec;
2876
- var secIcon = SECTION_ICONS[sec] || '▸';
2877
-
2878
- // 计算此分组下文档总数(含子文档)
2879
- var totalCount = 0;
2880
- for (var ci = 0; ci < docs.length; ci++) {
2881
- if (docs[ci].section === sec) totalCount++;
2882
- }
2883
-
2884
- html += '<div class="docs-group" data-section="' + sec + '">';
2885
- html += '<div class="docs-group-title" onclick="toggleDocsGroup(this)">';
2886
- html += '<span class="docs-group-arrow">▼</span>';
2887
- html += '<span>' + secName + '</span>';
2888
- html += '<span class="docs-group-count">' + totalCount + '</span>';
2889
- html += '</div>';
2890
- html += '<div class="docs-group-items">';
2891
-
2892
- for (var ii = 0; ii < items.length; ii++) {
2893
- html += renderDocItemWithChildren(items[ii], childrenMap, secIcon);
2894
- }
2895
-
2896
- html += '</div></div>';
2897
- }
2898
-
2899
- list.innerHTML = html;
2900
- }
2901
-
2902
- /** 递归渲染文档项及其子文档 */
2903
- function renderDocItemWithChildren(item, childrenMap, secIcon) {
2904
- var docKey = docItemKey(item);
2905
- var isActive = docKey === currentDocKey ? ' active' : '';
2906
- var children = childrenMap[docKey] || [];
2907
- var hasChildren = children.length > 0;
2908
- var isCollapsed = docsCollapsedState[docKey] === true;
2909
-
2910
- var html = '<div class="docs-item-wrapper">';
2911
-
2912
- // 文档项本身
2913
- html += '<div class="docs-item' + isActive + '" data-key="' + escHtml(docKey) + '" onclick="selectDoc(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)">';
2914
-
2915
- if (hasChildren) {
2916
- var toggleIcon = isCollapsed ? '+' : '−';
2917
- html += '<span class="docs-item-toggle" onclick="event.stopPropagation();toggleDocChildren(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)" title="' + (isCollapsed ? '展开子文档' : '收起子文档') + '">' + toggleIcon + '</span>';
2918
- }
2919
-
2920
- // 不显示 emoji 图标,仅保留标题
2921
- html += '<span class="docs-item-text" title="' + escHtml(item.title) + '">' + escHtml(item.title) + '</span>';
2922
- if (hasChildren) {
2923
- html += '<span class="docs-item-sub" style="color:#818cf8;">' + children.length + ' 子文档</span>';
2924
- }
2925
- // 右侧显示日期(替代原来的 subSection 标签)
2926
- if (item.updatedAt) {
2927
- html += '<span class="docs-item-sub">' + fmtDateShort(item.updatedAt) + '</span>';
2928
- }
2929
- html += '</div>';
2930
-
2931
- // 子文档列表
2932
- if (hasChildren) {
2933
- html += '<div class="docs-children' + (isCollapsed ? ' collapsed' : '') + '" data-parent="' + escHtml(docKey) + '">';
2934
- for (var ci = 0; ci < children.length; ci++) {
2935
- html += renderDocItemWithChildren(children[ci], childrenMap, secIcon);
2936
- }
2937
- html += '</div>';
2938
- }
2939
-
2940
- html += '</div>';
2941
- return html;
2942
- }
2943
-
2944
- /** 展开/折叠子文档 */
2945
- function toggleDocChildren(docKey) {
2946
- docsCollapsedState[docKey] = !docsCollapsedState[docKey];
2947
- var container = document.querySelector('.docs-children[data-parent="' + docKey + '"]');
2948
- if (!container) return;
2949
- container.classList.toggle('collapsed');
2950
- // 更新切换按钮图标
2951
- var wrapper = container.previousElementSibling;
2952
- if (wrapper) {
2953
- var toggle = wrapper.querySelector('.docs-item-toggle');
2954
- if (toggle) {
2955
- toggle.textContent = docsCollapsedState[docKey] ? '+' : '−';
2956
- toggle.title = docsCollapsedState[docKey] ? '展开子文档' : '收起子文档';
2957
- }
2958
- }
2959
- }
2960
-
2961
- /** 展开/折叠文档分组 */
2962
- function toggleDocsGroup(el) {
2963
- var group = el.closest('.docs-group');
2964
- if (group) group.classList.toggle('collapsed');
2965
- }
2966
-
2967
- /** 控制搜索框清除按钮的显示/隐藏 */
2968
- function toggleSearchClear() {
2969
- var input = document.getElementById('docsSearch');
2970
- var btn = document.getElementById('docsSearchClear');
2971
- if (input && btn) {
2972
- if (input.value.length > 0) { btn.classList.add('show'); } else { btn.classList.remove('show'); }
2973
- }
2974
- }
2975
-
2976
- /** 清空搜索框并重置列表 */
2977
- function clearDocsSearch() {
2978
- var input = document.getElementById('docsSearch');
2979
- if (input) { input.value = ''; input.focus(); }
2980
- toggleSearchClear();
2981
- filterDocs();
2982
- }
2983
-
2984
- /** 搜索过滤文档列表 */
2985
- function filterDocs() {
2986
- var query = (document.getElementById('docsSearch').value || '').toLowerCase().trim();
2987
- if (!query) {
2988
- renderDocsList(docsData);
2989
- return;
2990
- }
2991
- var filtered = [];
2992
- for (var i = 0; i < docsData.length; i++) {
2993
- var d = docsData[i];
2994
- var text = (d.title || '') + ' ' + (d.section || '') + ' ' + (d.subSection || '');
2995
- if (text.toLowerCase().indexOf(query) >= 0) {
2996
- filtered.push(d);
2997
- }
2998
- }
2999
- renderDocsList(filtered);
3000
- }
3001
-
3002
- /** 选中并加载文档内容 */
3003
- function selectDoc(docKey) {
3004
- currentDocKey = docKey;
3005
-
3006
- // 更新左侧选中状态
3007
- var items = document.querySelectorAll('.docs-item');
3008
- for (var i = 0; i < items.length; i++) {
3009
- items[i].classList.remove('active');
3010
- if (items[i].getAttribute('data-key') === docKey) {
3011
- items[i].classList.add('active');
3012
- }
3013
- }
3014
-
3015
- // 解析 key
3016
- var parts = docKey.split('|');
3017
- var section = parts[0];
3018
- var subSection = parts[1] || null;
3019
-
3020
- // 显示内容区,隐藏空状态
3021
- document.getElementById('docsEmptyState').style.display = 'none';
3022
- var contentView = document.getElementById('docsContentView');
3023
- contentView.style.display = 'flex';
3024
-
3025
- // 显示加载状态
3026
- document.getElementById('docsContentTitle').textContent = '加载中...';
3027
- document.getElementById('docsContentMeta').innerHTML = '';
3028
- document.getElementById('docsContentInner').innerHTML = '<div style="text-align:center;padding:40px;color:#6b7280;"><div class="spinner" style="margin:0 auto 12px;width:24px;height:24px;border-width:3px;"></div></div>';
3029
-
3030
- // 请求文档内容
3031
- var url = '/api/doc?section=' + encodeURIComponent(section);
3032
- if (subSection) url += '&subSection=' + encodeURIComponent(subSection);
3033
-
3034
- fetch(url).then(function(r) {
3035
- if (!r.ok) throw new Error('HTTP ' + r.status);
3036
- return r.json();
3037
- }).then(function(doc) {
3038
- renderDocContent(doc, section, subSection);
3039
- }).catch(function(err) {
3040
- document.getElementById('docsContentTitle').textContent = '加载失败';
3041
- document.getElementById('docsContentInner').innerHTML = '<div style="text-align:center;padding:40px;color:#f87171;">加载失败: ' + escHtml(err.message) + '</div>';
3042
- });
3043
- }
3044
-
3045
- /** 渲染文档内容到右侧面板 */
3046
- function renderDocContent(doc, section, subSection) {
3047
- var secName = SECTION_NAMES[section] || section;
3048
-
3049
- // 标题
3050
- document.getElementById('docsContentTitle').textContent = doc.title || secName;
3051
-
3052
- // 元信息标签
3053
- var metaHtml = '<span class="docs-content-tag section">' + secName + '</span>';
3054
- if (subSection) {
3055
- metaHtml += '<span class="docs-content-tag section">' + escHtml(subSection) + '</span>';
3056
- }
3057
- if (doc.version) {
3058
- metaHtml += '<span class="docs-content-tag version">v' + escHtml(doc.version) + '</span>';
3059
- }
3060
- if (doc.updatedAt) {
3061
- metaHtml += '<span class="docs-content-tag">' + fmtTime(doc.updatedAt) + '</span>';
3062
- }
3063
- document.getElementById('docsContentMeta').innerHTML = metaHtml;
3064
-
3065
- // Markdown 内容
3066
- var contentHtml = '';
3067
- if (doc.content) {
3068
- contentHtml = renderMarkdown(doc.content);
3069
- } else {
3070
- contentHtml = '<div style="text-align:center;padding:40px;color:#6b7280;">文档内容为空</div>';
3071
- }
3072
-
3073
- // 父文档链接
3074
- if (doc.parentDoc) {
3075
- var parentTitle = findDocTitle(doc.parentDoc);
3076
- contentHtml += '<div class="docs-related" style="margin-top: 12px;">';
3077
- contentHtml += '<div class="docs-related-title">⬆️ 父文档</div>';
3078
- contentHtml += '<div class="docs-related-item" style="cursor:pointer;" onclick="selectDoc(\\x27' + doc.parentDoc.replace(/'/g, "\\\\'") + '\\x27)">';
3079
- contentHtml += '<span class="rel-icon" style="background:#1e3a5f;color:#93c5fd;">📄</span>';
3080
- contentHtml += '<span style="flex:1;color:#818cf8;">' + escHtml(parentTitle || doc.parentDoc) + '</span>';
3081
- contentHtml += '<span style="font-size:10px;color:#6b7280;font-family:monospace;">' + escHtml(doc.parentDoc) + '</span>';
3082
- contentHtml += '</div></div>';
3083
- }
3084
-
3085
- // 子文档列表
3086
- var childDocs = doc.childDocs || [];
3087
- if (childDocs.length > 0) {
3088
- contentHtml += '<div class="docs-related" style="margin-top: 12px;">';
3089
- contentHtml += '<div class="docs-related-title">⬇️ 子文档 (' + childDocs.length + ')</div>';
3090
- for (var ci = 0; ci < childDocs.length; ci++) {
3091
- var childKey = childDocs[ci];
3092
- var childTitle = findDocTitle(childKey);
3093
- contentHtml += '<div class="docs-related-item" style="cursor:pointer;" onclick="selectDoc(\\x27' + childKey.replace(/'/g, "\\\\'") + '\\x27)">';
3094
- contentHtml += '<span class="rel-icon" style="background:#1e1b4b;color:#c084fc;">📄</span>';
3095
- contentHtml += '<span style="flex:1;color:#c084fc;">' + escHtml(childTitle || childKey) + '</span>';
3096
- contentHtml += '<span style="font-size:10px;color:#6b7280;font-family:monospace;">' + escHtml(childKey) + '</span>';
3097
- contentHtml += '</div>';
3098
- }
3099
- contentHtml += '</div>';
3100
- }
3101
-
3102
- // 关联任务
3103
- var relatedTasks = doc.relatedTasks || [];
3104
- if (relatedTasks.length > 0) {
3105
- contentHtml += '<div class="docs-related">';
3106
- contentHtml += '<div class="docs-related-title">🔗 关联任务 (' + relatedTasks.length + ')</div>';
3107
- for (var i = 0; i < relatedTasks.length; i++) {
3108
- var t = relatedTasks[i];
3109
- var tStatus = t.status || 'pending';
3110
- var tIcon = tStatus === 'completed' ? '✓' : tStatus === 'in_progress' ? '▶' : '○';
3111
- var iconBg = tStatus === 'completed' ? '#064e3b' : tStatus === 'in_progress' ? '#1e3a5f' : '#374151';
3112
- var iconColor = tStatus === 'completed' ? '#6ee7b7' : tStatus === 'in_progress' ? '#93c5fd' : '#6b7280';
3113
- contentHtml += '<div class="docs-related-item">';
3114
- contentHtml += '<span class="rel-icon" style="background:' + iconBg + ';color:' + iconColor + ';">' + tIcon + '</span>';
3115
- contentHtml += '<span style="flex:1;">' + escHtml(t.title) + '</span>';
3116
- contentHtml += '<span style="font-size:10px;color:#6b7280;font-family:monospace;">' + escHtml(t.taskId) + '</span>';
3117
- if (t.priority) {
3118
- contentHtml += '<span class="status-badge priority-' + t.priority + '" style="font-size:10px;">' + t.priority + '</span>';
3119
- }
3120
- contentHtml += '</div>';
3121
- }
3122
- contentHtml += '</div>';
3123
- }
3124
-
3125
- document.getElementById('docsContentInner').innerHTML = contentHtml;
3126
- }
3127
-
3128
- // ========== RAG Chat ==========
3129
- var chatHistory = []; // [{role:'user'|'assistant', content:string, results?:array}]
3130
- var chatBusy = false;
3131
-
3132
- /** 点击推荐话题 */
3133
- function chatSendTip(el) {
3134
- var input = document.getElementById('docsChatInput');
3135
- if (input) { input.value = el.textContent; chatSend(); }
3136
- }
3137
-
3138
- /** Enter 发送(Shift+Enter 换行) */
3139
- function chatInputKeydown(e) {
3140
- if (e.key === 'Enter' && !e.shiftKey) {
3141
- e.preventDefault();
3142
- chatSend();
3143
- }
3144
- }
3145
-
3146
- /** 自动调整 textarea 高度 */
3147
- function chatAutoResize(el) {
3148
- el.style.height = 'auto';
3149
- el.style.height = Math.min(el.scrollHeight, 120) + 'px';
3150
- }
3151
-
3152
- /** 发送消息并搜索 */
3153
- function chatSend() {
3154
- if (chatBusy) return;
3155
- var input = document.getElementById('docsChatInput');
3156
- var query = (input.value || '').trim();
3157
- if (!query) return;
3158
-
3159
- // 隐藏欢迎信息
3160
- var welcome = document.getElementById('docsChatWelcome');
3161
- if (welcome) welcome.style.display = 'none';
3162
-
3163
- // 添加用户消息
3164
- chatHistory.push({ role: 'user', content: query });
3165
- chatRenderBubble('user', query);
3166
- input.value = '';
3167
- chatAutoResize(input);
3168
-
3169
- // 显示加载动画
3170
- chatBusy = true;
3171
- document.getElementById('docsChatSend').disabled = true;
3172
- var loadingId = 'chat-loading-' + Date.now();
3173
- var msgBox = document.getElementById('docsChatMessages');
3174
- var loadingHtml = '<div class="chat-bubble assistant" id="' + loadingId + '"><div class="chat-bubble-inner"><div class="chat-typing"><div class="chat-typing-dot"></div><div class="chat-typing-dot"></div><div class="chat-typing-dot"></div></div></div></div>';
3175
- msgBox.insertAdjacentHTML('beforeend', loadingHtml);
3176
- msgBox.scrollTop = msgBox.scrollHeight;
3177
-
3178
- // 调用搜索 API
3179
- fetch('/api/chat', {
3180
- method: 'POST',
3181
- headers: { 'Content-Type': 'application/json' },
3182
- body: JSON.stringify({ query: query, limit: 5 })
3183
- }).then(function(r) { return r.json(); }).then(function(data) {
3184
- // 移除加载动画
3185
- var loadEl = document.getElementById(loadingId);
3186
- if (loadEl) loadEl.remove();
3187
-
3188
- var replyHtml = '';
3189
-
3190
- if (data.type === 'meta') {
3191
- // ---- 元信息直接回答 ----
3192
- replyHtml = chatFormatMarkdown(data.answer || '');
3193
- } else {
3194
- // ---- 文档搜索结果 ----
3195
- var results = data.results || [];
3196
- if (results.length > 0) {
3197
- replyHtml += '<div style="margin-bottom:8px;color:#9ca3af;font-size:12px;">找到 <strong style="color:#a5b4fc;">' + results.length + '</strong> 篇相关文档';
3198
- if (data.mode === 'hybrid') replyHtml += ' <span style="font-size:10px;color:#6b7280;">(语义+字面混合)</span>';
3199
- else if (data.mode === 'semantic') replyHtml += ' <span style="font-size:10px;color:#6b7280;">(语义搜索)</span>';
3200
- else replyHtml += ' <span style="font-size:10px;color:#6b7280;">(字面搜索)</span>';
3201
- replyHtml += '</div>';
3202
-
3203
- for (var i = 0; i < results.length; i++) {
3204
- var r = results[i];
3205
- var docKey = r.section + (r.subSection ? '|' + r.subSection : '');
3206
- replyHtml += '<div class="chat-result-card" onclick="chatOpenDoc(\\x27' + docKey.replace(/'/g, "\\\\'") + '\\x27)">';
3207
- replyHtml += '<div class="chat-result-title">';
3208
- replyHtml += '<span>📄 ' + escHtml(r.title) + '</span>';
3209
- if (r.score != null) replyHtml += '<span class="chat-result-score">' + r.score.toFixed(3) + '</span>';
3210
- replyHtml += '</div>';
3211
- if (r.snippet) replyHtml += '<div class="chat-result-snippet">' + escHtml(r.snippet) + '</div>';
3212
- var metaParts = [];
3213
- if (r.section) metaParts.push(r.section);
3214
- if (r.updatedAt) metaParts.push(fmtDateShort(r.updatedAt));
3215
- if (r.version) metaParts.push('v' + r.version);
3216
- if (metaParts.length > 0) replyHtml += '<div class="chat-result-meta">' + metaParts.join(' · ') + '</div>';
3217
- replyHtml += '</div>';
3218
- }
3219
- } else {
3220
- replyHtml += '<div class="chat-no-result">🤔 未找到高度相关的文档。</div>';
3221
- replyHtml += '<div style="margin-top:8px;font-size:12px;color:#6b7280;line-height:1.6;">';
3222
- replyHtml += '建议:<br>';
3223
- replyHtml += '• 尝试使用更具体的 <strong>关键词</strong>(如 "向量搜索"、"GPU"、"LanceDB")<br>';
3224
- replyHtml += '• 问项目统计问题(如 "有多少篇文档"、"项目进度"、"有哪些阶段")<br>';
3225
- replyHtml += '• 输入 <strong>"帮助"</strong> 查看我的全部能力';
3226
- replyHtml += '</div>';
3227
- }
3228
- }
3229
-
3230
- chatHistory.push({ role: 'assistant', content: replyHtml, results: data.results || [] });
3231
- chatRenderBubble('assistant', replyHtml, true);
3232
-
3233
- }).catch(function(err) {
3234
- var loadEl = document.getElementById(loadingId);
3235
- if (loadEl) loadEl.remove();
3236
- chatRenderBubble('assistant', '<span style="color:#f87171;">搜索出错: ' + escHtml(err.message) + '</span>', true);
3237
- }).finally(function() {
3238
- chatBusy = false;
3239
- document.getElementById('docsChatSend').disabled = false;
3240
- document.getElementById('docsChatInput').focus();
3241
- });
3242
- }
3243
-
3244
- /** 简单 Markdown → HTML 转换(用于元信息回答) */
3245
- function chatFormatMarkdown(text) {
3246
- return text
3247
- .replace(/\\*\\*(.+?)\\*\\*/g, '<strong style="color:#a5b4fc;">$1</strong>')
3248
- .replace(/\\n/g, '<br>');
3249
- }
3250
-
3251
- /** 渲染一条消息气泡 */
3252
- function chatRenderBubble(role, content, isHtml) {
3253
- var msgBox = document.getElementById('docsChatMessages');
3254
- var bubble = document.createElement('div');
3255
- bubble.className = 'chat-bubble ' + role;
3256
- var inner = document.createElement('div');
3257
- inner.className = 'chat-bubble-inner';
3258
- if (isHtml) { inner.innerHTML = content; }
3259
- else { inner.textContent = content; }
3260
- bubble.appendChild(inner);
3261
- msgBox.appendChild(bubble);
3262
- msgBox.scrollTop = msgBox.scrollHeight;
3263
- }
3264
-
3265
- /** 从聊天结果中点击打开文档 */
3266
- function chatOpenDoc(docKey) {
3267
- selectDoc(docKey);
3268
- }
3269
-
3270
- /** 返回聊天视图 */
3271
- function backToChat() {
3272
- document.getElementById('docsContentView').style.display = 'none';
3273
- document.getElementById('docsEmptyState').style.display = 'flex';
3274
- // 取消左侧选中
3275
- currentDocKey = '';
3276
- var items = document.querySelectorAll('.docs-item');
3277
- for (var i = 0; i < items.length; i++) items[i].classList.remove('active');
3278
- // 聚焦输入框
3279
- var input = document.getElementById('docsChatInput');
3280
- if (input) input.focus();
3281
- }
3282
-
3283
- // ========== Stats Dashboard ==========
3284
- var statsLoaded = false;
3285
-
3286
- function loadStatsPage() {
3287
- var container = document.getElementById('statsContent');
3288
- if (!container) return;
3289
- container.innerHTML = '<div style="text-align:center;padding:60px;color:#6b7280;"><div class="spinner" style="margin:0 auto 12px;"></div>加载统计数据...</div>';
3290
-
3291
- fetch('/api/stats').then(function(r) { return r.json(); }).then(function(data) {
3292
- statsLoaded = true;
3293
- renderStatsPage(data);
3294
- }).catch(function(err) {
3295
- container.innerHTML = '<div style="text-align:center;padding:60px;color:#f87171;">加载失败: ' + err.message + '<br><button class="refresh-btn" onclick="loadStatsPage()" style="margin-top:12px;">重试</button></div>';
3296
- });
3297
- }
3298
-
3299
- function renderStatsPage(data) {
3300
- var container = document.getElementById('statsContent');
3301
- if (!container) return;
3302
-
3303
- var pct = data.overallPercent || 0;
3304
- var totalSub = data.subTaskCount || 0;
3305
- var doneSub = data.completedSubTasks || 0;
3306
- var totalMain = data.mainTaskCount || 0;
3307
- var doneMain = data.completedMainTasks || 0;
3308
- var docCount = data.docCount || 0;
3309
- var modCount = data.moduleCount || 0;
3310
-
3311
- // 激励语
3312
- var motivate = '';
3313
- if (pct >= 100) motivate = '🎉 项目已全部完成!太棒了!';
3314
- else if (pct >= 75) motivate = '🚀 即将大功告成,冲刺阶段!';
3315
- else if (pct >= 50) motivate = '💪 已过半程,保持节奏!';
3316
- else if (pct >= 25) motivate = '🌱 稳步推进中,继续加油!';
3317
- else if (pct > 0) motivate = '🏗️ 万事开头难,已迈出第一步!';
3318
- else motivate = '📋 项目已规划就绪,开始行动吧!';
3319
-
3320
- var html = '';
3321
-
3322
- // ===== 总体进度环 =====
3323
- var ringR = 54;
3324
- var ringC = 2 * Math.PI * ringR;
3325
- var ringOffset = ringC - (pct / 100) * ringC;
3326
- html += '<div class="progress-ring-wrap">';
3327
- html += '<svg class="ring-svg" width="140" height="140" viewBox="0 0 140 140">';
3328
- html += '<circle cx="70" cy="70" r="' + ringR + '" stroke="#374151" stroke-width="10" fill="none"/>';
3329
- html += '<circle cx="70" cy="70" r="' + ringR + '" stroke="url(#ringGrad)" stroke-width="10" fill="none" stroke-linecap="round" stroke-dasharray="' + ringC + '" stroke-dashoffset="' + ringOffset + '" transform="rotate(-90 70 70)" style="transition:stroke-dashoffset 1s ease;"/>';
3330
- html += '<defs><linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#10b981"/><stop offset="100%" stop-color="#3b82f6"/></linearGradient></defs>';
3331
- html += '<text x="70" y="65" text-anchor="middle" fill="#f3f4f6" font-size="28" font-weight="800">' + pct + '%</text>';
3332
- html += '<text x="70" y="84" text-anchor="middle" fill="#6b7280" font-size="11">完成率</text>';
3333
- html += '</svg>';
3334
- html += '<div class="progress-ring-info">';
3335
- html += '<h3>项目总体进度</h3>';
3336
- html += '<p>子任务完成 <strong style="color:#10b981;">' + doneSub + '</strong> / ' + totalSub + ',主任务完成 <strong style="color:#3b82f6;">' + doneMain + '</strong> / ' + totalMain + '</p>';
3337
- html += '<div class="motivate">' + motivate + '</div>';
3338
- html += '</div></div>';
3339
-
3340
- // ===== 概览卡片 =====
3341
- html += '<div class="stats-grid">';
3342
- html += statCard('📋', totalMain, '主任务', doneMain + ' 已完成', 'blue');
3343
- html += statCard('✅', doneSub, '已完成子任务', '共 ' + totalSub + ' 个子任务', 'green');
3344
- html += statCard('📄', docCount, '文档', Object.keys(data.docBySection || {}).length + ' 种类型', 'purple');
3345
- html += statCard('🧩', modCount, '功能模块', '', 'amber');
3346
- var remainSub = totalSub - doneSub;
3347
- html += statCard('⏳', remainSub, '待完成子任务', remainSub > 0 ? '继续努力!' : '全部完成!', 'rose');
3348
- html += '</div>';
3349
-
3350
- // ===== 按优先级统计 =====
3351
- var bp = data.byPriority || {};
3352
- html += '<div class="stats-section">';
3353
- html += '<div class="stats-section-title"><span class="sec-icon">🎯</span> 按优先级统计</div>';
3354
- html += '<div class="priority-bars">';
3355
- var priorities = ['P0', 'P1', 'P2'];
3356
- for (var pi = 0; pi < priorities.length; pi++) {
3357
- var pk = priorities[pi];
3358
- var pd = bp[pk] || { total: 0, completed: 0 };
3359
- var ppct = pd.total > 0 ? Math.round(pd.completed / pd.total * 100) : 0;
3360
- html += '<div class="priority-row">';
3361
- html += '<span class="priority-label ' + pk + '">' + pk + '</span>';
3362
- html += '<div class="priority-bar-track"><div class="priority-bar-fill ' + pk + '" style="width:' + ppct + '%"></div></div>';
3363
- html += '<span class="priority-nums">' + pd.completed + '/' + pd.total + ' (' + ppct + '%)</span>';
3364
- html += '</div>';
3365
- }
3366
- html += '</div></div>';
3367
-
3368
- // ===== 进行中的任务 =====
3369
- var inProg = data.inProgressPhases || [];
3370
- if (inProg.length > 0) {
3371
- html += '<div class="stats-section">';
3372
- html += '<div class="stats-section-title"><span class="sec-icon">🔄</span> 进行中 (' + inProg.length + ')</div>';
3373
- html += '<div class="phase-list">';
3374
- for (var ii = 0; ii < inProg.length; ii++) {
3375
- html += phaseItem(inProg[ii], 'in_progress', '▶');
3376
- }
3377
- html += '</div></div>';
3378
- }
3379
-
3380
- // ===== 已完成的里程碑 =====
3381
- var done = data.completedPhases || [];
3382
- if (done.length > 0) {
3383
- html += '<div class="stats-section">';
3384
- html += '<div class="stats-section-title"><span class="sec-icon">🏆</span> 已完成里程碑 (' + done.length + ')</div>';
3385
- html += '<div class="phase-list">';
3386
- for (var di = 0; di < done.length; di++) {
3387
- html += phaseItem(done[di], 'completed', '✓');
3388
- }
3389
- html += '</div></div>';
3390
- }
3391
-
3392
- // ===== 待开始的任务 =====
3393
- var pending = data.pendingPhases || [];
3394
- if (pending.length > 0) {
3395
- html += '<div class="stats-section">';
3396
- html += '<div class="stats-section-title"><span class="sec-icon">📌</span> 待开始 (' + pending.length + ')</div>';
3397
- html += '<div class="phase-list">';
3398
- for (var qi = 0; qi < pending.length; qi++) {
3399
- html += phaseItem(pending[qi], 'pending', '○');
3400
- }
3401
- html += '</div></div>';
3402
- }
3403
-
3404
- // ===== 模块概览 =====
3405
- var mods = data.moduleStats || [];
3406
- if (mods.length > 0) {
3407
- html += '<div class="stats-section">';
3408
- html += '<div class="stats-section-title"><span class="sec-icon">🧩</span> 模块概览</div>';
3409
- html += '<div class="module-grid">';
3410
- for (var mi = 0; mi < mods.length; mi++) {
3411
- var mod = mods[mi];
3412
- var mpct = mod.subTaskCount > 0 ? Math.round(mod.completedSubTaskCount / mod.subTaskCount * 100) : 0;
3413
- html += '<div class="module-card">';
3414
- html += '<div class="module-card-header"><div class="module-card-dot" style="background:' + (mpct >= 100 ? '#10b981' : mpct > 0 ? '#3b82f6' : '#4b5563') + ';"></div><span class="module-card-name">' + escHtml(mod.name) + '</span></div>';
3415
- html += '<div class="module-card-bar"><div class="module-card-bar-fill" style="width:' + mpct + '%"></div></div>';
3416
- html += '<div class="module-card-stats"><span>' + mod.completedSubTaskCount + '/' + mod.subTaskCount + ' 子任务</span><span>' + mpct + '%</span></div>';
3417
- html += '</div>';
3418
- }
3419
- html += '</div></div>';
3420
- }
3421
-
3422
- // ===== 文档分布 =====
3423
- var docSec = data.docBySection || {};
3424
- var docKeys = Object.keys(docSec);
3425
- if (docKeys.length > 0) {
3426
- html += '<div class="stats-section">';
3427
- html += '<div class="stats-section-title"><span class="sec-icon">📚</span> 文档分布</div>';
3428
- html += '<div class="stats-grid">';
3429
- var secNames = { overview: '概述', core_concepts: '核心概念', api_design: 'API 设计', file_structure: '文件结构', config: '配置', examples: '示例', technical_notes: '技术笔记', api_endpoints: 'API 端点', milestones: '里程碑', changelog: '变更日志', custom: '自定义' };
3430
- for (var si = 0; si < docKeys.length; si++) {
3431
- var sk = docKeys[si];
3432
- html += '<div class="stat-card purple" style="padding:14px;">';
3433
- html += '<div style="font-size:20px;font-weight:800;color:#a5b4fc;">' + docSec[sk] + '</div>';
3434
- html += '<div style="font-size:11px;color:#9ca3af;margin-top:4px;">' + (secNames[sk] || sk) + '</div>';
3435
- html += '</div>';
3436
- }
3437
- html += '</div></div>';
3438
- }
3439
-
3440
- container.innerHTML = html;
3441
- }
3442
-
3443
- function statCard(icon, value, label, sub, color) {
3444
- return '<div class="stat-card ' + color + '"><div class="stat-card-icon">' + icon + '</div><div class="stat-card-value">' + value + '</div><div class="stat-card-label">' + label + '</div>' + (sub ? '<div class="stat-card-sub">' + sub + '</div>' : '') + '</div>';
3445
- }
3446
-
3447
- function phaseItem(task, status, icon) {
3448
- var ppct = task.percent || 0;
3449
- var subText = task.total !== undefined ? (task.completed || 0) + '/' + task.total + ' 子任务' : task.taskId;
3450
- var subs = task.subTasks || [];
3451
- var rDocsCheck = task.relatedDocs || [];
3452
- var hasSubs = subs.length > 0 || rDocsCheck.length > 0;
3453
- var subIcons = { completed: '✓', in_progress: '◉', pending: '○', cancelled: '⊘' };
3454
- var mainTime = task.completedAt ? fmtTime(task.completedAt) : '';
3455
- var h = '<div class="phase-item-wrap">';
3456
- h += '<div class="phase-item-main" ' + (hasSubs ? 'onclick="togglePhaseExpand(this)"' : '') + '>';
3457
- if (hasSubs) { h += '<div class="phase-expand-icon">▶</div>'; }
3458
- h += '<div class="phase-status-icon ' + status + '">' + icon + '</div>';
3459
- h += '<div class="phase-info" style="flex:1;min-width:0;"><div class="phase-info-title">' + escHtml(task.title) + '</div>';
3460
- h += '<div class="phase-info-sub">' + escHtml(task.taskId) + ' · ' + subText;
3461
- if (mainTime) { h += ' · <span class="phase-time">✓ ' + mainTime + '</span>'; }
3462
- h += '</div></div>';
3463
- h += '<div class="phase-bar-mini"><div class="phase-bar-mini-fill" style="width:' + ppct + '%"></div></div>';
3464
- h += '<div class="phase-pct">' + ppct + '%</div>';
3465
- h += '</div>';
3466
- var rDocs = task.relatedDocs || [];
3467
- if (hasSubs || rDocs.length > 0) {
3468
- h += '<div class="phase-subtasks">';
3469
- for (var si = 0; si < subs.length; si++) {
3470
- var s = subs[si];
3471
- var ss = s.status || 'pending';
3472
- var subTime = s.completedAt ? fmtTime(s.completedAt) : '';
3473
- h += '<div class="phase-sub-item">';
3474
- h += '<div class="phase-sub-icon ' + ss + '">' + (subIcons[ss] || '○') + '</div>';
3475
- h += '<span class="phase-sub-name ' + ss + '">' + escHtml(s.title) + '</span>';
3476
- if (subTime) { h += '<span class="phase-sub-time">' + subTime + '</span>'; }
3477
- h += '<span class="phase-sub-id">' + escHtml(s.taskId) + '</span>';
3478
- h += '</div>';
3479
- }
3480
- if (rDocs.length > 0) {
3481
- h += '<div style="padding:6px 0 2px 8px;font-size:11px;color:#f59e0b;font-weight:600;">关联文档</div>';
3482
- for (var rd = 0; rd < rDocs.length; rd++) {
3483
- var rdoc = rDocs[rd];
3484
- var rdLabel = rdoc.section || '';
3485
- if (rdoc.subSection) rdLabel += ' / ' + rdoc.subSection;
3486
- h += '<div class="phase-sub-item">';
3487
- h += '<div class="phase-sub-icon" style="color:#f59e0b;">📄</div>';
3488
- h += '<span class="phase-sub-name">' + escHtml(rdoc.title) + '</span>';
3489
- h += '<span class="phase-sub-id">' + escHtml(rdLabel) + '</span>';
3490
- h += '</div>';
3491
- }
3492
- }
3493
- h += '</div>';
3494
- }
3495
- h += '</div>';
3496
- return h;
3497
- }
3498
61
 
3499
62
  // ========== Init: 动态加载渲染引擎 ==========
3500
63
  loadRenderEngine();