@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,174 @@
|
|
|
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
|
+
const MockResizeObserver = class ResizeObserver {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
};
|
|
23
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
24
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
28
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
29
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
30
|
+
import { StateEditor } from "../state-editor";
|
|
31
|
+
|
|
32
|
+
vi.mock("react-i18next", () => ({
|
|
33
|
+
useTranslation: () => ({
|
|
34
|
+
t: (key: string) => key,
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("@/lib/utils", () => ({
|
|
39
|
+
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function renderEditor(props: Partial<React.ComponentProps<typeof StateEditor>>) {
|
|
43
|
+
return render(
|
|
44
|
+
<TooltipProvider>
|
|
45
|
+
<StateEditor value="todo" onChange={vi.fn()} {...props} />
|
|
46
|
+
</TooltipProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPopoverDialog() {
|
|
51
|
+
return document.querySelector("[role=dialog]") as HTMLElement | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getOptionButtons() {
|
|
55
|
+
const dialog = getPopoverDialog();
|
|
56
|
+
if (!dialog) return [];
|
|
57
|
+
return [...dialog.querySelectorAll("button")];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("StateEditor", () => {
|
|
65
|
+
it("chip variant 渲染当前 state 的 label 作为 chip", () => {
|
|
66
|
+
renderEditor({ value: "in_progress" });
|
|
67
|
+
expect(screen.getByText("state.in_progress")).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("todo 状态渲染对应 chip", () => {
|
|
71
|
+
renderEditor({ value: "todo" });
|
|
72
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("backlog 状态渲染对应 chip", () => {
|
|
76
|
+
renderEditor({ value: "backlog" });
|
|
77
|
+
expect(screen.getByText("state.backlog")).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("点击 chip 打开 Popover 显示所有可选 state", async () => {
|
|
81
|
+
render(
|
|
82
|
+
<TooltipProvider>
|
|
83
|
+
<StateEditor value="todo" onChange={vi.fn()} />
|
|
84
|
+
</TooltipProvider>,
|
|
85
|
+
);
|
|
86
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
87
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
88
|
+
const buttons = getOptionButtons();
|
|
89
|
+
expect(buttons.map((b) => b.textContent)).toEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
expect.stringContaining("state.backlog"),
|
|
92
|
+
expect.stringContaining("state.todo"),
|
|
93
|
+
expect.stringContaining("state.in_progress"),
|
|
94
|
+
expect.stringContaining("state.done"),
|
|
95
|
+
expect.stringContaining("state.cancelled"),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("点击不同 state 触发 onChange 并关闭 Popover", async () => {
|
|
101
|
+
const onChange = vi.fn();
|
|
102
|
+
render(
|
|
103
|
+
<TooltipProvider>
|
|
104
|
+
<StateEditor value="todo" onChange={onChange} />
|
|
105
|
+
</TooltipProvider>,
|
|
106
|
+
);
|
|
107
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
108
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
109
|
+
const buttons = getOptionButtons();
|
|
110
|
+
const doneBtn = buttons.find((b) => b.textContent?.includes("state.done"));
|
|
111
|
+
fireEvent.click(doneBtn!);
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith("done");
|
|
113
|
+
expect(getPopoverDialog()).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("已选中的 state 在 Popover 中带 Check 图标(active 类)", () => {
|
|
117
|
+
render(
|
|
118
|
+
<TooltipProvider>
|
|
119
|
+
<StateEditor value="done" onChange={vi.fn()} />
|
|
120
|
+
</TooltipProvider>,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.click(screen.getByText("state.done"));
|
|
123
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
124
|
+
const checkedBtn = getOptionButtons().find((b) => {
|
|
125
|
+
return b.textContent?.includes("state.done") && b.className.includes("font-medium");
|
|
126
|
+
});
|
|
127
|
+
expect(checkedBtn).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("multi 模式", () => {
|
|
131
|
+
it("selected 为空时显示当前 state 的 chip", () => {
|
|
132
|
+
renderEditor({ mode: "multi", selected: [] });
|
|
133
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("selected 有值时渲染对应 state 的多个 chip", () => {
|
|
137
|
+
renderEditor({ mode: "multi", selected: ["todo", "in_progress"], onMultiChange: vi.fn() });
|
|
138
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByText("state.in_progress")).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("点击 multi 选项触发 onMultiChange 而非 onChange", () => {
|
|
143
|
+
const onChange = vi.fn();
|
|
144
|
+
const onMultiChange = vi.fn();
|
|
145
|
+
render(
|
|
146
|
+
<TooltipProvider>
|
|
147
|
+
<StateEditor
|
|
148
|
+
value="todo"
|
|
149
|
+
mode="multi"
|
|
150
|
+
selected={["todo"]}
|
|
151
|
+
onChange={onChange}
|
|
152
|
+
onMultiChange={onMultiChange}
|
|
153
|
+
/>
|
|
154
|
+
</TooltipProvider>,
|
|
155
|
+
);
|
|
156
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
157
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
158
|
+
const buttons = getOptionButtons();
|
|
159
|
+
const targetBtn = buttons.find((b) => b.textContent?.includes("state.in_progress"));
|
|
160
|
+
fireEvent.click(targetBtn!);
|
|
161
|
+
expect(onMultiChange).toHaveBeenCalledWith("in_progress");
|
|
162
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
163
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("variant=button 时渲染 InlineTrigger(无 chip 边框)", () => {
|
|
168
|
+
renderEditor({ variant: "button", value: "todo" });
|
|
169
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
170
|
+
// InlineTrigger 走 Button,外层无 chip 边框(span.border-border 不存在)
|
|
171
|
+
const chip = screen.getByText("state.todo").closest("span.border-border");
|
|
172
|
+
expect(chip).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
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 { useState } from "react";
|
|
19
|
+
import { useTranslation } from "react-i18next";
|
|
20
|
+
import { Check, Waypoints } from "lucide-react";
|
|
21
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
22
|
+
import { Button, buttonVariants } from "@/components/ui/button";
|
|
23
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
24
|
+
import { DEPENDENCY_TYPE_OPTIONS, type DependencyTypeOption } from "../constants";
|
|
25
|
+
import { cn } from "@/lib/utils";
|
|
26
|
+
|
|
27
|
+
const OPTION_GROUPS = ["dependsOn", "dependents"] as const;
|
|
28
|
+
|
|
29
|
+
/** 按选项配置渲染图标(旋转角度来自 constants 的 iconRotation,用内联 style 避免 JIT 动态类名) */
|
|
30
|
+
function OptionIcon({
|
|
31
|
+
option,
|
|
32
|
+
className,
|
|
33
|
+
}: {
|
|
34
|
+
option: DependencyTypeOption;
|
|
35
|
+
className?: string;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<option.icon
|
|
39
|
+
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
|
|
40
|
+
style={{
|
|
41
|
+
transform:
|
|
42
|
+
option.iconRotation ? `rotate(${option.iconRotation}deg)` : undefined,
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface DependencyTypeSelectProps {
|
|
49
|
+
/** 当前选中的依赖类型(受控) */
|
|
50
|
+
value?: DependencyTypeOption["key"];
|
|
51
|
+
onChange: (key: DependencyTypeOption["key"]) => void;
|
|
52
|
+
/** trigger=按钮形态(添加依赖弹窗底部);badge=行内标签形态(依赖列表就地编辑) */
|
|
53
|
+
variant?: "trigger" | "badge";
|
|
54
|
+
/** badge 形态:Badge 文案中插值的对方需求编号 */
|
|
55
|
+
identifier?: string;
|
|
56
|
+
/** badge 形态:tooltip 中当前需求编号(传入才使用 tooltips 文案,否则用 labels) */
|
|
57
|
+
currentIdentifier?: string;
|
|
58
|
+
/** Popover 开合(受控,Dialog 嵌套场景由外部持有以便统一外部点击关闭) */
|
|
59
|
+
open?: boolean;
|
|
60
|
+
onOpenChange?: (open: boolean) => void;
|
|
61
|
+
className?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DependencyTypeSelect({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
variant = "trigger",
|
|
68
|
+
identifier,
|
|
69
|
+
currentIdentifier,
|
|
70
|
+
open: openProp,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
className,
|
|
73
|
+
}: DependencyTypeSelectProps) {
|
|
74
|
+
const { t } = useTranslation("story");
|
|
75
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
|
76
|
+
const open = openProp ?? uncontrolledOpen;
|
|
77
|
+
const handleOpenChange = (next: boolean) => {
|
|
78
|
+
if (openProp === undefined) setUncontrolledOpen(next);
|
|
79
|
+
onOpenChange?.(next);
|
|
80
|
+
};
|
|
81
|
+
const current = value
|
|
82
|
+
? DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === value)
|
|
83
|
+
: undefined;
|
|
84
|
+
|
|
85
|
+
const content = (
|
|
86
|
+
<PopoverContent align="start" className="w-80 p-1">
|
|
87
|
+
{OPTION_GROUPS.map((group, gi) => (
|
|
88
|
+
<div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
|
|
89
|
+
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
90
|
+
{t(`dependency.optionGroups.${group}`)}
|
|
91
|
+
</p>
|
|
92
|
+
{DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
|
|
93
|
+
const checked = value === opt.key;
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
key={opt.key}
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
onChange(opt.key);
|
|
100
|
+
handleOpenChange(false);
|
|
101
|
+
}}
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
|
|
104
|
+
checked && "font-medium",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<OptionIcon option={opt} />
|
|
108
|
+
<span className="flex-1">{t(opt.optionLabelKey)}</span>
|
|
109
|
+
{checked && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
110
|
+
</button>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</PopoverContent>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (variant === "badge") {
|
|
119
|
+
const tooltipKey = current
|
|
120
|
+
? currentIdentifier
|
|
121
|
+
? current.tooltipLabelKey
|
|
122
|
+
: current.badgeLabelKey
|
|
123
|
+
: null;
|
|
124
|
+
return (
|
|
125
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
126
|
+
<Tooltip>
|
|
127
|
+
<TooltipTrigger asChild>
|
|
128
|
+
<PopoverTrigger asChild>
|
|
129
|
+
{/* 行打开按钮是 button,此处必须用 span 复刻 Button 样式(button 嵌 button 会 hydration 报错) */}
|
|
130
|
+
<span
|
|
131
|
+
className={cn(
|
|
132
|
+
buttonVariants({ variant: "outline", size: "sm" }),
|
|
133
|
+
"h-7 max-w-56 gap-1 rounded-md px-2 text-xs font-normal",
|
|
134
|
+
className,
|
|
135
|
+
)}
|
|
136
|
+
onClick={(e) => e.stopPropagation()}
|
|
137
|
+
>
|
|
138
|
+
{current && <OptionIcon option={current} />}
|
|
139
|
+
<span className="truncate">
|
|
140
|
+
{current
|
|
141
|
+
? t(current.badgeLabelKey, { identifier: identifier ?? "" })
|
|
142
|
+
: t("dependency.typePlaceholder")}
|
|
143
|
+
</span>
|
|
144
|
+
</span>
|
|
145
|
+
</PopoverTrigger>
|
|
146
|
+
</TooltipTrigger>
|
|
147
|
+
{tooltipKey && (
|
|
148
|
+
<TooltipContent side="top" className="text-xs">
|
|
149
|
+
{t(tooltipKey, {
|
|
150
|
+
identifier: identifier ?? "",
|
|
151
|
+
me: currentIdentifier ?? "",
|
|
152
|
+
})}
|
|
153
|
+
</TooltipContent>
|
|
154
|
+
)}
|
|
155
|
+
</Tooltip>
|
|
156
|
+
{content}
|
|
157
|
+
</Popover>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
163
|
+
<PopoverTrigger asChild>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="sm"
|
|
167
|
+
className={cn("h-9 gap-1.5", !value && "text-muted-foreground", className)}
|
|
168
|
+
>
|
|
169
|
+
{current ? (
|
|
170
|
+
<OptionIcon option={current} className="text-current" />
|
|
171
|
+
) : (
|
|
172
|
+
<Waypoints className="size-3.5" />
|
|
173
|
+
)}
|
|
174
|
+
{current ? t(current.optionLabelKey) : t("dependency.typePlaceholder")}
|
|
175
|
+
</Button>
|
|
176
|
+
</PopoverTrigger>
|
|
177
|
+
{content}
|
|
178
|
+
</Popover>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -21,4 +21,5 @@ export { AssigneeEditor } from "./assignee-editor";
|
|
|
21
21
|
export { DateEditor } from "./date-editor";
|
|
22
22
|
export { LabelsEditor } from "./labels-editor";
|
|
23
23
|
export { TitleEditor } from "./title-editor";
|
|
24
|
-
export { ParentStoryEditor } from "./parent-story-editor";
|
|
24
|
+
export { ParentStoryEditor } from "./parent-story-editor";
|
|
25
|
+
export { DependencyTypeSelect } from "./dependency-type-select";
|
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { useState } from "react";
|
|
19
|
-
import { Tag, Check } from "lucide-react";
|
|
19
|
+
import { Tag, Check, Loader2, Sparkles } from "lucide-react";
|
|
20
20
|
import { useTranslation } from "react-i18next";
|
|
21
21
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
22
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
22
23
|
import { cn } from "@/lib/utils";
|
|
24
|
+
import { useSession } from "@/lib/auth/auth-client";
|
|
25
|
+
import { toast } from "sonner";
|
|
26
|
+
import { createLabelFn, suggestStoryLabelsFn } from "@/server/serverFns/story";
|
|
23
27
|
import { InlineTrigger } from "./inline-trigger";
|
|
24
28
|
|
|
25
29
|
interface LabelsEditorProps {
|
|
@@ -28,13 +32,29 @@ interface LabelsEditorProps {
|
|
|
28
32
|
onChange: (labelIds: string[]) => void;
|
|
29
33
|
mode?: "single" | "multi";
|
|
30
34
|
className?: string;
|
|
31
|
-
/** 渲染形态:chip=有值时显示 chip 列表(默认,筛选场景),button=始终 Button(列表/详情场景) */
|
|
32
35
|
variant?: "chip" | "button";
|
|
36
|
+
aiContext?: { name: () => string; description?: () => string | undefined };
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
type AiPanel =
|
|
40
|
+
| { phase: "idle" }
|
|
41
|
+
| { phase: "loading" }
|
|
42
|
+
| {
|
|
43
|
+
phase: "done";
|
|
44
|
+
existingLabels: { id: string; name: string; color: string }[];
|
|
45
|
+
newNames: string[];
|
|
46
|
+
}
|
|
47
|
+
| { phase: "error" };
|
|
48
|
+
|
|
49
|
+
export function LabelsEditor({ labelIds, labels, onChange, mode = "multi", className, variant = "chip", aiContext }: LabelsEditorProps) {
|
|
36
50
|
const { t } = useTranslation("story");
|
|
37
51
|
const [open, setOpen] = useState(false);
|
|
52
|
+
const { data: session } = useSession();
|
|
53
|
+
const hasAiKey = session?.user?.hasAiKey ?? false;
|
|
54
|
+
const [ai, setAi] = useState<AiPanel>({ phase: "idle" });
|
|
55
|
+
const [selectedExisting, setSelectedExisting] = useState<string[]>([]);
|
|
56
|
+
const [selectedNew, setSelectedNew] = useState<string[]>([]);
|
|
57
|
+
const aiEnabled = !!aiContext && hasAiKey;
|
|
38
58
|
const isMulti = mode === "multi";
|
|
39
59
|
const showChip = variant === "chip";
|
|
40
60
|
|
|
@@ -43,6 +63,10 @@ export function LabelsEditor({ labelIds, labels, onChange, mode = "multi", class
|
|
|
43
63
|
? labelIds.map((id) => labels.find((l) => l.id === id)?.name).filter(Boolean).join("、")
|
|
44
64
|
: t("inline.notSelected");
|
|
45
65
|
|
|
66
|
+
const currentNames = labelIds
|
|
67
|
+
.map((id) => labels.find((l) => l.id === id)?.name)
|
|
68
|
+
.filter(Boolean) as string[];
|
|
69
|
+
|
|
46
70
|
const toggle = (id: string) => {
|
|
47
71
|
if (isMulti) {
|
|
48
72
|
const next = labelIds.includes(id)
|
|
@@ -55,6 +79,51 @@ export function LabelsEditor({ labelIds, labels, onChange, mode = "multi", class
|
|
|
55
79
|
}
|
|
56
80
|
};
|
|
57
81
|
|
|
82
|
+
const generateAiSuggestions = async () => {
|
|
83
|
+
if (!aiContext) return;
|
|
84
|
+
const name = aiContext.name().trim();
|
|
85
|
+
if (!name) {
|
|
86
|
+
toast.error(t("inline.aiNeedName"));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setAi({ phase: "loading" });
|
|
90
|
+
try {
|
|
91
|
+
const result = await suggestStoryLabelsFn({
|
|
92
|
+
data: {
|
|
93
|
+
name,
|
|
94
|
+
description: aiContext.description?.() || undefined,
|
|
95
|
+
currentLabelNames: currentNames,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
if (result.existingLabels.length === 0 && result.newNames.length === 0) {
|
|
99
|
+
setAi({ phase: "error" });
|
|
100
|
+
toast.error(t("inline.aiEmpty"));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setAi({ phase: "done", ...result });
|
|
104
|
+
setSelectedExisting(result.existingLabels.map((l) => l.id));
|
|
105
|
+
setSelectedNew(result.newNames);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setAi({ phase: "error" });
|
|
108
|
+
toast.error(e instanceof Error ? e.message : t("inline.aiFailed"));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const applyAiSuggestions = async () => {
|
|
113
|
+
if (ai.phase !== "done") return;
|
|
114
|
+
const newIds: string[] = [];
|
|
115
|
+
for (const name of selectedNew) {
|
|
116
|
+
const created = await createLabelFn({ data: { name } });
|
|
117
|
+
newIds.push(created.id);
|
|
118
|
+
}
|
|
119
|
+
const merged = Array.from(new Set([...labelIds, ...selectedExisting, ...newIds]));
|
|
120
|
+
onChange(merged);
|
|
121
|
+
toast.success(t("inline.aiApplied", { count: merged.length - labelIds.length }));
|
|
122
|
+
setAi({ phase: "idle" });
|
|
123
|
+
setSelectedExisting([]);
|
|
124
|
+
setSelectedNew([]);
|
|
125
|
+
};
|
|
126
|
+
|
|
58
127
|
return (
|
|
59
128
|
<Popover open={open} onOpenChange={setOpen}>
|
|
60
129
|
{showChip && labelIds.length > 0 ? (
|
|
@@ -113,6 +182,93 @@ export function LabelsEditor({ labelIds, labels, onChange, mode = "multi", class
|
|
|
113
182
|
);
|
|
114
183
|
})
|
|
115
184
|
)}
|
|
185
|
+
{aiEnabled && (
|
|
186
|
+
<div className="border-t border-border mt-1 pt-1">
|
|
187
|
+
{ai.phase === "idle" && (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={generateAiSuggestions}
|
|
191
|
+
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted"
|
|
192
|
+
>
|
|
193
|
+
<Sparkles className="size-3.5" />
|
|
194
|
+
{t("inline.aiGenerate")}
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
{ai.phase === "loading" && (
|
|
198
|
+
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
|
199
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
200
|
+
{t("inline.aiLoading")}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
{(ai.phase === "done" || ai.phase === "error") && (
|
|
204
|
+
<div className="max-h-44 overflow-y-auto p-1">
|
|
205
|
+
{ai.phase === "error" ? (
|
|
206
|
+
<div className="px-1 py-1.5 text-xs text-destructive">{t("inline.aiFailed")}</div>
|
|
207
|
+
) : (
|
|
208
|
+
<>
|
|
209
|
+
{ai.existingLabels.map((l) => (
|
|
210
|
+
<div
|
|
211
|
+
key={l.id}
|
|
212
|
+
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-muted cursor-pointer"
|
|
213
|
+
onClick={() =>
|
|
214
|
+
setSelectedExisting((prev) =>
|
|
215
|
+
prev.includes(l.id) ? prev.filter((x) => x !== l.id) : [...prev, l.id],
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
<Checkbox checked={selectedExisting.includes(l.id)} />
|
|
220
|
+
<span className="inline-block size-2.5 rounded-full shrink-0" style={{ backgroundColor: l.color }} />
|
|
221
|
+
{l.name}
|
|
222
|
+
</div>
|
|
223
|
+
))}
|
|
224
|
+
{ai.newNames.map((n) => (
|
|
225
|
+
<div
|
|
226
|
+
key={n}
|
|
227
|
+
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-muted cursor-pointer"
|
|
228
|
+
onClick={() =>
|
|
229
|
+
setSelectedNew((prev) =>
|
|
230
|
+
prev.includes(n) ? prev.filter((x) => x !== n) : [...prev, n],
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
>
|
|
234
|
+
<Checkbox checked={selectedNew.includes(n)} />
|
|
235
|
+
<span className="rounded-sm border border-dashed border-primary/60 px-1 text-[10px] leading-none text-primary">
|
|
236
|
+
{t("inline.aiNew")}
|
|
237
|
+
</span>
|
|
238
|
+
{n}
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
<div className="flex items-center gap-1 pt-1">
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
onClick={applyAiSuggestions}
|
|
245
|
+
className="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
|
|
246
|
+
>
|
|
247
|
+
{t("inline.aiApply")}
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={generateAiSuggestions}
|
|
252
|
+
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
|
253
|
+
>
|
|
254
|
+
{t("inline.aiRegenerate")}
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
</>
|
|
258
|
+
)}
|
|
259
|
+
{ai.phase === "error" && (
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={generateAiSuggestions}
|
|
263
|
+
className="mt-1 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
|
264
|
+
>
|
|
265
|
+
{t("inline.aiRegenerate")}
|
|
266
|
+
</button>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
116
272
|
</PopoverContent>
|
|
117
273
|
</Popover>
|
|
118
274
|
);
|