@allbluecn/web-app 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +230 -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/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -0,0 +1,82 @@
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 { lazy, Suspense, useState, type ReactNode } from "react";
19
+ import { Button } from "@/components/ui/button";
20
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
21
+ import { LucideIconByName } from "./lucide-icon-by-name";
22
+ import { pushRecentIcon } from "./icon-data";
23
+
24
+ const IconPickerPanel = lazy(() => import("./panel"));
25
+
26
+ interface LucideIconPickerProps {
27
+ /** 当前选中(kebab-case 官方名);空值时默认触发按钮显示微笑占位图标 */
28
+ value?: string;
29
+ /** 选中回调:触发后弹窗自动关闭并记录最近使用 */
30
+ onChange: (name: string) => void;
31
+ /** 自定义 Popover trigger(asChild 模式);不传时使用内置触发按钮(未选显示微笑,选后回显) */
32
+ children?: ReactNode;
33
+ align?: "start" | "center" | "end";
34
+ side?: "top" | "right" | "bottom" | "left";
35
+ /**
36
+ * 模态模式(在 Dialog 内嵌套使用时必须开启):
37
+ * 外层 Dialog 的滚动锁会拦截 Portal 到 body 的 Popover 的 wheel 事件,
38
+ * modal 使 Popover Content 自身注册为滚动锁容器,放行内部滚动。
39
+ */
40
+ modal?: boolean;
41
+ }
42
+
43
+ export function LucideIconPicker({
44
+ value,
45
+ onChange,
46
+ children,
47
+ align = "start",
48
+ side = "bottom",
49
+ modal = false,
50
+ }: LucideIconPickerProps) {
51
+ const [open, setOpen] = useState(false);
52
+
53
+ return (
54
+ <Popover open={open} onOpenChange={setOpen} modal={modal}>
55
+ <PopoverTrigger asChild>
56
+ {children ?? (
57
+ <Button
58
+ type="button"
59
+ variant="outline"
60
+ size="icon"
61
+ className="size-10 shrink-0"
62
+ aria-label="选择图标"
63
+ >
64
+ <LucideIconByName name={value} size={20} />
65
+ </Button>
66
+ )}
67
+ </PopoverTrigger>
68
+ <PopoverContent align={align} side={side} className="w-fit p-1.5">
69
+ <Suspense fallback={<div className="h-105 w-88" />}>
70
+ <IconPickerPanel
71
+ value={value}
72
+ onPick={(name) => {
73
+ onChange(name);
74
+ pushRecentIcon(name);
75
+ setOpen(false);
76
+ }}
77
+ />
78
+ </Suspense>
79
+ </PopoverContent>
80
+ </Popover>
81
+ );
82
+ }
@@ -0,0 +1,78 @@
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 type { ReactNode } from "react";
19
+ import { Smile } from "lucide-react";
20
+ import { cn } from "@/lib/utils";
21
+ import { CURATED_ICON_MAP } from "./curated-icons";
22
+ import { pascalToKebab } from "./icon-data";
23
+
24
+ /**
25
+ * 旧数据兼容:PROJECT_ICONS 时代存的名字与 Lucide 官方名不一致的映射
26
+ * (flask/cart/earth 为旧 icon-packs/project 遗留值)
27
+ */
28
+ const LEGACY_ALIASES: Record<string, string> = {
29
+ flask: "flask-conical",
30
+ cart: "shopping-cart",
31
+ earth: "globe",
32
+ };
33
+
34
+ /** 解析任意历史格式的图标名:别名 → camelCase 转 kebab → 原样 */
35
+ function resolveIconName(name: string): string {
36
+ if (CURATED_ICON_MAP[name]) return name;
37
+ const aliased = LEGACY_ALIASES[name];
38
+ if (aliased && CURATED_ICON_MAP[aliased]) return aliased;
39
+ if (/[a-z][A-Z]/.test(name)) {
40
+ const kebab = pascalToKebab(name);
41
+ if (CURATED_ICON_MAP[kebab]) return kebab;
42
+ }
43
+ return name;
44
+ }
45
+
46
+ interface LucideIconByNameProps {
47
+ /** kebab-case 精选名单内的图标名;无效/精选外渲染 fallback */
48
+ name?: string;
49
+ size?: number;
50
+ className?: string;
51
+ /** 默认:Smile 微笑脸(未选择图标时的占位) */
52
+ fallback?: ReactNode;
53
+ }
54
+
55
+ export function LucideIconByName({
56
+ name: rawName,
57
+ size = 20,
58
+ className,
59
+ fallback,
60
+ }: LucideIconByNameProps) {
61
+ const name = rawName ? resolveIconName(rawName) : rawName;
62
+ const found = name ? CURATED_ICON_MAP[name] : undefined;
63
+ if (!found) {
64
+ return (
65
+ <>
66
+ {fallback ?? (
67
+ <Smile
68
+ size={size}
69
+ aria-hidden
70
+ className={cn("text-muted-foreground", className)}
71
+ />
72
+ )}
73
+ </>
74
+ );
75
+ }
76
+ const Icon = found.Icon;
77
+ return <Icon size={size} aria-hidden className={className} />;
78
+ }
@@ -0,0 +1,163 @@
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 { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
19
+ import { useTranslation } from "react-i18next";
20
+ import { Search } from "lucide-react";
21
+ import { cn } from "@/lib/utils";
22
+ import { Input } from "@/components/ui/input";
23
+ import { CURATED_ICONS } from "./curated-icons";
24
+ import {
25
+ buildSearchIndex,
26
+ filterIcons,
27
+ getRecentIcons,
28
+ pruneRecentIcons,
29
+ type IconSearchEntry,
30
+ } from "./icon-data";
31
+
32
+ const SEARCH_INDEX = buildSearchIndex(CURATED_ICONS);
33
+
34
+ interface IconPickerPanelProps {
35
+ value?: string;
36
+ onPick: (name: string) => void;
37
+ }
38
+
39
+ function IconGrid({
40
+ items,
41
+ value,
42
+ onPick,
43
+ }: {
44
+ items: IconSearchEntry[];
45
+ value?: string;
46
+ onPick: (name: string) => void;
47
+ }) {
48
+ return (
49
+ <div className="grid grid-cols-8 gap-1 px-2 pb-2">
50
+ {items.map((item) => {
51
+ const Icon = item.Icon;
52
+ const selected = item.name === value;
53
+ return (
54
+ <button
55
+ key={item.name}
56
+ type="button"
57
+ aria-label={`icon-cell-${item.name}`}
58
+ data-selected={selected}
59
+ onClick={() => onPick(item.name)}
60
+ className={cn(
61
+ "flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
62
+ selected && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
63
+ )}
64
+ >
65
+ <Icon size={18} aria-hidden />
66
+ </button>
67
+ );
68
+ })}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function SectionTitle({ children, className }: { children: ReactNode; className?: string }) {
74
+ return (
75
+ <h3 className={cn("px-3 pb-1 text-xs font-medium text-muted-foreground", className)}>
76
+ {children}
77
+ </h3>
78
+ );
79
+ }
80
+
81
+ export function IconPickerPanel({ value, onPick }: IconPickerPanelProps) {
82
+ const { t } = useTranslation("common");
83
+ const [query, setQuery] = useState("");
84
+ const searchRef = useRef<HTMLInputElement>(null);
85
+
86
+ useEffect(() => {
87
+ searchRef.current?.focus();
88
+ // 清理最近使用中已失效的记录(如精选名单收缩前存入的图标名)
89
+ const stored = getRecentIcons();
90
+ const invalid = stored.filter((n) => !SEARCH_INDEX.some((e) => e.name === n));
91
+ if (invalid.length > 0) pruneRecentIcons(new Set(stored.filter((n) => !invalid.includes(n))));
92
+ }, []);
93
+
94
+ const searching = query.trim().length > 0;
95
+ const results = useMemo(
96
+ () => (searching ? filterIcons(query.trim(), SEARCH_INDEX) : []),
97
+ [query, searching]
98
+ );
99
+ const recent = getRecentIcons()
100
+ .map((n) => SEARCH_INDEX.find((e) => e.name === n))
101
+ .filter((e): e is IconSearchEntry => Boolean(e));
102
+ const firstVisible = searching ? results[0] : (recent[0] ?? SEARCH_INDEX[0]);
103
+
104
+ return (
105
+ <div className="flex flex-col overflow-auto" style={{ height: 26.25 * 16, width: 22 * 16 }}>
106
+ {/* 搜索框 */}
107
+ <div className="relative shrink-0 p-1.5 pb-2">
108
+ <Search
109
+ size={14}
110
+ aria-hidden
111
+ className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
112
+ />
113
+ <Input
114
+ ref={searchRef}
115
+ value={query}
116
+ onChange={(e) => setQuery(e.target.value)}
117
+ onKeyDown={(e) => {
118
+ if (e.key === "Enter" && firstVisible) {
119
+ e.preventDefault();
120
+ onPick(firstVisible.name);
121
+ }
122
+ }}
123
+ aria-label={t("iconPicker.searchPlaceholder")}
124
+ placeholder={t("iconPicker.searchPlaceholder")}
125
+ className="h-8 pl-8 text-xs"
126
+ />
127
+ </div>
128
+
129
+ {searching ? (
130
+ <>
131
+ <div className="shrink-0 px-3 pb-1 text-xs text-muted-foreground">
132
+ {t("iconPicker.resultCount", { count: results.length })}
133
+ </div>
134
+ {results.length === 0 ? (
135
+ <div className="flex flex-1 items-center justify-center px-6 text-center text-xs text-muted-foreground">
136
+ {t("iconPicker.noResults")}
137
+ </div>
138
+ ) : (
139
+ <IconGrid items={results} value={value} onPick={onPick} />
140
+ )}
141
+ </>
142
+ ) : (
143
+ <>
144
+ {/* 最近使用:仅在有记录时显示,与全部图标同页上下排列 */}
145
+ {recent.length > 0 && (
146
+ <section>
147
+ <SectionTitle>{t("iconPicker.recent")}</SectionTitle>
148
+ <IconGrid items={recent} value={value} onPick={onPick} />
149
+ </section>
150
+ )}
151
+ <section>
152
+ <SectionTitle className={cn(recent.length === 0 && "pt-1")}>
153
+ {t("iconPicker.all")}
154
+ </SectionTitle>
155
+ <IconGrid items={SEARCH_INDEX} value={value} onPick={onPick} />
156
+ </section>
157
+ </>
158
+ )}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export default IconPickerPanel;
@@ -0,0 +1,255 @@
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 { describe, it, expect } from "vitest";
19
+ import {
20
+ STORY_STATES,
21
+ STORY_PRIORITIES,
22
+ STATE_LABEL,
23
+ STATE_COLOR,
24
+ STATE_ICON_COLOR,
25
+ PRIORITY_LABEL,
26
+ PRIORITY_RANK,
27
+ PRIORITY_ICON,
28
+ DEPENDENCY_TYPE_OPTIONS,
29
+ dependencyTypeOption,
30
+ dependencyTypeBadgeKey,
31
+ toDependencyEdge,
32
+ type StoryState,
33
+ type StoryPriority,
34
+ } from "../constants";
35
+
36
+ describe("STORY_STATES", () => {
37
+ it("包含 5 个状态", () => {
38
+ expect(STORY_STATES).toHaveLength(5);
39
+ });
40
+
41
+ it("每项包含 value/label/color 字段", () => {
42
+ STORY_STATES.forEach((s) => {
43
+ expect(typeof s.value).toBe("string");
44
+ expect(typeof s.label).toBe("string");
45
+ expect(typeof s.color).toBe("string");
46
+ });
47
+ });
48
+
49
+ it("label 均以 state. 开头", () => {
50
+ STORY_STATES.forEach((s) => {
51
+ expect(s.label).toMatch(/^state\./);
52
+ });
53
+ });
54
+
55
+ it("value 覆盖全部状态", () => {
56
+ expect(STORY_STATES.map((s) => s.value)).toEqual([
57
+ "backlog",
58
+ "todo",
59
+ "in_progress",
60
+ "done",
61
+ "cancelled",
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("STORY_PRIORITIES", () => {
67
+ it("包含 5 个优先级", () => {
68
+ expect(STORY_PRIORITIES).toHaveLength(5);
69
+ });
70
+
71
+ it("rank 值覆盖 0 到 4", () => {
72
+ expect(STORY_PRIORITIES.map((p) => p.rank).sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
73
+ });
74
+
75
+ it("各优先级 rank 值正确", () => {
76
+ const rankMap = Object.fromEntries(STORY_PRIORITIES.map((p) => [p.value, p.rank]));
77
+ expect(rankMap).toEqual({ urgent: 4, high: 3, medium: 2, low: 1, none: 0 });
78
+ });
79
+
80
+ it("label 均以 priority. 开头", () => {
81
+ STORY_PRIORITIES.forEach((p) => {
82
+ expect(p.label).toMatch(/^priority\./);
83
+ });
84
+ });
85
+ });
86
+
87
+ describe("STATE_LABEL", () => {
88
+ it("每个 StoryState 都有对应 label", () => {
89
+ const allStates: StoryState[] = ["backlog", "todo", "in_progress", "done", "cancelled"];
90
+ allStates.forEach((state) => {
91
+ expect(STATE_LABEL[state]).toBeDefined();
92
+ expect(STATE_LABEL[state]).toMatch(/^state\./);
93
+ });
94
+ });
95
+
96
+ it("label 值与 STORY_STATES 一致", () => {
97
+ STORY_STATES.forEach((s) => {
98
+ expect(STATE_LABEL[s.value]).toBe(s.label);
99
+ });
100
+ });
101
+ });
102
+
103
+ describe("STATE_COLOR", () => {
104
+ it("每个 StoryState 都有对应 color", () => {
105
+ const allStates: StoryState[] = ["backlog", "todo", "in_progress", "done", "cancelled"];
106
+ allStates.forEach((state) => {
107
+ expect(STATE_COLOR[state]).toBeDefined();
108
+ expect(typeof STATE_COLOR[state]).toBe("string");
109
+ });
110
+ });
111
+
112
+ it("color 值与 STORY_STATES 一致", () => {
113
+ STORY_STATES.forEach((s) => {
114
+ expect(STATE_COLOR[s.value]).toBe(s.color);
115
+ });
116
+ });
117
+ });
118
+
119
+ describe("STATE_ICON_COLOR", () => {
120
+ it("每个 StoryState 都有对应 icon color", () => {
121
+ const allStates: StoryState[] = ["backlog", "todo", "in_progress", "done", "cancelled"];
122
+ allStates.forEach((state) => {
123
+ expect(STATE_ICON_COLOR[state]).toBeDefined();
124
+ expect(typeof STATE_ICON_COLOR[state]).toBe("string");
125
+ });
126
+ });
127
+
128
+ it("icon color 包含 text- 前缀", () => {
129
+ Object.entries(STATE_ICON_COLOR).forEach(([_, color]) => {
130
+ expect(color).toMatch(/^text-/);
131
+ });
132
+ });
133
+ });
134
+
135
+ describe("PRIORITY_LABEL", () => {
136
+ it("每个 StoryPriority 都有对应 label", () => {
137
+ const allPriorities: StoryPriority[] = ["urgent", "high", "medium", "low", "none"];
138
+ allPriorities.forEach((p) => {
139
+ expect(PRIORITY_LABEL[p]).toBeDefined();
140
+ expect(PRIORITY_LABEL[p]).toMatch(/^priority\./);
141
+ });
142
+ });
143
+
144
+ it("label 值与 STORY_PRIORITIES 一致", () => {
145
+ STORY_PRIORITIES.forEach((p) => {
146
+ expect(PRIORITY_LABEL[p.value]).toBe(p.label);
147
+ });
148
+ });
149
+ });
150
+
151
+ describe("PRIORITY_RANK", () => {
152
+ it("值与 STORY_PRIORITIES.rank 一致", () => {
153
+ STORY_PRIORITIES.forEach((p) => {
154
+ expect(PRIORITY_RANK[p.value]).toBe(p.rank);
155
+ });
156
+ });
157
+
158
+ it("urgent 最高 none 最低", () => {
159
+ expect(PRIORITY_RANK["urgent"]).toBeGreaterThanOrEqual(PRIORITY_RANK["high"]);
160
+ expect(PRIORITY_RANK["high"]).toBeGreaterThanOrEqual(PRIORITY_RANK["medium"]);
161
+ expect(PRIORITY_RANK["medium"]).toBeGreaterThanOrEqual(PRIORITY_RANK["low"]);
162
+ expect(PRIORITY_RANK["low"]).toBeGreaterThanOrEqual(PRIORITY_RANK["none"]);
163
+ });
164
+ });
165
+
166
+ describe("PRIORITY_ICON", () => {
167
+ it("每个 StoryPriority 都有对应图标", () => {
168
+ const allPriorities: StoryPriority[] = ["urgent", "high", "medium", "low", "none"];
169
+ allPriorities.forEach((p) => {
170
+ expect(PRIORITY_ICON[p]).toBeDefined();
171
+ expect(typeof PRIORITY_ICON[p]).not.toBe("string");
172
+ });
173
+ });
174
+
175
+ it("各优先级图标互不相同", () => {
176
+ const icons = Object.values(PRIORITY_ICON);
177
+ expect(new Set(icons).size).toBe(icons.length);
178
+ });
179
+ });
180
+
181
+ describe("DEPENDENCY_TYPE_OPTIONS", () => {
182
+ it("包含 6 项", () => {
183
+ expect(DEPENDENCY_TYPE_OPTIONS).toHaveLength(6);
184
+ });
185
+
186
+ it("key 唯一", () => {
187
+ const keys = DEPENDENCY_TYPE_OPTIONS.map((o) => o.key);
188
+ expect(new Set(keys).size).toBe(keys.length);
189
+ });
190
+
191
+ it("包含 3 个 dependsOn 和 3 个 dependents", () => {
192
+ const dependsOn = DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependsOn");
193
+ const dependents = DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependents");
194
+ expect(dependsOn).toHaveLength(3);
195
+ expect(dependents).toHaveLength(3);
196
+ });
197
+
198
+ it("dependsOn 项 reversed=true", () => {
199
+ DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependsOn").forEach((o) => {
200
+ expect(o.reversed).toBe(true);
201
+ });
202
+ });
203
+
204
+ it("dependents 项 reversed=false", () => {
205
+ DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependents").forEach((o) => {
206
+ expect(o.reversed).toBe(false);
207
+ });
208
+ });
209
+ });
210
+
211
+ describe("dependencyTypeOption", () => {
212
+ it("合法 group+depType 返回对应选项", () => {
213
+ expect(dependencyTypeOption("dependsOn", "BLOCKS").key).toBe("blocked_by");
214
+ expect(dependencyTypeOption("dependsOn", "STARTS_BEFORE").key).toBe("starts_after");
215
+ expect(dependencyTypeOption("dependsOn", "FINISHES_BEFORE").key).toBe("finishes_after");
216
+ expect(dependencyTypeOption("dependents", "BLOCKS").key).toBe("blocking");
217
+ expect(dependencyTypeOption("dependents", "STARTS_BEFORE").key).toBe("starts_before");
218
+ expect(dependencyTypeOption("dependents", "FINISHES_BEFORE").key).toBe("finishes_before");
219
+ });
220
+
221
+ it("非法组合抛 Error", () => {
222
+ expect(() => dependencyTypeOption("dependsOn", "INVALID_EDGE" as any)).toThrow();
223
+ });
224
+ });
225
+
226
+ describe("dependencyTypeBadgeKey", () => {
227
+ it("返回对应 key", () => {
228
+ expect(dependencyTypeBadgeKey("dependsOn", "BLOCKS")).toBe("blocked_by");
229
+ expect(dependencyTypeBadgeKey("dependents", "BLOCKS")).toBe("blocking");
230
+ expect(dependencyTypeBadgeKey("dependsOn", "STARTS_BEFORE")).toBe("starts_after");
231
+ expect(dependencyTypeBadgeKey("dependents", "STARTS_BEFORE")).toBe("starts_before");
232
+ });
233
+ });
234
+
235
+ describe("toDependencyEdge", () => {
236
+ it("reversed=true 时 sourceId=targetStoryId, targetId=currentStoryId", () => {
237
+ const reversed = DEPENDENCY_TYPE_OPTIONS.find((o) => o.reversed) as any;
238
+ const result = toDependencyEdge(reversed, "current", "target");
239
+ expect(result).toEqual({
240
+ sourceId: "target",
241
+ targetId: "current",
242
+ type: reversed.edge,
243
+ });
244
+ });
245
+
246
+ it("reversed=false 时 sourceId=currentStoryId, targetId=targetStoryId", () => {
247
+ const normal = DEPENDENCY_TYPE_OPTIONS.find((o) => !o.reversed) as any;
248
+ const result = toDependencyEdge(normal, "current", "target");
249
+ expect(result).toEqual({
250
+ sourceId: "current",
251
+ targetId: "target",
252
+ type: normal.edge,
253
+ });
254
+ });
255
+ });
@@ -30,7 +30,7 @@ import {
30
30
  SelectValue,
31
31
  } from "@/components/ui/select"
32
32
  import { toast } from "sonner"
33
- import { createStoryFn, addChildFn } from "@/server/serverFns/story"
33
+ import { createStoryFn, addChildFn, createLabelFn } from "@/server/serverFns/story"
34
34
  import { useSession } from "@/lib/auth/auth-client"
35
35
  import {
36
36
  fetchStoryAiStream,
@@ -217,18 +217,26 @@ export function StoryAiContent({
217
217
  try {
218
218
  const results = await Promise.allSettled(
219
219
  selected.map(async (item) => {
220
- const created = await createStoryFn({
221
- data: {
222
- name: item.name.trim(),
223
- description: item.description,
224
- projectId: gen.projectId || undefined,
225
- state: "backlog",
226
- priority: (item.priority ?? "none") as "urgent" | "high" | "medium" | "low" | "none",
227
- assigneeId: item.assigneeId,
228
- labelIds: item.labelIds,
229
- isAI: true,
230
- },
231
- })
220
+ const unmatched = item.labelNames.filter((n) => !labels.some((l) => l.name === n))
221
+ let finalLabelIds = item.labelIds
222
+ if (unmatched.length > 0) {
223
+ const createdLabels = await Promise.all(
224
+ unmatched.map((n) => createLabelFn({ data: { name: n } })),
225
+ )
226
+ finalLabelIds = Array.from(new Set([...item.labelIds, ...createdLabels.map((c) => c.id)]))
227
+ }
228
+ const created = await createStoryFn({
229
+ data: {
230
+ name: item.name.trim(),
231
+ description: item.description,
232
+ projectId: gen.projectId || undefined,
233
+ state: "backlog",
234
+ priority: (item.priority ?? "none") as "urgent" | "high" | "medium" | "low" | "none",
235
+ assigneeId: item.assigneeId,
236
+ labelIds: finalLabelIds,
237
+ isAI: true,
238
+ },
239
+ })
232
240
  if (gen.tab === "split" && gen.splitMode === "children" && gen.parentStory) {
233
241
  await addChildFn({ data: { parentId: gen.parentStory.id, childId: created.id } })
234
242
  }
@@ -285,7 +293,7 @@ export function StoryAiContent({
285
293
  <SelectTrigger className="w-full">
286
294
  <SelectValue placeholder={t("storyAi.panel.projectLabel")} />
287
295
  </SelectTrigger>
288
- <SelectContent>
296
+ <SelectContent>
289
297
  {projects.map((p) => (
290
298
  <SelectItem key={p.id} value={p.id}>
291
299
  {p.name}
@@ -353,7 +361,7 @@ export function StoryAiContent({
353
361
  <SelectTrigger className="w-full">
354
362
  <SelectValue placeholder={t("storyAi.panel.projectLabel")} />
355
363
  </SelectTrigger>
356
- <SelectContent>
364
+ <SelectContent>
357
365
  {projects.map((p) => (
358
366
  <SelectItem key={p.id} value={p.id}>
359
367
  {p.name}
@@ -162,6 +162,25 @@ export function StoryAiPreviewList({
162
162
  </button>
163
163
  )
164
164
  })}
165
+ {item.labelNames
166
+ .filter((n) => !labels.some((l) => l.name === n))
167
+ .map((n) => (
168
+ <button
169
+ key={`new-${n}`}
170
+ type="button"
171
+ disabled={disabled}
172
+ title={t("inline.aiNew")}
173
+ onClick={() =>
174
+ onUpdate(item.localId, {
175
+ labelNames: item.labelNames.filter((x) => x !== n),
176
+ })
177
+ }
178
+ className="rounded-full border border-dashed border-primary/60 px-2 py-0.5 text-xs text-primary transition-colors hover:bg-primary/10"
179
+ >
180
+ {n}
181
+ <span className="ml-1 text-[10px] leading-none opacity-70">{t("inline.aiNew")}</span>
182
+ </button>
183
+ ))}
165
184
  </div>
166
185
  </div>
167
186
  </div>