@allbluecn/web-app 0.5.1 → 0.6.1

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 (125) hide show
  1. package/package.json +230 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -15,7 +15,7 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { GitPullRequestArrow, CircleFadingArrowUp, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, ListTree, ArrowLeftRight, Link2, Paperclip, BookOpen, type LucideIcon } from "lucide-react";
18
+ import { GitPullRequestArrow, CircleFadingArrowUp, Repeat, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, ListTree, ArrowLeftRight, ArrowDownToDot, Link2, Paperclip, BookOpen, Unlink, Trash2, BookX, FileX, ArrowRightFromLine, ArrowLeftToLine, ArrowUpFromDot, type LucideIcon } from "lucide-react";
19
19
  import type { DependencyEdgeType, DependencyGroup } from "./types";
20
20
 
21
21
  export type StoryState = "backlog" | "todo" | "in_progress" | "done" | "cancelled";
@@ -103,9 +103,17 @@ export const ACTIVITY_LABEL: Record<string, string> = {
103
103
  parent_removed: "activity.parent_removed",
104
104
  child_added: "activity.child_added",
105
105
  dependency_added: "activity.dependency_added",
106
+ dependency_removed: "activity.dependency_removed",
106
107
  link_added: "activity.link_added",
108
+ link_updated: "activity.link_updated",
109
+ link_removed: "activity.link_removed",
107
110
  attachment_added: "activity.attachment_added",
111
+ attachment_updated: "activity.attachment_updated",
112
+ attachment_removed: "activity.attachment_removed",
108
113
  kb_linked: "activity.kb_linked",
114
+ kb_unlinked: "activity.kb_unlinked",
115
+ prd_unlinked: "activity.prd_unlinked",
116
+ iteration_changed: "activity.iteration_changed",
109
117
  };
110
118
 
111
119
  export const ACTIVITY_ICON: Record<string, LucideIcon> = {
@@ -126,9 +134,17 @@ startDate_changed: CalendarPlus2,
126
134
  parent_removed: GitPullRequestArrow,
127
135
  child_added: ListTree,
128
136
  dependency_added: ArrowLeftRight,
137
+ dependency_removed: Unlink,
129
138
  link_added: Link2,
139
+ link_updated: Link2,
140
+ link_removed: Unlink,
130
141
  attachment_added: Paperclip,
142
+ attachment_updated: FilePenLine,
143
+ attachment_removed: Trash2,
131
144
  kb_linked: BookOpen,
145
+ kb_unlinked: BookX,
146
+ prd_unlinked: FileX,
147
+ iteration_changed: Repeat,
132
148
  };
133
149
 
134
150
  // ====== 依赖关系常量(对齐 Plane,新模型) ======
@@ -154,6 +170,10 @@ export interface DependencyTypeOption {
154
170
  edge: DependencyEdgeType;
155
171
  /** 与 group 等价的方向标志:true = 存 (Y, X, edge) */
156
172
  reversed: boolean;
173
+ /** 选择器/行内标签显示的图标(lucide 组件引用) */
174
+ icon: LucideIcon;
175
+ /** 图标旋转角度(deg),每个图标方向语义不同可单独设定;0 或不填不旋转 */
176
+ iconRotation: number;
157
177
  }
158
178
 
159
179
  export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
@@ -165,6 +185,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
165
185
  tooltipLabelKey: "dependency.tooltips.blocked_by",
166
186
  edge: "BLOCKS",
167
187
  reversed: true,
188
+ icon: ArrowRightFromLine,
189
+ iconRotation: 0,
168
190
  },
169
191
  {
170
192
  key: "starts_after",
@@ -174,6 +196,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
174
196
  tooltipLabelKey: "dependency.tooltips.starts_after",
175
197
  edge: "STARTS_BEFORE",
176
198
  reversed: true,
199
+ icon: ArrowUpFromDot,
200
+ iconRotation: 90,
177
201
  },
178
202
  {
179
203
  key: "finishes_after",
@@ -183,6 +207,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
183
207
  tooltipLabelKey: "dependency.tooltips.finishes_after",
184
208
  edge: "FINISHES_BEFORE",
185
209
  reversed: true,
210
+ icon: ArrowLeftToLine,
211
+ iconRotation: 0,
186
212
  },
187
213
  {
188
214
  key: "blocking",
@@ -192,6 +218,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
192
218
  tooltipLabelKey: "dependency.tooltips.blocking",
193
219
  edge: "BLOCKS",
194
220
  reversed: false,
221
+ icon: ArrowRightFromLine,
222
+ iconRotation: 0,
195
223
  },
196
224
  {
197
225
  key: "starts_before",
@@ -201,6 +229,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
201
229
  tooltipLabelKey: "dependency.tooltips.starts_before",
202
230
  edge: "STARTS_BEFORE",
203
231
  reversed: false,
232
+ icon: ArrowUpFromDot,
233
+ iconRotation: 90,
204
234
  },
205
235
  {
206
236
  key: "finishes_before",
@@ -210,6 +240,8 @@ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
210
240
  tooltipLabelKey: "dependency.tooltips.finishes_before",
211
241
  edge: "FINISHES_BEFORE",
212
242
  reversed: false,
243
+ icon: ArrowLeftToLine,
244
+ iconRotation: 0,
213
245
  },
214
246
  ];
215
247
 
@@ -20,7 +20,7 @@ import { CalendarCheck, CalendarPlus2, Sprout } from "lucide-react"
20
20
  import { UserAvatar } from "@allbluecn/ui"
21
21
  import { useForm } from "@tanstack/react-form"
22
22
  import { useTranslation } from "react-i18next"
23
- import { ProjectIconDisplay } from "@/components/project/project-icon"
23
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name"
24
24
  import { Button } from "@/components/ui/button"
25
25
  import { Input } from "@/components/ui/input"
26
26
 
@@ -123,10 +123,12 @@ export function CreateStoryDialog({
123
123
  form.setFieldValue("startDate", initialStartDate ?? "")
124
124
  form.setFieldValue("targetDate", "")
125
125
  form.setFieldValue("labelIds", [])
126
- if (initialProjectId) {
127
- form.setFieldValue("projectId", initialProjectId)
128
- } else {
129
- ensureBuiltinProjectFn().then((p) => form.setFieldValue("projectId", p.id)).catch(() => {})
126
+ if (!continuousCreate) {
127
+ if (initialProjectId) {
128
+ form.setFieldValue("projectId", initialProjectId)
129
+ } else {
130
+ ensureBuiltinProjectFn().then((p) => form.setFieldValue("projectId", p.id)).catch(() => {})
131
+ }
130
132
  }
131
133
  setDescription({ html: "", markdown: "" })
132
134
  setIsEditorFocused(false)
@@ -176,8 +178,26 @@ export function CreateStoryDialog({
176
178
  labelIds: values.labelIds,
177
179
  },
178
180
  })
179
- form.reset()
180
- setDescription({ html: "", markdown: "" })
181
+ if (tiptapEditorRef.current) {
182
+ tiptapEditorRef.current.commands.clearContent()
183
+ }
184
+ if (continuousCreate) {
185
+ const currentProjectId = form.state.values.projectId
186
+ form.setFieldValue("name", "")
187
+ setDescription({ html: "", markdown: "" })
188
+ form.setFieldValue("state", initialState ?? "backlog")
189
+ form.setFieldValue("priority", "none")
190
+ form.setFieldValue("assigneeId", "none")
191
+ form.setFieldValue("startDate", initialStartDate ?? "")
192
+ form.setFieldValue("targetDate", "")
193
+ form.setFieldValue("labelIds", [])
194
+ if (currentProjectId) {
195
+ form.setFieldValue("projectId", currentProjectId)
196
+ }
197
+ } else {
198
+ form.reset()
199
+ setDescription({ html: "", markdown: "" })
200
+ }
181
201
  if (!continuousCreate) onOpenChange(false)
182
202
  onSuccess?.(
183
203
  {
@@ -220,7 +240,7 @@ export function CreateStoryDialog({
220
240
  className="h-8 gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
221
241
  >
222
242
  {selectedProject ? (
223
- <ProjectIconDisplay name={selectedProject.icon} size={16} />
243
+ <LucideIconByName name={selectedProject.icon} size={16} />
224
244
  ) : (
225
245
  <Sprout className="size-4 text-muted-foreground" />
226
246
  )}
@@ -236,7 +256,7 @@ export function CreateStoryDialog({
236
256
  className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${field.value === p.id ? "bg-accent font-medium" : "hover:bg-muted"}`}
237
257
  >
238
258
  <span className="flex min-w-0 items-center gap-2">
239
- <ProjectIconDisplay name={p.icon} size={16} />
259
+ <LucideIconByName name={p.icon} size={16} />
240
260
  <span className="truncate">{p.name}</span>
241
261
  </span>
242
262
  {p.displayId && (
@@ -324,7 +344,7 @@ export function CreateStoryDialog({
324
344
  <DateEditor value={field.value || null} onChange={(v) => field.handleChange(v ?? "")} label={t("create.dueDate")} icon={CalendarCheck} />
325
345
  )} />
326
346
  <form.Field name="labelIds" children={(field) => (
327
- <LabelsEditor labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" />
347
+ <LabelsEditor labelIds={field.value as string[]} labels={labels} onChange={(v) => field.handleChange(v as string[])} variant="button" aiContext={{ name: () => ((form.state.values.name as string) ?? "").trim() }} />
328
348
  )} />
329
349
  </div>
330
350
 
@@ -0,0 +1,62 @@
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
+ import { describe, it, expect } from "vitest";
19
+ import { render, screen } from "@testing-library/react";
20
+ import { TooltipProvider } from "@/components/ui/tooltip";
21
+ import { StoryDetailCategories } from "../story-detail-toolbar/categories";
22
+
23
+ vi.mock("react-i18next", () => ({
24
+ useTranslation: () => ({ t: (key: string) => key }),
25
+ }));
26
+
27
+ const renderWithProviders = (ui: React.ReactElement) =>
28
+ render(<TooltipProvider>{ui}</TooltipProvider>);
29
+
30
+ describe("StoryDetailCategories", () => {
31
+ it("渲染 6 个 category button", () => {
32
+ renderWithProviders(<StoryDetailCategories />);
33
+ const buttons = screen.getAllByRole("button");
34
+ expect(buttons).toHaveLength(6);
35
+ });
36
+
37
+ it("渲染 6 个 category 标签", () => {
38
+ renderWithProviders(<StoryDetailCategories />);
39
+ expect(screen.getByText("categories.prd.label")).toBeTruthy();
40
+ expect(screen.getByText("categories.ui.label")).toBeTruthy();
41
+ expect(screen.getByText("categories.bug.label")).toBeTruthy();
42
+ expect(screen.getByText("categories.testCase.label")).toBeTruthy();
43
+ expect(screen.getByText("categories.acceptance.label")).toBeTruthy();
44
+ expect(screen.getByText("categories.retro.label")).toBeTruthy();
45
+ });
46
+
47
+ it("bug 和 test-case 之间有一个分隔线", () => {
48
+ renderWithProviders(<StoryDetailCategories />);
49
+ const spans = document.querySelectorAll("span");
50
+ const separator = Array.from(spans).find((s) =>
51
+ s.className.includes("w-px") && s.className.includes("h-4"),
52
+ );
53
+ expect(separator).toBeTruthy();
54
+ });
55
+
56
+ it("bug 前后各有一个 button", () => {
57
+ renderWithProviders(<StoryDetailCategories />);
58
+ const bugBtn = screen.getByText("categories.bug.label")
59
+ .closest("button")!;
60
+ expect(bugBtn).toBeTruthy();
61
+ });
62
+ });
@@ -0,0 +1,49 @@
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
+ import { describe, it, expect, vi } from "vitest";
19
+ import { render, screen, fireEvent } from "@testing-library/react";
20
+ import { TooltipProvider } from "@/components/ui/tooltip";
21
+ import { StoryDetailDependencies } from "../story-detail-toolbar/dependencies";
22
+
23
+ vi.mock("react-i18next", () => ({
24
+ useTranslation: () => ({ t: (key: string) => key }),
25
+ }));
26
+
27
+ const renderWithProviders = (ui: React.ReactElement) =>
28
+ render(<TooltipProvider>{ui}</TooltipProvider>);
29
+
30
+ describe("StoryDetailDependencies", () => {
31
+ it("渲染 button,包含 Waypoints 图标和文本", () => {
32
+ renderWithProviders(
33
+ <StoryDetailDependencies onOpenAdd={vi.fn()} />,
34
+ );
35
+ const btn = screen.getByRole("button");
36
+ expect(btn).toBeTruthy();
37
+ expect(screen.getByText("relations.addDependency")).toBeTruthy();
38
+ expect(btn).toHaveAttribute("title", "relations.dependencyTitle");
39
+ });
40
+
41
+ it("点击 button 触发 onOpenAdd", () => {
42
+ const onOpenAdd = vi.fn();
43
+ renderWithProviders(
44
+ <StoryDetailDependencies onOpenAdd={onOpenAdd} />,
45
+ );
46
+ fireEvent.click(screen.getByRole("button"));
47
+ expect(onOpenAdd).toHaveBeenCalledTimes(1);
48
+ });
49
+ });
@@ -30,11 +30,11 @@ import {
30
30
  type StoryState,
31
31
  type StoryPriority,
32
32
  } from "@/components/story/constants";
33
- import { ProjectIconDisplay } from "@/components/project/project-icon";
33
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
34
34
  import { listProjectsBriefFn } from "@/server/serverFns/project";
35
35
  import type { ActivityItem, UserBrief } from "@/components/story/types";
36
36
  import { groupActivities, type ActivityGroup } from "@/lib/story/activity-grouper";
37
- import { ArrowUpFromDot, ChevronDown, Dot, Sparkles, Layers, UserRound } from "lucide-react";
37
+ import { ArrowUpFromDot, ArrowRight, ChevronDown, Dot, Sparkles, Layers, UserRound } from "lucide-react";
38
38
  import { sanitize } from "@/lib/sanitize";
39
39
 
40
40
  function getDiffDecorator(
@@ -55,7 +55,7 @@ function getDiffDecorator(
55
55
  return <Icon className="size-3 shrink-0 text-muted-foreground" />;
56
56
  }
57
57
  if (action === "project_transferred") {
58
- return <ProjectIconDisplay name={projectIconName} size={12} className="shrink-0" />;
58
+ return <LucideIconByName name={projectIconName} size={12} className="shrink-0" />;
59
59
  }
60
60
  if (action === "assignee_changed") {
61
61
  // 优先用用户头像,找不到时降级为通用图标
@@ -250,7 +250,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
250
250
  </div>
251
251
  </div>
252
252
  <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex size-5 items-center justify-center rounded-full bg-background border">
253
- <ArrowUpFromDot className="size-2.5 text-muted-foreground rotate-90" />
253
+ <ArrowRight className="size-2.5 text-muted-foreground" />
254
254
  </div>
255
255
  <div className="w-1/2 bg-green-50/40 dark:bg-green-950/20 p-2 pl-4">
256
256
  <div className="flex items-center gap-1.5">
@@ -326,9 +326,16 @@ interface ActivityTimelineProps {
326
326
  const OBJECT_ACTIVITY_ACTIONS: Record<string, true> = {
327
327
  child_added: true,
328
328
  dependency_added: true,
329
+ dependency_removed: true,
329
330
  link_added: true,
331
+ link_updated: true,
332
+ link_removed: true,
330
333
  attachment_added: true,
334
+ attachment_updated: true,
335
+ attachment_removed: true,
331
336
  kb_linked: true,
337
+ kb_unlinked: true,
338
+ prd_unlinked: true,
332
339
  };
333
340
 
334
341
  function describeActivity(a: ActivityItem, t: TFunction): string {
@@ -15,7 +15,7 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
18
+ import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import {
21
21
  ChevronsDownUp,
@@ -45,7 +45,7 @@ import { EmojiPicker } from "./emoji-picker";
45
45
  import { StoryAiToolbar } from "../ai/story-ai-toolbar";
46
46
  import { useStoryAiModel } from "../ai/use-story-ai-model";
47
47
  import { StateEditor, PriorityEditor, AssigneeEditor, DateEditor, LabelsEditor, ParentStoryEditor } from "../inline-editors";
48
- import { ProjectIconDisplay } from "@/components/project/project-icon";
48
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
49
49
  import type { Editor } from "@tiptap/react";
50
50
  import { StoryProperties } from "./story-properties";
51
51
  import { StoryDetailToolbar } from "./story-detail-toolbar";
@@ -76,12 +76,15 @@ export function StoryDetailBody({
76
76
  labels,
77
77
  stories,
78
78
  projects,
79
+ iterations,
79
80
  relationsData,
80
81
  onRelationsUpdate,
81
82
  onAppendActivities,
82
83
  onAppendComment,
83
84
  onRemoveComment,
84
85
  onStoryUpdate,
86
+ onNavigateInPanel,
87
+ breadcrumbItems,
85
88
  }: {
86
89
  story: StoryDetail;
87
90
  users: UserBrief[];
@@ -97,8 +100,10 @@ export function StoryDetailBody({
97
100
  labels?: { id: string; name: string; color: string }[];
98
101
  /** 所有需求列表(用于父需求选择) */
99
102
  stories?: { id: string; identifier: string; name: string; state: StoryState; projectId?: string }[];
100
- /** 所有项目列表(用于父需求选择) */
101
- projects?: { id: string; name: string; icon: string; displayId?: string | null }[];
103
+ /** 所有项目列表(用于父需求选择) */
104
+ projects?: { id: string; name: string; icon: string; displayId?: string | null }[];
105
+ /** 迭代列表(用于迭代选择器) */
106
+ iterations?: import("@/server/serverFns/iteration/schemas").IterationSummary[];
102
107
  /** 关联数据(从路由 loader 预加载) */
103
108
  relationsData?: RelationsData;
104
109
  onRelationsUpdate?: (next: RelationsData | ((prev: RelationsData) => RelationsData)) => void;
@@ -106,6 +111,9 @@ export function StoryDetailBody({
106
111
  onAppendComment?: (comment: CommentWithReplies) => void;
107
112
  onRemoveComment?: (commentId: string) => void;
108
113
  onStoryUpdate?: (s: StoryDetail) => void;
114
+ onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
115
+ /** 内嵌面板导航面包屑(在面板内跳转时替代单层 identifier 显示) */
116
+ breadcrumbItems?: Array<{ identifier?: string; projectId?: string; label: string; onClick?: () => void }>;
109
117
  }) {
110
118
  const { t } = useTranslation("story");
111
119
  const [name, setName] = useState(story.name);
@@ -125,6 +133,12 @@ export function StoryDetailBody({
125
133
  const [expanded, setExpanded] = useState(false);
126
134
  const [needsTruncation, setNeedsTruncation] = useState(false);
127
135
  const [isEditorFocused, setIsEditorFocused] = useState(false);
136
+ // 描述是否为空(跟随编辑器 onChange 实时更新)。不能用 descContent.markdown 判断——
137
+ // 编辑会话期间 onChange 只进 pendingDescRef(防抖保存),descContent 不更新,
138
+ // 否则输入文字后失焦仍会显示「点击添加需求描述」占位并与文字重合
139
+ const [isDescEmpty, setIsDescEmpty] = useState(
140
+ !(parsedDesc.markdown ?? "").trim(),
141
+ );
128
142
  const [reactions, setReactions] = useState<ReactionGroup[]>([]);
129
143
  const pendingReactionsRef = useRef<Set<string>>(new Set());
130
144
  useEffect(() => {
@@ -291,6 +305,7 @@ export function StoryDetailBody({
291
305
  // 兜底防抖避免用户长时间不操作也最终落盘(防崩溃丢数据)。
292
306
  const handleDescriptionChange = useCallback((content: { html: string; markdown: string }) => {
293
307
  pendingDescRef.current = content;
308
+ setIsDescEmpty(!content.markdown.trim());
294
309
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
295
310
  saveTimerRef.current = setTimeout(
296
311
  () => flushDescriptionRef.current(),
@@ -317,6 +332,7 @@ export function StoryDetailBody({
317
332
  }
318
333
  const newContent = { html: newMarkdown, markdown: newMarkdown };
319
334
  setDescContent(newContent);
335
+ setIsDescEmpty(!newMarkdown.trim());
320
336
  const json = serializeStoryDescription(newMarkdown, newMarkdown);
321
337
  lastPersistedRef.current = json;
322
338
  updateField("description", json);
@@ -345,38 +361,62 @@ export function StoryDetailBody({
345
361
  <div className="flex items-start justify-between gap-4 px-6">
346
362
  <div className="flex-1 space-y-4">
347
363
  <div className="flex items-center gap-2">
348
- {project && (
349
- <Tooltip>
350
- <TooltipTrigger asChild>
351
- <span className="flex cursor-default items-center">
352
- <ProjectIconDisplay name={project.icon} size={16} className="shrink-0 text-muted-foreground" />
353
- </span>
354
- </TooltipTrigger>
355
- <TooltipContent>
356
- <div className="flex flex-col">
357
- <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{t("detail.belongsTo")}</span>
358
- <span>{project.name}</span>
359
- </div>
360
- </TooltipContent>
361
- </Tooltip>
362
- )}
363
- {identifier && (
364
- <Tooltip>
365
- <TooltipTrigger asChild>
366
- <button
367
- type="button"
368
- onClick={() => {
369
- navigator.clipboard.writeText(identifier)
370
- .then(() => toast.success(t("detail.copied", { identifier })))
371
- .catch(() => toast.error(t("detail.copyFailed")));
372
- }}
373
- className="text-sm font-mono text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
374
- >
375
- {identifier}
376
- </button>
377
- </TooltipTrigger>
378
- <TooltipContent>{t("detail.clickToCopy")}</TooltipContent>
379
- </Tooltip>
364
+ {breadcrumbItems && breadcrumbItems.length > 0 ? (
365
+ <div className="flex items-center gap-1.5 text-sm font-mono text-muted-foreground">
366
+ {breadcrumbItems.map((item, i) => (
367
+ <React.Fragment key={i}>
368
+ {i > 0 && <span className="text-muted-foreground/40 px-1">/</span>}
369
+ <button
370
+ type="button"
371
+ onClick={item.onClick}
372
+ className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
373
+ >
374
+ <LucideIconByName
375
+ name={projects?.find((p) => p.id === item.projectId)?.icon ?? project?.icon}
376
+ size={16}
377
+ className="shrink-0 text-muted-foreground"
378
+ />
379
+ {item.identifier}
380
+ </button>
381
+ </React.Fragment>
382
+ ))}
383
+ </div>
384
+ ) : (
385
+ <>
386
+ {project && (
387
+ <Tooltip>
388
+ <TooltipTrigger asChild>
389
+ <span className="flex cursor-default items-center">
390
+ <LucideIconByName name={project.icon} size={16} className="shrink-0 text-muted-foreground" />
391
+ </span>
392
+ </TooltipTrigger>
393
+ <TooltipContent>
394
+ <div className="flex flex-col">
395
+ <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{t("detail.belongsTo")}</span>
396
+ <span>{project.name}</span>
397
+ </div>
398
+ </TooltipContent>
399
+ </Tooltip>
400
+ )}
401
+ {identifier && (
402
+ <Tooltip>
403
+ <TooltipTrigger asChild>
404
+ <button
405
+ type="button"
406
+ onClick={() => {
407
+ navigator.clipboard.writeText(identifier)
408
+ .then(() => toast.success(t("detail.copied", { identifier })))
409
+ .catch(() => toast.error(t("detail.copyFailed")));
410
+ }}
411
+ className="text-sm font-mono text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
412
+ >
413
+ {identifier}
414
+ </button>
415
+ </TooltipTrigger>
416
+ <TooltipContent>{t("detail.clickToCopy")}</TooltipContent>
417
+ </Tooltip>
418
+ )}
419
+ </>
380
420
  )}
381
421
  </div>
382
422
  {/* {saving && <span className="text-xs text-muted-foreground">保存中...</span>} */}
@@ -389,7 +429,7 @@ export function StoryDetailBody({
389
429
  </div>
390
430
  </div>
391
431
  {metaBar}
392
- <div ref={editorContainerRef} className={`relative notion-full-width${!isEditorFocused && !(descContent.markdown ?? "").trim() ? " story-editor-no-placeholder" : ""}`} style={{ "--notion-content-width": "1fr", "--notion-margin-width": "0px" } as React.CSSProperties} onPointerDown={onEditorPointerDown}>
432
+ <div ref={editorContainerRef} className={`relative notion-full-width${!isEditorFocused && isDescEmpty ? " story-editor-no-placeholder" : ""}`} style={{ "--notion-content-width": "1fr", "--notion-margin-width": "0px" } as React.CSSProperties} onPointerDown={onEditorPointerDown}>
393
433
  <div className={!expanded && needsTruncation ? "max-h-[90px] overflow-hidden" : ""}>
394
434
  <Suspense fallback={
395
435
  <div className="flex flex-col space-y-2 p-3 min-h-24">
@@ -411,7 +451,7 @@ export function StoryDetailBody({
411
451
  />
412
452
  </Suspense>
413
453
  </div>
414
- {!isEditorFocused && !(descContent.markdown ?? "").trim() && (
454
+ {!isEditorFocused && isDescEmpty && (
415
455
  <div className="pointer-events-none absolute top-3 left-6 text-sm text-muted-foreground select-none">
416
456
  {t("create.addDescription")}
417
457
  </div>
@@ -549,6 +589,7 @@ export function StoryDetailBody({
549
589
  users={users}
550
590
  projects={projects?.map((p) => ({ ...p, displayId: p.displayId ?? null }))}
551
591
  labels={labels}
592
+ onNavigateInPanel={onNavigateInPanel}
552
593
  />
553
594
  </div>
554
595
  )}
@@ -556,15 +597,25 @@ export function StoryDetailBody({
556
597
  <StoryProperties
557
598
  story={{
558
599
  id: story.id,
600
+ name: story.name,
601
+ description: story.description,
559
602
  parentId: story.parentId,
560
603
  parent: story.parent,
561
604
  labels: story.labels,
562
605
  projectId: story.projectId,
606
+ iteration: story.iteration ?? null,
563
607
  }}
564
608
  labels={labels}
565
609
  projects={projects}
610
+ iterations={iterations}
566
611
  onParentChange={updateParent}
567
612
  onLabelsChange={updateLabels}
613
+ onIterationChange={(it) => {
614
+ if (onStoryUpdate) {
615
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
616
+ onStoryUpdate({ ...story, iteration: it } as any);
617
+ }
618
+ }}
568
619
  onActivities={onAppendActivities}
569
620
  />
570
621