@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
@@ -26,8 +26,6 @@ import globalsCss from '@/styles/globals.css?url'
26
26
  import type { RouterContext } from '@/lib/router-context'
27
27
  import { Providers } from '@/providers'
28
28
  import { Toaster } from '@/components/ui/sonner'
29
- import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
30
- import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
31
29
  import { DevtoolsPanel } from '@allblue/devtools'
32
30
  import { getSessionFn } from '@/server/serverFns/auth/session'
33
31
  import { getLocaleFn } from '@/server/serverFns/locale'
@@ -82,12 +80,6 @@ function RootComponent() {
82
80
  <Outlet />
83
81
  <ShortcutsPanel />
84
82
  <Toaster position="top-center" />
85
- {import.meta.env.DEV && (
86
- <ReactQueryDevtools initialIsOpen={false} />
87
- )}
88
- {import.meta.env.DEV && (
89
- <TanStackRouterDevtools initialIsOpen={false} />
90
- )}
91
83
  {import.meta.env.DEV && <DevtoolsPanel />}
92
84
  </HotkeysProvider>
93
85
  </Providers>
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { inspiration_route_0 } from '@allbluecn/plugins-core/inspiration/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/inspiration')({
7
+ component: inspiration_route_0.Component as any,
8
+ })
@@ -23,7 +23,7 @@ import { Button } from '@/components/ui/button'
23
23
  import { Card } from '@/components/ui/card'
24
24
  import { AnimatedGridPattern } from '@/components/magicui/animated-grid-pattern'
25
25
  import { BookOpenTextIcon } from '@/components/animated-icons/book-open-text'
26
- import { KnowledgeIconDisplay } from '@/components/knowledge/knowledge-icon'
26
+ import { LucideIconByName } from '@/components/shared/lucide-icon-picker/lucide-icon-by-name'
27
27
  import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
28
28
 
29
29
  type KnowledgeList = ReturnType<typeof import('@/server/serverFns/knowledge')['listKnowledgeFn']> extends Promise<infer T> ? T : never
@@ -81,7 +81,7 @@ function KnowledgeListPage() {
81
81
  <div className="flex-1">
82
82
  <div className="flex items-start justify-between">
83
83
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
84
- <KnowledgeIconDisplay name={item.icon} size={20} />
84
+ <LucideIconByName name={item.icon} size={20} />
85
85
  </div>
86
86
  </div>
87
87
  <h3 className="mt-4 font-medium text-lg">{item.name}</h3>
@@ -0,0 +1,9 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_2 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/prd/$id')({
7
+ beforeLoad: ((ctx: PluginRouteContext) => prd_route_2.beforeLoad(ctx)) as any,
8
+ component: prd_route_2.Component as any,
9
+ })
@@ -0,0 +1,9 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_1 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/prd/')({
7
+ loader: ((ctx: PluginRouteContext) => prd_route_1.loader(ctx)) as any,
8
+ component: prd_route_1.Component as any,
9
+ })
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_0 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/prd')({
7
+ component: prd_route_0.Component as any,
8
+ })
@@ -20,25 +20,44 @@ import { useTranslation } from "react-i18next";
20
20
  import { ArrowLeft, ChevronRight } from "lucide-react";
21
21
  import { Button } from "@/components/ui/button";
22
22
  import { Badge } from "@/components/ui/badge";
23
- import { ProjectIconDisplay } from "@/components/project/project-icon";
24
- import { PROJECT_MODULE_META } from "@/lib/project-mock";
23
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
24
+ import { PROJECT_MODULE_META } from "@/lib/project-module-meta";
25
25
  import type { ProjectModuleItem } from "@/types/project";
26
26
 
27
- const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
28
- 已完成: "secondary",
29
- 已验收: "default",
30
- 已关闭: "outline",
31
- 进行中: "default",
32
- 待处理: "outline",
27
+ const STATE_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
28
+ done: "secondary",
29
+ cancelled: "outline",
30
+ in_progress: "default",
31
+ todo: "outline",
32
+ backlog: "outline",
33
33
  };
34
34
 
35
35
  const PRIORITY_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
36
- 高: "destructive",
37
- 中: "secondary",
38
- 低: "outline",
36
+ urgent: "destructive",
37
+ high: "destructive",
38
+ medium: "secondary",
39
+ low: "outline",
40
+ none: "outline",
39
41
  };
40
42
 
41
- function ModuleRow({ item, t }: { item: ProjectModuleItem; t: (key: string) => string }) {
43
+ const STATE_LABEL_KEYS: Record<string, string> = {
44
+ backlog: "state.backlog",
45
+ todo: "state.todo",
46
+ in_progress: "state.in_progress",
47
+ done: "state.done",
48
+ cancelled: "state.cancelled",
49
+ };
50
+
51
+ const PRIORITY_LABEL_KEYS: Record<string, string> = {
52
+ urgent: "priority.urgent",
53
+ high: "priority.high",
54
+ medium: "priority.medium",
55
+ low: "priority.low",
56
+ none: "priority.none",
57
+ };
58
+
59
+ function ModuleRow({ item, t }: { item: ProjectModuleItem; t: (key: string, opts?: Record<string, unknown>) => string }) {
60
+ const stateLabel = STATE_LABEL_KEYS[item.state] ?? item.state;
42
61
  return (
43
62
  <div className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-accent/40">
44
63
  <div className="min-w-0 flex-1">
@@ -47,9 +66,9 @@ function ModuleRow({ item, t }: { item: ProjectModuleItem; t: (key: string) => s
47
66
  {t('module.assigneeLabel')}{item.assignee ?? t('module.unassigned')}
48
67
  </p>
49
68
  </div>
50
- <Badge variant={STATUS_VARIANT[item.status] ?? "outline"}>{item.status}</Badge>
69
+ <Badge variant={STATE_VARIANT[item.state] ?? "outline"}>{t(`story:${stateLabel}`)}</Badge>
51
70
  <Badge variant={PRIORITY_VARIANT[item.priority] ?? "outline"} className="hidden sm:inline-flex">
52
- {item.priority}
71
+ {t(`story:${PRIORITY_LABEL_KEYS[item.priority] ?? item.priority}`)}
53
72
  </Badge>
54
73
  <span className="hidden text-xs text-muted-foreground md:block">
55
74
  {new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(new Date(item.updatedAt))}
@@ -60,7 +79,7 @@ function ModuleRow({ item, t }: { item: ProjectModuleItem; t: (key: string) => s
60
79
  }
61
80
 
62
81
  function ProjectModulePage() {
63
- const { t } = useTranslation('projects');
82
+ const { t } = useTranslation(["projects", "story"]);
64
83
  const { project, items, moduleKey } = Route.useLoaderData();
65
84
  const meta = PROJECT_MODULE_META[moduleKey];
66
85
  const Icon = meta.icon;
@@ -86,25 +105,33 @@ function ProjectModulePage() {
86
105
  <p className="text-sm text-muted-foreground">{meta.description}</p>
87
106
  </div>
88
107
  <div className="ml-auto flex items-center gap-2">
89
- <ProjectIconDisplay name={project.icon} size={20} />
108
+ <LucideIconByName name={project.icon} size={20} />
90
109
  <span className="text-sm text-muted-foreground">{project.name}</span>
91
110
  </div>
92
111
  </div>
93
112
 
94
113
  <div className="mb-3 flex items-center justify-between">
95
114
  <p className="text-sm text-muted-foreground">
96
- {t('module.totalItems', { count: items.length })} · {t('module.mockDataLabel')}
115
+ {t('projects:module.totalItems', { count: items.length })}
97
116
  </p>
98
- <Badge variant="outline" className="text-muted-foreground">
99
- {t('module.mockBadge')}
100
- </Badge>
117
+ {moduleKey === "requirement" && (
118
+ <Badge variant="outline" className="text-muted-foreground">
119
+ {t('projects:module.liveBadge')}
120
+ </Badge>
121
+ )}
101
122
  </div>
102
123
 
103
- <div className="divide-y rounded-lg border bg-card">
104
- {items.map((item) => (
105
- <ModuleRow key={item.id} item={item} t={t} />
106
- ))}
107
- </div>
124
+ {items.length === 0 ? (
125
+ <div className="rounded-lg border bg-card px-4 py-10 text-center text-sm text-muted-foreground">
126
+ {t('projects:module.empty')}
127
+ </div>
128
+ ) : (
129
+ <div className="divide-y rounded-lg border bg-card">
130
+ {items.map((item) => (
131
+ <ModuleRow key={item.id} item={item} t={t} />
132
+ ))}
133
+ </div>
134
+ )}
108
135
  </div>
109
136
  </div>
110
137
  );
@@ -17,10 +17,10 @@
17
17
 
18
18
  import { createFileRoute, notFound } from "@tanstack/react-router";
19
19
  import { getProjectDetailFn } from "@/server/serverFns/project";
20
+ import { listModuleStoriesFn } from "@/server/serverFns/project/module-overview";
20
21
  import {
21
- listModuleItems,
22
22
  PROJECT_MODULE_KEYS,
23
- } from "@/lib/project-mock";
23
+ } from "@/lib/project-module-meta";
24
24
  import type { ProjectModuleKey } from "@/types/project";
25
25
 
26
26
  export const Route = createFileRoute("/_nav/projects/$projectId/$storyId")({
@@ -32,7 +32,10 @@ export const Route = createFileRoute("/_nav/projects/$projectId/$storyId")({
32
32
  const project = await getProjectDetailFn({
33
33
  data: { projectId: params.projectId },
34
34
  });
35
- const items = listModuleItems(params.projectId, moduleKey);
35
+ const items =
36
+ moduleKey === "requirement"
37
+ ? (await listModuleStoriesFn({ data: { projectId: params.projectId } })).items
38
+ : [];
36
39
  return { project, items, moduleKey };
37
40
  },
38
41
  });
@@ -17,17 +17,17 @@
17
17
 
18
18
  import { createLazyFileRoute, Link, useLoaderData } from "@tanstack/react-router";
19
19
  import { useTranslation } from "react-i18next";
20
- import { Settings, LayoutGrid, Activity as ActivityIcon } from "lucide-react";
20
+ import { Settings, LayoutGrid, Activity as ActivityIcon, CalendarRange } from "lucide-react";
21
21
  import { useTheme } from "next-themes";
22
22
  import { UserAvatar } from "@allbluecn/ui";
23
23
  import type { AvatarConfig, ShapeType } from "@allbluecn/shared";
24
24
  import { Button } from "@/components/ui/button";
25
25
  import { AvatarGroup } from "@/components/magicui/components/animate/avatar-group";
26
26
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
27
- import { ProjectAnimatedIcon } from "@/components/project/project-animated-icon";
27
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
28
28
  import { ProjectModuleCard } from "@/components/project/project-module-card";
29
29
  import { ProjectActivityList } from "@/components/project/project-activity-list";
30
- import { buildModuleOverview } from "@/lib/project-mock";
30
+ import { PROJECT_MODULE_META, PROJECT_MODULE_KEYS } from "@/lib/project-module-meta";
31
31
  import { useMemo } from "react";
32
32
  import { formatInTimeZone } from "date-fns-tz";
33
33
  import { subDays } from "date-fns";
@@ -63,14 +63,20 @@ function buildContributionData(
63
63
 
64
64
  function ProjectDetailPage() {
65
65
  const { t } = useTranslation('projects');
66
- const { project, members, activities } = useLoaderData({
66
+ const { project, members, activities, moduleOverview } = useLoaderData({
67
67
  from: "/_nav/projects/$projectId",
68
68
  });
69
69
  const { resolvedTheme } = useTheme();
70
70
  const glyphColor = resolvedTheme === "dark" ? "#6b7280" : "#9ca3af";
71
71
  const timezone = useTimezone();
72
72
 
73
- const modules = buildModuleOverview(project.id);
73
+ const modules = PROJECT_MODULE_KEYS.map((key) => ({
74
+ ...PROJECT_MODULE_META[key],
75
+ count: moduleOverview[key]?.count ?? 0,
76
+ lastUpdatedAt: moduleOverview[key]?.lastUpdatedAt
77
+ ? new Date(moduleOverview[key]!.lastUpdatedAt!)
78
+ : null,
79
+ }));
74
80
  const contributionData = useMemo(
75
81
  () => buildContributionData(activities, timezone),
76
82
  [activities, timezone],
@@ -85,16 +91,27 @@ function ProjectDetailPage() {
85
91
  </div>
86
92
  <div className="relative z-10 mx-auto max-w-6xl px-6 py-6">
87
93
  <div className="relative mb-10 mt-2 flex flex-col items-center py-10 text-center">
88
- <Button variant="outline" size="sm" asChild className="absolute right-0 top-0">
89
- <Link
90
- to="/projects/$projectId/settings"
91
- params={{ projectId: project.id }}
92
- >
93
- <Settings className="size-4" />
94
- {t('detail.settings')}
95
- </Link>
96
- </Button>
97
- <ProjectAnimatedIcon name={project.icon} size={56} className="mb-4 text-primary" />
94
+ <div className="absolute right-0 top-0 flex gap-2">
95
+ <Button variant="outline" size="sm" asChild>
96
+ <Link
97
+ to="/projects/$projectId/iterations"
98
+ params={{ projectId: project.id }}
99
+ >
100
+ <CalendarRange className="size-4" />
101
+ {t('detail.iterations')}
102
+ </Link>
103
+ </Button>
104
+ <Button variant="outline" size="sm" asChild>
105
+ <Link
106
+ to="/projects/$projectId/settings"
107
+ params={{ projectId: project.id }}
108
+ >
109
+ <Settings className="size-4" />
110
+ {t('detail.settings')}
111
+ </Link>
112
+ </Button>
113
+ </div>
114
+ <LucideIconByName name={project.icon} size={56} className="mb-4 text-primary" />
98
115
  <h1 className="text-3xl font-semibold">{project.name}</h1>
99
116
  <p className="mt-2 max-w-2xl text-sm text-muted-foreground">
100
117
  {project.description || t('detail.noDescription')}
@@ -0,0 +1,289 @@
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 { createLazyFileRoute, Link, useLoaderData, useRouter } from "@tanstack/react-router";
20
+ import { useTranslation } from "react-i18next";
21
+ import { CalendarRange, MoreVertical, Pencil, Trash2, Archive, ArchiveX, Plus } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { IterationStatusBadge } from "@/components/iteration/iteration-status-badge";
24
+ import { IterationProgressBar } from "@/components/iteration/iteration-progress-bar";
25
+ import { BurndownChart } from "@/components/iteration/burndown-chart";
26
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
27
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
28
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
29
+ import { StorySelectDialog } from "@/components/story/inline-editors/story-select-dialog";
30
+ import { listIterationStoriesFn, archiveIterationsFn, deleteIterationsFn, transferUnfinishedFn, getIterationBurndownFn, listIterationsFn, addStoriesToIterationFn } from "@/server/serverFns/iteration";
31
+ import { listProjectsBriefFn } from "@/server/serverFns/project";
32
+ import { toast } from "@/components/ui/sonner";
33
+ import { useQuery } from "@tanstack/react-query";
34
+ import type { IterationSummary } from "@/server/serverFns/iteration/schemas";
35
+ import type { StoryState } from "@/components/story/constants";
36
+
37
+ interface IterationStoryRow {
38
+ id: string;
39
+ identifier: string;
40
+ name: string;
41
+ state: StoryState;
42
+ priority: string;
43
+ assignee?: { id: string; username: string; email: string; avatarConfig?: any; bgShape?: string; bgColor?: string } | null;
44
+ }
45
+
46
+ export const Route = createLazyFileRoute("/_nav/projects/$projectId/iterations/$iterationId")({
47
+ component: IterationDetailPage,
48
+ });
49
+
50
+ function IterationDetailPage() {
51
+ const { t } = useTranslation("iterations");
52
+ const { iteration, stories } = useLoaderData({ from: "/_nav/projects/$projectId/iterations/$iterationId" }) as {
53
+ iteration: IterationSummary;
54
+ stories: { items: IterationStoryRow[] };
55
+ };
56
+ const projectId = Route.useParams().projectId;
57
+ const iterationId = Route.useParams().iterationId;
58
+ const today = new Date().toISOString().slice(0, 10);
59
+ const remainingDays = Math.round((Date.parse(iteration.endDate) - Date.parse(today)) / 86400000);
60
+ const isCompleted = iteration.status === "completed";
61
+ const isOverdue = remainingDays < 0;
62
+ const [transferOpen, setTransferOpen] = useState(false);
63
+ const [addStoryOpen, setAddStoryOpen] = useState(false);
64
+ const router = useRouter();
65
+
66
+ const { data: burndown, isLoading: burndownLoading } = useQuery({
67
+ queryKey: ["iteration-burndown", iterationId],
68
+ queryFn: () => getIterationBurndownFn({ data: { iterationId } }),
69
+ enabled: iteration.storyCount > 0,
70
+ });
71
+
72
+ async function handleRemoveStory(storyId: string) {
73
+ await import("@/server/serverFns/iteration/iteration-stories").then((m) => m.removeStoryFromIterationFn({ data: { storyId } }));
74
+ toast.success(t("actions.removeStory"));
75
+ }
76
+
77
+ return (
78
+ <div className="h-full overflow-y-auto">
79
+ <div className="mx-auto max-w-4xl px-6 py-6">
80
+ {/* Header */}
81
+ <div className="mb-6 flex items-start justify-between">
82
+ <div>
83
+ <Link to="/projects/$projectId/iterations" params={{ projectId }} className="text-sm text-muted-foreground hover:text-foreground">
84
+ ← {t("title")}
85
+ </Link>
86
+ <div className="mt-2 flex items-center gap-3">
87
+ <h1 className="text-2xl font-semibold">{iteration.name}</h1>
88
+ <IterationStatusBadge status={iteration.status} />
89
+ </div>
90
+ <p className="mt-1 text-sm text-muted-foreground">
91
+ {iteration.startDate} ~ {iteration.endDate}
92
+ {iteration.status === "active" && !isOverdue && (
93
+ <span className="ml-2">({t("progress.remainingDays", { days: remainingDays })})</span>
94
+ )}
95
+ {isOverdue && (
96
+ <span className="ml-2 text-red-500">{t("progress.overdueDays", { days: Math.abs(remainingDays) })}</span>
97
+ )}
98
+ </p>
99
+ </div>
100
+ <div className="flex items-center gap-2">
101
+ <Button variant="outline" size="sm" disabled={isCompleted}>
102
+ <Pencil className="mr-1 size-4" />
103
+ {t("actions.edit")}
104
+ </Button>
105
+ <DropdownMenu>
106
+ <DropdownMenuTrigger asChild>
107
+ <Button variant="outline" size="sm">
108
+ <MoreVertical className="size-4" />
109
+ </Button>
110
+ </DropdownMenuTrigger>
111
+ <DropdownMenuContent align="end">
112
+ <DropdownMenuItem onClick={() => void (async () => {
113
+ await archiveIterationsFn({ data: { id: iterationId, archived: !iteration.archived } });
114
+ toast.success(iteration.archived ? t("actions.unarchive") : t("actions.archive"));
115
+ })()}>
116
+ {iteration.archived ? <ArchiveX className="mr-2 size-4" /> : <Archive className="mr-2 size-4" />}
117
+ {iteration.archived ? t("actions.unarchive") : t("actions.archive")}
118
+ </DropdownMenuItem>
119
+ <DropdownMenuItem className="text-destructive" onClick={() => document.getElementById(`delete-iter-${iterationId}`)?.click()}>
120
+ <Trash2 className="mr-2 size-4" />
121
+ {t("actions.delete")}
122
+ </DropdownMenuItem>
123
+ </DropdownMenuContent>
124
+ </DropdownMenu>
125
+ <AlertDialog>
126
+ <AlertDialogTrigger asChild>
127
+ <Button variant="destructive" size="sm" id={`delete-iter-${iterationId}`} className="hidden">Delete</Button>
128
+ </AlertDialogTrigger>
129
+ <AlertDialogContent>
130
+ <AlertDialogHeader>
131
+ <AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
132
+ <AlertDialogDescription>{t("errors.deleteConfirm")}</AlertDialogDescription>
133
+ </AlertDialogHeader>
134
+ <AlertDialogFooter>
135
+ <AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
136
+ <AlertDialogAction
137
+ onClick={async () => {
138
+ await deleteIterationsFn({ data: { id: iterationId } });
139
+ toast.success(t("actions.delete"));
140
+ void router.navigate({ to: "/projects/$projectId/iterations", params: { projectId } });
141
+ }}
142
+ >
143
+ {t("actions.delete")}
144
+ </AlertDialogAction>
145
+ </AlertDialogFooter>
146
+ </AlertDialogContent>
147
+ </AlertDialog>
148
+ {isCompleted && (
149
+ <Button variant="outline" size="sm" onClick={() => setTransferOpen(true)}>
150
+ {t("actions.transfer")}
151
+ </Button>
152
+ )}
153
+ {!isCompleted && (
154
+ <Button variant="outline" size="sm" onClick={() => setAddStoryOpen(true)}>
155
+ <Plus className="mr-1 size-4" />
156
+ {t("actions.addStories")}
157
+ </Button>
158
+ )}
159
+ </div>
160
+ </div>
161
+
162
+ {/* Stats + Burndown */}
163
+ {iteration.storyCount > 0 && (
164
+ <div className="mb-6 space-y-4">
165
+ <div className="rounded-lg border bg-card p-4">
166
+ <IterationProgressBar stateCounts={iteration.stateCounts} />
167
+ </div>
168
+ <div className="rounded-lg border bg-card p-4">
169
+ {burndownLoading ? (
170
+ <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">加载中...</div>
171
+ ) : burndown ? (
172
+ <BurndownChart data={burndown} />
173
+ ) : null}
174
+ </div>
175
+ </div>
176
+ )}
177
+
178
+ {/* Stories list */}
179
+ <div className="space-y-2">
180
+ {stories.items.length === 0 ? (
181
+ <p className="py-8 text-center text-sm text-muted-foreground">{t("empty.description")}</p>
182
+ ) : (
183
+ stories.items.map((story) => (
184
+ <div key={story.id} className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
185
+ <span className="font-mono text-xs text-muted-foreground">{story.identifier}</span>
186
+ <Link to="/stories/$storyId" params={{ storyId: story.id }} className="flex-1 text-sm font-medium hover:underline">
187
+ {story.name}
188
+ </Link>
189
+ <span className="text-xs text-muted-foreground">{story.state}</span>
190
+ <Button variant="ghost" size="sm" className="ml-auto text-muted-foreground" onClick={() => void handleRemoveStory(story.id)}>
191
+ {t("actions.removeStory")}
192
+ </Button>
193
+ </div>
194
+ ))
195
+ )}
196
+ </div>
197
+ </div>
198
+
199
+ {/* Transfer dialog */}
200
+ <TransferDialog open={transferOpen} onOpenChange={setTransferOpen} iterationId={iterationId} projectId={projectId} onSaved={() => void router.invalidate()} />
201
+ <AddStoriesDialog open={addStoryOpen} onOpenChange={setAddStoryOpen} iterationId={iterationId} projectId={projectId} onAdded={() => void router.invalidate()} />
202
+ </div>
203
+ );
204
+ }
205
+
206
+ function TransferDialog({ open, onOpenChange, iterationId, projectId, onSaved }: { open: boolean; onOpenChange: (v: boolean) => void; iterationId: string; projectId: string; onSaved?: () => void }) {
207
+ const { t } = useTranslation("iterations");
208
+ const [targetId, setTargetId] = useState("");
209
+ const [loading, setLoading] = useState(false);
210
+ const { data } = useQuery({
211
+ queryKey: ["iterations-list", projectId],
212
+ queryFn: () => listIterationsFn({ data: { projectId, includeArchived: false } }),
213
+ enabled: open,
214
+ });
215
+ const targets = (data?.items ?? []).filter((i: IterationSummary) => i.status !== "completed");
216
+
217
+ async function handleTransfer() {
218
+ if (!targetId) return;
219
+ setLoading(true);
220
+ try {
221
+ const r = await transferUnfinishedFn({ data: { iterationId, targetIterationId: targetId } });
222
+ toast.success(t("transfer.success", { count: r.count }));
223
+ onSaved?.();
224
+ onOpenChange(false);
225
+ } catch (e) {
226
+ toast.error(e instanceof Error ? e.message : "转移失败");
227
+ } finally {
228
+ setLoading(false);
229
+ }
230
+ }
231
+
232
+ return (
233
+ <Dialog open={open} onOpenChange={onOpenChange}>
234
+ <DialogContent className="sm:max-w-md">
235
+ <DialogHeader>
236
+ <DialogTitle>{t("transfer.title")}</DialogTitle>
237
+ </DialogHeader>
238
+ <p className="text-sm text-muted-foreground">{t("transfer.description")}</p>
239
+ <div className="space-y-2 py-4">
240
+ <label className="text-sm font-medium">{t("transfer.target")}</label>
241
+ <select
242
+ className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors"
243
+ value={targetId}
244
+ onChange={(e) => setTargetId(e.target.value)}
245
+ >
246
+ <option value="">{t("transfer.noTarget") ?? "请选择目标迭代"}</option>
247
+ {targets.map((i: IterationSummary) => (
248
+ <option key={i.id} value={i.id}>{i.name} ({i.startDate} ~ {i.endDate})</option>
249
+ ))}
250
+ </select>
251
+ </div>
252
+ <DialogFooter>
253
+ <Button variant="outline" onClick={() => onOpenChange(false)}>{t("actions.cancel")}</Button>
254
+ <Button onClick={() => void handleTransfer()} disabled={!targetId || loading}>{loading ? "转移中..." : t("actions.transfer")}</Button>
255
+ </DialogFooter>
256
+ </DialogContent>
257
+ </Dialog>
258
+ );
259
+ }
260
+
261
+ function AddStoriesDialog({ open, onOpenChange, iterationId, projectId, onAdded }: { open: boolean; onOpenChange: (v: boolean) => void; iterationId: string; projectId: string; onAdded?: () => void }) {
262
+ const { t } = useTranslation("iterations");
263
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
264
+ const { data: projects } = useQuery({
265
+ queryKey: ["projects-brief"],
266
+ queryFn: () => listProjectsBriefFn(),
267
+ });
268
+
269
+ async function handleSelected(ids: string | string[]) {
270
+ const storyIds = Array.isArray(ids) ? ids : [ids];
271
+ if (!storyIds.length) return;
272
+ await addStoriesToIterationFn({ data: { iterationId, storyIds } });
273
+ toast.success(t("actions.addStories"));
274
+ onAdded?.();
275
+ onOpenChange(false);
276
+ }
277
+
278
+ return (
279
+ <StorySelectDialog
280
+ open={open}
281
+ onOpenChange={onOpenChange}
282
+ storyId=""
283
+ projects={projects ?? []}
284
+ mode="iteration"
285
+ title={t("actions.addStories")}
286
+ onSelected={handleSelected}
287
+ />
288
+ );
289
+ }
@@ -0,0 +1,27 @@
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 { createFileRoute, notFound } from "@tanstack/react-router";
19
+ import { getIterationFn, listIterationStoriesFn } from "@/server/serverFns/iteration";
20
+
21
+ export const Route = createFileRoute("/_nav/projects/$projectId/iterations/$iterationId")({
22
+ loader: async ({ params }) => {
23
+ const iteration = await getIterationFn({ data: { id: params.iterationId } });
24
+ const stories = await listIterationStoriesFn({ data: { iterationId: params.iterationId } });
25
+ return { iteration, stories };
26
+ },
27
+ });