@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.
Files changed (170) hide show
  1. package/package.json +239 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  12. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  13. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  14. package/src/components/project/create-project-dialog.tsx +44 -57
  15. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  16. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  17. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  18. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  19. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  20. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  21. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  22. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  23. package/src/components/story/__tests__/constants.test.ts +255 -0
  24. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  25. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  26. package/src/components/story/constants.ts +43 -1
  27. package/src/components/story/create-story-dialog.tsx +42 -16
  28. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  29. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  30. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  31. package/src/components/story/detail/activity-timeline.tsx +26 -4
  32. package/src/components/story/detail/story-detail-body.tsx +90 -38
  33. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
  34. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  35. package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
  36. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
  37. package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
  38. package/src/components/story/detail/story-properties.tsx +21 -4
  39. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  40. package/src/components/story/display/group-resolver.tsx +2 -2
  41. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  42. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  43. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  44. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  45. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  46. package/src/components/story/inline-editors/index.ts +2 -1
  47. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  48. package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
  49. package/src/components/story/peek-panel.tsx +121 -21
  50. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  51. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  52. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  53. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  54. package/src/components/story/relations/file-preview.tsx +173 -0
  55. package/src/components/story/relations/format.ts +120 -0
  56. package/src/components/story/relations/general-groups.ts +32 -0
  57. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  58. package/src/components/story/relations/link-manage-dialog.tsx +192 -0
  59. package/src/components/story/relations/story-ref-row.tsx +17 -27
  60. package/src/components/story/story-panel-header.tsx +25 -19
  61. package/src/components/story/story-toolbar.tsx +3 -3
  62. package/src/components/story/transfer-story-popover.tsx +2 -2
  63. package/src/components/story/types.ts +14 -0
  64. package/src/components/story/views/create-view-dialog.tsx +3 -3
  65. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  66. package/src/components/story/views/story-list/group.tsx +1 -1
  67. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  68. package/src/components/story/views/story-list/row.tsx +1 -1
  69. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  70. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  71. package/src/lib/changelog/sdk-versions.ts +20 -20
  72. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  73. package/src/lib/iteration/status.ts +28 -0
  74. package/src/lib/project-display-id.ts +14 -5
  75. package/src/lib/project-module-meta.ts +77 -0
  76. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  77. package/src/lib/story/video-embed.ts +59 -0
  78. package/src/lib/utils.d.ts +1 -1
  79. package/src/plugins/.gen-manifest.json +13 -0
  80. package/src/plugins/modules.gen.ts +20 -0
  81. package/src/plugins/plugins.gen.ts +20 -0
  82. package/src/plugins/ui.gen.ts +8 -0
  83. package/src/providers/index.tsx +6 -0
  84. package/src/routeTree.gen.ts +96 -0
  85. package/src/routes/__root.tsx +0 -8
  86. package/src/routes/_nav/inspiration/route.tsx +8 -0
  87. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  88. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  89. package/src/routes/_nav/prd/index.tsx +9 -0
  90. package/src/routes/_nav/prd/route.tsx +8 -0
  91. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  92. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  93. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  94. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  95. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  96. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  97. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  98. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  99. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
  100. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  101. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  102. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  103. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  104. package/src/routes/_nav/tags/index.tsx +8 -0
  105. package/src/routes/_nav/tags/route.tsx +8 -0
  106. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  107. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  108. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  109. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  110. package/src/routes/api/attachments/$id/content.ts +60 -0
  111. package/src/routes/api/attachments/upload.ts +148 -0
  112. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  114. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  115. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  116. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  117. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  118. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  119. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  120. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  121. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  122. package/src/server/serverFns/iteration/index.ts +22 -0
  123. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  124. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  125. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  126. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  127. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  128. package/src/server/serverFns/iteration/schemas.ts +58 -0
  129. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  130. package/src/server/serverFns/knowledge.ts +50 -4
  131. package/src/server/serverFns/project/module-overview.ts +109 -0
  132. package/src/server/serverFns/project.ts +45 -2
  133. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  134. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  135. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  136. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  137. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
  138. package/src/server/serverFns/story/index.ts +9 -4
  139. package/src/server/serverFns/story/kb-general.ts +39 -0
  140. package/src/server/serverFns/story/kb-scope.ts +52 -0
  141. package/src/server/serverFns/story/link-preview.ts +115 -0
  142. package/src/server/serverFns/story/story-assoc.ts +372 -51
  143. package/src/server/serverFns/story/story-attachments.ts +32 -45
  144. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  145. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  146. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  147. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  148. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  149. package/src/server/serverFns/story/story-crud.ts +53 -6
  150. package/src/server/serverFns/story/story-dependencies.ts +158 -5
  151. package/src/server/serverFns/story/story-links.ts +43 -7
  152. package/src/server/serverFns/story/story-transfer.ts +5 -4
  153. package/src/server/serverFns/story/story-tree.ts +28 -1
  154. package/src/server/serverFns/story/types.ts +38 -0
  155. package/src/styles/globals.css +25 -0
  156. package/src/types/project.ts +5 -5
  157. package/vite.shared.ts +2 -0
  158. package/src/components/project/project-animated-icon.tsx +0 -73
  159. package/src/components/shared/icon-packs/index.ts +0 -1
  160. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  161. package/src/components/shared/icon-packs/prd.ts +0 -1
  162. package/src/components/shared/icon-packs/project.ts +0 -1
  163. package/src/components/shared/icon-picker.tsx +0 -1
  164. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  165. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  166. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  167. package/src/components/story/relations/attachments-panel.tsx +0 -179
  168. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  169. package/src/components/story/relations/links-panel.tsx +0 -240
  170. 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
- export function LabelsEditor({ labelIds, labels, onChange, mode = "multi", className, variant = "chip" }: LabelsEditorProps) {
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
  );