@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
@@ -35,11 +35,14 @@ import {
35
35
  ShieldOff,
36
36
  Trash2,
37
37
  Unlink,
38
+ ChartNoAxesGantt,
38
39
  } from "lucide-react";
39
40
  import { Button } from "@/components/ui/button";
40
41
  import { Badge } from "@/components/ui/badge";
41
42
  import { Dialog, DialogContent } from "@/components/ui/dialog";
42
43
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
44
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
45
+ import { cn } from "@/lib/utils";
43
46
  import { FilePreview } from "@/components/story/relations/file-preview";
44
47
  import { resolveVideoEmbed } from "@/lib/story/video-embed";
45
48
  import { removeLinkFn, removeAttachmentFn, unlinkDocFn, checkLinkEmbeddableFn } from "@/server/serverFns/story";
@@ -47,7 +50,7 @@ import { getDocumentFn } from "@/server/serverFns/knowledge";
47
50
  import { sanitize } from "@/lib/sanitize";
48
51
  import { formatFileSize, getFaviconUrl, getFileTypeIcon, extractLinkTitle, getMimeTypeLabel } from "../../relations/format";
49
52
  import { generalGroupLabel } from "../../relations/general-groups";
50
- import { KnowledgeIconDisplay } from "@/components/knowledge/knowledge-icon";
53
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
51
54
  import type { AssociatedItem, RelationsData, StoryLinkItem, StoryAttachmentItem } from "../../types";
52
55
 
53
56
  export type AssociatedDialogKind = "link" | "attachment" | "knowledge";
@@ -57,6 +60,7 @@ export interface AssociatedAttachmentsPanelProps {
57
60
  data: RelationsData;
58
61
  onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
59
62
  onOpenDialog: (kind: AssociatedDialogKind, editLink?: StoryLinkItem, editAttachment?: StoryAttachmentItem) => void;
63
+ onAppendActivities?: (items: import("../../types").ActivityItem[]) => void;
60
64
  }
61
65
 
62
66
  // 记住列表/预览显示方式
@@ -297,6 +301,7 @@ export function AssociatedAttachmentsPanel({
297
301
  data,
298
302
  onRefresh,
299
303
  onOpenDialog,
304
+ onAppendActivities,
300
305
  }: AssociatedAttachmentsPanelProps) {
301
306
  const { t } = useTranslation("story");
302
307
  const removeLink = useServerFn(removeLinkFn);
@@ -305,6 +310,35 @@ export function AssociatedAttachmentsPanel({
305
310
  const [mode, setMode] = React.useState<"list" | "preview">("list");
306
311
  const [groupCollapsed, setGroupCollapsed] = React.useState<Record<string, boolean>>({});
307
312
  const [fullscreenAttachment, setFullscreenAttachment] = React.useState<StoryAttachmentItem | null>(null);
313
+ const [activeTypeFilters, setActiveTypeFilters] = React.useState<Set<string>>(new Set());
314
+
315
+ // 附件类型分组定义
316
+ const attachmentTypeGroups = React.useMemo(() => {
317
+ const groups: Array<{ key: string; label: string; count: number }> = [];
318
+ const typeMap = new Map<string, number>();
319
+
320
+ for (const item of data.attachments) {
321
+ const type = getMimeTypeLabel(item.mimeType, item.fileName);
322
+ typeMap.set(type, (typeMap.get(type) ?? 0) + 1);
323
+ }
324
+
325
+ const order = ["图片", "视频", "音频", "PDF", "压缩包", "文本", "Word", "表格", "演示", "电子书", "未识别文件"];
326
+ for (const type of order) {
327
+ const count = typeMap.get(type) ?? 0;
328
+ if (count > 0) groups.push({ key: type, label: type, count });
329
+ }
330
+
331
+ return groups;
332
+ }, [data.attachments]);
333
+
334
+ // 根据筛选条件过滤附件
335
+ const filteredAttachments = React.useMemo(() => {
336
+ if (activeTypeFilters.size === 0) return data.attachments;
337
+ return data.attachments.filter((a) => {
338
+ const type = getMimeTypeLabel(a.mimeType, a.fileName);
339
+ return activeTypeFilters.has(type);
340
+ });
341
+ }, [data.attachments, activeTypeFilters]);
308
342
 
309
343
  // 下次进入时恢复上次的显示方式(挂载后读取,避免 SSR hydration 不匹配)
310
344
  React.useEffect(() => {
@@ -333,16 +367,26 @@ export function AssociatedAttachmentsPanel({
333
367
  const linkItems = items.filter((i) => i.kind === "link");
334
368
  const attachmentItems = items.filter((i) => i.kind === "attachment");
335
369
  const docItems = items.filter((i) => i.kind === "doc");
370
+
371
+ // 如果启用了附件类型筛选,则过滤 attachmentItems
372
+ const filteredAttachmentItems = activeTypeFilters.size > 0
373
+ ? attachmentItems.filter((item) => {
374
+ const type = getMimeTypeLabel(item.data.mimeType, item.data.fileName);
375
+ return activeTypeFilters.has(type);
376
+ })
377
+ : attachmentItems;
378
+
336
379
  return [
337
380
  { kind: "link" as const, label: t("associatedAttachments.groupLink"), count: linkItems.length, items: linkItems },
338
- { kind: "attachment" as const, label: t("associatedAttachments.groupAttachment"), count: attachmentItems.length, items: attachmentItems },
381
+ { kind: "attachment" as const, label: t("associatedAttachments.groupAttachment"), count: filteredAttachmentItems.length, items: filteredAttachmentItems },
339
382
  { kind: "doc" as const, label: t("associatedAttachments.groupKb"), count: docItems.length, items: docItems },
340
383
  ].filter((g) => g.count > 0);
341
- }, [items, t]);
384
+ }, [items, t, activeTypeFilters]);
342
385
 
343
386
  const handleRemoveLink = async (id: string) => {
344
387
  try {
345
- await removeLink({ data: { linkId: id } });
388
+ const res = await removeLink({ data: { linkId: id } });
389
+ if (res?.activity) onAppendActivities?.([res.activity]);
346
390
  onRefresh((prev) => ({ ...prev, links: prev.links.filter((l) => l.id !== id) }));
347
391
  } catch (err) {
348
392
  toast.error((err as Error)?.message || t("linkDialog.removeFailed"));
@@ -351,7 +395,8 @@ export function AssociatedAttachmentsPanel({
351
395
 
352
396
  const handleRemoveAttachment = async (id: string) => {
353
397
  try {
354
- await removeAttachment({ data: { attachmentId: id } });
398
+ const res = await removeAttachment({ data: { attachmentId: id } });
399
+ if (res?.activity) onAppendActivities?.([res.activity]);
355
400
  onRefresh((prev) => ({
356
401
  ...prev,
357
402
  attachments: prev.attachments.filter((a) => a.id !== id),
@@ -363,7 +408,8 @@ export function AssociatedAttachmentsPanel({
363
408
 
364
409
  const handleUnlinkDoc = async (docId: string) => {
365
410
  try {
366
- await unlinkDoc({ data: { storyId, docId } });
411
+ const res = await unlinkDoc({ data: { storyId, docId } });
412
+ if (res?.activity) onAppendActivities?.([res.activity]);
367
413
  onRefresh((prev) => ({
368
414
  ...prev,
369
415
  knowledge: { ...prev.knowledge, docs: prev.knowledge.docs.filter((d) => d.id !== docId) },
@@ -558,7 +604,7 @@ export function AssociatedAttachmentsPanel({
558
604
  <span className="w-4 shrink-0" aria-hidden />
559
605
  <BookOpenText className="size-4 shrink-0 text-muted-foreground" />
560
606
  <span className="min-w-0 flex-1 truncate text-sm text-foreground">{item.data.title}</span>
561
- <KnowledgeIconDisplay name={item.data.kbIcon} size={14} className="shrink-0 text-muted-foreground" />
607
+ <LucideIconByName name={item.data.kbIcon} size={14} className="shrink-0 text-muted-foreground" />
562
608
  <span className="shrink-0 text-xs text-muted-foreground">{item.data.kbId.startsWith("ext:") ? generalGroupLabel(t, item.data.kbName) : item.data.kbName}</span>
563
609
  <div className="flex items-center gap-0.5 shrink-0">{renderActionsInline(item)}</div>
564
610
  </div>
@@ -619,7 +665,7 @@ export function AssociatedAttachmentsPanel({
619
665
  <span className="min-w-0 truncate text-sm font-medium text-foreground">{item.data.title}</span>
620
666
  </div>
621
667
  <div className="flex items-center gap-1.5">
622
- <KnowledgeIconDisplay name={item.data.kbIcon} size={12} className="shrink-0 text-muted-foreground" />
668
+ <LucideIconByName name={item.data.kbIcon} size={12} className="shrink-0 text-muted-foreground" />
623
669
  <span className="truncate text-xs text-muted-foreground">{item.data.kbId.startsWith("ext:") ? generalGroupLabel(t, item.data.kbName) : item.data.kbName}</span>
624
670
  </div>
625
671
  <div className="absolute right-2 top-2 hidden gap-0.5 rounded-md border bg-background p-0.5 group-hover:flex">
@@ -693,7 +739,70 @@ export function AssociatedAttachmentsPanel({
693
739
  <ChevronDown className={`size-3 shrink-0 text-muted-foreground transition-transform ${isCollapsed ? "-rotate-90" : "rotate-0"}`} />
694
740
  <span>{group.label}</span>
695
741
  <span className="ml-1 text-xs text-muted-foreground">{group.count}</span>
696
- <div className="ml-auto flex items-center" onClick={(e) => e.stopPropagation()}>
742
+ <div className="ml-auto flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
743
+ {group.kind === "attachment" && (
744
+ <Popover>
745
+ <PopoverTrigger asChild>
746
+ <Button
747
+ variant="ghost"
748
+ size="sm"
749
+ className={`size-6 p-0 ${activeTypeFilters.size > 0 ? "bg-muted text-foreground" : ""}`}
750
+ aria-label={t("associatedAttachments.filter")}
751
+ title={t("associatedAttachments.filter")}
752
+ >
753
+ <ChartNoAxesGantt className="size-3.5" />
754
+ {activeTypeFilters.size > 0 && (
755
+ <span className="absolute -right-1 -top-1 size-1.5 rounded-full bg-primary" />
756
+ )}
757
+ </Button>
758
+ </PopoverTrigger>
759
+ <PopoverContent align="end" className="w-52 p-2">
760
+ <div className="text-xs font-medium text-muted-foreground my-1 px-1">
761
+ {t("associatedAttachments.filterByType")}
762
+ </div>
763
+ <div className="flex flex-wrap gap-1">
764
+ <button
765
+ type="button"
766
+ onClick={() => {
767
+ if (activeTypeFilters.size === attachmentTypeGroups.length) {
768
+ setActiveTypeFilters(new Set());
769
+ } else {
770
+ setActiveTypeFilters(new Set(attachmentTypeGroups.map((g) => g.key)));
771
+ }
772
+ }}
773
+ className={cn(
774
+ "rounded border px-2 py-0.5 text-xs transition-all",
775
+ activeTypeFilters.size === attachmentTypeGroups.length && attachmentTypeGroups.length > 0
776
+ ? "border-primary bg-primary text-primary-foreground"
777
+ : "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
778
+ )}
779
+ >
780
+ {t("associatedAttachments.filterAll")}
781
+ </button>
782
+ {attachmentTypeGroups.map((g) => (
783
+ <button
784
+ type="button"
785
+ key={g.key}
786
+ onClick={() => {
787
+ const next = new Set(activeTypeFilters);
788
+ if (next.has(g.key)) next.delete(g.key);
789
+ else next.add(g.key);
790
+ setActiveTypeFilters(next);
791
+ }}
792
+ className={cn(
793
+ "rounded border px-2 py-0.5 text-xs transition-all",
794
+ activeTypeFilters.has(g.key)
795
+ ? "border-primary bg-primary text-primary-foreground"
796
+ : "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
797
+ )}
798
+ >
799
+ {g.label}({g.count})
800
+ </button>
801
+ ))}
802
+ </div>
803
+ </PopoverContent>
804
+ </Popover>
805
+ )}
697
806
  <Button
698
807
  variant="ghost"
699
808
  size="sm"
@@ -16,7 +16,6 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import * as React from "react";
19
- import { useNavigate } from "@tanstack/react-router";
20
19
  import { useTranslation } from "react-i18next";
21
20
  import {
22
21
  Plus,
@@ -66,6 +65,7 @@ export interface StoryDetailChildrenPanelProps {
66
65
  projects?: { id: string; name: string; icon: string; displayId: string | null }[];
67
66
  labels?: { id: string; name: string; color: string }[];
68
67
  onAction: (action: "create" | "select") => void;
68
+ onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
69
69
  }
70
70
 
71
71
  export function StoryDetailChildrenButton({ onAction }: StoryDetailChildrenButtonProps) {
@@ -131,9 +131,9 @@ export function StoryDetailChildrenPanel({
131
131
  projects,
132
132
  labels,
133
133
  onAction,
134
+ onNavigateInPanel,
134
135
  }: StoryDetailChildrenPanelProps) {
135
136
  const { t } = useTranslation("story");
136
- const navigate = useNavigate();
137
137
  const childrenList = data.children as StoryListItem[];
138
138
  const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
139
139
  const [busy, setBusy] = React.useState(false);
@@ -176,7 +176,10 @@ export function StoryDetailChildrenPanel({
176
176
  };
177
177
 
178
178
  const handleOpenStory = (id: string) => {
179
- navigate({ to: "/stories/$storyId", params: { storyId: id } });
179
+ if (onNavigateInPanel) {
180
+ const story = childrenList.find((s) => s.id === id);
181
+ onNavigateInPanel(id, story?.identifier);
182
+ }
180
183
  };
181
184
 
182
185
  const handleRemoveChild = async (childId: string) => {
@@ -321,7 +324,7 @@ export function StoryDetailChildrenPanel({
321
324
  <Collapsible defaultOpen>
322
325
  <CollapsibleTrigger
323
326
  asChild
324
- className="group flex w-full cursor-pointer items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
327
+ className="group flex w-full cursor-pointer select-none items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
325
328
  >
326
329
  <div
327
330
  role="button"
@@ -16,7 +16,6 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import * as React from "react";
19
- import { useNavigate } from "@tanstack/react-router";
20
19
  import { useTranslation } from "react-i18next";
21
20
  import { Plus, ChevronDown, ChartNoAxesGantt } from "lucide-react";
22
21
  import { Button } from "@/components/ui/button";
@@ -26,10 +25,12 @@ import { useServerFn } from "@tanstack/react-start";
26
25
  import { toast } from "sonner";
27
26
  import {
28
27
  removeDependencyFn,
28
+ updateDependencyFn,
29
29
  bulkArchiveStoriesFn,
30
30
  bulkDeleteStoriesFn,
31
31
  bulkUpdateStoriesFn,
32
32
  } from "@/server/serverFns/story";
33
+ import { DEPENDENCY_TYPE_OPTIONS, toDependencyEdge, type DependencyTypeOption } from "../../constants";
33
34
  import { BatchToolbar } from "../../views/story-list/batch-toolbar";
34
35
  import { useGroupedStories } from "../../display/use-grouped-stories";
35
36
  import { VirtualGroupHeader } from "../../views/story-list/group";
@@ -59,6 +60,8 @@ export interface StoryDetailDependencyPanelProps {
59
60
  projects?: { id: string; name: string; icon: string; displayId: string | null }[];
60
61
  labels?: { id: string; name: string; color: string }[];
61
62
  onOpenAdd: () => void;
63
+ onAppendActivities?: (items: import("../../types").ActivityItem[]) => void;
64
+ onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
62
65
  }
63
66
 
64
67
  export function StoryDetailDependencyPanel({
@@ -72,10 +75,12 @@ export function StoryDetailDependencyPanel({
72
75
  projects,
73
76
  labels,
74
77
  onOpenAdd,
78
+ onAppendActivities,
79
+ onNavigateInPanel,
75
80
  }: StoryDetailDependencyPanelProps) {
76
81
  const { t } = useTranslation("story");
77
- const navigate = useNavigate();
78
82
  const removeDependency = useServerFn(removeDependencyFn);
83
+ const updateDependency = useServerFn(updateDependencyFn);
79
84
  const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
80
85
  const [busy, setBusy] = React.useState(false);
81
86
  const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
@@ -135,7 +140,10 @@ export function StoryDetailDependencyPanel({
135
140
  };
136
141
 
137
142
  const handleOpenStory = (id: string) => {
138
- navigate({ to: "/stories/$storyId", params: { storyId: id } });
143
+ if (onNavigateInPanel) {
144
+ const story = allRows.find((r) => r.id === id);
145
+ onNavigateInPanel(id, story?.identifier, "dependency");
146
+ }
139
147
  };
140
148
 
141
149
  const applyRowsUpdate = (
@@ -155,7 +163,12 @@ export function StoryDetailDependencyPanel({
155
163
  ? "dependsOn"
156
164
  : "dependents";
157
165
  try {
158
- await removeDependency({ data: { depId: row.depId } });
166
+ const res = await removeDependency({ data: { depId: row.depId } });
167
+ // 两端各有一条记录,只追加当前需求的
168
+ const mine = (res?.activities ?? [])
169
+ .filter((a) => a.storyId === storyId)
170
+ .map(({ storyId: _sid, ...rest }) => rest);
171
+ if (mine.length > 0) onAppendActivities?.(mine);
159
172
  toast.success(t("dependency.removed"));
160
173
  applyRowsUpdate((rows) => rows.filter((r) => r.id !== rowId), group);
161
174
  setSelectedIds((prev) => {
@@ -168,13 +181,59 @@ export function StoryDetailDependencyPanel({
168
181
  }
169
182
  };
170
183
 
184
+ const handleDependencyTypeChange = async (
185
+ otherStoryId: string,
186
+ key: DependencyTypeOption["key"],
187
+ ) => {
188
+ const row = rowById.get(otherStoryId);
189
+ const option = DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === key);
190
+ if (!row || !option) return;
191
+ const edge = toDependencyEdge(option, storyId, otherStoryId);
192
+ try {
193
+ const res = await updateDependency({ data: { depId: row.depId, ...edge } });
194
+ if (!res.ok) {
195
+ toast.warning(t(`dependency.errors.${res.reason}`));
196
+ return;
197
+ }
198
+ const mine = (res.activities ?? [])
199
+ .filter((a) => a.storyId === storyId)
200
+ .map(({ storyId: _sid, ...rest }) => rest);
201
+ if (mine.length > 0) onAppendActivities?.(mine);
202
+ toast.success(t("dependency.updated"));
203
+ // 行的 group 跟随 option 方向:同 group 原地更新,跨 group 移动到另一 section
204
+ const oldGroup: DependencySection = data.dependsOn.some((r) => r.id === otherStoryId)
205
+ ? "dependsOn"
206
+ : "dependents";
207
+ if (option.group === oldGroup) {
208
+ applyRowsUpdate(
209
+ (rows) => rows.map((r) => (r.id === otherStoryId ? { ...r, depType: option.edge } : r)),
210
+ oldGroup,
211
+ );
212
+ } else {
213
+ const moved = { ...row, depType: option.edge };
214
+ applyRowsUpdate((rows) => rows.filter((r) => r.id !== otherStoryId), oldGroup);
215
+ applyRowsUpdate((rows) => [moved, ...rows], option.group);
216
+ }
217
+ } catch (e) {
218
+ toast.error(
219
+ isNetworkError(e) ? t("dependency.networkFailed") : (e as Error)?.message || t("dependency.updateFailed"),
220
+ );
221
+ }
222
+ };
223
+
171
224
  const handleBulkRemoveRelations = async (storyIds: string[]) => {
172
225
  if (storyIds.length === 0) return;
173
226
  setBusy(true);
174
227
  try {
175
228
  for (const id of storyIds) {
176
229
  const row = rowById.get(id);
177
- if (row) await removeDependency({ data: { depId: row.depId } });
230
+ if (row) {
231
+ const res = await removeDependency({ data: { depId: row.depId } });
232
+ const mine = (res?.activities ?? [])
233
+ .filter((a) => a.storyId === storyId)
234
+ .map(({ storyId: _sid, ...rest }) => rest);
235
+ if (mine.length > 0) onAppendActivities?.(mine);
236
+ }
178
237
  }
179
238
  toast.success(t("dependency.removed"));
180
239
  setSelectedIds(new Set());
@@ -308,6 +367,7 @@ export function StoryDetailDependencyPanel({
308
367
  onRemove={handleRemoveRow}
309
368
  removeMenuLabel={t("dependency.remove")}
310
369
  dependencyMeta={{ depType: row.depType, group }}
370
+ onDependencyTypeChange={handleDependencyTypeChange}
311
371
  currentIdentifier={storyIdentifier}
312
372
  onStoryRefresh={handleStoryRefresh}
313
373
  />
@@ -364,7 +424,7 @@ export function StoryDetailDependencyPanel({
364
424
  return (
365
425
  <Collapsible open={!isCollapsed} onOpenChange={(open) => setCollapsed((p) => ({ ...p, [`${sectionKey}:root`]: !open }))}>
366
426
  <CollapsibleTrigger asChild>
367
- <div className="flex h-9 cursor-pointer items-center gap-1.5 bg-muted/50 px-3 text-sm font-medium hover:text-foreground transition-colors">
427
+ <div className="flex h-9 cursor-pointer select-none items-center gap-1.5 bg-muted/50 px-3 text-sm font-medium hover:text-foreground transition-colors">
368
428
  <ChevronDown className={`size-3 shrink-0 text-muted-foreground transition-transform ${isCollapsed ? "-rotate-90" : "rotate-0"}`} />
369
429
  <span>{t(titleKey)}</span>
370
430
  <span className="ml-1 text-xs text-muted-foreground">{rows.length}</span>
@@ -390,7 +450,7 @@ export function StoryDetailDependencyPanel({
390
450
  <Collapsible defaultOpen>
391
451
  <CollapsibleTrigger
392
452
  asChild
393
- className="group flex w-full cursor-pointer items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
453
+ className="group flex w-full cursor-pointer select-none items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
394
454
  >
395
455
  <div
396
456
  role="button"
@@ -51,9 +51,10 @@ interface StoryDetailToolbarProps {
51
51
  users?: UserBrief[];
52
52
  projects?: { id: string; name: string; icon: string; displayId: string | null }[];
53
53
  labels?: { id: string; name: string; color: string }[];
54
+ onNavigateInPanel?: (targetId: string, targetIdentifier?: string, relationKind?: "child" | "dependency") => void;
54
55
  }
55
56
 
56
- export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, onAppendActivities, users, projects, labels }: StoryDetailToolbarProps) {
57
+ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, onAppendActivities, users, projects, labels, onNavigateInPanel }: StoryDetailToolbarProps) {
57
58
  const { t } = useTranslation("story");
58
59
  const [actionOpen, setActionOpen] = React.useState<"create" | "select" | null>(null);
59
60
  const [dependencyDialogOpen, setDependencyDialogOpen] = React.useState(false);
@@ -244,6 +245,7 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
244
245
  projects={projects}
245
246
  labels={labels}
246
247
  onAction={handleChildAction}
248
+ onNavigateInPanel={onNavigateInPanel}
247
249
  />
248
250
 
249
251
  <StoryDetailDependencyPanel
@@ -257,6 +259,8 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
257
259
  projects={projects}
258
260
  labels={labels}
259
261
  onOpenAdd={() => setDependencyDialogOpen(true)}
262
+ onAppendActivities={onAppendActivities}
263
+ onNavigateInPanel={onNavigateInPanel}
260
264
  />
261
265
 
262
266
  <AssociatedAttachmentsPanel
@@ -264,6 +268,7 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
264
268
  data={data}
265
269
  onRefresh={onRefresh}
266
270
  onOpenDialog={handleOpenAssocDialog}
271
+ onAppendActivities={onAppendActivities}
267
272
  />
268
273
 
269
274
  <StorySelectDialog
@@ -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, Tag } from "lucide-react";
18
+ import { GitPullRequestArrow, Tag, CalendarRange } from "lucide-react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
21
21
  import { ChevronDown } from "lucide-react";
@@ -23,6 +23,8 @@ import type { StoryState } from "../constants";
23
23
  import type { ActivityItem } from "../types";
24
24
  import { ParentStoryEditor } from "../inline-editors/parent-story-editor";
25
25
  import { LabelsEditor } from "../inline-editors/labels-editor";
26
+ import { IterationEditor } from "@/components/iteration/iteration-editor";
27
+ import type { IterationSummary } from "@/server/serverFns/iteration/schemas";
26
28
 
27
29
  interface PropertyItemProps {
28
30
  icon: React.ReactNode;
@@ -45,15 +47,20 @@ function PropertyItem({ icon, label, children }: PropertyItemProps) {
45
47
  interface StoryPropertiesProps {
46
48
  story: {
47
49
  id: string;
50
+ name: string;
51
+ description?: string | null;
48
52
  parentId: string | null;
49
53
  parent: { id: string; title: string; status: StoryState; identifier?: string } | null;
50
54
  labels: { id: string; name: string; color: string }[];
51
55
  projectId?: string | null;
56
+ iteration?: { id: string; name: string } | null;
52
57
  };
53
58
  labels?: { id: string; name: string; color: string }[];
54
59
  projects?: { id: string; name: string; icon: string; displayId?: string | null }[];
60
+ iterations?: IterationSummary[];
55
61
  onParentChange: (parentId: string | null) => void;
56
62
  onLabelsChange: (labelIds: string[]) => void;
63
+ onIterationChange?: (iteration: { id: string; name: string } | null) => void;
57
64
  onActivities?: (activities: ActivityItem[]) => void;
58
65
  }
59
66
 
@@ -61,8 +68,10 @@ export function StoryProperties({
61
68
  story,
62
69
  labels,
63
70
  projects,
71
+ iterations,
64
72
  onParentChange,
65
73
  onLabelsChange,
74
+ onIterationChange,
66
75
  onActivities,
67
76
  }: StoryPropertiesProps) {
68
77
  const { t } = useTranslation("story");
@@ -71,7 +80,7 @@ export function StoryProperties({
71
80
  <div className="px-6 py-4 text-sm font-semibold">{t("properties.title")}</div>
72
81
  <CollapsibleContent className="px-6 pb-2 space-y-2">
73
82
  <Collapsible defaultOpen>
74
- <CollapsibleTrigger className="flex items-center gap-2 pb-4 text-xs font-semibold hover:text-foreground transition-colors">
83
+ <CollapsibleTrigger className="flex select-none items-center gap-2 pb-4 text-xs font-semibold hover:text-foreground transition-colors">
75
84
  {t("properties.basic")}
76
85
  <ChevronDown className="size-3" />
77
86
  </CollapsibleTrigger>
@@ -98,18 +107,26 @@ export function StoryProperties({
98
107
  labels={labels}
99
108
  onChange={onLabelsChange}
100
109
  variant="button"
110
+ aiContext={{ name: () => story.name, description: () => story.description ?? undefined }}
101
111
  />
102
112
  </PropertyItem>
103
113
  )}
104
114
  </CollapsibleContent>
105
115
  </Collapsible>
106
116
  <Collapsible>
107
- <CollapsibleTrigger className="flex items-center gap-2 pb-4 text-xs font-semibold hover:text-foreground transition-colors">
117
+ <CollapsibleTrigger className="flex select-none items-center gap-2 pb-4 text-xs font-semibold hover:text-foreground transition-colors">
108
118
  {t("properties.iteration")}
109
119
  <ChevronDown className="size-3" />
110
120
  </CollapsibleTrigger>
111
121
  <CollapsibleContent className="pb-2">
112
- {/* 迭代属性区域 - 预留 */}
122
+ <PropertyItem icon={<CalendarRange className="size-3.5" />} label={t("properties.iteration")}>
123
+ <IterationEditor
124
+ storyId={story.id}
125
+ current={story.iteration ?? null}
126
+ options={iterations ?? []}
127
+ onChanged={(it) => onIterationChange?.(it)}
128
+ />
129
+ </PropertyItem>
113
130
  </CollapsibleContent>
114
131
  </Collapsible>
115
132
  </CollapsibleContent>
@@ -0,0 +1,107 @@
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 { GROUP_FIELD, toGroupFieldPatch } from "../group-fields";
20
+
21
+ describe("GROUP_FIELD", () => {
22
+ it("state 映射到 state", () => {
23
+ expect(GROUP_FIELD.state).toBe("state");
24
+ });
25
+
26
+ it("priority 映射到 priority", () => {
27
+ expect(GROUP_FIELD.priority).toBe("priority");
28
+ });
29
+
30
+ it("assignee 映射到 assigneeId", () => {
31
+ expect(GROUP_FIELD.assignee).toBe("assigneeId");
32
+ });
33
+
34
+ it("project 映射到 projectId", () => {
35
+ expect(GROUP_FIELD.project).toBe("projectId");
36
+ });
37
+
38
+ it("createdBy 为 null", () => {
39
+ expect(GROUP_FIELD.createdBy).toBeNull();
40
+ });
41
+
42
+ it("labels 为 null", () => {
43
+ expect(GROUP_FIELD.labels).toBeNull();
44
+ });
45
+
46
+ it("none 为 null", () => {
47
+ expect(GROUP_FIELD.none).toBeNull();
48
+ });
49
+ });
50
+
51
+ describe("toGroupFieldPatch", () => {
52
+ describe("state 字段", () => {
53
+ it("todo → { state: todo }", () => {
54
+ expect(toGroupFieldPatch("state", "todo")).toEqual({ state: "todo" });
55
+ });
56
+
57
+ it("backlog → { state: backlog }", () => {
58
+ expect(toGroupFieldPatch("state", "backlog")).toEqual({ state: "backlog" });
59
+ });
60
+
61
+ it("in_progress → { state: in_progress }", () => {
62
+ expect(toGroupFieldPatch("state", "in_progress")).toEqual({ state: "in_progress" });
63
+ });
64
+
65
+ it("done → { state: done }", () => {
66
+ expect(toGroupFieldPatch("state", "done")).toEqual({ state: "done" });
67
+ });
68
+
69
+ it("cancelled → { state: cancelled }", () => {
70
+ expect(toGroupFieldPatch("state", "cancelled")).toEqual({ state: "cancelled" });
71
+ });
72
+ });
73
+
74
+ describe("priority 字段", () => {
75
+ it("high → { priority: high }", () => {
76
+ expect(toGroupFieldPatch("priority", "high")).toEqual({ priority: "high" });
77
+ });
78
+
79
+ it("urgent → { priority: urgent }", () => {
80
+ expect(toGroupFieldPatch("priority", "urgent")).toEqual({ priority: "urgent" });
81
+ });
82
+
83
+ it("none → { priority: none }", () => {
84
+ expect(toGroupFieldPatch("priority", "none")).toEqual({ priority: "none" });
85
+ });
86
+ });
87
+
88
+ describe("assigneeId 字段", () => {
89
+ it("unassigned → { assigneeId: null }", () => {
90
+ expect(toGroupFieldPatch("assigneeId", "unassigned")).toEqual({ assigneeId: null });
91
+ });
92
+
93
+ it("普通 userId → { assigneeId: userId }", () => {
94
+ expect(toGroupFieldPatch("assigneeId", "u123")).toEqual({ assigneeId: "u123" });
95
+ });
96
+ });
97
+
98
+ describe("projectId 字段", () => {
99
+ it("no-project → { projectId: null }", () => {
100
+ expect(toGroupFieldPatch("projectId", "no-project")).toEqual({ projectId: null });
101
+ });
102
+
103
+ it("普通 projectId → { projectId: projectId }", () => {
104
+ expect(toGroupFieldPatch("projectId", "proj1")).toEqual({ projectId: "proj1" });
105
+ });
106
+ });
107
+ });
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { Sprout, CircleUser } from "lucide-react";
19
19
  import { UserAvatar } from "@allbluecn/ui";
20
- import { ProjectIconDisplay } from "@/components/project/project-icon";
20
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
21
21
  import { cn } from "@/lib/utils";
22
22
  import { STORY_STATES, STATE_LABEL, STATE_COLOR, STATE_ICON, STORY_PRIORITIES, PRIORITY_LABEL, PRIORITY_ICON } from "../constants";
23
23
  import { PRIORITY_COLOR } from "../priority-icon";
@@ -127,7 +127,7 @@ export function resolveGroupIcon(k: string, groupKey: GroupKey, ctx: GroupContex
127
127
  }
128
128
  if (!project) return undefined;
129
129
  const IconComponent = ({ className }: { className?: string }) => (
130
- <ProjectIconDisplay name={project.icon} size={16} className={className} />
130
+ <LucideIconByName name={project.icon} size={16} className={className} />
131
131
  );
132
132
  return IconComponent;
133
133
  }