@allbluecn/web-app 0.18.2 → 0.19.0

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 (761) hide show
  1. package/package.json +66 -51
  2. package/src/__tests__/components/auth/auth-layout.test.tsx +13 -1
  3. package/src/__tests__/{knowledge-public → knowledge/public}/canonical-id.test.ts +1 -1
  4. package/src/__tests__/{knowledge-public → knowledge/public}/favicon.test.ts +1 -1
  5. package/src/__tests__/{knowledge-public → knowledge/public}/github-sync.test.ts +1 -1
  6. package/src/__tests__/{knowledge-public → knowledge/public}/share-merge.test.ts +7 -7
  7. package/src/__tests__/security/xss-sanitize.test.ts +9 -8
  8. package/src/components/ai/__tests__/chat-thread-id-stability.test.tsx +125 -0
  9. package/src/components/ai/ai-chat-prompt.tsx +8 -2
  10. package/src/components/ai/ai-prompt-input/index.tsx +2 -1
  11. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +1 -2
  12. package/src/components/ai/global-harness-sidebar/index.tsx +22 -10
  13. package/src/components/bug/__tests__/board-subgroups.test.tsx +512 -0
  14. package/src/components/bug/__tests__/board-view.test.tsx +350 -0
  15. package/src/components/bug/__tests__/bug-list-loading-defer.test.tsx +85 -0
  16. package/src/components/bug/__tests__/bug-toolbar.test.tsx +7 -1
  17. package/src/components/bug/__tests__/comment-reactions.test.tsx +182 -0
  18. package/src/components/bug/__tests__/date-and-lightbox.test.tsx +96 -0
  19. package/src/components/bug/__tests__/export-entry.test.tsx +217 -0
  20. package/src/components/bug/__tests__/filters-compat.test.tsx +157 -0
  21. package/src/components/bug/__tests__/fix-request-race.test.tsx +396 -0
  22. package/src/components/bug/__tests__/peek-modes.test.tsx +365 -0
  23. package/src/components/bug/__tests__/saved-views.test.tsx +309 -0
  24. package/src/components/bug/__tests__/story-bugs-panel.test.tsx +146 -0
  25. package/src/components/bug/__tests__/subgroup-header.test.tsx +150 -0
  26. package/src/components/bug/__tests__/toolbar-shell.test.tsx +369 -0
  27. package/src/components/bug/__tests__/use-paged-bug-groups.test.tsx +58 -0
  28. package/src/components/bug/__tests__/view-type.test.tsx +247 -0
  29. package/src/components/bug/bug-list-view.tsx +310 -13
  30. package/src/components/bug/bug-panel-header.tsx +84 -8
  31. package/src/components/bug/bug-toolbar.tsx +268 -116
  32. package/src/components/bug/create-bug-dialog.tsx +1 -1
  33. package/src/components/bug/detail/__tests__/activity-timeline.test.tsx +1 -0
  34. package/src/components/bug/detail/__tests__/bug-detail-collab.test.tsx +236 -0
  35. package/src/components/bug/detail/__tests__/repro-report-card.test.tsx +2 -1
  36. package/src/components/bug/detail/activity-timeline.tsx +1 -1
  37. package/src/components/bug/detail/bug-detail-body.tsx +316 -18
  38. package/src/components/bug/detail/comment-item.tsx +7 -2
  39. package/src/components/bug/detail/comment-reactions.tsx +138 -0
  40. package/src/components/bug/detail/error-context.tsx +29 -9
  41. package/src/components/bug/detail/props-section.tsx +31 -3
  42. package/src/components/bug/detail/repro-report-card.tsx +1 -1
  43. package/src/components/bug/display/constants.ts +4 -2
  44. package/src/components/bug/display/display-dropdown.tsx +76 -280
  45. package/src/components/bug/display/types.ts +24 -8
  46. package/src/components/bug/display/use-paged-bug-groups.ts +183 -32
  47. package/src/components/bug/filters/{filter-value-input.tsx → field-configs.tsx} +218 -106
  48. package/src/components/bug/filters/index.tsx +14 -5
  49. package/src/components/bug/inline-editors/assignee-editor.tsx +2 -2
  50. package/src/components/bug/inline-editors/labels-editor.tsx +1 -1
  51. package/src/components/bug/inline-editors/severity-editor.tsx +37 -18
  52. package/src/components/bug/inline-editors/state-editor.tsx +1 -1
  53. package/src/components/bug/peek-panel.tsx +153 -35
  54. package/src/components/bug/select-bug-dialog.tsx +500 -0
  55. package/src/components/bug/story-bugs-panel.tsx +1090 -0
  56. package/src/components/bug/types.ts +9 -2
  57. package/src/components/bug/views/board-view.tsx +661 -0
  58. package/src/components/bug/views/bug-list/batch-toolbar.tsx +25 -34
  59. package/src/components/bug/views/bug-list/group.tsx +28 -13
  60. package/src/components/bug/views/bug-list/index.tsx +223 -59
  61. package/src/components/bug/views/bug-list/row.tsx +7 -1
  62. package/src/components/bug/views/create-view-dialog.tsx +160 -0
  63. package/src/components/bug/views/view-list-section.tsx +140 -0
  64. package/src/components/bug/views/view-selector-button.tsx +145 -0
  65. package/src/components/bug/views/view-switcher.tsx +8 -6
  66. package/src/components/bug/views/view-types.ts +162 -0
  67. package/src/components/collab/__tests__/activity-attribution.test.tsx +211 -0
  68. package/src/components/collab/__tests__/canvas-collab-panel.test.tsx +118 -0
  69. package/src/components/collab/__tests__/collab-presence-header.test.tsx +335 -0
  70. package/src/components/collab/__tests__/entity-presence-badge.test.tsx +226 -0
  71. package/src/components/collab/activity-attribution.tsx +221 -0
  72. package/src/components/collab/canvas-collab-panel.tsx +104 -0
  73. package/src/components/collab/collab-presence-header.tsx +220 -0
  74. package/src/components/collab/entity-presence-badge.tsx +130 -0
  75. package/src/components/collab/field-editing-pill.tsx +137 -0
  76. package/src/components/command/command-palette.tsx +6 -6
  77. package/src/components/design/canvas/document/__tests__/canvas-tree-fixture-snapshot.ts +589 -0
  78. package/src/components/design/canvas/document/__tests__/canvas-tree-reconcile.test.ts +159 -0
  79. package/src/components/design/canvas/panels/toolbar.tsx +3 -2
  80. package/src/components/design/canvas/plugins/presence/__tests__/canvas-collab-wiring.test.tsx +660 -0
  81. package/src/components/design/canvas/plugins/presence/__tests__/layout.test.ts +292 -0
  82. package/src/components/design/canvas/plugins/presence/__tests__/presence-overlay.test.tsx +139 -0
  83. package/src/components/design/canvas/plugins/presence/layout.ts +222 -0
  84. package/src/components/design/canvas/plugins/presence/presence-layer.tsx +353 -0
  85. package/src/components/design/canvas/plugins/presence/presence-overlay.tsx +155 -0
  86. package/src/components/design/canvas/plugins/presence/viewer-badge.tsx +80 -0
  87. package/src/components/design/canvas/react/__tests__/hooks.test.tsx +4 -4
  88. package/src/components/design/editor/collaboration/__tests__/design-agent-panel-regenerate.test.tsx +3 -1
  89. package/src/components/design/editor/collaboration/__tests__/design-agent-panel-stream.test.tsx +3 -1
  90. package/src/components/design/editor/collaboration/design-agent-panel.tsx +3 -0
  91. package/src/components/design/editor/collaboration/design-comment-review-panel.tsx +5 -5
  92. package/src/components/design/editor/dashboard/create-design-dialog.tsx +1 -1
  93. package/src/components/design/editor/dashboard/design-dashboard.tsx +1 -10
  94. package/src/components/design/editor/layout/__tests__/collab-param-wiring.test.tsx +153 -0
  95. package/src/components/design/editor/layout/__tests__/no-page-save-toast.test.tsx +8 -0
  96. package/src/components/design/editor/layout/design-editor-layout.tsx +28 -1
  97. package/src/components/design/editor/layout/design-navigation.tsx +0 -4
  98. package/src/components/design/editor/layout/hooks/__tests__/use-design-auto-save-collab.test.tsx +130 -0
  99. package/src/components/design/editor/layout/hooks/use-design-auto-save.ts +18 -1
  100. package/src/components/design/editor/share/share-comment-panel.tsx +37 -20
  101. package/src/components/design/editor/share/share-layout.tsx +3 -0
  102. package/src/components/design/editor/share/share-nav-bar.tsx +7 -1
  103. package/src/components/design/editor/share/share-theme-toggle.tsx +10 -15
  104. package/src/components/design/editor/tiptap/node/paragraph-node/paragraph-node.scss +1 -1
  105. package/src/components/harness/__tests__/composer-draft.test.ts +126 -0
  106. package/src/components/harness/composer/composer-editor.tsx +64 -6
  107. package/src/components/harness/composer/harness-composer.tsx +1 -1
  108. package/src/components/harness/harness-layout.tsx +1 -1
  109. package/src/components/harness/harness-workbench-page.tsx +6 -1
  110. package/src/components/harness/turn-group.tsx +1 -1
  111. package/src/components/hotkeys/shortcut-badge.tsx +4 -4
  112. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +1 -1
  113. package/src/components/knowledge/editor/__tests__/knowledge-doc-editor-collab.test.tsx +542 -0
  114. package/src/components/knowledge/editor/doc-header.tsx +7 -49
  115. package/src/components/knowledge/editor/knowledge-doc-editor.tsx +264 -51
  116. package/src/components/knowledge/editor/mindmap-doc-editor.tsx +3 -19
  117. package/src/components/knowledge/knowledge-homepage.tsx +3 -3
  118. package/src/components/knowledge/knowledge-toolbar.tsx +8 -8
  119. package/src/components/{knowledge-public → knowledge/public}/bookmark-form-dialog.tsx +4 -4
  120. package/src/components/{knowledge-public → knowledge/public}/knowledge-card.tsx +1 -15
  121. package/src/components/{knowledge-public → knowledge/public}/knowledge-form-dialog.tsx +9 -9
  122. package/src/components/{knowledge-public → knowledge/public}/legal-compliance-editor.tsx +35 -28
  123. package/src/components/{knowledge-public → knowledge/public}/sharers-avatar-group.tsx +3 -2
  124. package/src/components/layout/layout-responsive-provider.tsx +79 -0
  125. package/src/components/layout/sidebar-03/__tests__/presence-avatar-stack.test.tsx +148 -0
  126. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -3
  127. package/src/components/layout/sidebar-03/index.tsx +19 -4
  128. package/src/components/layout/sidebar-03/nav-main.tsx +28 -10
  129. package/src/components/layout/sidebar-03/presence-avatar-stack.tsx +112 -0
  130. package/src/components/layout/sidebar-03/user-menu.tsx +3 -3
  131. package/src/components/project/create-project-dialog.tsx +1 -1
  132. package/src/components/project/invite-member-dialog.tsx +1 -1
  133. package/src/components/project/project-activity-list.tsx +2 -2
  134. package/src/components/project/project-member-manager.tsx +2 -2
  135. package/src/components/reui/__tests__/kanban-drop-indicator.test.ts +88 -0
  136. package/src/components/reui/__tests__/kanban-keyboard.test.ts +262 -0
  137. package/src/components/reui/event-calendar/event-calendar-agenda-view.tsx +236 -0
  138. package/src/components/reui/event-calendar/event-calendar-content.tsx +80 -0
  139. package/src/components/reui/event-calendar/event-calendar-dnd.tsx +1062 -0
  140. package/src/components/reui/event-calendar/event-calendar-event.tsx +555 -0
  141. package/src/components/reui/event-calendar/event-calendar-i18n.tsx +291 -0
  142. package/src/components/reui/event-calendar/event-calendar-lib.tsx +677 -0
  143. package/src/components/reui/event-calendar/event-calendar-month-view.tsx +1287 -0
  144. package/src/components/reui/event-calendar/event-calendar-nav.tsx +615 -0
  145. package/src/components/reui/event-calendar/event-calendar-recurrence.tsx +592 -0
  146. package/src/components/reui/event-calendar/event-calendar-resource-view.tsx +810 -0
  147. package/src/components/reui/event-calendar/event-calendar-time-grid.tsx +1366 -0
  148. package/src/components/reui/event-calendar/event-calendar-types.tsx +240 -0
  149. package/src/components/reui/event-calendar/event-calendar.tsx +1916 -0
  150. package/src/components/reui/gantt/__tests__/gantt-row-window.test.ts +243 -0
  151. package/src/components/reui/gantt/__tests__/gantt-view-virtualize.test.tsx +243 -0
  152. package/src/components/reui/gantt/gantt-bar.tsx +439 -0
  153. package/src/components/reui/gantt/gantt-dnd.tsx +1000 -0
  154. package/src/components/reui/gantt/gantt-i18n.tsx +287 -0
  155. package/src/components/reui/gantt/gantt-lib.tsx +695 -0
  156. package/src/components/reui/gantt/gantt-nav.tsx +446 -0
  157. package/src/components/reui/gantt/gantt-recurrence.tsx +402 -0
  158. package/src/components/reui/gantt/gantt-row-window.ts +119 -0
  159. package/src/components/reui/gantt/gantt-types.tsx +283 -0
  160. package/src/components/reui/gantt/gantt-view.tsx +4466 -0
  161. package/src/components/reui/gantt/gantt.tsx +1822 -0
  162. package/src/components/reui/gantt/use-gantt-row-window.ts +159 -0
  163. package/src/components/reui/icon-stack.tsx +90 -0
  164. package/src/components/reui/kanban-keyboard.ts +268 -0
  165. package/src/components/reui/kanban.tsx +1227 -0
  166. package/src/components/settings/personal-team/account/account-client.tsx +3 -2
  167. package/src/components/settings/personal-team/team/invitation-cards/invitation-history-list.tsx +5 -5
  168. package/src/components/settings/personal-team/team/team-client.tsx +2 -1
  169. package/src/components/settings/personal-team/team/team-member-list.tsx +4 -4
  170. package/src/components/share/share-presence-bar.tsx +182 -0
  171. package/src/components/shared-kit/auth/auth-client.ts +5 -0
  172. package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +44 -0
  173. package/src/components/shared-kit/avatars/__tests__/user-avatar.test.tsx +46 -0
  174. package/src/components/{tags/tags-collection.ts → shared-kit/avatars/avatar-shape.ts} +14 -20
  175. package/src/components/shared-kit/avatars/notion-avatar.tsx +62 -11
  176. package/src/components/shared-kit/avatars/user-avatar.tsx +7 -4
  177. package/src/components/shared-kit/list-kit/__tests__/batch-toolbar-shell.test.tsx +133 -0
  178. package/src/components/shared-kit/list-kit/__tests__/frozen-header.test.ts +89 -0
  179. package/src/components/shared-kit/list-kit/__tests__/level-badge.test.tsx +85 -0
  180. package/src/components/shared-kit/list-kit/__tests__/toolbar-shell.test.tsx +178 -0
  181. package/src/components/shared-kit/list-kit/batch-toolbar-shell.tsx +111 -0
  182. package/src/components/shared-kit/list-kit/csv-download.ts +46 -0
  183. package/src/components/shared-kit/list-kit/display/__tests__/dropdown.test.tsx +215 -0
  184. package/src/components/shared-kit/list-kit/display/dropdown.tsx +582 -0
  185. package/src/components/shared-kit/list-kit/display/types.ts +64 -0
  186. package/src/components/shared-kit/list-kit/display/values.ts +60 -0
  187. package/src/components/shared-kit/list-kit/filters/__tests__/create-filters-module.test.tsx +181 -0
  188. package/src/components/shared-kit/list-kit/filters/__tests__/types.test.ts +133 -0
  189. package/src/components/shared-kit/list-kit/filters/index.tsx +757 -0
  190. package/src/components/shared-kit/list-kit/filters/types.ts +68 -0
  191. package/src/components/shared-kit/list-kit/frozen-header.ts +45 -0
  192. package/src/components/shared-kit/list-kit/icon-semantics.ts +50 -0
  193. package/src/components/shared-kit/list-kit/kanban/__tests__/drop-layout.test.ts +139 -0
  194. package/src/components/shared-kit/list-kit/kanban/__tests__/frozen-header.test.ts +111 -0
  195. package/src/components/shared-kit/list-kit/kanban/__tests__/instance-key.test.ts +130 -0
  196. package/src/components/shared-kit/list-kit/kanban/__tests__/kanban-board-view.test.tsx +901 -0
  197. package/src/components/shared-kit/list-kit/kanban/__tests__/manual-order.test.ts +35 -0
  198. package/src/components/shared-kit/list-kit/kanban/drop-commit.ts +212 -0
  199. package/src/components/shared-kit/list-kit/kanban/drop-layout.ts +98 -0
  200. package/src/components/shared-kit/list-kit/kanban/frozen-header.ts +88 -0
  201. package/src/components/shared-kit/list-kit/kanban/instance-key.ts +83 -0
  202. package/src/components/shared-kit/list-kit/kanban/kanban-board-view.tsx +1414 -0
  203. package/src/components/shared-kit/list-kit/kanban/manual-order.ts +39 -0
  204. package/src/components/shared-kit/list-kit/level-badge.tsx +71 -0
  205. package/src/components/shared-kit/list-kit/toolbar-shell.tsx +103 -0
  206. package/src/components/shared-kit/loading/__tests__/use-deferred-flag.test.tsx +115 -0
  207. package/src/components/shared-kit/loading/use-deferred-flag.ts +35 -0
  208. package/src/components/shared-kit/peek/dock-slot.ts +62 -0
  209. package/src/components/shared-kit/peek/peek-mode.ts +34 -0
  210. package/src/components/shared-kit/theme/animated-theme-toggler.tsx +13 -7
  211. package/src/components/shared-kit/theme/theme-toggle.tsx +7 -18
  212. package/src/components/shared-kit/views/view-icon.tsx +49 -0
  213. package/src/components/story/__tests__/peek-panel-patch-sync.test.tsx +215 -0
  214. package/src/components/story/__tests__/story-list-loading-defer.test.tsx +88 -0
  215. package/src/components/story/__tests__/story-list-view-peek-sync.test.tsx +242 -0
  216. package/src/components/story/__tests__/story-list-view-restore.test.tsx +45 -3
  217. package/src/components/story/__tests__/story-list-view-ssr.test.tsx +0 -1
  218. package/src/components/story/ai/__tests__/action-registry.test.ts +11 -11
  219. package/src/components/story/ai/action-registry.ts +5 -7
  220. package/src/components/story/ai/handlers/stream-text.ts +1 -3
  221. package/src/components/story/ai/story-ai-toolbar.tsx +58 -16
  222. package/src/components/story/ai/use-toolbar-overflow.ts +4 -7
  223. package/src/components/story/columns.tsx +2 -2
  224. package/src/components/story/constants.ts +17 -1
  225. package/src/components/story/create-story-dialog.tsx +8 -7
  226. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +38 -4
  227. package/src/components/story/detail/__tests__/categories.test.tsx +52 -26
  228. package/src/components/story/detail/__tests__/story-collab.test.tsx +897 -0
  229. package/src/components/story/detail/activity-tabs.tsx +5 -4
  230. package/src/components/story/detail/activity-timeline.tsx +15 -3
  231. package/src/components/story/detail/comment-input.tsx +2 -2
  232. package/src/components/story/detail/comment-item.tsx +6 -3
  233. package/src/components/story/detail/comment-thread.tsx +1 -0
  234. package/src/components/story/detail/reaction-bar.tsx +1 -1
  235. package/src/components/story/detail/story-detail-body.tsx +501 -76
  236. package/src/components/story/detail/story-detail-toolbar/__tests__/related-test-cases.test.tsx +173 -10
  237. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +144 -8
  238. package/src/components/story/detail/story-detail-toolbar/categories.tsx +82 -38
  239. package/src/components/story/detail/story-detail-toolbar/children.tsx +254 -174
  240. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +140 -58
  241. package/src/components/story/detail/story-detail-toolbar/index.tsx +613 -0
  242. package/src/components/story/detail/story-detail-toolbar/related-test-cases.tsx +1010 -87
  243. package/src/components/story/detail/story-detail-toolbar/scroll-hint.tsx +57 -0
  244. package/src/components/story/detail/story-detail-toolbar/use-panel-frozen-groups.ts +97 -0
  245. package/src/components/story/detail/story-properties.tsx +9 -5
  246. package/src/components/story/display/__tests__/display-dropdown-helpers.test.ts +17 -1
  247. package/src/components/story/display/__tests__/display-dropdown.test.tsx +9 -0
  248. package/src/components/story/display/__tests__/flatten-story-groups.test.ts +42 -0
  249. package/src/components/story/display/__tests__/group-fields.test.ts +81 -1
  250. package/src/components/story/display/__tests__/group-resolver.test.ts +153 -1
  251. package/src/components/story/display/__tests__/use-collapsed-subgroups.test.ts +158 -0
  252. package/src/components/story/display/__tests__/use-display-options.test.ts +32 -3
  253. package/src/components/story/display/__tests__/use-fit-count.test.ts +198 -0
  254. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +77 -3
  255. package/src/components/story/display/constants.ts +23 -2
  256. package/src/components/story/display/display-dropdown.tsx +95 -397
  257. package/src/components/story/display/flatten-story-groups.ts +39 -0
  258. package/src/components/story/display/group-fields.ts +32 -1
  259. package/src/components/story/display/group-resolver.tsx +128 -5
  260. package/src/components/story/display/types.ts +23 -42
  261. package/src/components/story/display/use-collapsed-subgroups.ts +109 -0
  262. package/src/components/story/display/use-display-options.ts +21 -5
  263. package/src/components/story/display/use-fit-count.ts +124 -0
  264. package/src/components/story/display/use-paged-story-groups.ts +174 -31
  265. package/src/components/story/filters/field-configs.tsx +263 -0
  266. package/src/components/story/filters/index.tsx +14 -5
  267. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -0
  268. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +15 -0
  269. package/src/components/story/inline-editors/assignee-editor.tsx +7 -5
  270. package/src/components/story/inline-editors/date-editor.tsx +7 -2
  271. package/src/components/story/inline-editors/inline-trigger.tsx +17 -4
  272. package/src/components/story/inline-editors/labels-editor.tsx +243 -35
  273. package/src/components/story/inline-editors/parent-story-editor.tsx +1 -1
  274. package/src/components/story/inline-editors/priority-editor.tsx +6 -4
  275. package/src/components/story/inline-editors/state-editor.tsx +6 -4
  276. package/src/components/story/inline-editors/story-select-dialog.tsx +56 -19
  277. package/src/components/story/peek-panel.tsx +234 -51
  278. package/src/components/story/relations/__tests__/design-select-dialog.test.tsx +131 -0
  279. package/src/components/story/relations/attachment-manage-dialog.tsx +1 -1
  280. package/src/components/story/relations/design-select-dialog.tsx +285 -0
  281. package/src/components/story/relations/knowledge-select-dialog.tsx +3 -3
  282. package/src/components/story/relations/link-manage-dialog.tsx +2 -2
  283. package/src/components/story/story-list-view.tsx +175 -17
  284. package/src/components/story/story-panel-header.tsx +18 -11
  285. package/src/components/story/story-toolbar.tsx +177 -126
  286. package/src/components/story/transfer-story-popover.tsx +1 -1
  287. package/src/components/story/types.ts +11 -2
  288. package/src/components/story/views/__tests__/calendar-view.test.tsx +262 -0
  289. package/src/components/story/views/__tests__/kanban-drop-subgroup.test.ts +68 -0
  290. package/src/components/story/views/__tests__/kanban-peek-sync.test.tsx +184 -0
  291. package/src/components/story/views/__tests__/kanban-view-membership-sync.test.tsx +179 -0
  292. package/src/components/story/views/__tests__/timeline-view.test.tsx +453 -0
  293. package/src/components/story/views/__tests__/view-icon.test.tsx +45 -0
  294. package/src/components/story/views/__tests__/view-types.test.ts +0 -8
  295. package/src/components/story/views/calendar/__tests__/mappers.test.ts +201 -0
  296. package/src/components/story/views/calendar/__tests__/slot-draft.test.ts +78 -0
  297. package/src/components/story/views/calendar/drag-blocked.ts +41 -0
  298. package/src/components/story/views/calendar/i18n.ts +91 -0
  299. package/src/components/story/views/calendar/mappers.ts +119 -0
  300. package/src/components/story/views/calendar/slot-draft.ts +48 -0
  301. package/src/components/story/views/calendar-view.tsx +490 -223
  302. package/src/components/story/views/create-view-dialog.tsx +6 -6
  303. package/src/components/story/views/kanban-view.tsx +618 -0
  304. package/src/components/story/views/{__tests__ → story-list/__tests__}/use-flat-rows.test.ts +1 -1
  305. package/src/components/story/views/story-list/batch-preview-dialog.tsx +3 -3
  306. package/src/components/story/views/story-list/batch-toolbar.tsx +17 -34
  307. package/src/components/story/views/story-list/group.tsx +4 -2
  308. package/src/components/story/views/story-list/index.tsx +142 -34
  309. package/src/components/story/views/story-list/row.tsx +4 -0
  310. package/src/components/story/views/{use-flat-rows.ts → story-list/use-flat-rows.ts} +2 -2
  311. package/src/components/story/views/timeline/__tests__/mappers.test.ts +742 -0
  312. package/src/components/story/views/timeline/__tests__/sentinel-geometry.test.ts +76 -0
  313. package/src/components/story/views/timeline/__tests__/use-date-commit.test.tsx +372 -0
  314. package/src/components/story/views/timeline/i18n.ts +96 -0
  315. package/src/components/story/views/timeline/mappers.ts +506 -0
  316. package/src/components/story/views/timeline/sentinel-geometry.ts +43 -0
  317. package/src/components/story/views/timeline/use-date-commit.ts +169 -0
  318. package/src/components/story/views/timeline-view.tsx +593 -222
  319. package/src/components/story/views/view-list-section.tsx +4 -4
  320. package/src/components/story/views/view-selector-button.tsx +9 -10
  321. package/src/components/story/views/view-switcher.tsx +7 -1
  322. package/src/components/story/views/view-types.ts +1 -5
  323. package/src/components/testcase/__tests__/batch-toolbar.test.tsx +245 -0
  324. package/src/components/testcase/__tests__/board-view.test.tsx +241 -0
  325. package/src/components/testcase/__tests__/case-row.test.tsx +24 -10
  326. package/src/components/testcase/__tests__/constants.test.ts +151 -0
  327. package/src/components/testcase/__tests__/create-dialog-scope.test.tsx +173 -0
  328. package/src/components/testcase/__tests__/detail-properties.test.tsx +19 -0
  329. package/src/components/testcase/__tests__/display-options.test.tsx +154 -0
  330. package/src/components/testcase/__tests__/export-entry.test.tsx +279 -0
  331. package/src/components/testcase/__tests__/filters.test.tsx +280 -0
  332. package/src/components/testcase/__tests__/list-view-scope.test.tsx +290 -0
  333. package/src/components/testcase/__tests__/list-view.test.tsx +552 -7
  334. package/src/components/testcase/__tests__/manual-move.test.ts +69 -0
  335. package/src/components/testcase/__tests__/peek-panel.test.tsx +89 -8
  336. package/src/components/testcase/__tests__/toolbar.test.tsx +302 -0
  337. package/src/components/testcase/__tests__/view-fallback.test.tsx +272 -0
  338. package/src/components/testcase/__tests__/view-management.test.tsx +179 -0
  339. package/src/components/testcase/ai/generate-dialog.tsx +1 -1
  340. package/src/components/testcase/constants.ts +83 -0
  341. package/src/components/testcase/create-testcase-dialog.tsx +76 -8
  342. package/src/components/testcase/detail/__tests__/select-cases-dialog.test.tsx +163 -0
  343. package/src/components/testcase/detail/case-detail-body.tsx +311 -30
  344. package/src/components/testcase/detail/select-cases-dialog.tsx +465 -0
  345. package/src/components/testcase/detail/story-link-editor.tsx +1 -1
  346. package/src/components/testcase/display/display-dropdown.tsx +147 -0
  347. package/src/components/testcase/display/field-configs.ts +80 -0
  348. package/src/components/testcase/display/use-testcase-display-options.ts +59 -0
  349. package/src/components/testcase/filters/field-configs.tsx +748 -0
  350. package/src/components/testcase/filters/index.tsx +35 -127
  351. package/src/components/testcase/inline-editors/assignee-editor.tsx +6 -6
  352. package/src/components/testcase/inline-editors/folder-editor.tsx +9 -9
  353. package/src/components/testcase/inline-editors/priority-editor.tsx +47 -31
  354. package/src/components/testcase/inline-editors/state-editor.tsx +14 -12
  355. package/src/components/testcase/inline-editors/type-editor.tsx +31 -20
  356. package/src/components/testcase/list/case-row.tsx +60 -2
  357. package/src/components/testcase/list/grouped-list.tsx +3 -2
  358. package/src/components/testcase/list-view.tsx +633 -137
  359. package/src/components/testcase/peek-panel.tsx +69 -11
  360. package/src/components/testcase/plans/plan-list.tsx +6 -1
  361. package/src/components/testcase/step-editor.tsx +34 -3
  362. package/src/components/testcase/testcase-toolbar.tsx +349 -94
  363. package/src/components/testcase/views/batch-toolbar.tsx +203 -0
  364. package/src/components/testcase/views/board-view.tsx +546 -0
  365. package/src/components/testcase/views/create-view-dialog.tsx +26 -14
  366. package/src/components/testcase/views/move.ts +56 -0
  367. package/src/components/testcase/views/view-selector-button.tsx +82 -30
  368. package/src/components/testcase/views/view-switcher.tsx +12 -5
  369. package/src/components/testcase/views/view-types.ts +50 -27
  370. package/src/components/tiptap/mindmap/lib/__tests__/shortcuts.test.ts +5 -4
  371. package/src/components/tiptap/mindmap/lib/shortcuts.ts +4 -3
  372. package/src/components/tiptap/node/doc-card-node-view.tsx +2 -1
  373. package/src/components/tiptap/notion-like/editor-extensions.ts +231 -0
  374. package/src/components/tiptap/notion-like/notion-like-editor.tsx +29 -156
  375. package/src/components/tiptap/ui/ai-menu/ai-menu.tsx +6 -1
  376. package/src/components/tiptap/ui/doc-reference-interaction/doc-reference-interaction.tsx +2 -1
  377. package/src/components/ui/checkbox.tsx +4 -4
  378. package/src/components/ui/popover.tsx +1 -1
  379. package/src/components/workspace/dialogs/edit-workspace-dialog.tsx +1 -1
  380. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +1 -1
  381. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +1 -1
  382. package/src/components/workspace/members/workspace-member-item.tsx +2 -2
  383. package/src/components/workspace/members/workspace-members-manager.tsx +2 -2
  384. package/src/config/sidebar-routes.tsx +11 -13
  385. package/src/design-engine/document/serialize-leafer-json.ts +2 -1
  386. package/src/design-engine/engine/__tests__/engine-split.test.ts +1 -1
  387. package/src/design-engine/engine/__tests__/engine.test.ts +20 -0
  388. package/src/design-engine/engine/app.ts +5 -0
  389. package/src/design-engine/engine/engine-base.ts +17 -0
  390. package/src/design-engine/engine/engine-doc.ts +32 -0
  391. package/src/hooks/__tests__/use-commands.test.tsx +17 -5
  392. package/src/hooks/use-commands.ts +4 -4
  393. package/src/hooks/use-toolbar-search.ts +7 -2
  394. package/src/lib/__tests__/datetime-local.test.ts +46 -0
  395. package/src/lib/__tests__/datetime.test.ts +7 -0
  396. package/src/lib/__tests__/layout-responsive.test.ts +123 -0
  397. package/src/lib/__tests__/sidebar-utils.test.ts +4 -4
  398. package/src/lib/ai/story-ai-stream.ts +1 -1
  399. package/src/lib/ai/use-selected-model.ts +15 -5
  400. package/src/lib/changelog/sdk-versions.ts +22 -22
  401. package/src/lib/collab/__tests__/bindings-fields.test.ts +496 -0
  402. package/src/lib/collab/__tests__/bindings-steps.test.ts +332 -0
  403. package/src/lib/collab/__tests__/presence-store.test.ts +414 -0
  404. package/src/lib/collab/__tests__/provider-handle-contract.test.ts +89 -0
  405. package/src/lib/collab/__tests__/provider.test.ts +504 -0
  406. package/src/lib/collab/__tests__/use-presence-list-refresh.test.tsx +309 -0
  407. package/src/lib/collab/__tests__/use-presence-report.test.ts +356 -0
  408. package/src/lib/collab/__tests__/use-share-presence.test.ts +555 -0
  409. package/src/lib/collab/__tests__/user-color.test.ts +53 -0
  410. package/src/lib/collab/bindings/__tests__/canvas-local.test.ts +583 -0
  411. package/src/lib/collab/bindings/__tests__/canvas-presence.test.ts +378 -0
  412. package/src/lib/collab/bindings/__tests__/canvas-remote.test.ts +454 -0
  413. package/src/lib/collab/bindings/__tests__/canvas-tree-perf.test.ts +134 -0
  414. package/src/lib/collab/bindings/canvas-local.ts +415 -0
  415. package/src/lib/collab/bindings/canvas-presence.ts +420 -0
  416. package/src/lib/collab/bindings/canvas-remote.ts +226 -0
  417. package/src/lib/collab/bindings/fields.ts +463 -0
  418. package/src/lib/collab/bindings/steps.ts +332 -0
  419. package/src/lib/collab/env.ts +33 -0
  420. package/src/lib/collab/module-map.ts +131 -0
  421. package/src/lib/collab/presence-store.ts +350 -0
  422. package/src/lib/collab/provider.ts +378 -0
  423. package/src/lib/collab/use-collab-doc.ts +100 -0
  424. package/src/lib/collab/use-presence-list-refresh.ts +142 -0
  425. package/src/lib/collab/use-presence-report.ts +176 -0
  426. package/src/lib/collab/use-self-profile-broadcast.ts +113 -0
  427. package/src/lib/collab/use-share-presence.ts +561 -0
  428. package/src/lib/collab/user-color.ts +40 -0
  429. package/src/lib/collections/index.ts +0 -1
  430. package/src/lib/collections/reference/bookmark-categories-collection.ts +1 -1
  431. package/src/lib/commands/__tests__/registry.test.ts +2 -5
  432. package/src/lib/commands/registry.ts +0 -3
  433. package/src/lib/commands/types.ts +1 -1
  434. package/src/lib/datetime.ts +20 -3
  435. package/src/lib/hooks/__tests__/use-synced-state.test.tsx +54 -0
  436. package/src/lib/hooks/use-story-panel-open.ts +134 -0
  437. package/src/lib/hooks/use-synced-state.ts +17 -0
  438. package/src/lib/hotkeys/__tests__/registry.test.ts +1 -1
  439. package/src/lib/hotkeys/registry.ts +0 -3
  440. package/src/lib/layout-responsive.ts +140 -0
  441. package/src/lib/pacer.ts +10 -1
  442. package/src/lib/peek-dock.ts +32 -0
  443. package/src/lib/plugin-runtime/__tests__/plugin-tool-schema-parity.test.ts +140 -0
  444. package/src/lib/plugin-runtime/plugin-client-tools.ts +28 -46
  445. package/src/lib/plugin-runtime/plugin-tool-defs.ts +152 -0
  446. package/src/lib/project-module-meta.ts +1 -8
  447. package/src/lib/rate-limit.ts +15 -6
  448. package/src/lib/sanitize.ts +122 -10
  449. package/src/lib/share-events.ts +20 -11
  450. package/src/lib/sidebar-utils.ts +1 -1
  451. package/src/lib/theme/__tests__/theme-mode.test.ts +96 -0
  452. package/src/lib/theme/theme-mode.ts +77 -0
  453. package/src/paraglide/messages/_index.js +5312 -1591
  454. package/src/paraglide/messages/en.js +1880 -685
  455. package/src/paraglide/messages/zh.js +1877 -682
  456. package/src/providers/index.tsx +4 -1
  457. package/src/providers/theme-mode-provider.tsx +135 -0
  458. package/src/routeTree.gen.ts +78 -101
  459. package/src/routes/$lang/docs/$/route.lazy.tsx +7 -2
  460. package/src/routes/-tests/chat-body-validation.test.ts +61 -3
  461. package/src/routes/__tests__/-edition-routing.test.tsx +12 -8
  462. package/src/routes/_nav/-components/nav-not-found.tsx +2 -2
  463. package/src/routes/_nav/bugs/$bugId/route.lazy.tsx +16 -2
  464. package/src/routes/_nav/bugs/$bugId/route.tsx +11 -2
  465. package/src/routes/_nav/dashboard/-components/avatar-group.tsx +2 -2
  466. package/src/routes/_nav/knowledge/$kbId/doc/$docId/route.lazy.tsx +31 -17
  467. package/src/routes/_nav/knowledge/$kbId/doc/$docId/route.tsx +12 -1
  468. package/src/routes/_nav/knowledge/$kbId/index.lazy.tsx +4 -2
  469. package/src/routes/_nav/knowledge/$kbId/route.lazy.tsx +7 -3
  470. package/src/routes/_nav/knowledge/bookmarks/$id.lazy.tsx +6 -6
  471. package/src/routes/_nav/knowledge/bookmarks/index.lazy.tsx +10 -10
  472. package/src/routes/_nav/knowledge/compliance/$id.lazy.tsx +9 -9
  473. package/src/routes/_nav/knowledge/compliance/edit/$id.lazy.tsx +1 -1
  474. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +8 -8
  475. package/src/routes/_nav/knowledge/dictionaries/$id.lazy.tsx +4 -4
  476. package/src/routes/_nav/knowledge/dictionaries/index.lazy.tsx +6 -6
  477. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  478. package/src/routes/_nav/knowledge/laws/$id.lazy.tsx +9 -9
  479. package/src/routes/_nav/knowledge/laws/edit/$id.lazy.tsx +1 -1
  480. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +11 -11
  481. package/src/routes/_nav/knowledge/summary/index.lazy.tsx +1 -1
  482. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +14 -6
  483. package/src/routes/_nav/route.lazy.tsx +11 -6
  484. package/src/routes/_nav/settings/account/route.tsx +3 -2
  485. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +52 -8
  486. package/src/routes/_nav/stories/$storyId/route.tsx +11 -0
  487. package/src/routes/_nav/stories/-components/story-detail-skeleton.tsx +1 -1
  488. package/src/{components/tags/pages/tag-detail-page.tsx → routes/_nav/tasks/$taskId/route.lazy.tsx} +20 -10
  489. package/src/routes/_nav/tasks/$taskId/route.tsx +43 -0
  490. package/src/routes/_nav/tasks/-components/ai-task-detail.tsx +307 -0
  491. package/src/routes/_nav/tasks/-components/ai-task-list.tsx +217 -0
  492. package/src/routes/_nav/tasks/-components/ai-task-status-badge.tsx +46 -0
  493. package/src/routes/_nav/tasks/-components/assigned-list.tsx +157 -0
  494. package/src/routes/_nav/tasks/-components/create-ai-task-dialog.tsx +228 -0
  495. package/src/routes/_nav/tasks/-components/source-ai-exec-dialog.tsx +175 -0
  496. package/src/routes/_nav/tasks/-components/task-constants.ts +115 -0
  497. package/src/routes/_nav/tasks/-components/use-task-filter.ts +64 -0
  498. package/src/routes/_nav/tasks/-tests/create-ai-task-dialog.test.tsx +175 -0
  499. package/src/routes/_nav/tasks/-tests/task-filter.test.ts +134 -0
  500. package/src/routes/_nav/tasks/-tests/task-styles.test.tsx +119 -0
  501. package/src/routes/_nav/tasks/index.lazy.tsx +114 -0
  502. package/src/routes/_nav/tasks/index.tsx +47 -0
  503. package/src/routes/_nav/{tags → tasks}/route.tsx +3 -3
  504. package/src/routes/_nav/test-cases/$caseId/route.lazy.tsx +3 -1
  505. package/src/routes/_nav/test-cases/$caseId/route.tsx +11 -2
  506. package/src/routes/api/chat.ts +23 -19
  507. package/src/routes/api/design/prototype/route.ts +2 -4
  508. package/src/routes/{_nav/acceptance-criteria/route.tsx → api/internal/collab-reindex.ts} +11 -8
  509. package/src/routes/api/knowledge/sync-github.ts +1 -1
  510. package/src/routes/design-files/$projectId/$artboardId/$filename.ts +9 -0
  511. package/src/routes/invite/$token/route.lazy.tsx +2 -2
  512. package/src/routes/share/$versionId/route.lazy.tsx +8 -0
  513. package/src/routes/share/compliance/$id/route.lazy.tsx +2 -2
  514. package/src/routes/share/compliance/$id/route.tsx +1 -1
  515. package/src/routes/share/law/$id/route.lazy.tsx +2 -2
  516. package/src/routes/share/law/$id/route.tsx +1 -1
  517. package/src/server/__tests__/bug-groups-two-level.test.ts +263 -0
  518. package/src/server/__tests__/builtin-project.test.ts +45 -1
  519. package/src/server/__tests__/csv-export.test.ts +319 -0
  520. package/src/server/__tests__/share-version.test.ts +7 -2
  521. package/src/server/__tests__/task-assigned-query.test.ts +232 -0
  522. package/src/server/__tests__/task-context-builder.test.ts +175 -0
  523. package/src/server/__tests__/task-executor.test.ts +349 -0
  524. package/src/server/__tests__/task-priority-map.test.ts +41 -0
  525. package/src/server/__tests__/task-serverfn-crud.test.ts +639 -0
  526. package/src/server/__tests__/task-serverfn-queries.test.ts +403 -0
  527. package/src/server/__tests__/testcase-board.test.ts +301 -0
  528. package/src/server/__tests__/testcase-bulk.test.ts +155 -0
  529. package/src/server/__tests__/testcase-list-scope.test.ts +215 -0
  530. package/src/server/ai-gateway/__tests__/chat-handler-prompt-key.test.ts +106 -0
  531. package/src/server/ai-gateway/__tests__/chat-stream-builder-sandbox.test.ts +9 -3
  532. package/src/server/ai-gateway/__tests__/conformance.test.ts +4 -3
  533. package/src/server/ai-gateway/adapter.ts +4 -2
  534. package/src/server/ai-gateway/chat-handler.ts +13 -5
  535. package/src/server/ai-gateway/chat-stream-builder.ts +12 -7
  536. package/src/server/ai-gateway/sandbox/__tests__/harness-sandbox.test.ts +88 -0
  537. package/src/server/ai-gateway/sandbox/harness-sandbox.ts +1 -1
  538. package/src/server/ai-gateway/story-handler.ts +7 -2
  539. package/src/server/ai-gateway/tools/plugin-lifecycle-defs.ts +8 -99
  540. package/src/server/ai-gateway/tools/plugin-tools.ts +6 -10
  541. package/src/server/ai-task/assigned-query.ts +167 -0
  542. package/src/server/ai-task/context-builder.ts +182 -0
  543. package/src/server/ai-task/executor.ts +296 -0
  544. package/src/server/ai-task/priority-map.ts +37 -0
  545. package/src/server/ai-task/types.ts +15 -0
  546. package/src/server/bug-report/__tests__/report-handler.test.ts +55 -4
  547. package/src/server/bug-report/report-handler.ts +54 -8
  548. package/src/server/builtin-project.ts +24 -0
  549. package/src/server/cloudflare/prisma-wasm-base64-stub.ts +5 -0
  550. package/src/server/collab/__tests__/collab-permissions.test.ts +412 -0
  551. package/src/server/collab/__tests__/knowledge-additions.test.ts +121 -0
  552. package/src/server/collab/__tests__/reindex-endpoint.test.ts +168 -0
  553. package/src/server/collab/__tests__/resolve.test.ts +249 -0
  554. package/src/server/collab/__tests__/touch-snapshot.test.ts +58 -0
  555. package/src/server/collab/__tests__/write-guard-sdk.test.ts +501 -0
  556. package/src/server/collab/__tests__/write-guard.test.ts +373 -0
  557. package/src/server/collab/knowledge-additions.ts +37 -0
  558. package/src/server/collab/reindex-endpoint.ts +75 -0
  559. package/src/server/collab/resolve.ts +122 -0
  560. package/src/server/collab/seed.ts +408 -0
  561. package/src/server/collab/ticket-utils.ts +107 -0
  562. package/src/server/collab/touch-snapshot.ts +55 -0
  563. package/src/server/collab/write-guard.ts +258 -0
  564. package/src/server/collab-permissions.ts +248 -0
  565. package/src/server/csv.ts +61 -0
  566. package/src/server/{knowledge-public → knowledge/public}/github-sync.ts +12 -2
  567. package/src/server/{knowledge-public → knowledge/public}/share-merge.ts +31 -8
  568. package/src/server/lib/__tests__/sequence-number-retry.test.ts +160 -0
  569. package/src/server/lib/rate-limit.ts +13 -2
  570. package/src/server/lib/sequence-number-retry.ts +85 -0
  571. package/src/server/mcp/__tests__/tools.test.ts +10 -0
  572. package/src/server/mcp/tools.ts +26 -2
  573. package/src/server/middlewares/auth-rate-limit.ts +18 -9
  574. package/src/server/notifications/event-bus.ts +3 -0
  575. package/src/server/serverFns/__tests__/collab-snapshot.test.ts +665 -0
  576. package/src/server/serverFns/__tests__/collab.test.ts +459 -0
  577. package/src/server/serverFns/__tests__/knowledge-delete.test.ts +167 -0
  578. package/src/server/serverFns/__tests__/ownership-merge.test.ts +34 -2
  579. package/src/server/serverFns/__tests__/read-write-merge.test.ts +2 -2
  580. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +12 -2
  581. package/src/server/serverFns/__tests__/story-crud.test.ts +51 -11
  582. package/src/server/serverFns/__tests__/story-tree.test.ts +66 -34
  583. package/src/server/serverFns/__tests__/story-types.test.ts +14 -9
  584. package/src/server/serverFns/__tests__/update-bug-guard.test.ts +325 -0
  585. package/src/server/serverFns/__tests__/update-document-guard.test.ts +285 -0
  586. package/src/server/serverFns/__tests__/update-page-guard.test.ts +276 -0
  587. package/src/server/serverFns/__tests__/update-story-guard.test.ts +436 -0
  588. package/src/server/serverFns/__tests__/update-testcase-guard.test.ts +293 -0
  589. package/src/server/serverFns/bug/__tests__/bug-comments.test.ts +25 -0
  590. package/src/server/serverFns/bug/__tests__/bug-crud.test.ts +315 -15
  591. package/src/server/serverFns/bug/__tests__/bug-groups.test.ts +36 -18
  592. package/src/server/serverFns/bug/__tests__/bug-list-query.test.ts +21 -0
  593. package/src/server/serverFns/bug/__tests__/bug-reactions.test.ts +335 -0
  594. package/src/server/serverFns/bug/__tests__/bug-reorder.test.ts +165 -0
  595. package/src/server/serverFns/bug/__tests__/bug-story.test.ts +4 -1
  596. package/src/server/serverFns/bug/bug-comments.ts +5 -0
  597. package/src/server/serverFns/bug/bug-crud.ts +321 -72
  598. package/src/server/serverFns/bug/bug-display-options.ts +11 -0
  599. package/src/server/serverFns/bug/bug-export.ts +137 -0
  600. package/src/server/serverFns/bug/bug-group-query.ts +16 -5
  601. package/src/server/serverFns/bug/bug-groups.ts +39 -15
  602. package/src/server/serverFns/bug/bug-list-query.ts +8 -1
  603. package/src/server/serverFns/bug/bug-reactions.ts +123 -0
  604. package/src/server/serverFns/bug/bug-reorder.ts +64 -0
  605. package/src/server/serverFns/bug/bug-story-bulk.ts +280 -0
  606. package/src/server/serverFns/bug/bug-story.ts +47 -39
  607. package/src/server/serverFns/bug/index.ts +6 -0
  608. package/src/server/serverFns/collab-presence.ts +119 -0
  609. package/src/server/serverFns/collab-snapshot.ts +201 -0
  610. package/src/server/serverFns/collab.ts +153 -0
  611. package/src/server/serverFns/design/__tests__/designs.test.ts +0 -2
  612. package/src/server/serverFns/design/designs.ts +0 -64
  613. package/src/server/serverFns/design/detail.ts +36 -2
  614. package/src/server/serverFns/design/pages.ts +73 -10
  615. package/src/server/serverFns/iteration/iteration-crud.ts +25 -1
  616. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/create.ts +3 -3
  617. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/detail.ts +2 -2
  618. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/list.ts +3 -2
  619. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/refresh-favicon.ts +1 -1
  620. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/share.ts +1 -1
  621. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/sync-github.ts +1 -1
  622. package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/update.ts +5 -4
  623. package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/create.ts +1 -1
  624. package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/detail.ts +2 -2
  625. package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/list.ts +3 -2
  626. package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/share.ts +1 -1
  627. package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/update.ts +3 -2
  628. package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/create.ts +1 -1
  629. package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/detail.ts +2 -2
  630. package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/list.ts +3 -2
  631. package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/share.ts +1 -1
  632. package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/update.ts +3 -2
  633. package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/create.ts +1 -1
  634. package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/detail.ts +2 -2
  635. package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/list.ts +3 -2
  636. package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/share.ts +1 -1
  637. package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/update.ts +3 -2
  638. package/src/server/serverFns/knowledge.ts +135 -19
  639. package/src/server/serverFns/project/module-overview.ts +1 -2
  640. package/src/server/serverFns/share/version.ts +7 -1
  641. package/src/server/serverFns/story/__tests__/bulk.test.ts +6 -0
  642. package/src/server/serverFns/story/__tests__/create-story-internal.test.ts +112 -3
  643. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +77 -6
  644. package/src/server/serverFns/story/__tests__/story-crud-date-key.test.ts +187 -0
  645. package/src/server/serverFns/story/__tests__/story-gantt-deps.test.ts +163 -0
  646. package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
  647. package/src/server/serverFns/story/__tests__/story-list-query.test.ts +20 -0
  648. package/src/server/serverFns/story/__tests__/story-search.test.ts +5 -0
  649. package/src/server/serverFns/story/__tests__/story-transfer.test.ts +64 -1
  650. package/src/server/serverFns/story/index.ts +2 -2
  651. package/src/server/serverFns/story/story-assoc.ts +44 -14
  652. package/src/server/serverFns/story/story-crud/archive.ts +52 -28
  653. package/src/server/serverFns/story/story-crud/bulk.ts +14 -4
  654. package/src/server/serverFns/story/story-crud/create-story-internal.ts +39 -21
  655. package/src/server/serverFns/story/story-crud.ts +283 -116
  656. package/src/server/serverFns/story/story-dependencies.ts +31 -0
  657. package/src/server/serverFns/story/story-groups.ts +15 -11
  658. package/src/server/serverFns/story/story-list-query.ts +16 -4
  659. package/src/server/serverFns/story/story-search.ts +9 -6
  660. package/src/server/serverFns/story/story-transfer.ts +99 -42
  661. package/src/server/serverFns/story/story-tree.ts +70 -48
  662. package/src/server/serverFns/story-display-options.ts +21 -10
  663. package/src/server/serverFns/story-view.ts +2 -2
  664. package/src/server/serverFns/tasks/task-crud.ts +264 -0
  665. package/src/server/serverFns/tasks/task-queries.ts +172 -0
  666. package/src/server/serverFns/testcase/__tests__/generation.test.ts +3 -0
  667. package/src/server/serverFns/testcase/__tests__/stats.test.ts +18 -4
  668. package/src/server/serverFns/testcase/__tests__/testcase-board.test.ts +113 -0
  669. package/src/server/serverFns/testcase/__tests__/testcase-crud.test.ts +62 -14
  670. package/src/server/serverFns/testcase/__tests__/testcase-groups.test.ts +32 -10
  671. package/src/server/serverFns/testcase/bulk.ts +195 -0
  672. package/src/server/serverFns/testcase/generation.ts +10 -0
  673. package/src/server/serverFns/testcase/stats.ts +22 -8
  674. package/src/server/serverFns/testcase/testcase-board.ts +198 -0
  675. package/src/server/serverFns/testcase/testcase-crud.ts +165 -33
  676. package/src/server/serverFns/testcase/testcase-display-options.ts +166 -0
  677. package/src/server/serverFns/testcase/testcase-export.ts +125 -0
  678. package/src/server/serverFns/testcase/testcase-filter-views.ts +36 -9
  679. package/src/server/serverFns/testcase/testcase-groups.ts +27 -23
  680. package/src/server/serverFns/testcase/testcase-reorder.ts +66 -0
  681. package/src/server/story/__tests__/date-key.test.ts +57 -0
  682. package/src/server/story/date-key.ts +36 -0
  683. package/src/server/story/types.ts +3 -7
  684. package/src/server/testcase/__tests__/list-query.test.ts +269 -0
  685. package/src/server/testcase/list-query.ts +145 -0
  686. package/src/server-entry.ts +32 -0
  687. package/src/shared/bug/__tests__/constants.test.ts +44 -0
  688. package/src/shared/bug/constants.ts +37 -1
  689. package/src/start.ts +7 -2
  690. package/src/styles/globals.css +24 -3
  691. package/src/types/project.ts +2 -3
  692. package/vite.shared.ts +110 -3
  693. package/src/__tests__/tag-badge.test.tsx +0 -41
  694. package/src/components/bug/__tests__/bug-list-table.test.tsx +0 -289
  695. package/src/components/bug/bug-list-table.tsx +0 -187
  696. package/src/components/bug/columns.tsx +0 -193
  697. package/src/components/bug/filters/add-filter-dropdown.tsx +0 -70
  698. package/src/components/bug/filters/applied-filters.tsx +0 -214
  699. package/src/components/bug/filters/filter-condition.tsx +0 -151
  700. package/src/components/bug/filters/filter-row.tsx +0 -104
  701. package/src/components/bug/filters/filter-toggle.tsx +0 -61
  702. package/src/components/bug/filters/filter-types.ts +0 -146
  703. package/src/components/story/detail/story-detail-toolbar.tsx +0 -382
  704. package/src/components/story/filters/add-filter-dropdown.tsx +0 -70
  705. package/src/components/story/filters/applied-filters.tsx +0 -225
  706. package/src/components/story/filters/filter-condition.tsx +0 -152
  707. package/src/components/story/filters/filter-row.tsx +0 -116
  708. package/src/components/story/filters/filter-toggle.tsx +0 -60
  709. package/src/components/story/filters/filter-types.ts +0 -120
  710. package/src/components/story/filters/filter-value-input.tsx +0 -182
  711. package/src/components/story/views/kanban-board.tsx +0 -464
  712. package/src/components/story/views/kanban-card.tsx +0 -100
  713. package/src/components/tags/__tests__/tags.test.ts +0 -388
  714. package/src/components/tags/nav-tag.tsx +0 -121
  715. package/src/components/tags/pages/tag-settings-page.tsx +0 -32
  716. package/src/components/tags/pages/tags-index-page.tsx +0 -32
  717. package/src/components/tags/tag-badge.tsx +0 -56
  718. package/src/components/tags/tag-color-classes.ts +0 -61
  719. package/src/components/tags/tag-color-picker.tsx +0 -54
  720. package/src/components/tags/tag-components-index.ts +0 -23
  721. package/src/components/tags/tag-manager.tsx +0 -208
  722. package/src/components/tags/tag-selector.tsx +0 -164
  723. package/src/components/testcase/__tests__/table-view.test.tsx +0 -328
  724. package/src/components/testcase/plans/add-to-plan-bar.tsx +0 -64
  725. package/src/components/testcase/table/columns.tsx +0 -170
  726. package/src/components/testcase/table/table-view.tsx +0 -152
  727. package/src/lib/tag-colors.ts +0 -35
  728. package/src/routes/_nav/tags/$id/route.lazy.tsx +0 -23
  729. package/src/routes/_nav/tags/$id/route.tsx +0 -20
  730. package/src/routes/_nav/tags/index.lazy.tsx +0 -23
  731. package/src/routes/_nav/tags/index.tsx +0 -20
  732. package/src/routes/_nav/tags/settings/route.lazy.tsx +0 -23
  733. package/src/routes/_nav/tags/settings/route.tsx +0 -23
  734. package/src/server/serverFns/tags.ts +0 -138
  735. /package/src/components/{knowledge-public → knowledge/public}/bookmark-card.tsx +0 -0
  736. /package/src/components/{knowledge-public → knowledge/public}/category-icon.tsx +0 -0
  737. /package/src/components/{knowledge-public → knowledge/public}/chip-utils.ts +0 -0
  738. /package/src/components/{knowledge-public → knowledge/public}/knowledge-card-grid.tsx +0 -0
  739. /package/src/components/{knowledge-public → knowledge/public}/knowledge-filters.tsx +0 -0
  740. /package/src/components/{knowledge-public → knowledge/public}/knowledge-search-bar.tsx +0 -0
  741. /package/src/components/{knowledge-public → knowledge/public}/markdown-editor.tsx +0 -0
  742. /package/src/components/{knowledge-public → knowledge/public}/markdown-view.tsx +0 -0
  743. /package/src/components/{knowledge-public → knowledge/public}/tag-filter.tsx +0 -0
  744. /package/src/components/{story/detail → shared-kit/comments}/emoji-picker.tsx +0 -0
  745. /package/src/components/story/{schemas/story-create.ts → story-create-schema.ts} +0 -0
  746. /package/src/server/{knowledge-public → knowledge/public}/canonical-id.ts +0 -0
  747. /package/src/server/{knowledge-public → knowledge/public}/favicon.ts +0 -0
  748. /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/categories.ts +0 -0
  749. /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/delete.ts +0 -0
  750. /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/favorite.ts +0 -0
  751. /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/unshare.ts +0 -0
  752. /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/delete.ts +0 -0
  753. /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/public.ts +0 -0
  754. /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/unshare.ts +0 -0
  755. /package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/delete.ts +0 -0
  756. /package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/unshare.ts +0 -0
  757. /package/src/server/serverFns/{knowledge-public → knowledge/public}/entity-tags.ts +0 -0
  758. /package/src/server/serverFns/{knowledge-public → knowledge/public}/favorite.ts +0 -0
  759. /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/delete.ts +0 -0
  760. /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/public.ts +0 -0
  761. /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/unshare.ts +0 -0
@@ -0,0 +1,1414 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ /**
19
+ * 全能力共享看板组件(UI 对齐二期设计 §4.5,任务 24)。
20
+ *
21
+ * 以 story/views/kanban-view.tsx(1593 行蓝本)逐段搬移参数化,不改算法:
22
+ * reui Kanban 装配、useSyncedState 渲染期同步、拖拽期空列虚显(虚线壳)、
23
+ * ghost 落点淡化卡、列内虚拟化(canVirtualize 渐进增强)、拖拽让位 transform、
24
+ * 子分组吸附头、900ms 延迟静默刷新、失败还原 + toast、手动排序触发。
25
+ *
26
+ * story 特有业务(分组字段 patch 映射、PeekPatch 通道、转移、保存视图接线)
27
+ * 留在消费方——经 config 回调接线(story 任务 42 / testcase 任务 26 / bug 任务 33)。
28
+ *
29
+ * memo 约定:config 内的函数字段(columnMeta/renderCard/renderGhostCard/cardKey)
30
+ * 须引用稳定(消费方 useMemo/useCallback),否则列内 memo 与 dnd 缓存全部失效。
31
+ */
32
+
33
+ import {
34
+ memo,
35
+ useCallback,
36
+ useEffect,
37
+ useLayoutEffect,
38
+ useMemo,
39
+ useRef,
40
+ useState,
41
+ type ComponentType,
42
+ type ReactNode,
43
+ } from "react";
44
+ import {
45
+ Kanban,
46
+ KanbanBoard,
47
+ KanbanColumn,
48
+ KanbanColumnContent,
49
+ KanbanColumnHandle,
50
+ KanbanItem,
51
+ KanbanItemHandle,
52
+ KanbanOverlay,
53
+ useKanbanDropIndicator,
54
+ type KanbanCommitMeta,
55
+ } from "@/components/reui/kanban";
56
+ import { Badge } from "@/components/ui/badge";
57
+ import { Button } from "@/components/ui/button";
58
+ import { Card, CardContent, CardHeader } from "@/components/ui/card";
59
+ import {
60
+ ChevronRight,
61
+ ChevronsDownUp,
62
+ ChevronsUpDown,
63
+ GripVertical,
64
+ } from "lucide-react";
65
+ import { cn } from "@/lib/utils";
66
+ import { useSyncedState } from "@/lib/hooks/use-synced-state";
67
+ import { m } from "@/paraglide/messages.js";
68
+ import { useVirtualizer } from "@tanstack/react-virtual";
69
+ import { useDebouncedCallback } from "@/lib/pacer";
70
+ import {
71
+ applyInstanceKeys,
72
+ buildStoryIndex,
73
+ itemKey,
74
+ storyIdFromInstanceKey,
75
+ type KanbanStoryItem,
76
+ } from "./instance-key";
77
+ import { computeDropLayout, type DropLayoutRow } from "./drop-layout";
78
+ import {
79
+ computeFrozenSubHeader,
80
+ type FrozenSubHeader,
81
+ } from "./frozen-header";
82
+ import {
83
+ commitDropDecision,
84
+ resolveDropCommit,
85
+ resolveSubKeyForDrop,
86
+ type ReorderEntry,
87
+ } from "./drop-commit";
88
+
89
+ /* ------------------------------------------------------------------ */
90
+ /* 契约类型(设计 §4.5 KanbanModuleConfig + 复审 P1-1 / 实施必要扩展) */
91
+ /* ------------------------------------------------------------------ */
92
+
93
+ /** 列底色(与 story group-resolver 的 ColumnTint 同构:语义类 + 行内实色) */
94
+ export interface ColumnTint {
95
+ className?: string;
96
+ backgroundColor?: string;
97
+ }
98
+
99
+ /** 列/子分组图标类型:LucideIcon 的宽化超集(story GroupNode.icon 直通,无需转换) */
100
+ export type KanbanColumnIcon = ComponentType<{ className?: string }>;
101
+
102
+ export interface KanbanColumnMeta {
103
+ label: string;
104
+ icon?: KanbanColumnIcon;
105
+ /** 图标附加色类(如 "text-red-500");缺省 text-muted-foreground */
106
+ iconClassName?: string;
107
+ tint?: ColumnTint;
108
+ /** 列头计数;缺省取列内条目数 */
109
+ count?: number;
110
+ }
111
+
112
+ export interface KanbanSubGroupMeta {
113
+ key: string;
114
+ label: string;
115
+ icon?: KanbanColumnIcon;
116
+ /** 空格分隔的 class 串,取 text-* 前缀作子分组头图标色(蓝本 GroupNode.color 口径) */
117
+ color?: string;
118
+ order: number;
119
+ }
120
+
121
+ /** 子分组数据(capabilities.subGroups 开启时消费;蓝本 buildSubMeta/buildMembership 的产物) */
122
+ export interface KanbanSubGrouping {
123
+ /**
124
+ * 条目 id → 子分组 key(服务端权威值;拖拽期由组件本地乐观维护)。
125
+ * 引用稳定性要求:本表经 useSyncedState 渲染期同步——引用一变即把组件本地
126
+ * 乐观成员表重置回该值(拖拽中/落库未刷新窗口里的乐观归属会被覆盖,卡片
127
+ * 分区跳变)。消费方必须 useMemo/useCallback 固定引用,仅在服务端权威数据
128
+ * 变化时产生新引用;禁止每次渲染新建字面量。
129
+ */
130
+ membership: Record<string, string>;
131
+ meta: Record<string, KanbanSubGroupMeta>;
132
+ /** 子分组维度名(折叠存储键组成部分,如 "priority") */
133
+ dimension: string;
134
+ /**
135
+ * 是否允许拖拽跨子分组(蓝本 canMoveAcrossSubGroups 口径):缺省 true。
136
+ * false = 该维度不可改(如 createdBy),拖拽不更新成员表、move 决策不带 subKey,
137
+ * 子分组只做视觉分区(story 任务 42 对 GROUP_FIELD[subGroupBy] == null 的维度传 false)。
138
+ */
139
+ moveAcross?: boolean;
140
+ /** 折叠状态 localStorage 键;缺省退化为会话内状态 */
141
+ storageKey?: string;
142
+ }
143
+
144
+ export interface KanbanModuleConfig<T extends { id: string }> {
145
+ /** 服务端权威列集合(键序 = 列序;渲染期同步,拖拽乐观值不被覆盖) */
146
+ columns: Record<string, T[]>;
147
+ columnMeta: (key: string) => KanbanColumnMeta;
148
+ cardKey: (item: T) => string;
149
+ renderCard: (item: T) => ReactNode;
150
+ onOpenItem: (item: T) => void;
151
+ /** 跨列落库(分组字段 patch 由消费方映射);启用子分组时附带目标子分组 */
152
+ onDropToColumn: (
153
+ itemKey: string,
154
+ columnKey: string,
155
+ meta?: { subKey?: string },
156
+ ) => Promise<void>;
157
+ /**
158
+ * 同列跨子分组拖拽的子组字段落库(任务 42 必带③;capabilities.subGroups +
159
+ * moveAcross 时 reorder 决策携带 subKey 后经此落库)。同值跳写与成功 toast
160
+ * 由消费方负责(共享层不掌握权威成员表)。缺省不落库(纯 reorder)。
161
+ */
162
+ onSubGroupMove?: (itemKey: string, columnKey: string, subKey: string) => Promise<void>;
163
+ /** 列尾加载更多(列级分页,testcase 看板消费) */
164
+ onLoadMoreColumn?: (columnKey: string) => void;
165
+ capabilities: {
166
+ /** 手动排序:onReorder 收目标列全列索引;排序键切换决策 + 提示由消费方承担 */
167
+ manualOrder?: {
168
+ onReorder: (orders: ReorderEntry[]) => Promise<void>;
169
+ onAutoSwitchManual?: () => Promise<void>;
170
+ };
171
+ subGroups?: boolean;
172
+ columnDrag?: {
173
+ onColumnOrderCommit: (order: string[]) => Promise<void>;
174
+ };
175
+ };
176
+ /**
177
+ * 复审 P1-1:列是否可放置(缺省 true)。false 的列上的 drop 提交被忽略
178
+ * (labels 等多值维度分组禁拖用,bug 任务 33 消费)。
179
+ */
180
+ columnDroppable?: (key: string) => boolean;
181
+ /** 提交成功(延迟 900ms 静默)/失败(立即)后的权威刷新 */
182
+ onRefresh?: () => void;
183
+ /** 拖拽期虚显的全量列 key(showEmptyGroups=false 时缺失列的虚线壳);缺省无虚列 */
184
+ ghostColumnKeys?: string[];
185
+ subGrouping?: KanbanSubGrouping;
186
+ /** 落点淡化卡的静态镜像(蓝本 TaskCardGhost 的挂载成本口径);缺省回落 renderCard */
187
+ renderGhostCard?: (item: T) => ReactNode;
188
+ }
189
+
190
+ /* ------------------------------------------------------------------ */
191
+ /* 纯助手(蓝本逐段搬移,泛型化) */
192
+ /* ------------------------------------------------------------------ */
193
+
194
+ /** 剔除虚显列残留:不在服务端列集合内且仍为空的 key(拖拽途中临时插入的隐藏列) */
195
+ function stripEmptyExtra<T extends { id: string }>(
196
+ next: Record<string, T[]>,
197
+ serverColumns: Record<string, T[]>,
198
+ ): Record<string, T[]> {
199
+ const real = new Set(Object.keys(serverColumns));
200
+ let changed = false;
201
+ const kept: Record<string, T[]> = {};
202
+ for (const [key, items] of Object.entries(next)) {
203
+ if (!real.has(key) && items.length === 0) {
204
+ changed = true;
205
+ continue;
206
+ }
207
+ kept[key] = items;
208
+ }
209
+ return changed ? kept : next;
210
+ }
211
+
212
+ /**
213
+ * 列标题行内距:列头不使用下边框(去掉 border-b 后 CardHeader 基类的 `[.border-b]:pb-(--card-spacing)`
214
+ * 也不再生效),故此处只需常规内距。虚列标题行是纯 div(无该基类),两处必须共用此常量以免高度不一致。
215
+ */
216
+ const COLUMN_HEADER_SPACING = "px-3 py-2.5";
217
+
218
+ interface SubGroupRun<T extends { id: string }> {
219
+ key: string;
220
+ items: T[];
221
+ }
222
+
223
+ /** 按子分组顺序稳定排序(同一区内保持原排序) */
224
+ function orderBySubGroups<T extends { id: string }>(
225
+ items: T[],
226
+ membership: Record<string, string>,
227
+ subMeta: Record<string, KanbanSubGroupMeta>,
228
+ ): T[] {
229
+ return [...items].sort(
230
+ (a, b) =>
231
+ (subMeta[membership[a.id] ?? ""]?.order ?? Number.MAX_SAFE_INTEGER) -
232
+ (subMeta[membership[b.id] ?? ""]?.order ?? Number.MAX_SAFE_INTEGER),
233
+ );
234
+ }
235
+
236
+ /**
237
+ * 列表面(列 Card 与列内吸附的子分组头)的底色渲染口径,必须完全一致:
238
+ * 基础 bg-card + tint 语义类,有行内色时行内色覆盖(tint 已是实色)。
239
+ * 抽成一处,避免吸附层与列 Card 两处漂移。
240
+ */
241
+ function columnSurface(tint: ColumnTint) {
242
+ return {
243
+ className: cn("bg-card", tint.className),
244
+ style: tint.backgroundColor ? { backgroundColor: tint.backgroundColor } : undefined,
245
+ };
246
+ }
247
+
248
+ /** 依据成员表把列内卡片切成连续分区:渲染顺序 == 数组顺序,保证 sortable 索引与 DOM 顺序一致 */
249
+ function buildRuns<T extends { id: string }>(
250
+ items: T[],
251
+ membership: Record<string, string>,
252
+ enabled: boolean,
253
+ ): SubGroupRun<T>[] {
254
+ if (!enabled) return [{ key: "", items }];
255
+ const runs: SubGroupRun<T>[] = [];
256
+ for (const item of items) {
257
+ const key = membership[item.id] ?? "";
258
+ const last = runs[runs.length - 1];
259
+ if (last && last.key === key) last.items.push(item);
260
+ else runs.push({ key, items: [item] });
261
+ }
262
+ return runs;
263
+ }
264
+
265
+ /* ------------------------------------------------------------------ */
266
+ /* 子分组折叠状态(蓝本 story/display/use-collapsed-subgroups 等价机制) */
267
+ /* ------------------------------------------------------------------ */
268
+
269
+ /** 折叠键 = 列 key + 子分组维度 + 子分组 key:同一子分组在各列内独立折叠 */
270
+ export function collapseKey(
271
+ columnKey: string,
272
+ dimension: string,
273
+ subKey: string,
274
+ ): string {
275
+ return `${columnKey}:${dimension}:${subKey}`;
276
+ }
277
+
278
+ /**
279
+ * 子分组折叠状态。storageKey 提供时 localStorage 持久化(读写分居两个 effect,
280
+ * restore 用 skipWriteRef 抑制同一次 commit 内的默认值回写);缺省退化为会话内状态。
281
+ */
282
+ function useCollapsedSubGroups(storageKey?: string) {
283
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(
284
+ () => new Set<string>(),
285
+ );
286
+ const skipWriteRef = useRef(false);
287
+
288
+ useEffect(() => {
289
+ if (!storageKey) return;
290
+ let saved: string | null = null;
291
+ try {
292
+ saved = window.localStorage.getItem(storageKey);
293
+ } catch {
294
+ saved = null;
295
+ }
296
+ skipWriteRef.current = saved !== null;
297
+ let parsed: string[] = [];
298
+ try {
299
+ const raw: unknown = saved ? JSON.parse(saved) : null;
300
+ if (Array.isArray(raw)) {
301
+ parsed = raw.filter((value): value is string => typeof value === "string");
302
+ }
303
+ } catch {
304
+ parsed = [];
305
+ }
306
+ setCollapsed(new Set(parsed));
307
+ }, [storageKey]);
308
+
309
+ useEffect(() => {
310
+ if (!storageKey) return;
311
+ if (skipWriteRef.current) {
312
+ skipWriteRef.current = false;
313
+ return;
314
+ }
315
+ try {
316
+ window.localStorage.setItem(storageKey, JSON.stringify([...collapsed]));
317
+ } catch {
318
+ // 隐私模式 / 配额超限:折叠状态退化为会话内状态
319
+ }
320
+ }, [collapsed, storageKey]);
321
+
322
+ const toggle = useCallback((key: string) => {
323
+ setCollapsed((prev) => {
324
+ const next = new Set(prev);
325
+ if (next.has(key)) next.delete(key);
326
+ else next.add(key);
327
+ return next;
328
+ });
329
+ }, []);
330
+
331
+ const setMany = useCallback((targets: readonly string[], value: boolean) => {
332
+ setCollapsed((prev) => {
333
+ const next = new Set(prev);
334
+ for (const key of targets) {
335
+ if (value) next.add(key);
336
+ else next.delete(key);
337
+ }
338
+ return next;
339
+ });
340
+ }, []);
341
+
342
+ return { collapsed, toggle, setMany } as const;
343
+ }
344
+
345
+ /* ------------------------------------------------------------------ */
346
+ /* 常量(蓝本搬移) */
347
+ /* ------------------------------------------------------------------ */
348
+
349
+ /** KanbanColumnContent 的 `gap-2`(0.5rem)像素值:落点槽定位与让位位移都要用它 */
350
+ const COLUMN_ITEM_GAP_PX = 8;
351
+
352
+ /** 列内虚拟化:预估行高(卡片会被 measureElement 实测纠正)与 overscan */
353
+ const HEADER_ROW_ESTIMATE_PX = 28;
354
+ const CARD_ROW_ESTIMATE_PX = 158;
355
+ const COLUMN_ROW_OVERSCAN = 6;
356
+
357
+ /**
358
+ * 落库后的服务端刷新延迟:落库时的乐观态已经按子分组自洽(见 handleValueChange),
359
+ * 立即 onRefresh 会让「提交 → 拉取 → 整板重算」在同一帧里多背 ~100ms 长任务。
360
+ * 延后静默刷新把这段挪出交互瞬间,期间由 presence/轮询兜底新鲜度。
361
+ */
362
+ const DROP_REFRESH_DEBOUNCE_MS = 900;
363
+
364
+ const EMPTY_MEMBERSHIP: Record<string, string> = {};
365
+ const EMPTY_SUB_META: Record<string, KanbanSubGroupMeta> = {};
366
+
367
+ /* ------------------------------------------------------------------ */
368
+ /* 主组件 */
369
+ /* ------------------------------------------------------------------ */
370
+
371
+ type ColumnValue<T extends { id: string }> = Record<string, T[]>;
372
+
373
+ export function KanbanBoardView<T extends { id: string }>({
374
+ config,
375
+ }: {
376
+ config: KanbanModuleConfig<T>;
377
+ }) {
378
+ const {
379
+ columns: sourceColumns,
380
+ columnMeta,
381
+ cardKey,
382
+ renderCard,
383
+ onOpenItem,
384
+ onDropToColumn,
385
+ onSubGroupMove,
386
+ onLoadMoreColumn,
387
+ capabilities,
388
+ columnDroppable,
389
+ onRefresh,
390
+ ghostColumnKeys,
391
+ subGrouping,
392
+ renderGhostCard,
393
+ } = config;
394
+ const { manualOrder, columnDrag } = capabilities;
395
+ const hasSubGroups = !!capabilities.subGroups && !!subGrouping;
396
+ /** 子分组是否可跨区改字段:不可改维度只做视觉分区(蓝本 canMoveAcrossSubGroups) */
397
+ const canMoveAcrossSubGroups = hasSubGroups && (subGrouping?.moveAcross ?? true);
398
+ const subMeta = hasSubGroups ? (subGrouping?.meta ?? EMPTY_SUB_META) : EMPTY_SUB_META;
399
+ const dimension = subGrouping?.dimension ?? "";
400
+ const serverMembership = hasSubGroups
401
+ ? (subGrouping?.membership ?? EMPTY_MEMBERSHIP)
402
+ : EMPTY_MEMBERSHIP;
403
+
404
+ const { collapsed: collapsedSubGroups, toggle: toggleSubGroup, setMany: setSubGroupsCollapsed } =
405
+ useCollapsedSubGroups(hasSubGroups ? subGrouping?.storageKey : undefined);
406
+
407
+ // 受控 value:拖拽过程 onValueChange 乐观更新,服务端刷新后回到权威值。
408
+ // 用渲染期同步而非 useEffect——切换分组维度时源值同批到达,
409
+ // effect 在 paint 后才跑会先提交一帧「新列头 + 旧列 key」。
410
+ const [columns, setColumns] = useSyncedState<ColumnValue<T>>(sourceColumns);
411
+
412
+ // 成员表:卡片 → 分区 key。初值取自服务端字段,拖拽跨越分区时即时更新(渲染分区用)。
413
+ // 必须与 columns 同样渲染期同步:卡片顺序已按服务端成员表排好,分区切分(buildRuns)
414
+ // 若用 effect 滞后一帧的本地成员表,两者错配会切出两个同 key 分区(duplicate key + 幽灵卡)。
415
+ const [membership, setMembership] =
416
+ useSyncedState<Record<string, string>>(serverMembership);
417
+ const membershipRef = useRef(membership);
418
+ useEffect(() => {
419
+ membershipRef.current = membership;
420
+ });
421
+
422
+ const activeIdRef = useRef<string | null>(null);
423
+ // 本次拖拽落点 hover 到的卡/列 id:onDragEnd 先于落库提交触发,先暂存供归属判定用
424
+ const lastOverRef = useRef<string>("");
425
+ // 拖拽中的活动卡片实例 id:跨列移动由 Kanban 在 onDragOver 即时提交,须冻结该 id
426
+ // 才能让 dnd-kit 继续在目标列里定位 active、overlay 还能查到卡片(见 applyInstanceKeys)
427
+ const [activeInstanceKey, setActiveInstanceKey] = useState<string | null>(null);
428
+
429
+ // 拖拽期虚显被隐藏的空列(ghostColumnKeys 里不在 columns 的 key):
430
+ // 不补进 Kanban 的 value 的话 dnd-kit 认不出容器,卡片无处可放(KanbanColumnContent 也会直接抛错)
431
+ const [dragging, setDragging] = useState(false);
432
+ const universeKeys = useMemo(
433
+ () => ghostColumnKeys ?? Object.keys(sourceColumns),
434
+ [ghostColumnKeys, sourceColumns],
435
+ );
436
+
437
+ const ghostState = useMemo(() => {
438
+ if (!dragging) return null;
439
+ const missing = universeKeys.filter((key) => !(key in columns));
440
+ if (missing.length === 0) return null;
441
+ const withGhosts: ColumnValue<T> = { ...columns };
442
+ const keySet = new Set<string>();
443
+ for (const key of missing) {
444
+ withGhosts[key] = [];
445
+ keySet.add(key);
446
+ }
447
+ return { columns: withGhosts, keySet };
448
+ }, [columns, dragging, universeKeys]);
449
+
450
+ const handleValueChange = useCallback(
451
+ (next: Record<string, KanbanStoryItem<T>[]>) => {
452
+ const activeId = activeIdRef.current;
453
+ let members = membershipRef.current;
454
+ if (canMoveAcrossSubGroups && activeId) {
455
+ for (const items of Object.values(next)) {
456
+ if (!items.some((entry) => entry.id === activeId)) continue;
457
+ const target = resolveSubKeyForDrop(
458
+ items,
459
+ activeId,
460
+ members,
461
+ lastOverRef.current,
462
+ );
463
+ if (target && target !== members[activeId]) {
464
+ members = { ...members, [activeId]: target };
465
+ membershipRef.current = members;
466
+ setMembership(members);
467
+ }
468
+ break;
469
+ }
470
+ }
471
+ // 乐观顺序必须与本地成员表自洽:只按插入位摆放时,卡片会停在「原子分组」的位置却带
472
+ // 「新子分组」的成员标记 → buildRuns 把一列切成多个同名子分组(要等刷新才恢复)。
473
+ let ordered = stripEmptyExtra(next, sourceColumns);
474
+ if (hasSubGroups) {
475
+ const rebuilt: Record<string, KanbanStoryItem<T>[]> = {};
476
+ for (const [key, items] of Object.entries(ordered)) {
477
+ rebuilt[key] = orderBySubGroups(items, members, subMeta);
478
+ }
479
+ ordered = rebuilt;
480
+ }
481
+ setColumns(ordered);
482
+ },
483
+ [canMoveAcrossSubGroups, hasSubGroups, setColumns, setMembership, sourceColumns, subMeta],
484
+ );
485
+
486
+ // 落库后的服务端刷新改成延迟静默刷新(见 DROP_REFRESH_DEBOUNCE_MS),
487
+ // 刷新函数经 ref 取最新闭包(useDebouncedCallback 每次渲染可能返回新函数)
488
+ const refreshRef = useRef(onRefresh);
489
+ refreshRef.current = onRefresh;
490
+ const debouncedRefresh = useDebouncedCallback(() => refreshRef.current?.(), {
491
+ wait: DROP_REFRESH_DEBOUNCE_MS,
492
+ });
493
+ const debouncedRefreshRef = useRef(debouncedRefresh);
494
+ debouncedRefreshRef.current = debouncedRefresh;
495
+
496
+ const handleValueCommit = useCallback(
497
+ async (
498
+ next: Record<string, KanbanStoryItem<T>[]>,
499
+ meta: KanbanCommitMeta<KanbanStoryItem<T>>,
500
+ ) => {
501
+ // 列拖拽:持久化列顺序。失败仅回滚乐观列序,**不弹共享 moveFailed toast**——
502
+ // 列序失败的提示语义只有消费方知道(如 story 的 columnOrderFailed,卡移动/
503
+ // 排序分支的 moveFailed 对列序操作语义不准),消费方 catch 内自行 toast;
504
+ // 审查修复(任务 42):共享 catch 曾叠加 moveFailed 造成双 toast。
505
+ if (meta.kind === "column") {
506
+ if (!columnDrag) return;
507
+ try {
508
+ await columnDrag.onColumnOrderCommit(Object.keys(next));
509
+ } catch {
510
+ setColumns(meta.previousValue);
511
+ }
512
+ return;
513
+ }
514
+
515
+ const decision = resolveDropCommit({
516
+ activeId: String(meta.event.active.id),
517
+ activeContainer: meta.activeContainer,
518
+ overContainer: meta.overContainer,
519
+ nextValue: next,
520
+ columnDroppable,
521
+ membership: canMoveAcrossSubGroups ? membershipRef.current : undefined,
522
+ overItemId: lastOverRef.current,
523
+ });
524
+
525
+ // 文案带上落点:「已更新分组到 已取消-高」(无子分组时只显示列名)
526
+ let targetLabel: string | undefined;
527
+ if (decision.kind === "move") {
528
+ const groupLabel =
529
+ columnMeta(decision.columnKey)?.label ?? decision.columnKey;
530
+ const subLabel =
531
+ decision.subKey != null ? subMeta[decision.subKey]?.label : undefined;
532
+ targetLabel = subLabel ? `${groupLabel}-${subLabel}` : groupLabel;
533
+ }
534
+
535
+ await commitDropDecision({
536
+ decision,
537
+ previousValue: meta.previousValue,
538
+ setColumns,
539
+ onDropToColumn,
540
+ onSubGroupMove,
541
+ manualOrder,
542
+ onRefresh: refreshRef.current,
543
+ onSuccess: debouncedRefreshRef.current,
544
+ targetLabel,
545
+ });
546
+ },
547
+ [
548
+ canMoveAcrossSubGroups,
549
+ columnDrag,
550
+ columnDroppable,
551
+ columnMeta,
552
+ manualOrder,
553
+ onDropToColumn,
554
+ onSubGroupMove,
555
+ setColumns,
556
+ subMeta,
557
+ ],
558
+ );
559
+
560
+ const baseColumns = ghostState?.columns ?? columns;
561
+ // 引用必须稳定:它是 Kanban 数据 context 与 KanbanColumnContent 的 itemIds 缓存键的一部分
562
+ const getItemValue = useCallback(
563
+ (entry: KanbanStoryItem<T>) => entry.__instanceKey ?? cardKey(entry),
564
+ [cardKey],
565
+ );
566
+ const renderColumns = useMemo<Record<string, KanbanStoryItem<T>[]>>(
567
+ () => applyInstanceKeys(baseColumns, activeInstanceKey),
568
+ [activeInstanceKey, baseColumns],
569
+ );
570
+ // overlay 按 dnd-kit 的 active id 反查卡片,索引须跟着冻结后的实例 id 走
571
+ const storyMap = useMemo(() => buildStoryIndex(renderColumns), [renderColumns]);
572
+ // 落点列高亮:跨列拖拽由 Kanban 在 onDragOver 即时把卡片移入目标列(落空列则追加列尾),
573
+ // 故「活动卡片当前所在列」即此刻的落点列,无需另起 pointer 追踪
574
+ const overColumnKey = useMemo(() => {
575
+ if (!activeInstanceKey) return null;
576
+ const activeId = storyIdFromInstanceKey(activeInstanceKey);
577
+ for (const [columnKey, items] of Object.entries(columns)) {
578
+ if (items.some((item) => item.id === activeId)) return columnKey;
579
+ }
580
+ return null;
581
+ }, [activeInstanceKey, columns]);
582
+ const columnKeys = Object.keys(renderColumns);
583
+
584
+ if (Object.keys(sourceColumns).length === 0) {
585
+ return (
586
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
587
+ {m.list_kit_kanban_empty()}
588
+ </div>
589
+ );
590
+ }
591
+
592
+ const renderGhost = renderGhostCard ?? renderCard;
593
+
594
+ return (
595
+ <Kanban
596
+ value={renderColumns}
597
+ onValueChange={handleValueChange}
598
+ onValueCommit={handleValueCommit}
599
+ getItemValue={getItemValue}
600
+ restoreOnCancel
601
+ onDragStart={(event) => {
602
+ activeIdRef.current = storyIdFromInstanceKey(String(event.active.id));
603
+ setActiveInstanceKey(String(event.active.id));
604
+ setDragging(true);
605
+ }}
606
+ onDragEnd={(event) => {
607
+ lastOverRef.current = String(event?.over?.id ?? "");
608
+ activeIdRef.current = null;
609
+ setActiveInstanceKey(null);
610
+ setDragging(false);
611
+ setColumns((prev) => stripEmptyExtra(prev, sourceColumns));
612
+ }}
613
+ onDragCancel={() => {
614
+ activeIdRef.current = null;
615
+ setActiveInstanceKey(null);
616
+ setDragging(false);
617
+ setColumns((prev) => stripEmptyExtra(prev, sourceColumns));
618
+ // 取消拖拽:ReUI 已还原原始数组,成员表同步回服务端字段值
619
+ membershipRef.current = serverMembership;
620
+ setMembership(serverMembership);
621
+ }}
622
+ className="h-full min-h-0"
623
+ >
624
+ {/* pt-1 留出卡片 ring-1(outset box-shadow)的空间:容器 overflow-y-hidden 会裁掉贴边的 1px 上边框 */}
625
+ <KanbanBoard className="px-1 pt-1 pb-2">
626
+ {columnKeys.map((columnKey) => {
627
+ const meta = columnMeta(columnKey);
628
+ const Icon = meta?.icon;
629
+ const iconColor = meta?.iconClassName ?? "text-muted-foreground";
630
+ const items = renderColumns[columnKey] ?? [];
631
+ const isGhost = ghostState?.keySet.has(columnKey) ?? false;
632
+ const isOver = overColumnKey === columnKey;
633
+ const tint = meta?.tint ?? {};
634
+ const surface = columnSurface(tint);
635
+ const runs = buildRuns(items, membership, hasSubGroups);
636
+ const subKeys = runs.filter((run) => run.key).map((run) => run.key);
637
+ const subCollapseKeys = subKeys.map((subKey) =>
638
+ collapseKey(columnKey, dimension, subKey),
639
+ );
640
+ const allSubGroupsCollapsed =
641
+ subCollapseKeys.length > 0 &&
642
+ subCollapseKeys.every((key) => collapsedSubGroups.has(key));
643
+
644
+ if (isGhost) {
645
+ return (
646
+ <KanbanGhostColumn
647
+ key={columnKey}
648
+ columnKey={columnKey}
649
+ meta={meta}
650
+ isOver={isOver}
651
+ storyMap={storyMap}
652
+ renderGhost={renderGhost}
653
+ />
654
+ );
655
+ }
656
+
657
+ return (
658
+ <KanbanColumn
659
+ key={columnKey}
660
+ value={columnKey}
661
+ className="h-full w-72 shrink-0"
662
+ >
663
+ <Card
664
+ className={cn(
665
+ "flex h-full min-h-0 flex-col gap-0 overflow-hidden py-0",
666
+ surface.className,
667
+ isOver && "ring-2 ring-primary",
668
+ )}
669
+ style={surface.style}
670
+ >
671
+ <CardHeader
672
+ className={cn(
673
+ "flex shrink-0 flex-row items-center justify-between gap-2",
674
+ COLUMN_HEADER_SPACING,
675
+ )}
676
+ >
677
+ <div className="flex min-w-0 items-center gap-2">
678
+ {Icon && <Icon className={cn("size-4 shrink-0", iconColor)} />}
679
+ <span className="truncate text-sm font-semibold">
680
+ {meta?.label ?? columnKey}
681
+ </span>
682
+ <Badge
683
+ variant="secondary"
684
+ className="h-5 shrink-0 rounded-sm border-border px-1.5 text-xs"
685
+ >
686
+ {meta?.count ?? items.length}
687
+ </Badge>
688
+ </div>
689
+ <div className="flex shrink-0 items-center gap-1">
690
+ {subCollapseKeys.length > 0 && (
691
+ <Button
692
+ type="button"
693
+ variant="ghost"
694
+ size="icon-xs"
695
+ aria-label={
696
+ allSubGroupsCollapsed
697
+ ? m.list_kit_kanban_expandAllSubGroups()
698
+ : m.list_kit_kanban_collapseAllSubGroups()
699
+ }
700
+ onClick={() =>
701
+ setSubGroupsCollapsed(subCollapseKeys, !allSubGroupsCollapsed)
702
+ }
703
+ className="text-muted-foreground opacity-0 transition-opacity group-hover/kanban-column:opacity-100 focus-visible:opacity-100"
704
+ >
705
+ {allSubGroupsCollapsed ? (
706
+ <ChevronsUpDown className="size-4" />
707
+ ) : (
708
+ <ChevronsDownUp className="size-4" />
709
+ )}
710
+ </Button>
711
+ )}
712
+ {columnDrag && (
713
+ <KanbanColumnHandle
714
+ aria-label={m.list_kit_kanban_columnHandle()}
715
+ className="rounded-md p-1 text-muted-foreground"
716
+ >
717
+ <GripVertical className="size-4" />
718
+ </KanbanColumnHandle>
719
+ )}
720
+ </div>
721
+ </CardHeader>
722
+ {/* px-0 抵消 CardContent 基础类的 px-(--card-spacing)=24px:
723
+ 列内 padding 统一由滚动容器 p-2 提供,卡片宽度与虚拟化前一致(272px) */}
724
+ <CardContent className="min-h-0 flex-1 px-0">
725
+ {/* 滚动容器下移到 ColumnItems(列内虚拟化需要一个由 virtualizer 掌握的滚动元素) */}
726
+ <KanbanColumnContent
727
+ value={columnKey}
728
+ className="min-h-8 gap-2"
729
+ >
730
+ <ColumnItems
731
+ items={items}
732
+ storyMap={storyMap}
733
+ columnKey={columnKey}
734
+ membership={membership}
735
+ hasSubGroups={hasSubGroups}
736
+ tint={tint}
737
+ subMeta={subMeta}
738
+ dimension={dimension}
739
+ collapsedSubGroups={collapsedSubGroups}
740
+ onToggleSubGroup={toggleSubGroup}
741
+ renderCard={renderCard}
742
+ renderGhost={renderGhost}
743
+ onOpenItem={onOpenItem}
744
+ />
745
+ </KanbanColumnContent>
746
+ {onLoadMoreColumn && (
747
+ <div className="px-2 pb-2">
748
+ <Button
749
+ type="button"
750
+ variant="ghost"
751
+ size="sm"
752
+ className="w-full text-muted-foreground"
753
+ onClick={() => onLoadMoreColumn(columnKey)}
754
+ >
755
+ {m.list_kit_kanban_loadMore()}
756
+ </Button>
757
+ </div>
758
+ )}
759
+ </CardContent>
760
+ </Card>
761
+ </KanbanColumn>
762
+ );
763
+ })}
764
+ </KanbanBoard>
765
+ <KanbanOverlay>
766
+ {({ value, variant }) => {
767
+ const story = storyMap[String(value)];
768
+ if (variant !== "item" || !story) return null;
769
+ return (
770
+ <KanbanItem value={itemKey(story)} className="w-72">
771
+ <OpenableCard
772
+ item={story}
773
+ renderCard={renderCard}
774
+ onOpenItem={onOpenItem}
775
+ />
776
+ </KanbanItem>
777
+ );
778
+ }}
779
+ </KanbanOverlay>
780
+ </Kanban>
781
+ );
782
+ }
783
+
784
+ /* ------------------------------------------------------------------ */
785
+ /* 卡片包装:点击打开(蓝本 TaskCard 的 onClick 口径) */
786
+ /* ------------------------------------------------------------------ */
787
+
788
+ function OpenableCard<T extends { id: string }>({
789
+ item,
790
+ renderCard,
791
+ onOpenItem,
792
+ }: {
793
+ item: T;
794
+ renderCard: (item: T) => ReactNode;
795
+ onOpenItem: (item: T) => void;
796
+ }) {
797
+ return (
798
+ // 阻止冒泡到视图区 backdrop(消费方的面板关闭语义):先打开新条目再被 backdrop 覆盖
799
+ <div
800
+ className={cn(!!onOpenItem && "cursor-pointer")}
801
+ onClick={
802
+ onOpenItem
803
+ ? (e) => {
804
+ e.stopPropagation();
805
+ onOpenItem(item);
806
+ }
807
+ : undefined
808
+ }
809
+ >
810
+ {renderCard(item)}
811
+ </div>
812
+ );
813
+ }
814
+
815
+ /* ------------------------------------------------------------------ */
816
+ /* 列内卡片列表(蓝本 ColumnItems 逐段搬移,泛型化) */
817
+ /* ------------------------------------------------------------------ */
818
+
819
+ /** 列内扁平行:子分组标题 / 卡片(虚拟化的 item) */
820
+ type ColumnRow<T extends { id: string }> =
821
+ | {
822
+ kind: "header";
823
+ key: string;
824
+ runKey: string;
825
+ label: string;
826
+ count: number;
827
+ collapse: string;
828
+ isCollapsed: boolean;
829
+ icon?: KanbanColumnIcon;
830
+ color?: string;
831
+ }
832
+ | { kind: "card"; key: string; story: KanbanStoryItem<T> };
833
+
834
+ interface ColumnItemsProps<T extends { id: string }> {
835
+ items: KanbanStoryItem<T>[];
836
+ /** item key → 条目(跨列拖拽时落点渲染淡化卡需要取被拖卡数据) */
837
+ storyMap: Record<string, T>;
838
+ columnKey: string;
839
+ membership: Record<string, string>;
840
+ hasSubGroups: boolean;
841
+ /** 列底色(与列 Card 同源):吸附子分组头必须用同色实底,否则滚动内容会从半透明底透出 */
842
+ tint: ColumnTint;
843
+ subMeta: Record<string, KanbanSubGroupMeta>;
844
+ dimension: string;
845
+ collapsedSubGroups: ReadonlySet<string>;
846
+ onToggleSubGroup: (key: string) => void;
847
+ renderCard: (item: T) => ReactNode;
848
+ renderGhost: (item: T) => ReactNode;
849
+ onOpenItem: (item: T) => void;
850
+ }
851
+
852
+ /**
853
+ * 列内卡片列表。拖拽期只有源/目标列的 items 引用变化(见 applyInstanceKeys 的列级引用保持),
854
+ * 其余列在此 bailout——否则每次 onDragOver 都会重建整板 200 张卡的元素并触发重新渲染。
855
+ */
856
+ function ColumnItemsImpl<T extends { id: string }>({
857
+ items,
858
+ storyMap,
859
+ columnKey,
860
+ membership,
861
+ hasSubGroups,
862
+ tint,
863
+ subMeta,
864
+ dimension,
865
+ collapsedSubGroups,
866
+ onToggleSubGroup,
867
+ renderCard,
868
+ renderGhost,
869
+ onOpenItem,
870
+ }: ColumnItemsProps<T>) {
871
+ const surface = columnSurface(tint);
872
+ const runs = useMemo(
873
+ () => buildRuns(items, membership, hasSubGroups),
874
+ [items, membership, hasSubGroups],
875
+ );
876
+ const drop = useKanbanDropIndicator(columnKey);
877
+ const ghostItem = drop?.itemId ? storyMap[drop.itemId] : undefined;
878
+
879
+ // 扁平行表(子分组标题 + 卡片):列内虚拟化的数据源。
880
+ // 看板此前每列全量渲染(夹具 200 卡 → 205 个 droppable),dnd-kit 拖拽起手要逐个测量,
881
+ // 实测每次起手 ~130ms(生产);虚拟化后每列只渲染视口内约 15 行,droppable 降到 ~80。
882
+ const rows = useMemo<ColumnRow<T>[]>(() => {
883
+ const out: ColumnRow<T>[] = [];
884
+ for (const run of runs) {
885
+ const sub = hasSubGroups ? subMeta[run.key] : undefined;
886
+ const collapse = collapseKey(columnKey, dimension, run.key);
887
+ const isCollapsed = sub ? collapsedSubGroups.has(collapse) : false;
888
+ if (sub) {
889
+ out.push({
890
+ kind: "header",
891
+ key: `h:${run.key}`,
892
+ runKey: run.key,
893
+ label: sub.label,
894
+ count: run.items.length,
895
+ collapse,
896
+ isCollapsed,
897
+ icon: sub.icon,
898
+ color: sub.color?.split(" ").find((c) => c.startsWith("text")),
899
+ });
900
+ }
901
+ if (isCollapsed) continue;
902
+ for (const story of run.items) {
903
+ out.push({ kind: "card", key: itemKey(story), story });
904
+ }
905
+ }
906
+ return out;
907
+ }, [collapsedSubGroups, columnKey, dimension, hasSubGroups, runs, subMeta]);
908
+
909
+ const scrollRef = useRef<HTMLDivElement>(null);
910
+ // 渐进增强:只有能测出滚动容器高度(真实浏览器)才虚拟化。
911
+ // SSR / jsdom(无布局)保持全量渲染 → 测试与 hydration 都不受影响。
912
+ const [canVirtualize, setCanVirtualize] = useState(false);
913
+ useLayoutEffect(() => {
914
+ const el = scrollRef.current;
915
+ if (el && el.clientHeight > 0) setCanVirtualize(true);
916
+ }, []);
917
+ const rowVirtualizer = useVirtualizer({
918
+ count: rows.length,
919
+ getScrollElement: () => scrollRef.current,
920
+ estimateSize: (index) =>
921
+ rows[index]?.kind === "header" ? HEADER_ROW_ESTIMATE_PX : CARD_ROW_ESTIMATE_PX,
922
+ overscan: COLUMN_ROW_OVERSCAN,
923
+ getItemKey: (index) => rows[index]?.key ?? index,
924
+ // 初始视口尺寸:首帧(以及 jsdom 无布局时)不至于算不出可见行而白屏
925
+ initialRect: { width: 288, height: 800 },
926
+ });
927
+ const virtualRows = rowVirtualizer.getVirtualItems();
928
+
929
+ // 吸附子分组头:列内纵向滚动时,活跃子分组标题吸附在列头(CardHeader)正下方,
930
+ // 并在下一个子分组头逼近时被顶出,与原生 sticky 的滑动观感一致。
931
+ // 普通 sticky 失效(列内行是绝对定位的虚拟化行),故按等价换算渲染一份
932
+ // 「位置 = 吸附位 + offset」的副本;未吸附(尚未滚到列顶)时不渲染,保持自然滑动。
933
+ const subHeaderIndexes = useMemo(
934
+ () =>
935
+ rows.reduce<number[]>(
936
+ (acc, r, i) => (r.kind === "header" ? [...acc, i] : acc),
937
+ [],
938
+ ),
939
+ [rows],
940
+ );
941
+ const [frozenSub, setFrozenSub] = useState<FrozenSubHeader | null>(null);
942
+ const frozenSubRef = useRef<FrozenSubHeader | null>(null);
943
+ const frozenRafRef = useRef<number | null>(null);
944
+
945
+ const recomputeFrozenSubHeader = useCallback(() => {
946
+ frozenRafRef.current = null;
947
+ const el = scrollRef.current;
948
+ if (!el) return;
949
+ const next = computeFrozenSubHeader(
950
+ el.scrollTop,
951
+ rowVirtualizer.measurementsCache,
952
+ subHeaderIndexes,
953
+ );
954
+ const prev = frozenSubRef.current;
955
+ // offset 逐像素变化,两者都相同才跳过提交(避免滚动期无谓重渲)
956
+ if (prev?.index === next?.index && prev?.offset === next?.offset) return;
957
+ frozenSubRef.current = next;
958
+ setFrozenSub(next);
959
+ }, [rowVirtualizer, subHeaderIndexes]);
960
+
961
+ // rAF 节流:滚动事件可能一帧多次触发
962
+ const handleColumnScroll = useCallback(() => {
963
+ if (frozenRafRef.current != null) return;
964
+ frozenRafRef.current = requestAnimationFrame(recomputeFrozenSubHeader);
965
+ }, [recomputeFrozenSubHeader]);
966
+
967
+ // 挂载 / 行测量变化(总高随测量增长)时同步冻结头,保证首屏就有正确值
968
+ useEffect(() => {
969
+ recomputeFrozenSubHeader();
970
+ }, [recomputeFrozenSubHeader, rowVirtualizer.getTotalSize()]);
971
+
972
+ // 未启用子分组 / 未虚拟化(SSR、jsdom 无布局)时不需要吸附层
973
+ useEffect(() => {
974
+ if (hasSubGroups && canVirtualize) return;
975
+ frozenSubRef.current = null;
976
+ setFrozenSub(null);
977
+ }, [hasSubGroups, canVirtualize]);
978
+
979
+ useEffect(() => {
980
+ return () => {
981
+ if (frozenRafRef.current != null) {
982
+ cancelAnimationFrame(frozenRafRef.current);
983
+ }
984
+ };
985
+ }, []);
986
+
987
+ // 拖拽落点:命中本列时算出「哪些卡让位」和「落点槽挂在哪张卡上」。
988
+ // 让位用 transform 实现(不改数据、不插 DOM):插入真实占位元素会造成布局变化,
989
+ // dnd-kit 随即对 droppable 重新测量(拖拽期测量队列已空 → 全量 re-measure),
990
+ // 200 卡下就是卡顿主因;transform 不触发布局与重测,视觉上等价于卡片让位。
991
+ const dropLayoutRows: DropLayoutRow[] = [];
992
+ if (drop) {
993
+ for (const run of runs) {
994
+ const sub = hasSubGroups ? subMeta[run.key] : undefined;
995
+ const isCollapsed = sub
996
+ ? collapsedSubGroups.has(collapseKey(columnKey, dimension, run.key))
997
+ : false;
998
+ // 标题行也进落点行表(key 与渲染行一致:h:<runKey>),让位时与卡片一起下移
999
+ if (sub) dropLayoutRows.push({ key: `h:${run.key}`, kind: "header" });
1000
+ for (const story of run.items) {
1001
+ dropLayoutRows.push({ key: itemKey(story), kind: "card", hidden: isCollapsed });
1002
+ }
1003
+ }
1004
+ }
1005
+ // 落点语义判定:hover 在某分组「最后一张卡的下半区」时,插入位 = 该卡下标 + 1(= 下一分组
1006
+ // 首张卡的下标),但语义是「追加到上一分组末尾」。此时让位与落点槽都要到下一分组的
1007
+ // 标题之上,且卡片归属取 hover 卡的分组,否则会落进下一个分组(小分组尤其明显)。
1008
+ const cardKeys: string[] = [];
1009
+ for (const row of dropLayoutRows) {
1010
+ if (row.kind === "card") cardKeys.push(row.key);
1011
+ }
1012
+ const hoveredCardIndex = drop ? cardKeys.indexOf(drop.overItemKey) : -1;
1013
+ const anchorCardKey = drop ? cardKeys[drop.insertIndex] : undefined;
1014
+ const overItemId = drop?.overItemKey
1015
+ ? storyIdFromInstanceKey(drop.overItemKey)
1016
+ : "";
1017
+ const hoveredSub = overItemId ? membership[overItemId] : undefined;
1018
+ const anchorSub = anchorCardKey
1019
+ ? membership[storyIdFromInstanceKey(anchorCardKey)]
1020
+ : undefined;
1021
+ const appendToPreviousRun =
1022
+ drop != null &&
1023
+ hoveredCardIndex >= 0 &&
1024
+ drop.insertIndex === hoveredCardIndex + 1 &&
1025
+ hoveredSub != null &&
1026
+ anchorSub != null &&
1027
+ hoveredSub !== anchorSub;
1028
+ const {
1029
+ shifts: shiftByKey,
1030
+ slotBefore: slotBeforeKeys,
1031
+ slotAfter: slotAfterKey,
1032
+ slotBeforeHeader: slotBeforeHeaderKey,
1033
+ } = computeDropLayout(dropLayoutRows, drop, drop ? drop.height + COLUMN_ITEM_GAP_PX : 0, {
1034
+ appendToPreviousRun,
1035
+ });
1036
+ // 空列(含拖拽期虚显的空列)没有挂点卡片,落点淡化卡改为渲染在列首
1037
+ const emptyColumnDrop =
1038
+ drop !== null && dropLayoutRows.length === 0 && drop.insertIndex >= 0;
1039
+
1040
+ // 让位 transform 命令式写(不走 React props):跨列切换目标列时一次性要让 ~60 张卡换位,
1041
+ // 走 props 会触发 60 棵子树重渲染(实测单帧 59ms)。这里直接写 style,且批量变更
1042
+ // (进入/离开本列)时临时禁用 transition,避免 60 张卡同时起动画。
1043
+ const dropLayerRef = useRef<HTMLDivElement>(null);
1044
+ const appliedShiftsRef = useRef(new Map<string, number>());
1045
+ const prevTargetRef = useRef<string | null>(null);
1046
+ useLayoutEffect(() => {
1047
+ const layer = dropLayerRef.current;
1048
+ if (!layer) return;
1049
+ const bulk = prevTargetRef.current !== (drop ? columnKey : null);
1050
+ prevTargetRef.current = drop ? columnKey : null;
1051
+
1052
+ const applied = appliedShiftsRef.current;
1053
+ const next = new Map<string, number>();
1054
+ const changed: HTMLElement[] = [];
1055
+ layer
1056
+ .querySelectorAll<HTMLElement>("[data-card-key], [data-shift-key]")
1057
+ .forEach((node) => {
1058
+ // 卡片读 data-card-key;子分组标题读 data-shift-key(标题也要让位,见 computeDropLayout)
1059
+ const key = node.dataset.cardKey ?? node.dataset.shiftKey ?? "";
1060
+ const want = shiftByKey.get(key) ?? 0;
1061
+ if (want) next.set(key, want);
1062
+ if ((applied.get(key) ?? 0) === want) return;
1063
+ if (bulk) {
1064
+ node.style.transition = "none";
1065
+ changed.push(node);
1066
+ }
1067
+ node.style.transform = want ? `translateY(${want}px)` : "";
1068
+ });
1069
+ if (bulk && changed.length > 0) {
1070
+ // 下一帧再恢复 transition:让 transition:none 先被提交,避免 60 张卡同时起动画;
1071
+ // 这里不能用强制 reflow(void layer.offsetHeight)——那是整列布局,实测单帧 50–60ms
1072
+ requestAnimationFrame(() => {
1073
+ changed.forEach((node) => {
1074
+ node.style.transition = "";
1075
+ });
1076
+ });
1077
+ }
1078
+ appliedShiftsRef.current = next;
1079
+ });
1080
+
1081
+ // 子分组标题按钮:列内行渲染与冻结层共用(保证两者视觉与交互完全一致)
1082
+ const renderSubHeaderButton = (
1083
+ row: Extract<ColumnRow<T>, { kind: "header" }>,
1084
+ ) => (
1085
+ <Button
1086
+ type="button"
1087
+ variant="ghost"
1088
+ size="xs"
1089
+ aria-expanded={!row.isCollapsed}
1090
+ onClick={() => onToggleSubGroup(row.collapse)}
1091
+ className="h-auto w-full justify-between gap-1.5 px-1 py-1 text-xs font-medium text-muted-foreground aria-expanded:bg-transparent hover:bg-accent/50"
1092
+ >
1093
+ <span className="flex min-w-0 items-center gap-1.5">
1094
+ <ChevronRight
1095
+ className={cn(
1096
+ "size-3.5 shrink-0 transition-transform",
1097
+ !row.isCollapsed && "rotate-90",
1098
+ )}
1099
+ aria-hidden
1100
+ />
1101
+ {row.icon && <row.icon className={cn("size-3.5 shrink-0", row.color)} />}
1102
+ <span className="min-w-0 truncate">{row.label}</span>
1103
+ </span>
1104
+ <Badge
1105
+ variant="secondary"
1106
+ className="h-5 shrink-0 rounded-sm border-border px-1.5 text-xs"
1107
+ >
1108
+ {row.count}
1109
+ </Badge>
1110
+ </Button>
1111
+ );
1112
+
1113
+ const renderRow = (row: ColumnRow<T> | undefined) => {
1114
+ if (!row) return null;
1115
+ if (row.kind === "header") {
1116
+ // 包一层让位容器:拖拽落点在下半区时标题与卡片一起下移(否则卡片会压住标题)
1117
+ const headerSlot = row.key === slotBeforeHeaderKey;
1118
+ return (
1119
+ <div data-shift-key={row.key} className={cn(headerSlot && "relative")}>
1120
+ {headerSlot && ghostItem && (
1121
+ <div
1122
+ aria-hidden
1123
+ data-slot="kanban-drop-slot"
1124
+ className="pointer-events-none absolute inset-x-0 opacity-50"
1125
+ style={{
1126
+ height: drop?.height,
1127
+ bottom: "100%",
1128
+ marginBottom: COLUMN_ITEM_GAP_PX,
1129
+ }}
1130
+ >
1131
+ {renderGhost(ghostItem)}
1132
+ </div>
1133
+ )}
1134
+ {renderSubHeaderButton(row)}
1135
+ </div>
1136
+ );
1137
+ }
1138
+ const slotValue = slotBeforeKeys.has(row.key)
1139
+ ? "before"
1140
+ : slotAfterKey === row.key
1141
+ ? "after"
1142
+ : null;
1143
+ return (
1144
+ // pb-2 = 原 KanbanColumnContent 的 gap-2(行绝对定位后 flex gap 失效,改行内间距)
1145
+ <div className="pb-2">
1146
+ <CardSlot
1147
+ story={row.story}
1148
+ slot={slotValue}
1149
+ // 只给真正渲染落点的那 1-2 张卡传 ghostItem:进/出目标列时若整列
1150
+ // 都收到它,几十张卡的 prop 同时变化 → 大量子树重渲染(实测单帧 59ms)
1151
+ ghostItem={slotValue ? ghostItem : undefined}
1152
+ slotHeight={drop?.height ?? 0}
1153
+ renderCard={renderCard}
1154
+ renderGhost={renderGhost}
1155
+ onOpenItem={onOpenItem}
1156
+ />
1157
+ </div>
1158
+ );
1159
+ };
1160
+
1161
+ // 吸附层数据:仅当列内启用了子分组、且已虚拟化(真实浏览器)时生效。
1162
+ // 拖拽期隐藏,避免与落点淡化卡/让位层叠冲突。
1163
+ const frozenRow =
1164
+ !drop && frozenSub != null ? rows[frozenSub.index] : undefined;
1165
+ const frozenHeaderRow =
1166
+ frozenRow?.kind === "header" ? frozenRow : undefined;
1167
+
1168
+ return (
1169
+ <div className="relative min-h-0 flex-1">
1170
+ <div
1171
+ ref={scrollRef}
1172
+ onScroll={handleColumnScroll}
1173
+ className="h-full overflow-y-auto p-2"
1174
+ >
1175
+ <div
1176
+ ref={dropLayerRef}
1177
+ className="relative w-full"
1178
+ style={{ height: canVirtualize ? rowVirtualizer.getTotalSize() : undefined }}
1179
+ >
1180
+ {emptyColumnDrop && ghostItem && (
1181
+ <div
1182
+ aria-hidden
1183
+ data-slot="kanban-drop-slot"
1184
+ className="pointer-events-none absolute inset-x-0 top-0 opacity-50"
1185
+ style={{ height: drop?.height }}
1186
+ >
1187
+ {renderGhost(ghostItem)}
1188
+ </div>
1189
+ )}
1190
+ {canVirtualize
1191
+ ? virtualRows.map((vRow) => (
1192
+ <div
1193
+ key={vRow.key}
1194
+ data-index={vRow.index}
1195
+ ref={rowVirtualizer.measureElement}
1196
+ className="absolute top-0 left-0 w-full"
1197
+ style={{ transform: `translateY(${vRow.start}px)` }}
1198
+ >
1199
+ {renderRow(rows[vRow.index])}
1200
+ </div>
1201
+ ))
1202
+ : rows.map((row) => (
1203
+ <div key={row.key} className="w-full">
1204
+ {renderRow(row)}
1205
+ </div>
1206
+ ))}
1207
+ </div>
1208
+ </div>
1209
+ {/* 吸附子分组头:与在流子分组头同款渲染,吸附在列内**滚动容器顶边**
1210
+ (= 列头 CardHeader 正下方),并被下一个子分组头顶出。
1211
+ 外层 overflow-hidden 负责在顶出阶段裁掉超出容器上缘的部分。
1212
+ 底色(columnSurface,与列 Card 同源)挂在**满宽层**上,恰好覆盖组头行自身
1213
+ 的盒子(inset-x-0 + 无纵向内边距):内层只需 px-2 与滚动容器的 p-2 横向对齐,
1214
+ 若底色跟着内边距走,左右/上会留 8px 透明缝隙漏出下层卡片。
1215
+ 纵向**不留任何内边距**:吸附位由 computeFrozenSubHeader 锚在容器顶边
1216
+ (SUB_HEADER_STICKY_PAD 抵消内容盒 p-2 偏移),组头越过容器顶边前本层根本不渲染
1217
+ ——上滑由在流组头自己完成,故上方无需遮挡带;多留内边距会把紧邻首卡顶部一起盖住。 */}
1218
+ {canVirtualize && frozenHeaderRow && frozenSub && (
1219
+ <div
1220
+ data-slot="kanban-frozen-sub-header"
1221
+ className="pointer-events-none absolute inset-0 z-10 overflow-hidden"
1222
+ >
1223
+ <div
1224
+ className={cn("absolute inset-x-0 top-0", surface.className)}
1225
+ style={{ ...surface.style, transform: `translateY(${frozenSub.offset}px)` }}
1226
+ >
1227
+ <div className="pointer-events-auto px-2">
1228
+ {renderSubHeaderButton(frozenHeaderRow)}
1229
+ </div>
1230
+ </div>
1231
+ </div>
1232
+ )}
1233
+ </div>
1234
+ );
1235
+ }
1236
+
1237
+ const ColumnItems = memo(ColumnItemsImpl) as typeof ColumnItemsImpl;
1238
+
1239
+ /* ------------------------------------------------------------------ */
1240
+ /* 单张卡(含落点让位与落点槽)——蓝本 CardSlot 逐段搬移 */
1241
+ /* ------------------------------------------------------------------ */
1242
+
1243
+ interface CardSlotProps<T extends { id: string }> {
1244
+ story: KanbanStoryItem<T>;
1245
+ slot: "before" | "after" | null;
1246
+ /** 落点预览用的淡化卡数据(= 被拖卡片) */
1247
+ ghostItem?: T;
1248
+ slotHeight: number;
1249
+ renderCard: (item: T) => ReactNode;
1250
+ renderGhost: (item: T) => ReactNode;
1251
+ onOpenItem: (item: T) => void;
1252
+ }
1253
+
1254
+ /**
1255
+ * 单张卡(含落点让位与落点槽)。
1256
+ *
1257
+ * 必须独立 memo:落点每次变化只影响「让位边界」那 1-2 张卡,但落点 store 一变
1258
+ * 会让 ColumnItems 整个重渲染;若卡片直接在 ColumnItems 里就地渲染,每次落点变化
1259
+ * 都要重渲染整列(实测 62 卡列 ≈14ms/次 → 拖拽掉到 30fps)。memo 后只有
1260
+ * shift / slot 真正变化的卡片参与重渲染。
1261
+ */
1262
+ function CardSlotImpl<T extends { id: string }>({
1263
+ story,
1264
+ slot,
1265
+ ghostItem,
1266
+ slotHeight,
1267
+ renderCard,
1268
+ renderGhost,
1269
+ onOpenItem,
1270
+ }: CardSlotProps<T>) {
1271
+ return (
1272
+ <div
1273
+ // 让位位移与落点槽都在可滚动列内变更:不给卡片独立合成层时,任何一次变更
1274
+ // 都会让整片可见卡片重绘(实测列内变更 ~14ms/次 → 拖拽掉到 30fps)。
1275
+ // will-change 提升为合成层后变更只走合成,实测回到无指示器基线(中位 8ms/帧)。
1276
+ //
1277
+ // 让位 transform 不在 React 里写(见 ColumnItems 的 useLayoutEffect):跨列时
1278
+ // 一次性要让 ~60 张卡换位,走 props 会触发 60 棵子树重渲染(实测单帧 59ms);
1279
+ // 命令式直接改 style 只花几十次样式写入,且 memo 全 bail。
1280
+ className="relative transition-transform duration-150 ease-out will-change-transform"
1281
+ data-card-key={itemKey(story)}
1282
+ >
1283
+ {slot && (
1284
+ <div
1285
+ aria-hidden
1286
+ data-slot="kanban-drop-slot"
1287
+ className="pointer-events-none absolute inset-x-0"
1288
+ style={
1289
+ slot === "before"
1290
+ ? { bottom: "100%", marginBottom: COLUMN_ITEM_GAP_PX }
1291
+ : { top: "100%", marginTop: COLUMN_ITEM_GAP_PX }
1292
+ }
1293
+ >
1294
+ {ghostItem ? (
1295
+ // 落点预览 = 被拖卡片的淡化副本(与列内拖拽时源卡 opacity-50 观感一致,
1296
+ // 对齐官方 demo 的「淡化卡落在目标位」效果,而非只画一个虚线边框)
1297
+ <div className="opacity-50">{renderGhost(ghostItem)}</div>
1298
+ ) : (
1299
+ <div
1300
+ className="rounded-md border-2 border-dashed border-border/70 bg-muted/20"
1301
+ style={{ height: slotHeight }}
1302
+ />
1303
+ )}
1304
+ </div>
1305
+ )}
1306
+ <KanbanItem value={itemKey(story)}>
1307
+ <KanbanItemHandle>
1308
+ <OpenableCard item={story} renderCard={renderCard} onOpenItem={onOpenItem} />
1309
+ </KanbanItemHandle>
1310
+ </KanbanItem>
1311
+ </div>
1312
+ );
1313
+ }
1314
+
1315
+ const CardSlot = memo(CardSlotImpl) as typeof CardSlotImpl;
1316
+
1317
+ /* ------------------------------------------------------------------ */
1318
+ /* 空列(拖拽期虚显的 ghost 列)——蓝本 ghost 分支 + GhostColumnDrop */
1319
+ /* ------------------------------------------------------------------ */
1320
+
1321
+ interface GhostColumnDropProps<T extends { id: string }> {
1322
+ columnKey: string;
1323
+ storyMap: Record<string, T>;
1324
+ renderGhost: (item: T) => ReactNode;
1325
+ }
1326
+
1327
+ /**
1328
+ * 空列(拖拽期虚显的 ghost 列)里的落点淡化卡。
1329
+ *
1330
+ * 空列渲染的是虚线壳、不含 ColumnItems,因此不会自动订阅落点指示 —— 不单独订阅的话,
1331
+ * 拖到空列时只看到一个空虚线框(用户反馈的「空看板拖动没有淡化卡」)。
1332
+ */
1333
+ function GhostColumnDropImpl<T extends { id: string }>({
1334
+ columnKey,
1335
+ storyMap,
1336
+ renderGhost,
1337
+ }: GhostColumnDropProps<T>) {
1338
+ const drop = useKanbanDropIndicator(columnKey);
1339
+ const story = drop?.itemId ? storyMap[drop.itemId] : undefined;
1340
+ if (!drop || !story) return null;
1341
+ return (
1342
+ <div
1343
+ aria-hidden
1344
+ data-slot="kanban-drop-slot"
1345
+ className="pointer-events-none opacity-50"
1346
+ style={{ height: drop.height }}
1347
+ >
1348
+ {renderGhost(story)}
1349
+ </div>
1350
+ );
1351
+ }
1352
+
1353
+ const GhostColumnDrop = memo(GhostColumnDropImpl) as typeof GhostColumnDropImpl;
1354
+
1355
+ interface KanbanGhostColumnProps<T extends { id: string }> {
1356
+ columnKey: string;
1357
+ meta?: KanbanColumnMeta;
1358
+ isOver: boolean;
1359
+ storyMap: Record<string, T>;
1360
+ renderGhost: (item: T) => ReactNode;
1361
+ }
1362
+
1363
+ /** 拖拽期虚显的空列:虚线壳 + 列头(0 计数)+ 落点淡化卡订阅 */
1364
+ function KanbanGhostColumnImpl<T extends { id: string }>({
1365
+ columnKey,
1366
+ meta,
1367
+ isOver,
1368
+ storyMap,
1369
+ renderGhost,
1370
+ }: KanbanGhostColumnProps<T>) {
1371
+ const Icon = meta?.icon;
1372
+ const iconColor = meta?.iconClassName ?? "text-muted-foreground";
1373
+ return (
1374
+ <KanbanColumn value={columnKey} className="h-full w-72 shrink-0">
1375
+ <div
1376
+ className={cn(
1377
+ "flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-dashed border-muted-foreground/40 bg-muted/20 opacity-70",
1378
+ isOver && "border-primary opacity-100",
1379
+ )}
1380
+ >
1381
+ <div
1382
+ className={cn(
1383
+ "flex shrink-0 items-center gap-2",
1384
+ COLUMN_HEADER_SPACING,
1385
+ )}
1386
+ >
1387
+ {Icon && <Icon className={cn("size-4 shrink-0", iconColor)} />}
1388
+ <span className="truncate text-sm font-semibold">
1389
+ {meta?.label ?? columnKey}
1390
+ </span>
1391
+ <Badge
1392
+ variant="secondary"
1393
+ className="h-5 shrink-0 rounded-sm border-border px-1.5 text-xs"
1394
+ >
1395
+ 0
1396
+ </Badge>
1397
+ </div>
1398
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
1399
+ <KanbanColumnContent value={columnKey} className="min-h-8 gap-2">
1400
+ <GhostColumnDrop
1401
+ columnKey={columnKey}
1402
+ storyMap={storyMap}
1403
+ renderGhost={renderGhost}
1404
+ />
1405
+ </KanbanColumnContent>
1406
+ </div>
1407
+ </div>
1408
+ </KanbanColumn>
1409
+ );
1410
+ }
1411
+
1412
+ export const KanbanGhostColumn = memo(
1413
+ KanbanGhostColumnImpl,
1414
+ ) as typeof KanbanGhostColumnImpl;