@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 { useEffect, useState } from "react";
18
+ import { useCallback, useEffect, useMemo, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useHotkey } from "@tanstack/react-hotkeys";
21
21
  import { Skeleton } from "@/components/ui/skeleton";
@@ -33,11 +33,12 @@ import {
33
33
  getLinkedPrdsFn,
34
34
  getLinkedDocsFn,
35
35
  } from "@/server/serverFns/story";
36
+ import { listIterationsFn } from "@/server/serverFns/iteration";
36
37
  import type { StoryDetail, UserBrief, CommentWithReplies, ActivityItem, RelationsData } from "./types";
37
38
  import type { StoryState } from "./constants";
38
39
  import { StoryDetailBody } from "./detail/story-detail-body";
39
40
  import { StoryPanelHeader } from "./story-panel-header";
40
- import type { PeekMode } from "./story-panel-header";
41
+ import type { PeekMode, PeekRelationKind, PeekBreadcrumbItem } from "./story-panel-header";
41
42
 
42
43
  export type { PeekMode };
43
44
 
@@ -70,6 +71,13 @@ interface StoryPeekPanelProps {
70
71
  onTransferred?: () => void;
71
72
  }
72
73
 
74
+ interface PeekLocation {
75
+ storyId: string;
76
+ identifier?: string;
77
+ projectId?: string;
78
+ relationKind: PeekRelationKind;
79
+ }
80
+
73
81
  interface PeekData {
74
82
  story: StoryDetail;
75
83
  comments: CommentWithReplies[];
@@ -77,6 +85,7 @@ interface PeekData {
77
85
  labels: { id: string; name: string; color: string }[];
78
86
  stories: { id: string; identifier: string; name: string; state: StoryState }[];
79
87
  relationsData: RelationsData;
88
+ iterations: import("@/server/serverFns/iteration/schemas").IterationSummary[];
80
89
  }
81
90
 
82
91
  export function StoryPeekPanel({
@@ -100,25 +109,61 @@ export function StoryPeekPanel({
100
109
  const { t } = useTranslation("story");
101
110
  const [data, setData] = useState<PeekData | null>(null);
102
111
  const [error, setError] = useState<string | null>(null);
112
+ const [refreshing, setRefreshing] = useState(false);
113
+ const [history, setHistory] = useState<PeekLocation[]>([]);
114
+ const [rootIdentifier, setRootIdentifier] = useState<string | undefined>();
115
+ const [rootProjectId, setRootProjectId] = useState<string | undefined>();
116
+
117
+ const effectiveStoryId = history.length > 0 ? history[history.length - 1].storyId : storyId;
118
+
119
+ const onNavigateInPanel = useCallback((targetId: string, targetIdentifier?: string, relationKind: PeekRelationKind = "child") => {
120
+ setHistory((prev) => {
121
+ const currentId = prev.length > 0 ? prev[prev.length - 1].storyId : storyId;
122
+ return [...prev, { storyId: targetId, identifier: targetIdentifier, relationKind, parentId: currentId }];
123
+ });
124
+ }, [storyId]);
125
+
126
+ const onBackInPanel = useCallback(() => {
127
+ setHistory((prev) => prev.slice(0, -1));
128
+ }, []);
129
+
130
+ useEffect(() => {
131
+ setHistory([]);
132
+ }, [storyId]);
103
133
 
104
134
  useEffect(() => {
105
135
  let cancelled = false;
106
- setData(null);
107
136
  setError(null);
137
+ // 跳转时保留旧内容(不 setData(null)):同步卸载含 TipTap 等重节点的详情树会
138
+ // 阻塞主线程,导致顶部 MorphIcon 关闭/返回按钮的形变动画掉帧卡顿。
139
+ // 改为半透明遮罩过渡,新数据到达后经 key 变化整体替换。
140
+ setRefreshing(data !== null);
108
141
  (async () => {
109
142
  try {
110
- const story = await getStoryDetailFn({ data: { storyId } });
111
- const [comments, activities, labels, storiesResult, childrenStories, dependencies, links, attachments, prds, docs] = await Promise.all([
112
- listCommentsFn({ data: { storyId } }),
113
- listActivitiesFn({ data: { storyId, take: 50 } }),
143
+ const story = await getStoryDetailFn({ data: { storyId: effectiveStoryId } });
144
+ if (history.length === 0) {
145
+ setRootIdentifier(story.identifier);
146
+ setRootProjectId(story.projectId);
147
+ } else {
148
+ // 回填当前层 projectId:跨项目父子/依赖跳转时,面包屑各级需显示各自项目图标
149
+ setHistory((prev) => {
150
+ const last = prev[prev.length - 1];
151
+ if (!last || last.projectId === story.projectId) return prev;
152
+ return prev.map((h, i) => (i === prev.length - 1 ? { ...h, projectId: story.projectId } : h));
153
+ });
154
+ }
155
+ const [comments, activities, labels, storiesResult, childrenStories, dependencies, links, attachments, prds, docs, iterationsResult] = await Promise.all([
156
+ listCommentsFn({ data: { storyId: effectiveStoryId } }),
157
+ listActivitiesFn({ data: { storyId: effectiveStoryId, take: 50 } }),
114
158
  listLabelsFn(),
115
159
  listStoriesFn({ data: { projectId: story.projectId } }),
116
- listChildStoriesFn({ data: { parentId: storyId } }),
117
- getDependenciesFn({ data: { storyId } }),
118
- getLinksFn({ data: { storyId } }),
119
- getAttachmentsFn({ data: { storyId } }),
120
- getLinkedPrdsFn({ data: { storyId } }),
121
- getLinkedDocsFn({ data: { storyId } }),
160
+ listChildStoriesFn({ data: { parentId: effectiveStoryId } }),
161
+ getDependenciesFn({ data: { storyId: effectiveStoryId } }),
162
+ getLinksFn({ data: { storyId: effectiveStoryId } }),
163
+ getAttachmentsFn({ data: { storyId: effectiveStoryId } }),
164
+ getLinkedPrdsFn({ data: { storyId: effectiveStoryId } }),
165
+ getLinkedDocsFn({ data: { storyId: effectiveStoryId } }),
166
+ story.projectId ? listIterationsFn({ data: { projectId: story.projectId, includeArchived: false } }) : Promise.resolve({ items: [] }),
122
167
  ]);
123
168
  const relationsData: RelationsData = {
124
169
  hierarchy: {
@@ -131,33 +176,82 @@ export function StoryPeekPanel({
131
176
  attachments,
132
177
  knowledge: { prds, docs },
133
178
  };
134
- if (!cancelled) setData({ story, comments, activities: activities.items, labels: labels, stories: storiesResult.items.map((s) => ({ id: s.id, identifier: s.identifier, name: s.name, state: s.state })), relationsData });
179
+ if (!cancelled) {
180
+ setData({ story, comments, activities: activities.items, labels: labels, stories: storiesResult.items.map((s) => ({ id: s.id, identifier: s.identifier, name: s.name, state: s.state })), relationsData, iterations: iterationsResult.items });
181
+ setRefreshing(false);
182
+ }
135
183
  } catch (e) {
136
- if (!cancelled) setError(e instanceof Error ? e.message : t("peek.loadFailed"));
184
+ if (!cancelled) {
185
+ setError(e instanceof Error ? e.message : t("peek.loadFailed"));
186
+ setRefreshing(false);
187
+ }
137
188
  }
138
189
  })();
139
190
  return () => {
140
191
  cancelled = true;
141
192
  };
142
- }, [storyId]);
193
+ }, [effectiveStoryId]);
143
194
 
144
195
  useHotkey("Escape", onClose, {
145
- enabled: mode === "side-peek",
196
+ enabled: mode === "side-peek" && history.length === 0,
146
197
  });
147
198
 
148
199
  const canArchive = data?.story
149
200
  ? (data.story.state === "done" || data.story.state === "cancelled") && !data.story.archived
150
201
  : false;
151
202
 
203
+ const closeType: "collapse" | "close" | "back" =
204
+ history.length > 0
205
+ ? "back"
206
+ : mode === "side-peek"
207
+ ? "collapse"
208
+ : "close";
209
+
210
+ const handleClose = useCallback(() => {
211
+ if (history.length > 0) {
212
+ onBackInPanel();
213
+ } else {
214
+ onClose();
215
+ }
216
+ }, [history.length, onBackInPanel, onClose]);
217
+
218
+ const breadcrumbItems: PeekBreadcrumbItem[] = useMemo(() => {
219
+ const rootId = rootIdentifier;
220
+ const currentId = data?.story.identifier;
221
+ const items: PeekBreadcrumbItem[] = [];
222
+
223
+ if (history.length === 0) {
224
+ return items;
225
+ }
226
+
227
+ const entry = history[history.length - 1];
228
+ const parentLabel = entry.relationKind === "child" ? t("peek.parentStory") : t("peek.currentStory");
229
+ const childLabel = entry.relationKind === "child" ? t("peek.childStory") : t("peek.dependentStory");
230
+
231
+ items.push({
232
+ identifier: history.length >= 2 ? history[history.length - 2].identifier ?? rootId : rootId,
233
+ projectId: history.length >= 2 ? history[history.length - 2].projectId ?? rootProjectId : rootProjectId,
234
+ label: parentLabel,
235
+ onClick: () => onBackInPanel(),
236
+ });
237
+
238
+ items.push({
239
+ identifier: currentId,
240
+ projectId: data?.story.projectId ?? undefined,
241
+ label: childLabel,
242
+ });
243
+
244
+ return items;
245
+ }, [history, rootIdentifier, rootProjectId, data?.story.identifier, data?.story.projectId, t, onBackInPanel]);
246
+
152
247
  const header = (
153
248
  <StoryPanelHeader
154
- closeType={mode === "side-peek" ? "collapse" : "close"}
155
- onClose={onClose}
249
+ closeType={closeType}
250
+ onClose={handleClose}
156
251
  onFullscreen={onFullscreen}
157
252
  mode={mode}
158
253
  onModeChange={onModeChange}
159
254
  storyId={storyId}
160
- identifier={data?.story.identifier}
161
255
  canArchive={canArchive}
162
256
  onPrevious={onPrevious}
163
257
  onNext={onNext}
@@ -172,7 +266,10 @@ export function StoryPeekPanel({
172
266
  );
173
267
 
174
268
  const body = (
175
- <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-8" style={{ scrollbarGutter: "stable" } as React.CSSProperties}>
269
+ <div
270
+ className={`flex min-h-0 flex-1 flex-col overflow-y-auto px-8 transition-opacity duration-200${refreshing ? " pointer-events-none opacity-50" : ""}`}
271
+ style={{ scrollbarGutter: "stable" } as React.CSSProperties}
272
+ >
176
273
 
177
274
  {error && <div className="w-full p-6 text-sm text-destructive">{t("peek.loadFailed")}: {error}</div>}
178
275
  {!error && !data && (
@@ -230,12 +327,15 @@ export function StoryPeekPanel({
230
327
  labels={data.labels}
231
328
  stories={data.stories}
232
329
  projects={projects}
330
+ iterations={data.iterations}
233
331
  relationsData={data.relationsData}
234
332
  onRelationsUpdate={(next) => setData((d) => d ? { ...d, relationsData: typeof next === "function" ? next(d.relationsData) : next } : d)}
235
333
  onAppendActivities={(items) => setData((d) => d ? { ...d, activities: [...d.activities, ...items] } : d)}
236
334
  onAppendComment={(c) => setData((d) => d ? { ...d, comments: [...d.comments, c] } : d)}
237
335
  onRemoveComment={(id) => setData((d) => d ? { ...d, comments: d.comments.filter((c) => c.id !== id) } : d)}
238
336
  onStoryUpdate={(s) => setData((d) => d ? { ...d, story: s } : d)}
337
+ onNavigateInPanel={onNavigateInPanel}
338
+ breadcrumbItems={breadcrumbItems}
239
339
  />
240
340
  )}
241
341
  </div>
@@ -76,7 +76,7 @@ export function LinkManageDialog({
76
76
  setSubmitting(true);
77
77
  try {
78
78
  if (editing) {
79
- await updateLink({
79
+ const updated = await updateLink({
80
80
  data: {
81
81
  linkId: editing.id,
82
82
  url: form.url,
@@ -85,6 +85,7 @@ export function LinkManageDialog({
85
85
  },
86
86
  });
87
87
  toast.success(t("linkDialog.updated"));
88
+ if (updated.activity) onActivity?.(updated.activity);
88
89
  onUpdate(
89
90
  data.map((l) =>
90
91
  l.id === editing.id
@@ -36,11 +36,10 @@ import {
36
36
  unarchiveStoryFn,
37
37
  deleteStoryFn,
38
38
  } from "@/server/serverFns/story";
39
- import { PriorityEditor, StateEditor, AssigneeEditor, DateEditor, LabelsEditor } from "../inline-editors";
39
+ import { PriorityEditor, StateEditor, AssigneeEditor, DateEditor, LabelsEditor, DependencyTypeSelect } from "../inline-editors";
40
40
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
41
41
  import { cn } from "@/lib/utils";
42
- import { ProjectIconDisplay } from "@/components/project/project-icon";
43
- import { Badge } from "@/components/ui/badge";
42
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
44
43
  import { dependencyTypeOption } from "../constants";
45
44
  import type { DisplayProperty } from "../display/types";
46
45
  import type { StoryPatch } from "../display/group-fields";
@@ -50,6 +49,7 @@ import type {
50
49
  DependencyEdgeType,
51
50
  DependencyGroup,
52
51
  } from "../types";
52
+ import type { DependencyTypeOption } from "../constants";
53
53
 
54
54
  /** 行内编辑/归档/删除后回传给面板的补丁载荷(StoryPatch 字段 + 面板控制信号) */
55
55
  export type StoryRelationPatch = StoryPatch & {
@@ -72,8 +72,10 @@ export interface StoryRefRowProps {
72
72
  /** 移除关系:子需求=移除子需求,依赖=移除依赖 */
73
73
  onRemove: (storyId: string) => void;
74
74
  removeMenuLabel: string;
75
- /** 依赖行类型标签:传 depType + group 时行内渲染类型 Badge */
75
+ /** 依赖行类型标签:传 depType + group 时行内渲染可编辑的类型选择器 */
76
76
  dependencyMeta?: { depType: DependencyEdgeType; group: keyof DependencyGroup };
77
+ /** 依赖类型就地编辑回调(依赖列表场景;children 列表不传) */
78
+ onDependencyTypeChange?: (otherStoryId: string, key: DependencyTypeOption["key"]) => void;
77
79
  /** 当前需求编号(tooltip 中以编号替代「我」指代当前需求) */
78
80
  currentIdentifier?: string;
79
81
  }
@@ -92,6 +94,7 @@ export function StoryRefRow({
92
94
  onRemove,
93
95
  removeMenuLabel,
94
96
  dependencyMeta,
97
+ onDependencyTypeChange,
95
98
  currentIdentifier,
96
99
  }: StoryRefRowProps) {
97
100
  const { t } = useTranslation("story");
@@ -190,7 +193,7 @@ export function StoryRefRow({
190
193
  className="shrink-0"
191
194
  style={{ color: "oklch(80.517% 0.20237 152.675 / 0.909)" }}
192
195
  >
193
- <ProjectIconDisplay name={storyProjectInfo.icon} size={14} />
196
+ <LucideIconByName name={storyProjectInfo.icon} size={14} />
194
197
  </span>
195
198
  </TooltipTrigger>
196
199
  <TooltipContent side="top" sideOffset={6} className="flex flex-col items-start gap-0.5 text-xs">
@@ -199,7 +202,7 @@ export function StoryRefRow({
199
202
  </TooltipContent>
200
203
  </Tooltip>
201
204
  ) : storyProjectInfo ? (
202
- <ProjectIconDisplay
205
+ <LucideIconByName
203
206
  name={storyProjectInfo.icon}
204
207
  size={14}
205
208
  className="shrink-0 text-muted-foreground"
@@ -224,26 +227,13 @@ export function StoryRefRow({
224
227
  <span className="font-mono text-xs text-muted-foreground shrink-0">{storyState.identifier}</span>
225
228
  )}
226
229
  {dependencyMeta && (
227
- <Tooltip>
228
- <TooltipTrigger asChild>
229
- <Badge variant="outline" className="max-w-56 shrink-0 truncate text-[10px] font-normal text-muted-foreground">
230
- {t(dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).badgeLabelKey, {
231
- identifier: storyState.identifier,
232
- })}
233
- </Badge>
234
- </TooltipTrigger>
235
- <TooltipContent side="top" className="text-xs">
236
- {t(
237
- currentIdentifier
238
- ? dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).tooltipLabelKey
239
- : dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).badgeLabelKey,
240
- {
241
- identifier: storyState.identifier,
242
- me: currentIdentifier,
243
- },
244
- )}
245
- </TooltipContent>
246
- </Tooltip>
230
+ <DependencyTypeSelect
231
+ variant="badge"
232
+ value={dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).key}
233
+ onChange={(key) => onDependencyTypeChange?.(storyState.id, key)}
234
+ identifier={storyState.identifier}
235
+ currentIdentifier={currentIdentifier}
236
+ />
247
237
  )}
248
238
  <span className="min-w-0 flex-1 truncate font-medium">{storyState.name}</span>
249
239
  </button>
@@ -263,7 +253,7 @@ export function StoryRefRow({
263
253
  <DateEditor value={storyState.targetDate} onChange={(v) => updateField({ targetDate: v })} label={t("children.dueDate")} icon={CalendarCheck} overdue={overdue} />
264
254
  )}
265
255
  {properties.includes("labels") && (
266
- <LabelsEditor labelIds={currentLabelIds} labels={labels} onChange={(v) => updateField({ labelIds: v })} variant="button" />
256
+ <LabelsEditor labelIds={currentLabelIds} labels={labels} onChange={(v) => updateField({ labelIds: v })} variant="button" aiContext={{ name: () => storyState.name }} />
267
257
  )}
268
258
  <DropdownMenu>
269
259
  <DropdownMenuTrigger asChild>
@@ -20,9 +20,9 @@ import type { ReactNode } from "react";
20
20
  import { useTranslation } from "react-i18next";
21
21
  import { Link } from "@tanstack/react-router";
22
22
  import {
23
+ Archive,
23
24
  ArrowLeft,
24
25
  ArrowRight,
25
- Archive,
26
26
  ChevronDown,
27
27
  ChevronUp,
28
28
  Link2,
@@ -59,6 +59,14 @@ export const PEEK_MODE_OPTIONS: { value: PeekMode; label: string; icon: typeof P
59
59
  ];
60
60
 
61
61
  export type PanelCloseType = "collapse" | "close" | "back";
62
+ export type PeekRelationKind = "child" | "dependency";
63
+
64
+ export interface PeekBreadcrumbItem {
65
+ identifier?: string;
66
+ projectId?: string;
67
+ label: string;
68
+ onClick?: () => void;
69
+ }
62
70
 
63
71
  export interface StoryPanelHeaderProps {
64
72
  closeType?: PanelCloseType;
@@ -86,6 +94,7 @@ export interface StoryPanelHeaderProps {
86
94
  hideCopyLink?: boolean;
87
95
  hideMoreMenu?: boolean;
88
96
  hideTransfer?: boolean;
97
+ breadcrumbItems?: PeekBreadcrumbItem[];
89
98
  }
90
99
 
91
100
  export function StoryPanelHeader({
@@ -114,6 +123,7 @@ export function StoryPanelHeader({
114
123
  hideCopyLink = false,
115
124
  hideMoreMenu = false,
116
125
  hideTransfer = false,
126
+ breadcrumbItems,
117
127
  }: StoryPanelHeaderProps) {
118
128
  const { t } = useTranslation("story");
119
129
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -121,28 +131,24 @@ export function StoryPanelHeader({
121
131
 
122
132
  const currentMode = PEEK_MODE_OPTIONS.find((o) => o.value === mode) ?? PEEK_MODE_OPTIONS[0];
123
133
  const CurrentModeIcon = currentMode.icon;
134
+ const closeTooltip = closeType === "back" ? t("panel.back") : closeType === "collapse" ? t("panel.collapse") : t("panel.close");
135
+ // 形变动画(MorphIcon)因主线程阻塞掉帧卡顿已移除,静态切换图标
136
+ const CloseIcon = closeType === "back" ? ArrowLeft : closeType === "collapse" ? ArrowRight : X;
124
137
 
125
- const closeButton =
126
- closeType === "back" ? (
127
- <Button variant="ghost" size="icon" className="size-8" aria-label={t("panel.back")} onClick={onClose}>
128
- <ArrowLeft className="size-4" />
129
- </Button>
130
- ) : (
131
- <Button
132
- variant="ghost"
133
- size="icon"
134
- className="size-8"
135
- onClick={onClose}
136
- aria-label={closeType === "collapse" ? t("panel.collapse") : t("panel.close")}
137
- >
138
- {closeType === "collapse" ? <ArrowRight className="size-4" /> : <X className="size-4" />}
139
- </Button>
140
- );
138
+ const closeButton = (
139
+ <Button
140
+ variant="ghost"
141
+ size="icon"
142
+ className="size-8 panel-close-btn"
143
+ onClick={onClose}
144
+ aria-label={closeTooltip}
145
+ >
146
+ <CloseIcon className="size-4" />
147
+ </Button>
148
+ );
141
149
 
142
150
  const closeButtonWithLink = backTo ? <Link to={backTo}>{closeButton}</Link> : closeButton;
143
151
 
144
- const closeTooltip = closeType === "back" ? t("panel.back") : closeType === "collapse" ? t("panel.collapse") : t("panel.close");
145
-
146
152
  const handleCopyLink = async () => {
147
153
  const url = storyId ? `${window.location.origin}/stories/${storyId}` : window.location.href;
148
154
  try {
@@ -35,7 +35,7 @@ import type { SavedView } from "./views/view-types";
35
35
  import { DisplayDropdown } from "./display/display-dropdown";
36
36
  import type { DisplayOptions } from "./display/types";
37
37
  import { StoryFilterToggle, StoryFilterRow, AppliedFilters, type FilterCondition, type FilterKey } from "./filters";
38
- import { ProjectIconDisplay } from "@/components/project/project-icon";
38
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
39
39
  import { CreateProjectDialog } from "@/components/project/create-project-dialog";
40
40
  import type { UserBrief } from "./types";
41
41
 
@@ -121,7 +121,7 @@ export function StoryToolbar({ filters, filterConditions, onFilterChange, onFilt
121
121
  {filters.projectId === "__archived__" ? (
122
122
  <Archive className="size-4 text-muted-foreground" />
123
123
  ) : filters.projectId ? (
124
- <ProjectIconDisplay name={projects.find((p) => p.id === filters.projectId)?.icon ?? ""} size={16} className="text-muted-foreground" />
124
+ <LucideIconByName name={projects.find((p) => p.id === filters.projectId)?.icon ?? ""} size={16} className="text-muted-foreground" />
125
125
  ) : (
126
126
  <Trees className="size-4 text-muted-foreground" />
127
127
  )}
@@ -149,7 +149,7 @@ export function StoryToolbar({ filters, filterConditions, onFilterChange, onFilt
149
149
  className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${filters.projectId === p.id ? "bg-accent font-medium" : "hover:bg-muted"}`}
150
150
  >
151
151
  <span className="flex min-w-0 items-center gap-2">
152
- <ProjectIconDisplay name={p.icon} size={16} className="text-muted-foreground" />
152
+ <LucideIconByName name={p.icon} size={16} className="text-muted-foreground" />
153
153
  <span className="truncate">{p.name}</span>
154
154
  </span>
155
155
  {p.displayId && (
@@ -18,7 +18,7 @@
18
18
  import { useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { GitCompareArrows, GitPullRequest, Check } from "lucide-react";
21
- import { ProjectIconDisplay } from "@/components/project/project-icon";
21
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
22
22
  import { Button } from "@/components/ui/button";
23
23
  import { Separator } from "@/components/ui/separator";
24
24
  import {
@@ -194,7 +194,7 @@ export function TransferStoryPopover({
194
194
  isSelected ? "bg-accent" : "hover:bg-muted"
195
195
  }`}
196
196
  >
197
- <ProjectIconDisplay name={p.icon} size={16} />
197
+ <LucideIconByName name={p.icon} size={16} />
198
198
  <span className="min-w-0 flex-1 truncate">{p.name}</span>
199
199
  {p.displayId && (
200
200
  <span className="shrink-0 rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
@@ -51,6 +51,7 @@ export interface StoryListItem {
51
51
  labels: { id: string; name: string; color: string }[];
52
52
  createdAt: string;
53
53
  updatedAt: string;
54
+ iteration?: { id: string; name: string } | null;
54
55
  }
55
56
 
56
57
  export interface StoryDetail extends StoryListItem {
@@ -31,7 +31,7 @@ import {
31
31
  PopoverTrigger,
32
32
  } from "@/components/ui/popover";
33
33
  import { Button } from "@/components/ui/button";
34
- import { ProjectIconDisplay } from "@/components/project/project-icon";
34
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
35
35
  import { cn } from "@/lib/utils";
36
36
  import { StoryFilterRow } from "../filters/filter-row";
37
37
  import { createDefaultCondition, type FilterCondition } from "../filters/filter-types";
@@ -160,7 +160,7 @@ export function CreateViewDialog({ open, onOpenChange, mode, initialValues, view
160
160
  className="h-8 gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
161
161
  >
162
162
  {selectedProject ? (
163
- <ProjectIconDisplay name={selectedProject.icon} size={16} />
163
+ <LucideIconByName name={selectedProject.icon} size={16} />
164
164
  ) : (
165
165
  <Trees className="size-4 text-muted-foreground" />
166
166
  )}
@@ -190,7 +190,7 @@ export function CreateViewDialog({ open, onOpenChange, mode, initialValues, view
190
190
  className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${projectId === p.id ? "bg-accent font-medium" : "hover:bg-muted"}`}
191
191
  >
192
192
  <span className="flex min-w-0 items-center gap-2">
193
- <ProjectIconDisplay name={p.icon} size={16} />
193
+ <LucideIconByName name={p.icon} size={16} />
194
194
  <span className="truncate">{p.name}</span>
195
195
  </span>
196
196
  {p.displayId && (