@allbluecn/web-app 0.5.0 → 0.6.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.
- package/package.json +239 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- 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/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +14 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- 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 +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- 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/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/lib/project-mock.ts +0 -156
|
@@ -0,0 +1,88 @@
|
|
|
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 { prisma } from "@allbluecn/database"
|
|
19
|
+
import {
|
|
20
|
+
hasAiKey,
|
|
21
|
+
runChat,
|
|
22
|
+
buildLabelSuggestSystemPrompt,
|
|
23
|
+
buildLabelSuggestUserMessage,
|
|
24
|
+
parseLabelSuggestions,
|
|
25
|
+
} from "@allbluecn/ai-gateway"
|
|
26
|
+
|
|
27
|
+
const MAX_LABELS_IN_PROMPT = 100
|
|
28
|
+
|
|
29
|
+
export interface SuggestLabelsInput {
|
|
30
|
+
name: string
|
|
31
|
+
description?: string
|
|
32
|
+
currentLabelNames?: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SuggestLabelsResult {
|
|
36
|
+
existingLabels: { id: string; name: string; color: string }[]
|
|
37
|
+
newNames: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function suggestLabelsCore(
|
|
41
|
+
input: SuggestLabelsInput,
|
|
42
|
+
userId: string,
|
|
43
|
+
): Promise<SuggestLabelsResult> {
|
|
44
|
+
if (!(await hasAiKey(userId))) {
|
|
45
|
+
throw new Error("AI 未配置,请先前往 AI 设置页配置 API Key")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const allLabels = await prisma.label.findMany({
|
|
49
|
+
select: { id: true, name: true, color: true },
|
|
50
|
+
orderBy: { name: "asc" },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
let candidates = allLabels
|
|
54
|
+
if (allLabels.length > MAX_LABELS_IN_PROMPT) {
|
|
55
|
+
const usage = await prisma.storyLabel.groupBy({
|
|
56
|
+
by: ["labelId"],
|
|
57
|
+
_count: { _all: true },
|
|
58
|
+
orderBy: { _count: { labelId: "desc" } },
|
|
59
|
+
take: MAX_LABELS_IN_PROMPT,
|
|
60
|
+
})
|
|
61
|
+
const topIds = new Set(usage.map((u) => u.labelId))
|
|
62
|
+
candidates = allLabels.filter((l) => topIds.has(l.id))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const names = candidates.map((l) => l.name)
|
|
66
|
+
const raw = await runChat(
|
|
67
|
+
[{ role: "user", content: buildLabelSuggestUserMessage(input) }],
|
|
68
|
+
{
|
|
69
|
+
systemPrompts: [buildLabelSuggestSystemPrompt(names)],
|
|
70
|
+
modelOptions: { temperature: 0.2 },
|
|
71
|
+
userId,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const parsed = parseLabelSuggestions(raw, names)
|
|
76
|
+
const byName = new Map(candidates.map((l) => [l.name, l]))
|
|
77
|
+
const existingLabels: { id: string; name: string; color: string }[] = []
|
|
78
|
+
const unmatchedNames: string[] = []
|
|
79
|
+
for (const n of parsed.existing) {
|
|
80
|
+
const l = byName.get(n)
|
|
81
|
+
if (l) existingLabels.push(l)
|
|
82
|
+
else unmatchedNames.push(n)
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
existingLabels,
|
|
86
|
+
newNames: [...parsed.newNames, ...unmatchedNames],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { createServerFn } from "@tanstack/react-start"
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server"
|
|
20
|
+
import { z } from "zod"
|
|
21
|
+
import { requireAuth } from "@/server/auth-helpers"
|
|
22
|
+
import { suggestLabelsCore } from "./label-suggest-core"
|
|
23
|
+
|
|
24
|
+
const suggestSchema = z.object({
|
|
25
|
+
name: z.string().min(1).max(200),
|
|
26
|
+
description: z.string().max(4000).optional(),
|
|
27
|
+
currentLabelNames: z.array(z.string().min(1).max(10)).max(20).optional(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const suggestStoryLabelsFn = createServerFn({ method: "POST" })
|
|
31
|
+
.validator(suggestSchema)
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const request = getRequest()
|
|
34
|
+
const session = await requireAuth(request)
|
|
35
|
+
const userId = session.user?.id
|
|
36
|
+
if (!userId) throw new Error("用户未登录")
|
|
37
|
+
return suggestLabelsCore(data, userId)
|
|
38
|
+
})
|
|
@@ -15,24 +15,18 @@
|
|
|
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 {
|
|
19
|
-
import {
|
|
18
|
+
import { prisma } from "@allbluecn/database";
|
|
19
|
+
import { pickLabelColor } from "./label-palette";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
name,
|
|
33
|
-
size = 24,
|
|
34
|
-
className,
|
|
35
|
-
}: KnowledgeIconDisplayProps) {
|
|
36
|
-
const Icon = ICON_MAP.get(name ?? "") ?? KNOWLEDGE_ICONS[0].icon;
|
|
37
|
-
return <Icon size={size} className={cn("text-primary", className)} />;
|
|
21
|
+
export async function upsertLabel(name: string, createdById: string, color?: string) {
|
|
22
|
+
return prisma.label.upsert({
|
|
23
|
+
where: { name },
|
|
24
|
+
update: {},
|
|
25
|
+
create: {
|
|
26
|
+
name,
|
|
27
|
+
color: color ?? pickLabelColor(name),
|
|
28
|
+
createdById,
|
|
29
|
+
},
|
|
30
|
+
select: { id: true, name: true, color: true },
|
|
31
|
+
});
|
|
38
32
|
}
|
|
@@ -21,10 +21,11 @@ import { z } from "zod";
|
|
|
21
21
|
import { prisma } from "@allbluecn/database";
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
23
|
import { USER_BRIEF_SELECT } from "../types";
|
|
24
|
+
import { upsertLabel } from "./label-upsert";
|
|
24
25
|
|
|
25
26
|
const labelCreateSchema = z.object({
|
|
26
27
|
name: z.string().min(1).max(30),
|
|
27
|
-
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).
|
|
28
|
+
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
export const listUsersBriefFn = createServerFn({ method: "GET" })
|
|
@@ -98,8 +99,5 @@ export const createLabelFn = createServerFn({ method: "POST" })
|
|
|
98
99
|
.handler(async ({ data }) => {
|
|
99
100
|
const request = getRequest();
|
|
100
101
|
const session = await requireAuth(request);
|
|
101
|
-
return
|
|
102
|
-
data: { name: data.name, color: data.color, createdById: session.user!.id! },
|
|
103
|
-
select: { id: true, name: true, color: true },
|
|
104
|
-
});
|
|
102
|
+
return upsertLabel(data.name, session.user!.id!, data.color);
|
|
105
103
|
});
|
|
@@ -25,7 +25,7 @@ import { isUserInTeam } from "@/server/team-access";
|
|
|
25
25
|
import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
|
|
26
26
|
import type { ActivityItem } from "@/components/story/types";
|
|
27
27
|
import { diffDescriptionBlocks } from "@/lib/story/description-diff";
|
|
28
|
-
import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly } from "./types";
|
|
28
|
+
import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly, ACTIVITY_SELECT, toActivityItem } from "./types";
|
|
29
29
|
import { buildListWhere } from "./story-list-query";
|
|
30
30
|
|
|
31
31
|
async function validateAssignee(projectId: string, assigneeId: string | null | undefined) {
|
|
@@ -116,9 +116,10 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
116
116
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
117
117
|
try {
|
|
118
118
|
return await prisma.$transaction(async (tx) => {
|
|
119
|
+
let parentStory: { id: string; identifier: string; name: string } | null = null;
|
|
119
120
|
if (data.parentId) {
|
|
120
|
-
|
|
121
|
-
if (!
|
|
121
|
+
parentStory = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true, identifier: true, name: true } });
|
|
122
|
+
if (!parentStory) throw new Error("父需求不存在");
|
|
122
123
|
}
|
|
123
124
|
let sequenceId: number;
|
|
124
125
|
let identifier: string;
|
|
@@ -132,8 +133,8 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
132
133
|
const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
|
|
133
134
|
if (isIndependent) {
|
|
134
135
|
const counter = await tx.sequenceCounter.upsert({
|
|
135
|
-
where: { scope:
|
|
136
|
-
create: { scope:
|
|
136
|
+
where: { scope: projectId },
|
|
137
|
+
create: { scope: projectId, seq: 1 },
|
|
137
138
|
update: { seq: { increment: 1 } },
|
|
138
139
|
});
|
|
139
140
|
sequenceId = counter.seq;
|
|
@@ -177,7 +178,23 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
177
178
|
data: { storyId: story.id, userId, action: "created", isAI: data.isAI },
|
|
178
179
|
});
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
// 创建时带父需求:父需求侧补「添加子需求」活动
|
|
182
|
+
let parentActivity: ReturnType<typeof toActivityItem> | null = null;
|
|
183
|
+
if (parentStory) {
|
|
184
|
+
const pa = await tx.storyActivity.create({
|
|
185
|
+
data: {
|
|
186
|
+
storyId: parentStory.id,
|
|
187
|
+
userId,
|
|
188
|
+
action: "child_added",
|
|
189
|
+
newValue: `${story.identifier} ${story.name}`.trim(),
|
|
190
|
+
isAI: data.isAI,
|
|
191
|
+
},
|
|
192
|
+
select: ACTIVITY_SELECT,
|
|
193
|
+
});
|
|
194
|
+
parentActivity = toActivityItem(pa);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { ...castStoryUsers(story), activities: parentActivity ? [parentActivity] : [] };
|
|
181
198
|
});
|
|
182
199
|
} catch (e: unknown) {
|
|
183
200
|
const isUniqueViolation =
|
|
@@ -205,6 +222,7 @@ const updateSchema = z.object({
|
|
|
205
222
|
targetDate: z.string().nullable().optional(),
|
|
206
223
|
labelIds: z.array(z.string()).optional(),
|
|
207
224
|
parentId: z.string().nullable().optional(),
|
|
225
|
+
iterationId: z.string().nullable().optional(),
|
|
208
226
|
isAI: z.boolean().default(false),
|
|
209
227
|
});
|
|
210
228
|
|
|
@@ -335,6 +353,35 @@ export const updateStoryFn = createServerFn({ method: "POST" })
|
|
|
335
353
|
}
|
|
336
354
|
}
|
|
337
355
|
|
|
356
|
+
if (data.iterationId !== undefined) {
|
|
357
|
+
const newIterationId = data.iterationId;
|
|
358
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
359
|
+
const oldIterationId = (existing as any).iteration?.id ?? null;
|
|
360
|
+
if (newIterationId !== oldIterationId) {
|
|
361
|
+
let oldName = "";
|
|
362
|
+
let newName = "";
|
|
363
|
+
if (oldIterationId) {
|
|
364
|
+
const oldIt = await tx.iteration.findUnique({ where: { id: oldIterationId }, select: { name: true } });
|
|
365
|
+
oldName = oldIt?.name ?? "";
|
|
366
|
+
}
|
|
367
|
+
if (newIterationId) {
|
|
368
|
+
const newIt = await tx.iteration.findUnique({ where: { id: newIterationId }, select: { name: true } });
|
|
369
|
+
newName = newIt?.name ?? "";
|
|
370
|
+
}
|
|
371
|
+
if (newIterationId) {
|
|
372
|
+
updates.iteration = { connect: { id: newIterationId } };
|
|
373
|
+
} else {
|
|
374
|
+
updates.iteration = { disconnect: true };
|
|
375
|
+
}
|
|
376
|
+
activities.push({
|
|
377
|
+
action: "iteration_changed",
|
|
378
|
+
field: "iterationId",
|
|
379
|
+
oldValue: oldName,
|
|
380
|
+
newValue: newName,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
338
385
|
updates.updatedBy = { connect: { id: userId } };
|
|
339
386
|
|
|
340
387
|
const updated = await tx.story.update({
|
|
@@ -20,7 +20,7 @@ import { getRequest } from "@tanstack/react-start/server";
|
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { prisma } from "@allbluecn/database";
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
-
import { STORY_LIST_SELECT, castStoryUsers } from "./types";
|
|
23
|
+
import { STORY_LIST_SELECT, castStoryUsers, ACTIVITY_SELECT, toActivityItem } from "./types";
|
|
24
24
|
import type { DependencyRow } from "@/components/story/types";
|
|
25
25
|
|
|
26
26
|
const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
|
|
@@ -41,7 +41,8 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
|
|
|
41
41
|
)
|
|
42
42
|
.handler(async ({ data }) => {
|
|
43
43
|
const request = getRequest();
|
|
44
|
-
await requireAuth(request);
|
|
44
|
+
const session = await requireAuth(request);
|
|
45
|
+
const userId = session.user!.id!;
|
|
45
46
|
|
|
46
47
|
// 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
|
|
47
48
|
// 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
|
|
@@ -111,15 +112,167 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
|
|
|
111
112
|
batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
// 活动记录:依赖两端各记一条「添加依赖关系」(newValue 为对方需求描述),
|
|
116
|
+
// 返回带 storyId 供 UI 过滤当前需求侧的记录
|
|
117
|
+
let activities: Array<ReturnType<typeof toActivityItem> & { storyId: string }> = [];
|
|
118
|
+
if (created.length > 0) {
|
|
119
|
+
const endpointIds = [...new Set(batchEdges.flatMap((e) => [e.sourceId, e.targetId]))];
|
|
120
|
+
const endpoints = await prisma.story.findMany({
|
|
121
|
+
where: { id: { in: endpointIds } },
|
|
122
|
+
select: { id: true, identifier: true, name: true },
|
|
123
|
+
});
|
|
124
|
+
const labelById = new Map(
|
|
125
|
+
endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
|
|
126
|
+
);
|
|
127
|
+
const createdActivities = await Promise.all(
|
|
128
|
+
batchEdges.flatMap(({ sourceId, targetId }) => {
|
|
129
|
+
const sourceLabel = labelById.get(sourceId) ?? "";
|
|
130
|
+
const targetLabel = labelById.get(targetId) ?? "";
|
|
131
|
+
return [
|
|
132
|
+
prisma.storyActivity.create({
|
|
133
|
+
data: { storyId: sourceId, userId, action: "dependency_added", newValue: targetLabel },
|
|
134
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
135
|
+
}),
|
|
136
|
+
prisma.storyActivity.create({
|
|
137
|
+
data: { storyId: targetId, userId, action: "dependency_added", newValue: sourceLabel },
|
|
138
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
139
|
+
}),
|
|
140
|
+
];
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
activities = createdActivities.map((a) => ({ ...toActivityItem(a), storyId: a.storyId }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { created, failed, activities };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const updateDependencyFn = createServerFn({ method: "POST" })
|
|
150
|
+
.validator(
|
|
151
|
+
z.object({
|
|
152
|
+
depId: z.string(),
|
|
153
|
+
sourceId: z.string(),
|
|
154
|
+
targetId: z.string(),
|
|
155
|
+
type: edgeTypeSchema,
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
.handler(async ({ data }) => {
|
|
159
|
+
const request = getRequest();
|
|
160
|
+
const session = await requireAuth(request);
|
|
161
|
+
const userId = session.user!.id!;
|
|
162
|
+
|
|
163
|
+
const dep = await prisma.storyDependency.findUnique({ where: { id: data.depId } });
|
|
164
|
+
if (!dep) throw new Error("依赖关系不存在");
|
|
165
|
+
// 新边两端必须是原边的重定向(允许方向反转),防止越权改到别的需求对
|
|
166
|
+
const sameSet =
|
|
167
|
+
(data.sourceId === dep.sourceId && data.targetId === dep.targetId) ||
|
|
168
|
+
(data.sourceId === dep.targetId && data.targetId === dep.sourceId);
|
|
169
|
+
if (!sameSet) throw new Error("依赖关系两端不匹配");
|
|
170
|
+
if (
|
|
171
|
+
data.sourceId === dep.sourceId &&
|
|
172
|
+
data.targetId === dep.targetId &&
|
|
173
|
+
data.type === dep.type
|
|
174
|
+
) {
|
|
175
|
+
return { ok: true as const, activities: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 方向反转可能成环:写入 (S,T) 后成环 ⇔ 排除自身边后存在路径 T →…→ S
|
|
179
|
+
const edges = await prisma.storyDependency.findMany({
|
|
180
|
+
where: { id: { not: dep.id } },
|
|
181
|
+
select: { sourceId: true, targetId: true },
|
|
182
|
+
});
|
|
183
|
+
const adjacency = new Map<string, string[]>();
|
|
184
|
+
for (const e of edges) {
|
|
185
|
+
const list = adjacency.get(e.sourceId);
|
|
186
|
+
if (list) list.push(e.targetId);
|
|
187
|
+
else adjacency.set(e.sourceId, [e.targetId]);
|
|
188
|
+
}
|
|
189
|
+
const visited = new Set<string>();
|
|
190
|
+
const stack = [data.targetId];
|
|
191
|
+
let cyclic = false;
|
|
192
|
+
while (stack.length > 0) {
|
|
193
|
+
const current = stack.pop()!;
|
|
194
|
+
if (current === data.sourceId) {
|
|
195
|
+
cyclic = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (visited.has(current)) continue;
|
|
199
|
+
visited.add(current);
|
|
200
|
+
for (const next of adjacency.get(current) ?? []) {
|
|
201
|
+
if (!visited.has(next)) stack.push(next);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (cyclic) return { ok: false as const, reason: "CYCLE" as const };
|
|
205
|
+
|
|
206
|
+
await prisma.storyDependency.update({
|
|
207
|
+
where: { id: dep.id },
|
|
208
|
+
data: { sourceId: data.sourceId, targetId: data.targetId, type: data.type },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 两端各记一条「更新依赖关系」(newValue 为对方需求描述,与添加/移除一致)
|
|
212
|
+
const endpoints = await prisma.story.findMany({
|
|
213
|
+
where: { id: { in: [data.sourceId, data.targetId] } },
|
|
214
|
+
select: { id: true, identifier: true, name: true },
|
|
215
|
+
});
|
|
216
|
+
const labelById = new Map(
|
|
217
|
+
endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
|
|
218
|
+
);
|
|
219
|
+
const acts = await Promise.all([
|
|
220
|
+
prisma.storyActivity.create({
|
|
221
|
+
data: {
|
|
222
|
+
storyId: data.sourceId,
|
|
223
|
+
userId,
|
|
224
|
+
action: "dependency_updated",
|
|
225
|
+
newValue: labelById.get(data.targetId) ?? "",
|
|
226
|
+
},
|
|
227
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
228
|
+
}),
|
|
229
|
+
prisma.storyActivity.create({
|
|
230
|
+
data: {
|
|
231
|
+
storyId: data.targetId,
|
|
232
|
+
userId,
|
|
233
|
+
action: "dependency_updated",
|
|
234
|
+
newValue: labelById.get(data.sourceId) ?? "",
|
|
235
|
+
},
|
|
236
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
return {
|
|
240
|
+
ok: true as const,
|
|
241
|
+
activities: acts.map((a) => ({ ...toActivityItem(a), storyId: a.storyId })),
|
|
242
|
+
};
|
|
115
243
|
});
|
|
116
244
|
|
|
117
245
|
export const removeDependencyFn = createServerFn({ method: "POST" })
|
|
118
246
|
.validator(z.object({ depId: z.string() }))
|
|
119
247
|
.handler(async ({ data }) => {
|
|
120
248
|
const request = getRequest();
|
|
121
|
-
await requireAuth(request);
|
|
122
|
-
|
|
249
|
+
const session = await requireAuth(request);
|
|
250
|
+
const userId = session.user!.id!;
|
|
251
|
+
|
|
252
|
+
const dep = await prisma.storyDependency.findUnique({ where: { id: data.depId } });
|
|
253
|
+
if (!dep) return null;
|
|
254
|
+
|
|
255
|
+
const [sourceStory, targetStory] = await Promise.all([
|
|
256
|
+
prisma.story.findUnique({ where: { id: dep.sourceId }, select: { identifier: true, name: true } }),
|
|
257
|
+
prisma.story.findUnique({ where: { id: dep.targetId }, select: { identifier: true, name: true } }),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
await prisma.storyDependency.delete({ where: { id: data.depId } });
|
|
261
|
+
|
|
262
|
+
// 两端都记活动记录
|
|
263
|
+
const depLabel = targetStory?.identifier || targetStory?.name || dep.targetId;
|
|
264
|
+
const act1 = await prisma.storyActivity.create({
|
|
265
|
+
data: { storyId: dep.sourceId, userId, action: "dependency_removed", newValue: depLabel },
|
|
266
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
267
|
+
});
|
|
268
|
+
const depLabel2 = sourceStory?.identifier || sourceStory?.name || dep.sourceId;
|
|
269
|
+
const act2 = await prisma.storyActivity.create({
|
|
270
|
+
data: { storyId: dep.targetId, userId, action: "dependency_removed", newValue: depLabel2 },
|
|
271
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// toActivityItem:Date → ISO 字符串,Seroval 可序列化;带 storyId 供客户端过滤当前需求的记录
|
|
275
|
+
return { dep, activities: [act1, act2].map((a) => ({ ...toActivityItem(a), storyId: a.storyId })) };
|
|
123
276
|
});
|
|
124
277
|
|
|
125
278
|
export const getDependenciesFn = createServerFn({ method: "GET" })
|
|
@@ -20,12 +20,13 @@ import { getRequest } from "@tanstack/react-start/server";
|
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { prisma } from "@allbluecn/database";
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
-
|
|
23
|
+
import { ACTIVITY_SELECT, toActivityItem } from "./types";
|
|
24
24
|
export const addLinkFn = createServerFn({ method: "POST" })
|
|
25
25
|
.validator(z.object({ storyId: z.string(), url: z.string().url(), title: z.string().optional(), description: z.string().optional() }))
|
|
26
26
|
.handler(async ({ data }) => {
|
|
27
27
|
const request = getRequest();
|
|
28
28
|
const session = await requireAuth(request);
|
|
29
|
+
const userId = session.user!.id!;
|
|
29
30
|
|
|
30
31
|
const link = await prisma.storyLink.create({
|
|
31
32
|
data: {
|
|
@@ -36,7 +37,17 @@ export const addLinkFn = createServerFn({ method: "POST" })
|
|
|
36
37
|
},
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
const activity = await prisma.storyActivity.create({
|
|
41
|
+
data: {
|
|
42
|
+
storyId: data.storyId,
|
|
43
|
+
userId,
|
|
44
|
+
action: "link_added",
|
|
45
|
+
newValue: data.title || data.url,
|
|
46
|
+
},
|
|
47
|
+
select: ACTIVITY_SELECT,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { ...link, activity: toActivityItem(activity) };
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
export const updateLinkFn = createServerFn({ method: "POST" })
|
|
@@ -44,6 +55,7 @@ export const updateLinkFn = createServerFn({ method: "POST" })
|
|
|
44
55
|
.handler(async ({ data }) => {
|
|
45
56
|
const request = getRequest();
|
|
46
57
|
const session = await requireAuth(request);
|
|
58
|
+
const userId = session.user!.id!;
|
|
47
59
|
|
|
48
60
|
const link = await prisma.storyLink.update({
|
|
49
61
|
where: { id: data.linkId },
|
|
@@ -54,7 +66,17 @@ export const updateLinkFn = createServerFn({ method: "POST" })
|
|
|
54
66
|
},
|
|
55
67
|
});
|
|
56
68
|
|
|
57
|
-
|
|
69
|
+
const activity = await prisma.storyActivity.create({
|
|
70
|
+
data: {
|
|
71
|
+
storyId: link.storyId,
|
|
72
|
+
userId,
|
|
73
|
+
action: "link_updated",
|
|
74
|
+
newValue: link.title || link.url,
|
|
75
|
+
},
|
|
76
|
+
select: ACTIVITY_SELECT,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { ...link, activity: toActivityItem(activity) };
|
|
58
80
|
});
|
|
59
81
|
|
|
60
82
|
export const removeLinkFn = createServerFn({ method: "POST" })
|
|
@@ -62,12 +84,26 @@ export const removeLinkFn = createServerFn({ method: "POST" })
|
|
|
62
84
|
.handler(async ({ data }) => {
|
|
63
85
|
const request = getRequest();
|
|
64
86
|
const session = await requireAuth(request);
|
|
87
|
+
const userId = session.user!.id!;
|
|
65
88
|
|
|
66
|
-
const link = await prisma.storyLink.
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
const link = await prisma.storyLink.findUnique({ where: { id: data.linkId } });
|
|
90
|
+
await prisma.storyLink.delete({ where: { id: data.linkId } });
|
|
91
|
+
|
|
92
|
+
let activity = null;
|
|
93
|
+
if (link) {
|
|
94
|
+
const created = await prisma.storyActivity.create({
|
|
95
|
+
data: {
|
|
96
|
+
storyId: link.storyId,
|
|
97
|
+
userId,
|
|
98
|
+
action: "link_removed",
|
|
99
|
+
newValue: link.title || link.url,
|
|
100
|
+
},
|
|
101
|
+
select: ACTIVITY_SELECT,
|
|
102
|
+
});
|
|
103
|
+
activity = toActivityItem(created);
|
|
104
|
+
}
|
|
69
105
|
|
|
70
|
-
return
|
|
106
|
+
return { activity };
|
|
71
107
|
});
|
|
72
108
|
|
|
73
109
|
export const getLinksFn = createServerFn({ method: "GET" })
|
|
@@ -73,8 +73,8 @@ async function allocateStoryIdentifier(
|
|
|
73
73
|
const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
|
|
74
74
|
if (isIndependent) {
|
|
75
75
|
const counter = await tx.sequenceCounter.upsert({
|
|
76
|
-
where: { scope:
|
|
77
|
-
create: { scope:
|
|
76
|
+
where: { scope: projectId },
|
|
77
|
+
create: { scope: projectId, seq: 1 },
|
|
78
78
|
update: { seq: { increment: 1 } },
|
|
79
79
|
});
|
|
80
80
|
return { sequenceId: counter.seq, identifier: `STORY-${counter.seq}` };
|
|
@@ -350,7 +350,7 @@ const listProjectStoriesSchema = z.object({
|
|
|
350
350
|
cursor: z.string().optional(),
|
|
351
351
|
excludeStoryId: z.string().optional(),
|
|
352
352
|
excludeIds: z.array(z.string()).optional(),
|
|
353
|
-
mode: z.enum(["parent", "child", "dependency"]).default("parent"),
|
|
353
|
+
mode: z.enum(["parent", "child", "dependency", "iteration"]).default("parent"),
|
|
354
354
|
pageSize: z.number().int().min(1).max(50).default(20),
|
|
355
355
|
});
|
|
356
356
|
|
|
@@ -376,7 +376,7 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
|
|
|
376
376
|
const stories = await prisma.story.findMany({
|
|
377
377
|
where: {
|
|
378
378
|
...(data.projectId ? { projectId: data.projectId } : {}),
|
|
379
|
-
...(data.mode === "dependency" ? {} : { parentId: null }),
|
|
379
|
+
...(data.mode === "dependency" || data.mode === "iteration" ? {} : { parentId: null }),
|
|
380
380
|
archived: false,
|
|
381
381
|
...(excludeIds.size > 0
|
|
382
382
|
? { id: { notIn: Array.from(excludeIds) } }
|
|
@@ -400,6 +400,7 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
|
|
|
400
400
|
state: true,
|
|
401
401
|
priority: true,
|
|
402
402
|
projectId: true,
|
|
403
|
+
...(data.mode === "iteration" ? { iteration: { select: { id: true, name: true } } } : {}),
|
|
403
404
|
},
|
|
404
405
|
});
|
|
405
406
|
|
|
@@ -54,7 +54,8 @@ export const addChildFn = createServerFn({ method: "POST" })
|
|
|
54
54
|
.validator(z.object({ parentId: z.string(), childId: z.string() }))
|
|
55
55
|
.handler(async ({ data }) => {
|
|
56
56
|
const request = getRequest();
|
|
57
|
-
await requireAuth(request);
|
|
57
|
+
const session = await requireAuth(request);
|
|
58
|
+
const userId = session.user!.id!;
|
|
58
59
|
|
|
59
60
|
const existingChild = await prisma.story.findUnique({
|
|
60
61
|
where: { id: data.childId },
|
|
@@ -68,11 +69,37 @@ export const addChildFn = createServerFn({ method: "POST" })
|
|
|
68
69
|
throw new Error("不能将子需求设为其父需求的子需求(循环依赖)");
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
const parent = await prisma.story.findUnique({
|
|
73
|
+
where: { id: data.parentId },
|
|
74
|
+
select: { id: true, identifier: true, name: true },
|
|
75
|
+
});
|
|
76
|
+
|
|
71
77
|
const child = await prisma.story.update({
|
|
72
78
|
where: { id: data.childId },
|
|
73
79
|
data: { parentId: data.parentId },
|
|
74
80
|
});
|
|
75
81
|
|
|
82
|
+
// 活动记录:父需求记「添加子需求」,子需求记「变更父需求」(与 updateStoryFn 格式一致)
|
|
83
|
+
if (parent) {
|
|
84
|
+
const childLabel = `${existingChild?.identifier ?? ""} ${existingChild?.name ?? ""}`.trim();
|
|
85
|
+
const parentLabel = `${parent.identifier} ${parent.name}`.trim();
|
|
86
|
+
await prisma.$transaction([
|
|
87
|
+
prisma.storyActivity.create({
|
|
88
|
+
data: { storyId: parent.id, userId, action: "child_added", newValue: childLabel },
|
|
89
|
+
}),
|
|
90
|
+
prisma.storyActivity.create({
|
|
91
|
+
data: {
|
|
92
|
+
storyId: child.id,
|
|
93
|
+
userId,
|
|
94
|
+
action: "parent_changed",
|
|
95
|
+
field: "parentId",
|
|
96
|
+
oldValue: "",
|
|
97
|
+
newValue: parentLabel,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
return child;
|
|
77
104
|
});
|
|
78
105
|
|