@allbluecn/web-app 0.4.22 → 0.5.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.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +14 -3
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -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
|
-
|
|
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;
|
|
@@ -163,7 +145,9 @@ export interface StoryAttachmentItem {
|
|
|
163
145
|
fileName: string;
|
|
164
146
|
fileSize: number;
|
|
165
147
|
mimeType: string;
|
|
148
|
+
description?: string | null;
|
|
166
149
|
storageUrl: string;
|
|
150
|
+
fileHash?: string | null;
|
|
167
151
|
}
|
|
168
152
|
|
|
169
153
|
// PRD 简要信息
|
|
@@ -176,6 +160,11 @@ export interface PrdBrief {
|
|
|
176
160
|
export interface DocBrief {
|
|
177
161
|
id: string;
|
|
178
162
|
title: string;
|
|
163
|
+
kbId: string;
|
|
164
|
+
kbName: string;
|
|
165
|
+
kbIcon: string;
|
|
166
|
+
docIcon: string | null;
|
|
167
|
+
parentId: string | null;
|
|
179
168
|
}
|
|
180
169
|
|
|
181
170
|
// 关联 Tab 数据汇总
|
|
@@ -193,3 +182,26 @@ export interface RelationsData {
|
|
|
193
182
|
docs: DocBrief[];
|
|
194
183
|
};
|
|
195
184
|
}
|
|
185
|
+
|
|
186
|
+
/** 关联附件面板统一数据类型 */
|
|
187
|
+
export type AssociatedItem =
|
|
188
|
+
| { kind: "link"; data: StoryLinkItem }
|
|
189
|
+
| { kind: "attachment"; data: StoryAttachmentItem }
|
|
190
|
+
| { kind: "doc"; data: DocBrief };
|
|
191
|
+
|
|
192
|
+
// ====== 依赖关系(对齐 Plane,新模型) ======
|
|
193
|
+
|
|
194
|
+
// 规范化存储类型:语义统一为「source 领先于 target」
|
|
195
|
+
export type DependencyEdgeType = "BLOCKS" | "STARTS_BEFORE" | "FINISHES_BEFORE";
|
|
196
|
+
|
|
197
|
+
// 依赖行 = 完整需求列表项 + 依赖元信息(支撑内联编辑与分组)
|
|
198
|
+
export interface DependencyRow extends StoryListItem {
|
|
199
|
+
depId: string; // story_dependencies 行 id(移除依赖用)
|
|
200
|
+
depType: DependencyEdgeType;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 双视角分组(RelationsData.dependencies 最终形态,任务 2 切换引用)
|
|
204
|
+
export interface DependencyGroup {
|
|
205
|
+
dependsOn: DependencyRow[]; // 此需求依赖的(对方领先,边 targetId = 我)
|
|
206
|
+
dependents: DependencyRow[]; // 依赖此需求的(我领先,边 sourceId = 我)
|
|
207
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { useState } from "react";
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
|
-
import { ArrowUp, ArrowDown, Archive, Trash2, BrushCleaning, CalendarCheck, CalendarPlus2, GitCompareArrows } from "lucide-react";
|
|
20
|
+
import { ArrowUp, ArrowDown, Archive, Trash2, BrushCleaning, CalendarCheck, CalendarPlus2, GitCompareArrows, Waypoints } from "lucide-react";
|
|
21
21
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
22
22
|
import { Button } from "@/components/ui/button";
|
|
23
23
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
@@ -53,6 +53,8 @@ export interface BatchToolbarProps {
|
|
|
53
53
|
onMoveDown: () => void;
|
|
54
54
|
onArchive: (storyIds: string[]) => void;
|
|
55
55
|
onDelete: (storyIds: string[]) => void;
|
|
56
|
+
/** 可选:批量移除依赖关系(依赖面板传入时显示按钮;入参为需求 id) */
|
|
57
|
+
onRemoveRelations?: (storyIds: string[]) => void;
|
|
56
58
|
onBulkUpdate: (patch: StoryPatch) => void;
|
|
57
59
|
onClearSelection: () => void;
|
|
58
60
|
onTransfer: (updatedStories?: Array<{ id: string; identifier: string; projectId: string; parentId: string | null }>) => void;
|
|
@@ -76,6 +78,7 @@ export function BatchToolbar({
|
|
|
76
78
|
onMoveDown,
|
|
77
79
|
onArchive,
|
|
78
80
|
onDelete,
|
|
81
|
+
onRemoveRelations,
|
|
79
82
|
onBulkUpdate,
|
|
80
83
|
onClearSelection,
|
|
81
84
|
onTransfer,
|
|
@@ -141,6 +144,14 @@ export function BatchToolbar({
|
|
|
141
144
|
</Button>
|
|
142
145
|
}
|
|
143
146
|
/>
|
|
147
|
+
{onRemoveRelations && (
|
|
148
|
+
<ToolbarIconButton
|
|
149
|
+
label={t("dependency.remove")}
|
|
150
|
+
icon={Waypoints}
|
|
151
|
+
disabled={busy}
|
|
152
|
+
onClick={() => onRemoveRelations(stories.map((s) => s.id))}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
144
155
|
<ToolbarIconButton label={t("batchToolbar.archive")} icon={Archive} disabled={busy || !canArchive} onClick={() => setArchiveOpen(true)} />
|
|
145
156
|
<ToolbarIconButton label={t("batchToolbar.delete")} icon={Trash2} disabled={busy} onClick={() => setDeleteOpen(true)} />
|
|
146
157
|
<div className="mx-1 h-4 w-px bg-border" />
|
|
@@ -36,8 +36,9 @@ import { StoryListRow } from "./row";
|
|
|
36
36
|
import { BatchToolbar } from "./batch-toolbar";
|
|
37
37
|
import { computeMove, canMove } from "./move";
|
|
38
38
|
import { computeGroupCheckState, toggleGroupSelection } from "./group-select";
|
|
39
|
-
import { GROUP_FIELD, toGroupFieldPatch, type GroupField } from "../../display/group-fields";
|
|
39
|
+
import { GROUP_FIELD, toGroupFieldPatch, type GroupField, type StoryPatch } from "../../display/group-fields";
|
|
40
40
|
import type { GroupKey } from "../../display/types";
|
|
41
|
+
import { WavePhysicsLoader } from "@/components/shared/wave-physics-loader";
|
|
41
42
|
|
|
42
43
|
const HEADER_ROW_HEIGHT = 36;
|
|
43
44
|
const STORY_ROW_HEIGHT = 44;
|
|
@@ -45,11 +46,15 @@ const OVERSCAN = 8;
|
|
|
45
46
|
|
|
46
47
|
interface StoryListProps {
|
|
47
48
|
groups: PagedGroupNode[];
|
|
49
|
+
/** 首屏加载中:显示骨架行而非「暂无需求」空态,避免加载期间闪烁空态文案 */
|
|
50
|
+
isLoading?: boolean;
|
|
48
51
|
displayFilters: DisplayOptions;
|
|
49
52
|
users: UserBrief[];
|
|
50
53
|
labels: { id: string; name: string; color: string }[];
|
|
51
54
|
projects: { id: string; name: string; displayId?: string | null; icon: string }[];
|
|
52
55
|
onRefresh: () => void;
|
|
56
|
+
/** 行内编辑乐观原地更新(替代整表 refresh,保持未触发规则的行位置不变) */
|
|
57
|
+
onPatchStory: (storyId: string, patch: StoryPatch) => void;
|
|
53
58
|
onOpenStory: (id: string) => void;
|
|
54
59
|
onCreate?: (startDate?: string, state?: string) => void;
|
|
55
60
|
onLoadMoreGroup?: (groupKey: string, subKey?: string) => void;
|
|
@@ -57,7 +62,7 @@ interface StoryListProps {
|
|
|
57
62
|
projectId?: string;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
export function StoryList({ groups, displayFilters, users, labels, projects, onRefresh, onOpenStory, onCreate, onLoadMoreGroup, projectContext, projectId }: StoryListProps) {
|
|
65
|
+
export function StoryList({ groups, isLoading = false, displayFilters, users, labels, projects, onRefresh, onPatchStory, onOpenStory, onCreate, onLoadMoreGroup, projectContext, projectId }: StoryListProps) {
|
|
61
66
|
const { t } = useTranslation("story");
|
|
62
67
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
63
68
|
const { groupBy, subGroupBy, properties } = displayFilters;
|
|
@@ -399,8 +404,8 @@ await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
|
|
|
399
404
|
labels={labels}
|
|
400
405
|
properties={properties}
|
|
401
406
|
onOpenStory={onOpenStory}
|
|
402
|
-
|
|
403
|
-
|
|
407
|
+
onPatchStory={onPatchStory}
|
|
408
|
+
selected={selectedIds.has(row.story.id)}
|
|
404
409
|
onToggle={toggleStory}
|
|
405
410
|
/>
|
|
406
411
|
</div>
|
|
@@ -439,7 +444,13 @@ await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
|
|
|
439
444
|
<div style={{ height: totalHeight, position: "relative" }}>
|
|
440
445
|
{virtualRows.map((virtualRow) => renderVirtualRow(virtualRow))}
|
|
441
446
|
</div>
|
|
442
|
-
{groups.length === 0 && (
|
|
447
|
+
{isLoading && groups.length === 0 && (
|
|
448
|
+
<div className="flex flex-col items-center gap-3 py-12" aria-busy="true">
|
|
449
|
+
<WavePhysicsLoader />
|
|
450
|
+
<p className="text-sm text-muted-foreground">{t("group.loadingData")}</p>
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
{!isLoading && groups.length === 0 && (
|
|
443
454
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
|
444
455
|
{t("table.empty")}
|
|
445
456
|
</div>
|
|
@@ -39,12 +39,13 @@ interface StoryListRowProps {
|
|
|
39
39
|
labels: { id: string; name: string; color: string }[];
|
|
40
40
|
properties: DisplayProperty[];
|
|
41
41
|
onOpenStory: (id: string) => void;
|
|
42
|
-
|
|
42
|
+
/** 行内编辑保存成功后的乐观原地更新回调(未触发分组/排序规则时保持位置不变) */
|
|
43
|
+
onPatchStory: (storyId: string, patch: StoryPatch) => void;
|
|
43
44
|
selected: boolean;
|
|
44
45
|
onToggle: (id: string) => void;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export const StoryListRow = memo(function StoryListRow({ story, group, indent, isChild, users, labels, properties, onOpenStory,
|
|
48
|
+
export const StoryListRow = memo(function StoryListRow({ story, group, indent, isChild, users, labels, properties, onOpenStory, onPatchStory, selected, onToggle }: StoryListRowProps) {
|
|
48
49
|
const { t } = useTranslation("story");
|
|
49
50
|
const updateStory = useServerFn(updateStoryFn);
|
|
50
51
|
|
|
@@ -52,12 +53,12 @@ export const StoryListRow = memo(function StoryListRow({ story, group, indent, i
|
|
|
52
53
|
async (patch: StoryPatch) => {
|
|
53
54
|
try {
|
|
54
55
|
await updateStory({ data: { storyId: story.id, ...patch } });
|
|
55
|
-
|
|
56
|
+
onPatchStory(story.id, patch);
|
|
56
57
|
} catch (e) {
|
|
57
58
|
toast.error((e as Error)?.message || t("row.updateFailed"));
|
|
58
59
|
}
|
|
59
60
|
},
|
|
60
|
-
[story.id, updateStory,
|
|
61
|
+
[story.id, updateStory, onPatchStory, t],
|
|
61
62
|
);
|
|
62
63
|
|
|
63
64
|
const setPriority = useCallback((v: StoryPriority) => updateField({ priority: v }), [updateField]);
|
|
@@ -34,7 +34,7 @@ function Separator({
|
|
|
34
34
|
decorative={decorative}
|
|
35
35
|
orientation={orientation}
|
|
36
36
|
className={cn(
|
|
37
|
-
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:
|
|
37
|
+
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
|
38
38
|
className
|
|
39
39
|
)}
|
|
40
40
|
{...props}
|
|
@@ -8,7 +8,9 @@ import type { NotificationSseValue, NotificationItem } from '@allbluecn/shared'
|
|
|
8
8
|
import { isInDndWindow } from '@allbluecn/shared'
|
|
9
9
|
import { getUnreadCountFn, getNotificationPreferencesFn } from '@/server/serverFns/notifications'
|
|
10
10
|
|
|
11
|
+
export const NOTIFICATION_ROOT_KEY = ['notifications'] as const
|
|
11
12
|
export const NOTIFICATION_LIST_KEY = ['notifications', 'list'] as const
|
|
13
|
+
export const NOTIFICATION_POPOVER_KEY = ['notifications', 'popover'] as const
|
|
12
14
|
export const NOTIFICATION_UNREAD_KEY = ['notifications', 'unread'] as const
|
|
13
15
|
export const NOTIFICATION_PREFERENCES_KEY = ['notifications', 'preferences'] as const
|
|
14
16
|
|
|
@@ -92,7 +94,8 @@ export function useNotifications(options?: { toastEnabled?: boolean }) {
|
|
|
92
94
|
const count = (old as { count?: number } | undefined)?.count ?? 0
|
|
93
95
|
return { count: count + 1 }
|
|
94
96
|
})
|
|
95
|
-
|
|
97
|
+
// 通知到达:所有通知相关查询(列表/详情页分页/popover)标记过期
|
|
98
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_ROOT_KEY })
|
|
96
99
|
if (toastEnabled) {
|
|
97
100
|
const prefs = queryClient.getQueryData<{ dndEnabled: boolean; dndStart: string; dndEnd: string }>(NOTIFICATION_PREFERENCES_KEY)
|
|
98
101
|
const now = new Date()
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { describe, it, expect } from 'vitest'
|
|
19
|
-
import { formatDate, formatRelative, resolveTimezone } from '../datetime'
|
|
19
|
+
import { formatDate, formatRelative, resolveTimezone, formatTimestamp } from '../datetime'
|
|
20
20
|
|
|
21
21
|
describe('resolveTimezone', () => {
|
|
22
22
|
it('项目时区优先', () => {
|
|
@@ -71,4 +71,19 @@ describe('formatRelative', () => {
|
|
|
71
71
|
const result = formatRelative(date, 'Asia/Shanghai', now)
|
|
72
72
|
expect(result).toMatch(/2026-06-01/)
|
|
73
73
|
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('formatTimestamp', () => {
|
|
77
|
+
it('固定 Asia/Shanghai 输出,与运行环境 locale/时区无关', () => {
|
|
78
|
+
expect(formatTimestamp('2026-07-31T08:00:00.000Z')).toBe('2026-07-31 16:00')
|
|
79
|
+
})
|
|
80
|
+
it('接受 Date 对象', () => {
|
|
81
|
+
expect(formatTimestamp(new Date('2026-07-31T08:00:00.000Z'))).toBe('2026-07-31 16:00')
|
|
82
|
+
})
|
|
83
|
+
it('自定义格式(监控历史秒级精度)', () => {
|
|
84
|
+
expect(formatTimestamp('2026-07-31T08:00:05.000Z', 'yyyy-MM-dd HH:mm:ss')).toBe('2026-07-31 16:00:05')
|
|
85
|
+
})
|
|
86
|
+
it('无效输入返回占位符不抛错', () => {
|
|
87
|
+
expect(formatTimestamp('not-a-date')).toBe('-')
|
|
88
|
+
})
|
|
74
89
|
})
|