@allbluecn/web-app 0.4.22 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. package/src/routes/share/prd/$prdId/route.tsx +0 -10
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
21
21
  import { cn } from "@/lib/utils";
22
- import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag } from "lucide-react";
22
+ import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag, Waypoints } from "lucide-react";
23
23
  import { Badge } from "@/components/ui/badge";
24
24
  import { Checkbox } from "@/components/ui/checkbox";
25
25
  import { ProjectIconDisplay } from "@/components/project/project-icon";
@@ -50,7 +50,9 @@ import {
50
50
  STATE_LABEL,
51
51
  STORY_STATES,
52
52
  STORY_PRIORITIES,
53
+ DEPENDENCY_TYPE_OPTIONS,
53
54
  type StoryState,
55
+ type DependencyTypeOption,
54
56
  } from "../constants";
55
57
  import { PriorityIcon } from "../priority-icon";
56
58
 
@@ -71,7 +73,7 @@ interface StorySelectDialogProps {
71
73
  initialProjectId?: string | null;
72
74
  defaultSelectedParentId?: string | null;
73
75
  defaultSelectedParentIds?: string[];
74
- mode?: "parent" | "child";
76
+ mode?: "parent" | "child" | "dependency";
75
77
  onSelected: (parentId: string | string[]) => void;
76
78
  onActivities?: (activities: ActivityItem[]) => void;
77
79
  skipTransfer?: boolean;
@@ -79,9 +81,15 @@ interface StorySelectDialogProps {
79
81
  excludeProjectIds?: string[];
80
82
  hideAllProjects?: boolean;
81
83
  filteredTip?: string;
84
+ /** dependency 模式:额外排除的需求 id(已建立依赖的对方) */
85
+ excludeIds?: string[];
86
+ /** dependency 模式:当前选中的依赖类型(受控),由父组件持有以便提交时映射方向 */
87
+ dependencyType?: DependencyTypeOption["key"];
88
+ onDependencyTypeChange?: (key: DependencyTypeOption["key"]) => void;
82
89
  }
83
90
 
84
91
  const ALL_PROJECTS = "__all__";
92
+ const EMPTY_IDS: string[] = [];
85
93
  const ALL_STATES = STORY_STATES.map((s) => s.value);
86
94
  const ALL_PRIORITIES = STORY_PRIORITIES.map((p) => p.value);
87
95
 
@@ -101,10 +109,13 @@ export function StorySelectDialog({
101
109
  excludeProjectIds = [],
102
110
  hideAllProjects = false,
103
111
  filteredTip,
112
+ excludeIds = EMPTY_IDS,
113
+ dependencyType,
114
+ onDependencyTypeChange,
104
115
  }: StorySelectDialogProps) {
105
116
  const { t } = useTranslation("story");
106
117
  const resolvedTitle = title ?? t("select.defaultTitle");
107
- const isMulti = mode === "child";
118
+ const isMulti = mode === "child" || mode === "dependency";
108
119
  const [projectFilter, setProjectFilter] = useState<string>(ALL_PROJECTS);
109
120
  const [items, setItems] = useState<ParentStoryItem[]>([]);
110
121
  const [search, setSearch] = useState("");
@@ -117,6 +128,7 @@ export function StorySelectDialog({
117
128
  const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
118
129
  const [statePopoverOpen, setStatePopoverOpen] = useState(false);
119
130
  const [priorityPopoverOpen, setPriorityPopoverOpen] = useState(false);
131
+ const [depPopoverOpen, setDepPopoverOpen] = useState(false);
120
132
  const [selectedStates, setSelectedStates] = useState<Set<string>>(
121
133
  () => new Set(ALL_STATES),
122
134
  );
@@ -209,6 +221,7 @@ export function StorySelectDialog({
209
221
  projectId: projectFilter === ALL_PROJECTS ? undefined : projectFilter,
210
222
  search: debouncedSearch || undefined,
211
223
  excludeStoryId: storyId,
224
+ excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
212
225
  mode,
213
226
  },
214
227
  })
@@ -226,7 +239,7 @@ export function StorySelectDialog({
226
239
  if (reqId !== requestIdRef.current) return;
227
240
  setLoading(false);
228
241
  });
229
- }, [open, projectFilter, debouncedSearch, storyId, mode]);
242
+ }, [open, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
230
243
 
231
244
  const loadMore = useCallback(async () => {
232
245
  if (loading || !hasMore || !nextCursor) return;
@@ -239,6 +252,7 @@ export function StorySelectDialog({
239
252
  search: debouncedSearch || undefined,
240
253
  cursor: nextCursor,
241
254
  excludeStoryId: storyId,
255
+ excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
242
256
  mode,
243
257
  },
244
258
  });
@@ -253,11 +267,17 @@ export function StorySelectDialog({
253
267
  if (reqId !== requestIdRef.current) return;
254
268
  setLoading(false);
255
269
  }
256
- }, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, mode]);
270
+ }, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
257
271
 
258
272
  // 点击 Popover 外部时关闭(Dialog 嵌套下 Radix 事件被拦截)
259
273
  useEffect(() => {
260
- if (!projectPopoverOpen && !statePopoverOpen && !priorityPopoverOpen) return;
274
+ if (
275
+ !projectPopoverOpen &&
276
+ !statePopoverOpen &&
277
+ !priorityPopoverOpen &&
278
+ !depPopoverOpen
279
+ )
280
+ return;
261
281
  const handlePointerDown = (e: PointerEvent) => {
262
282
  const target = e.target as Node;
263
283
  const content = document.querySelector('[data-slot="popover-content"]');
@@ -271,11 +291,12 @@ export function StorySelectDialog({
271
291
  setProjectPopoverOpen(false);
272
292
  setStatePopoverOpen(false);
273
293
  setPriorityPopoverOpen(false);
294
+ setDepPopoverOpen(false);
274
295
  }
275
296
  };
276
297
  document.addEventListener("pointerdown", handlePointerDown);
277
298
  return () => document.removeEventListener("pointerdown", handlePointerDown);
278
- }, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen]);
299
+ }, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen, depPopoverOpen]);
279
300
 
280
301
  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
281
302
  const el = e.currentTarget;
@@ -288,8 +309,8 @@ export function StorySelectDialog({
288
309
  const handleSubmit = async () => {
289
310
  if (selectedIds.length === 0) return;
290
311
  if (skipTransfer) {
312
+ // dependency 模式:关闭时机由回调方控制(部分失败时保留弹窗与已选项)
291
313
  const parentId = isMulti ? selectedIds : selectedIds[0];
292
- onOpenChange(false);
293
314
  onSelected(parentId);
294
315
  return;
295
316
  }
@@ -384,7 +405,7 @@ export function StorySelectDialog({
384
405
  )}
385
406
  >
386
407
  <Icon className={cn("size-3.5 shrink-0", STATE_ICON_COLOR[s.value])} />
387
- <span className="flex-1">{s.label}</span>
408
+ <span className="flex-1">{t(s.label)}</span>
388
409
  {checked && <Check className="size-3.5 shrink-0 text-primary" />}
389
410
  </button>
390
411
  );
@@ -432,7 +453,7 @@ export function StorySelectDialog({
432
453
  )}
433
454
  >
434
455
  <PriorityIcon priority={p.value} />
435
- <span className="flex-1">{p.label}</span>
456
+ <span className="flex-1">{t(p.label)}</span>
436
457
  {checked && <Check className="size-3.5 shrink-0 text-primary" />}
437
458
  </button>
438
459
  );
@@ -579,7 +600,7 @@ export function StorySelectDialog({
579
600
  STATE_COLOR[state] ?? STATE_COLOR.backlog
580
601
  }`}
581
602
  >
582
- {STATE_LABEL[state] ?? state}
603
+ {t(STATE_LABEL[state] ?? state)}
583
604
  </span>
584
605
  <PriorityIcon priority={priority} />
585
606
  <span className="shrink-0 font-mono text-xs text-muted-foreground">
@@ -638,6 +659,57 @@ export function StorySelectDialog({
638
659
  <span className="text-xs text-muted-foreground">
639
660
  {selectedCount > 0 ? t("select.selectedCount", { count: selectedCount }) : t("select.pleaseSelect")}
640
661
  </span>
662
+ {mode === "dependency" && (
663
+ <Popover open={depPopoverOpen} onOpenChange={setDepPopoverOpen}>
664
+ <PopoverTrigger asChild>
665
+ <Button
666
+ variant="ghost"
667
+ size="sm"
668
+ className={cn(
669
+ "h-9 gap-1.5",
670
+ !dependencyType && "text-muted-foreground",
671
+ )}
672
+ >
673
+ <Waypoints className="size-3.5" />
674
+ {dependencyType
675
+ ? t(
676
+ DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType)!
677
+ .optionLabelKey,
678
+ )
679
+ : t("dependency.typePlaceholder")}
680
+ </Button>
681
+ </PopoverTrigger>
682
+ <PopoverContent align="start" className="w-80 p-1">
683
+ {(["dependsOn", "dependents"] as const).map((group, gi) => (
684
+ <div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
685
+ <p className="px-2 py-1 text-xs font-medium text-muted-foreground">
686
+ {t(`dependency.optionGroups.${group}`)}
687
+ </p>
688
+ {DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
689
+ const checked = dependencyType === opt.key;
690
+ return (
691
+ <button
692
+ key={opt.key}
693
+ type="button"
694
+ onClick={() => {
695
+ onDependencyTypeChange?.(opt.key);
696
+ setDepPopoverOpen(false);
697
+ }}
698
+ className={cn(
699
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
700
+ checked && "font-medium",
701
+ )}
702
+ >
703
+ <span className="flex-1">{t(opt.optionLabelKey)}</span>
704
+ {checked && <Check className="size-3.5 shrink-0 text-primary" />}
705
+ </button>
706
+ );
707
+ })}
708
+ </div>
709
+ ))}
710
+ </PopoverContent>
711
+ </Popover>
712
+ )}
641
713
  </div>
642
714
  )}
643
715
  <div className="flex items-center gap-3">
@@ -650,7 +722,11 @@ export function StorySelectDialog({
650
722
  </Button>
651
723
  <Button
652
724
  onClick={handleSubmit}
653
- disabled={(isMulti ? selectedCount === 0 : selectedIds.length === 0) || submitting}
725
+ disabled={
726
+ (isMulti ? selectedCount === 0 : selectedIds.length === 0) ||
727
+ submitting ||
728
+ (mode === "dependency" && !dependencyType)
729
+ }
654
730
  >
655
731
  {submitting ? t("select.loading") : isMulti ? t("select.confirmWithCount", { count: selectedCount }) : t("select.confirm")}
656
732
  </Button>
@@ -246,7 +246,14 @@ export function StoryPeekPanel({
246
246
  <>
247
247
  <div
248
248
  className="absolute inset-y-0 right-0 z-20 flex w-[min(100%,max(85vw,560px),1200px)] flex-col overflow-hidden border-l bg-popover rounded-r-2xl rounded-l-none shadow-xl ring-1 ring-foreground/10 @max-[1399px]:rounded-l-2xl"
249
- onClick={(e) => e.stopPropagation()}
249
+ onClick={(e) => {
250
+ // 仅拦截本容器 DOM 内的点击(防止冒泡到列表背景误关 peek)。
251
+ // Radix 弹层 portal 到 body、不在本容器 DOM 内,其合成事件冒泡会经过这里,
252
+ // 若 stopPropagation 会同时终止原生 click 冒泡到 document,
253
+ // 导致 DismissableLayer 的 outside-dismiss 判定失效(弹窗内 popover 无法关闭)。
254
+ if (!e.currentTarget.contains(e.target as Node)) return;
255
+ e.stopPropagation();
256
+ }}
250
257
  >
251
258
  {header}
252
259
  {body}
@@ -0,0 +1,319 @@
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 * as React from "react";
19
+ import { useTranslation } from "react-i18next";
20
+ import { Archive, MoreHorizontal, Trash2, Waypoints, CalendarCheck, CalendarPlus2 } from "lucide-react";
21
+ import { Button } from "@/components/ui/button";
22
+ import { Checkbox } from "@/components/ui/checkbox";
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuSeparator,
28
+ DropdownMenuTrigger,
29
+ } from "@/components/ui/dropdown-menu";
30
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
31
+ import { useServerFn } from "@tanstack/react-start";
32
+ import { toast } from "sonner";
33
+ import {
34
+ updateStoryFn,
35
+ archiveStoryFn,
36
+ unarchiveStoryFn,
37
+ deleteStoryFn,
38
+ } from "@/server/serverFns/story";
39
+ import { PriorityEditor, StateEditor, AssigneeEditor, DateEditor, LabelsEditor } from "../inline-editors";
40
+ import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
41
+ import { cn } from "@/lib/utils";
42
+ import { ProjectIconDisplay } from "@/components/project/project-icon";
43
+ import { Badge } from "@/components/ui/badge";
44
+ import { dependencyTypeOption } from "../constants";
45
+ import type { DisplayProperty } from "../display/types";
46
+ import type { StoryPatch } from "../display/group-fields";
47
+ import type {
48
+ StoryListItem,
49
+ UserBrief,
50
+ DependencyEdgeType,
51
+ DependencyGroup,
52
+ } from "../types";
53
+
54
+ /** 行内编辑/归档/删除后回传给面板的补丁载荷(StoryPatch 字段 + 面板控制信号) */
55
+ export type StoryRelationPatch = StoryPatch & {
56
+ archived?: boolean;
57
+ archivedAt?: string | null;
58
+ deleted?: boolean;
59
+ };
60
+
61
+ export interface StoryRefRowProps {
62
+ story: StoryListItem;
63
+ users: UserBrief[];
64
+ labels: { id: string; name: string; color: string }[];
65
+ parentProjectId: string | null;
66
+ projects: { id: string; name: string; icon: string; displayId: string | null }[];
67
+ selected: boolean;
68
+ properties: DisplayProperty[];
69
+ onToggle: (id: string) => void;
70
+ onOpenStory: (id: string) => void;
71
+ onStoryRefresh: (storyId: string, patch: StoryRelationPatch) => void;
72
+ /** 移除关系:子需求=移除子需求,依赖=移除依赖 */
73
+ onRemove: (storyId: string) => void;
74
+ removeMenuLabel: string;
75
+ /** 依赖行类型标签:传 depType + group 时行内渲染类型 Badge */
76
+ dependencyMeta?: { depType: DependencyEdgeType; group: keyof DependencyGroup };
77
+ /** 当前需求编号(tooltip 中以编号替代「我」指代当前需求) */
78
+ currentIdentifier?: string;
79
+ }
80
+
81
+ export function StoryRefRow({
82
+ story,
83
+ users,
84
+ labels,
85
+ parentProjectId,
86
+ projects,
87
+ selected,
88
+ properties,
89
+ onToggle,
90
+ onOpenStory,
91
+ onStoryRefresh,
92
+ onRemove,
93
+ removeMenuLabel,
94
+ dependencyMeta,
95
+ currentIdentifier,
96
+ }: StoryRefRowProps) {
97
+ const { t } = useTranslation("story");
98
+ const [storyState, setStoryState] = React.useState(story);
99
+ React.useEffect(() => {
100
+ setStoryState(story);
101
+ }, [story]);
102
+ const updateStory = useServerFn(updateStoryFn);
103
+ const archiveStory = useServerFn(archiveStoryFn);
104
+ const unarchiveStory = useServerFn(unarchiveStoryFn);
105
+ const deleteStory = useServerFn(deleteStoryFn);
106
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
107
+
108
+ const crossProject = storyState.projectId !== parentProjectId;
109
+ const storyProjectInfo = projects.find((p) => p.id === storyState.projectId) ?? null;
110
+
111
+ const updateField = async (patch: Record<string, any>) => {
112
+ const updated = { ...storyState, ...patch };
113
+ setStoryState(updated);
114
+ try {
115
+ await updateStory({ data: { storyId: story.id, ...patch } });
116
+ onStoryRefresh(story.id, patch);
117
+ } catch (e) {
118
+ setStoryState(story);
119
+ toast.error((e as Error)?.message || t("children.updateFailed"));
120
+ }
121
+ };
122
+
123
+ const currentLabelIds = storyState.labels.map((l) => l.id);
124
+ const overdue = !!(
125
+ storyState.targetDate &&
126
+ storyState.state !== "done" &&
127
+ new Date(storyState.targetDate.slice(0, 10)) < new Date(new Date().toISOString().slice(0, 10))
128
+ );
129
+
130
+ const canArchiveSingle = storyState.state === "done" || storyState.state === "cancelled";
131
+
132
+ const handleArchive = async () => {
133
+ if (!canArchiveSingle) {
134
+ toast.error(t("children.archiveOnlyDone"));
135
+ return;
136
+ }
137
+ try {
138
+ await archiveStory({ data: { storyId: story.id } });
139
+ onStoryRefresh(story.id, { archived: true, archivedAt: new Date().toISOString() });
140
+ toast.success(t("children.archived"));
141
+ } catch (e) {
142
+ toast.error((e as Error)?.message || t("children.archiveFailed"));
143
+ }
144
+ };
145
+
146
+ const handleUnarchive = async () => {
147
+ try {
148
+ await unarchiveStory({ data: { storyId: story.id } });
149
+ onStoryRefresh(story.id, { archived: false, archivedAt: null });
150
+ toast.success(t("children.unarchived"));
151
+ } catch (e) {
152
+ toast.error((e as Error)?.message || t("children.unarchiveFailed"));
153
+ }
154
+ };
155
+
156
+ const handleDelete = async () => {
157
+ try {
158
+ await deleteStory({ data: { storyId: story.id } });
159
+ onStoryRefresh(story.id, { deleted: true });
160
+ toast.success(t("children.deleted"));
161
+ } catch (e) {
162
+ toast.error((e as Error)?.message || t("children.deleteFailed"));
163
+ }
164
+ };
165
+
166
+ const showArchived = !storyState.archived;
167
+
168
+ return (
169
+ <div
170
+ className={`group flex items-center gap-1.5 px-2 py-1.5 text-sm transition-colors ${selected ? "bg-muted/20" : "bg-card hover:bg-accent/40"} border-b border-border/40`}
171
+ >
172
+ <Checkbox
173
+ checked={selected}
174
+ onCheckedChange={() => onToggle(story.id)}
175
+ aria-label={t("children.selectStory", { name: story.name })}
176
+ className={cn(
177
+ "shrink-0 transition-opacity",
178
+ selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
179
+ )}
180
+ />
181
+ <button
182
+ type="button"
183
+ onClick={() => onOpenStory(storyState.id)}
184
+ className="flex h-8 min-w-0 flex-1 items-center gap-2 text-left"
185
+ >
186
+ {crossProject && storyProjectInfo ? (
187
+ <Tooltip>
188
+ <TooltipTrigger asChild>
189
+ <span
190
+ className="shrink-0"
191
+ style={{ color: "oklch(80.517% 0.20237 152.675 / 0.909)" }}
192
+ >
193
+ <ProjectIconDisplay name={storyProjectInfo.icon} size={14} />
194
+ </span>
195
+ </TooltipTrigger>
196
+ <TooltipContent side="top" sideOffset={6} className="flex flex-col items-start gap-0.5 text-xs">
197
+ <span className="opacity-80">{t("children.crossProject")}</span>
198
+ <span className="font-medium">{storyProjectInfo.name}</span>
199
+ </TooltipContent>
200
+ </Tooltip>
201
+ ) : storyProjectInfo ? (
202
+ <ProjectIconDisplay
203
+ name={storyProjectInfo.icon}
204
+ size={14}
205
+ className="shrink-0 text-muted-foreground"
206
+ />
207
+ ) : null}
208
+ {crossProject && storyProjectInfo ? (
209
+ <Tooltip>
210
+ <TooltipTrigger asChild>
211
+ <span
212
+ className="font-mono text-xs shrink-0"
213
+ style={{ color: "oklch(80.517% 0.20237 152.675 / 0.909)" }}
214
+ >
215
+ {storyState.identifier}
216
+ </span>
217
+ </TooltipTrigger>
218
+ <TooltipContent side="top" sideOffset={6} className="flex flex-col items-start gap-0.5 text-xs">
219
+ <span className="opacity-80">{t("children.crossProject")}</span>
220
+ <span className="font-medium">{storyProjectInfo.name}</span>
221
+ </TooltipContent>
222
+ </Tooltip>
223
+ ) : (
224
+ <span className="font-mono text-xs text-muted-foreground shrink-0">{storyState.identifier}</span>
225
+ )}
226
+ {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>
247
+ )}
248
+ <span className="min-w-0 flex-1 truncate font-medium">{storyState.name}</span>
249
+ </button>
250
+ {properties.includes("priority") && (
251
+ <PriorityEditor value={storyState.priority} onChange={(v) => updateField({ priority: v })} variant="button" />
252
+ )}
253
+ {properties.includes("state") && (
254
+ <StateEditor value={storyState.state} onChange={(v) => updateField({ state: v })} variant="button" />
255
+ )}
256
+ {properties.includes("assignee") && (
257
+ <AssigneeEditor assignee={storyState.assignee} users={users} onChange={(v) => updateField({ assigneeId: v })} variant="button" />
258
+ )}
259
+ {properties.includes("startDate") && (
260
+ <DateEditor value={storyState.startDate} onChange={(v) => updateField({ startDate: v })} label={t("children.startDate")} icon={CalendarPlus2} />
261
+ )}
262
+ {properties.includes("dueDate") && (
263
+ <DateEditor value={storyState.targetDate} onChange={(v) => updateField({ targetDate: v })} label={t("children.dueDate")} icon={CalendarCheck} overdue={overdue} />
264
+ )}
265
+ {properties.includes("labels") && (
266
+ <LabelsEditor labelIds={currentLabelIds} labels={labels} onChange={(v) => updateField({ labelIds: v })} variant="button" />
267
+ )}
268
+ <DropdownMenu>
269
+ <DropdownMenuTrigger asChild>
270
+ <Button variant="ghost" size="icon" className="size-7 p-0 ml-auto border border-border/60 rounded hover:bg-muted">
271
+ <MoreHorizontal className="size-4" />
272
+ </Button>
273
+ </DropdownMenuTrigger>
274
+ <DropdownMenuContent align="end" className="w-40" sideOffset={4}>
275
+ <DropdownMenuItem onClick={() => onRemove(story.id)}>
276
+ <Waypoints className="mr-2 size-4" />
277
+ {removeMenuLabel}
278
+ </DropdownMenuItem>
279
+ <DropdownMenuSeparator />
280
+ {showArchived ? (
281
+ <>
282
+ {canArchiveSingle && (
283
+ <>
284
+ <DropdownMenuItem onClick={handleArchive}>
285
+ <Archive className="mr-2 size-4" />
286
+ {t("children.archive")}
287
+ </DropdownMenuItem>
288
+ <DropdownMenuSeparator />
289
+ </>
290
+ )}
291
+ <DropdownMenuItem
292
+ onClick={() => setDeleteDialogOpen(true)}
293
+ className="text-destructive focus:text-destructive"
294
+ >
295
+ <Trash2 className="mr-2 size-4" />
296
+ {t("children.delete")}
297
+ </DropdownMenuItem>
298
+ </>
299
+ ) : (
300
+ <DropdownMenuItem onClick={handleUnarchive}>
301
+ <Archive className="mr-2 size-4" />
302
+ {t("children.unarchive")}
303
+ </DropdownMenuItem>
304
+ )}
305
+ </DropdownMenuContent>
306
+ </DropdownMenu>
307
+
308
+ <NameConfirmDialog
309
+ open={deleteDialogOpen}
310
+ onOpenChange={setDeleteDialogOpen}
311
+ title={t("children.deleteTitle")}
312
+ description={t("children.deleteDescription")}
313
+ confirmText={t("children.delete")}
314
+ expectedName={storyState.identifier ?? storyState.id ?? ""}
315
+ onConfirm={handleDelete}
316
+ />
317
+ </div>
318
+ );
319
+ }
@@ -170,8 +170,10 @@ export function StoryListView({ stories: initialStories, users: initialUsers, pr
170
170
 
171
171
  const {
172
172
  groups: pagedGroups,
173
+ isLoading: pagedGroupsLoading,
173
174
  refresh: refreshPagedGroups,
174
175
  loadMore: loadGroupMore,
176
+ patchStory: patchPagedStory,
175
177
  } = usePagedStoryGroups({
176
178
  filters,
177
179
  conditions: filterConditions,
@@ -501,7 +503,7 @@ export function StoryListView({ stories: initialStories, users: initialUsers, pr
501
503
  )}
502
504
  {view === "calendar" && <CalendarView stories={stories.items} onCreate={handleCreate} onOpenStory={handleOpenStory} orderBy={displayFilters.orderBy} />}
503
505
  {view === "list" && (
504
- <StoryList groups={pagedGroups} displayFilters={displayFilters} users={users} labels={labels} projects={projects} onRefresh={listRefresh} onOpenStory={handleOpenStory} onCreate={handleCreate} onLoadMoreGroup={loadGroupMore} projectContext={projectContext} projectId={projectId} />
506
+ <StoryList groups={pagedGroups} isLoading={pagedGroupsLoading} displayFilters={displayFilters} users={users} labels={labels} projects={projects} onRefresh={listRefresh} onPatchStory={patchPagedStory} onOpenStory={handleOpenStory} onCreate={handleCreate} onLoadMoreGroup={loadGroupMore} projectContext={projectContext} projectId={projectId} />
505
507
  )}
506
508
  {view === "timeline" && <TimelineView stories={stories.items} onOpenStory={handleOpenStory} orderBy={displayFilters.orderBy} onLoadMore={loadMore} hasMore={hasMore} />}
507
509
  </div>
@@ -217,7 +217,10 @@ export function StoryPanelHeader({
217
217
 
218
218
  {!hidePrevNext && (onPrevious || onNext) && (
219
219
  <>
220
- <Separator orientation="vertical" data-vertical className="h-4 data-vertical:self-center mx-1" />
220
+ {/* 外层 div 限高:Separator 默认 data-[orientation=vertical]:h-full(属性选择器特异性高于单类),直接写 h-4 无法覆盖,会撑满整个 header 高度 */}
221
+ <div className="mx-1 flex h-4 items-center">
222
+ <Separator orientation="vertical" className="h-full" />
223
+ </div>
221
224
  {onPrevious && (
222
225
  <Tooltip>
223
226
  <TooltipTrigger asChild>
@@ -111,9 +111,6 @@ export function serializeStoryDescription(html: string, markdown: string): strin
111
111
  return JSON.stringify({ html, markdown });
112
112
  }
113
113
 
114
- // 依赖类型
115
- export type DependencyType = "blocks" | "blocked_by" | "relates_to";
116
-
117
114
  // Story 简要信息(用于关联关系)
118
115
  export interface StoryBrief {
119
116
  id: string;
@@ -134,21 +131,6 @@ export interface StoryTreeNode {
134
131
  children?: StoryTreeNode[];
135
132
  }
136
133
 
137
- // 依赖项(去重)
138
- export interface DependencyItem {
139
- id: string;
140
- title: string;
141
- status: StoryState;
142
- type: DependencyType;
143
- }
144
-
145
- // 依赖分组
146
- export interface DependencyGroup {
147
- blocking: DependencyItem[];
148
- blockedBy: DependencyItem[];
149
- relatesTo: DependencyItem[];
150
- }
151
-
152
134
  // 链接项
153
135
  export interface StoryLinkItem {
154
136
  id: string;
@@ -193,3 +175,20 @@ export interface RelationsData {
193
175
  docs: DocBrief[];
194
176
  };
195
177
  }
178
+
179
+ // ====== 依赖关系(对齐 Plane,新模型) ======
180
+
181
+ // 规范化存储类型:语义统一为「source 领先于 target」
182
+ export type DependencyEdgeType = "BLOCKS" | "STARTS_BEFORE" | "FINISHES_BEFORE";
183
+
184
+ // 依赖行 = 完整需求列表项 + 依赖元信息(支撑内联编辑与分组)
185
+ export interface DependencyRow extends StoryListItem {
186
+ depId: string; // story_dependencies 行 id(移除依赖用)
187
+ depType: DependencyEdgeType;
188
+ }
189
+
190
+ // 双视角分组(RelationsData.dependencies 最终形态,任务 2 切换引用)
191
+ export interface DependencyGroup {
192
+ dependsOn: DependencyRow[]; // 此需求依赖的(对方领先,边 targetId = 我)
193
+ dependents: DependencyRow[]; // 依赖此需求的(我领先,边 sourceId = 我)
194
+ }