@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,111 @@
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, Plus, Settings } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { PlaceholderPage } from "@/components/shared/placeholder-page";
24
+ import { IterationCard } from "@/components/iteration/iteration-card";
25
+ import { CreateIterationDialog } from "@/components/iteration/create-iteration-dialog";
26
+ import { AutoScheduleDialog } from "@/components/iteration/auto-schedule-dialog";
27
+ import { listIterationsFn } from "@/server/serverFns/iteration";
28
+ import type { IterationSummary } from "@/server/serverFns/iteration/schemas";
29
+
30
+ export const Route = createLazyFileRoute("/_nav/projects/$projectId/iterations/")({
31
+ component: IterationsPage,
32
+ });
33
+
34
+ function IterationsPage() {
35
+ const { t } = useTranslation("iterations");
36
+ const router = useRouter();
37
+ const { items } = useLoaderData({ from: "/_nav/projects/$projectId/iterations/" });
38
+ const projectId = Route.useParams().projectId;
39
+ const [dialogOpen, setDialogOpen] = useState(false);
40
+ const [scheduleOpen, setScheduleOpen] = useState(false);
41
+
42
+ const active = items.filter((i) => i.status === "active").sort((a, b) => a.startDate.localeCompare(b.startDate));
43
+ const upcoming = items.filter((i) => i.status === "upcoming").sort((a, b) => a.startDate.localeCompare(b.startDate));
44
+ const completed = items.filter((i) => i.status === "completed").sort((a, b) => b.startDate.localeCompare(a.startDate)).slice(0, 8);
45
+
46
+ const groups = [
47
+ { title: t("group.active"), items: active },
48
+ { title: t("group.upcoming"), items: upcoming },
49
+ { title: t("group.completed"), items: completed },
50
+ ].filter((g) => g.items.length > 0);
51
+
52
+ return (
53
+ <div className="h-full overflow-y-auto">
54
+ <div className="mx-auto max-w-4xl px-6 py-6">
55
+ <div className="mb-6 flex items-center justify-between">
56
+ <Link
57
+ to="/projects/$projectId"
58
+ params={{ projectId }}
59
+ className="text-sm text-muted-foreground hover:text-foreground"
60
+ >
61
+ ← {t("title")}
62
+ </Link>
63
+ <div className="flex items-center gap-2">
64
+ <Button variant="outline" size="sm" onClick={() => setScheduleOpen(true)}>
65
+ <Settings className="mr-1 size-4" />
66
+ {t("actions.autoSchedule")}
67
+ </Button>
68
+ <Button size="sm" onClick={() => setDialogOpen(true)}>
69
+ <Plus className="mr-1 size-4" />
70
+ {t("actions.create")}
71
+ </Button>
72
+ </div>
73
+ </div>
74
+
75
+ {groups.length === 0 ? (
76
+ <PlaceholderPage
77
+ title={t("empty.title")}
78
+ description={t("empty.description")}
79
+ icon={CalendarRange}
80
+ />
81
+ ) : (
82
+ <div className="space-y-8">
83
+ {groups.map((group) => (
84
+ <div key={group.title}>
85
+ <h2 className="mb-3 text-sm font-semibold text-muted-foreground">{group.title}</h2>
86
+ <div className="grid gap-3 sm:grid-cols-2">
87
+ {group.items.map((item) => (
88
+ <IterationCard key={item.id} item={item} projectId={projectId} />
89
+ ))}
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )}
95
+ </div>
96
+
97
+ <CreateIterationDialog
98
+ open={dialogOpen}
99
+ onOpenChange={setDialogOpen}
100
+ projectId={projectId}
101
+ onSaved={() => void router.invalidate()}
102
+ />
103
+ <AutoScheduleDialog
104
+ open={scheduleOpen}
105
+ onOpenChange={setScheduleOpen}
106
+ projectId={projectId}
107
+ onSaved={() => void router.invalidate()}
108
+ />
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,24 @@
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 } from "@tanstack/react-router";
19
+ import { listIterationsFn } from "@/server/serverFns/iteration";
20
+
21
+ export const Route = createFileRoute("/_nav/projects/$projectId/iterations/")({
22
+ loader: async ({ params }) =>
23
+ listIterationsFn({ data: { projectId: params.projectId, includeArchived: false } }),
24
+ });
@@ -21,16 +21,18 @@ import {
21
21
  listProjectMembersFn,
22
22
  listProjectActivitiesFn,
23
23
  } from "@/server/serverFns/project";
24
+ import { getModuleOverviewFn } from "@/server/serverFns/project/module-overview";
24
25
 
25
26
  export const Route = createFileRoute("/_nav/projects/$projectId")({
26
27
  loader: async ({ params }) => {
27
- const [project, members, activities] = await Promise.all([
28
+ const [project, members, activities, moduleOverview] = await Promise.all([
28
29
  getProjectDetailFn({ data: { projectId: params.projectId } }),
29
30
  listProjectMembersFn({ data: { projectId: params.projectId } }),
30
31
  listProjectActivitiesFn({
31
32
  data: { projectId: params.projectId, take: 15 },
32
33
  }),
34
+ getModuleOverviewFn({ data: { projectId: params.projectId } }),
33
35
  ]);
34
- return { project, members, activities };
36
+ return { project, members, activities, moduleOverview };
35
37
  },
36
38
  });
@@ -19,19 +19,21 @@ import { createLazyFileRoute, Link } from "@tanstack/react-router";
19
19
  import { useRouter } from "@tanstack/react-router";
20
20
  import { useTranslation } from "react-i18next";
21
21
  import { useEffect, useState } from "react";
22
- import { ArrowLeft, Loader2 } from "lucide-react";
22
+ import { ArrowLeft, BookOpenText, Loader2 } from "lucide-react";
23
23
  import { toast } from '@/components/ui/sonner';
24
24
  import { Button } from "@/components/ui/button";
25
25
  import { Input } from "@/components/ui/input";
26
26
  import { Textarea } from "@/components/ui/textarea";
27
27
  import { Label } from "@/components/ui/label";
28
28
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
29
- import { IconPicker } from "@/components/shared/icon-picker";
30
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
29
+ import { Checkbox } from "@/components/ui/checkbox";
30
+ import { Badge } from "@/components/ui/badge";
31
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
32
+
31
33
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
32
34
  import { ProjectMemberManager } from "@/components/project/project-member-manager";
33
35
  import { extractDisplayId, validateDisplayId } from "@/lib/project-display-id";
34
- import { checkDisplayIdAvailableFn, updateProjectFn, deleteProjectFn } from "@/server/serverFns/project";
36
+ import { checkDisplayIdAvailableFn, updateProjectFn, deleteProjectFn, setProjectKbsFn } from "@/server/serverFns/project";
35
37
  import { useTimezone } from "@/providers/timezone-provider";
36
38
  import { TimezoneSelect } from "@/components/shared/timezone-select";
37
39
  import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
@@ -39,7 +41,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
39
41
  function ProjectSettingsPage() {
40
42
  const { t } = useTranslation('projects');
41
43
  const router = useRouter();
42
- const { project, members } = Route.useLoaderData();
44
+ const { project, members, linkedKbIds, candidateKbs } = Route.useLoaderData();
43
45
  const session = Route.useRouteContext({ select: (c) => c.session });
44
46
  const currentUserId = session?.user?.id;
45
47
  const currentUserRole =
@@ -57,6 +59,8 @@ function ProjectSettingsPage() {
57
59
  const [projectTimezone, setProjectTimezone] = useState(project.timezone ?? "");
58
60
  const [saving, setSaving] = useState(false);
59
61
  const [deleteOpen, setDeleteOpen] = useState(false);
62
+ const [kbChecked, setKbChecked] = useState<Set<string>>(() => new Set(linkedKbIds));
63
+ const [savingKbs, setSavingKbs] = useState(false);
60
64
  const [deleteMode, setDeleteMode] = useState<"transfer" | "clear">("transfer");
61
65
  const userTimezone = useTimezone();
62
66
 
@@ -102,6 +106,18 @@ function ProjectSettingsPage() {
102
106
  }
103
107
  };
104
108
 
109
+ const saveKbs = async () => {
110
+ setSavingKbs(true);
111
+ try {
112
+ await setProjectKbsFn({ data: { projectId: project.id, kbIds: [...kbChecked] } });
113
+ toast.success(t('settings.kbSection.saved'));
114
+ } catch (err) {
115
+ toast.error((err as Error).message || t('settings.kbSection.saveFailed'));
116
+ } finally {
117
+ setSavingKbs(false);
118
+ }
119
+ };
120
+
105
121
  const handleDelete = async (mode: "transfer" | "clear") => {
106
122
  try {
107
123
  await deleteProjectFn({ data: { projectId: project.id, mode } })
@@ -133,9 +149,9 @@ function ProjectSettingsPage() {
133
149
  <CardTitle>{t('settings.basicInfo')}</CardTitle>
134
150
  </CardHeader>
135
151
  <CardContent className="space-y-5">
136
- <div className="flex flex-col gap-3">
152
+ <div className="flex items-center gap-3">
137
153
  <Label>{t('settings.projectIcon')}</Label>
138
- <IconPicker icons={PROJECT_ICONS} value={icon} onChange={setIcon} />
154
+ <LucideIconPicker value={icon} onChange={setIcon} />
139
155
  </div>
140
156
  <div className="grid gap-2">
141
157
  <Label htmlFor="project-name">{t('settings.projectName')}</Label>
@@ -225,6 +241,61 @@ function ProjectSettingsPage() {
225
241
  </Card>
226
242
  )}
227
243
 
244
+ <Card className="mb-6">
245
+ <CardHeader>
246
+ <CardTitle>{t('settings.kbSection.title')}</CardTitle>
247
+ </CardHeader>
248
+ <CardContent>
249
+ <p className="mb-3 text-sm text-muted-foreground">
250
+ {t('settings.kbSection.desc')}
251
+ </p>
252
+ {candidateKbs.filter((kb) => kb.visibility !== 'private').length === 0 && (
253
+ <p className="text-sm text-muted-foreground">{t('settings.kbSection.empty')}</p>
254
+ )}
255
+ <div className="flex flex-col gap-2">
256
+ {candidateKbs
257
+ .filter((kb) => kb.visibility !== 'private')
258
+ .map((kb) => {
259
+ const isGeneral = kb.visibility === 'general';
260
+ return (
261
+ <label
262
+ key={kb.id}
263
+ className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
264
+ isGeneral ? 'opacity-60' : 'cursor-pointer'
265
+ }`}
266
+ >
267
+ <Checkbox
268
+ checked={isGeneral ? false : kbChecked.has(kb.id)}
269
+ disabled={isGeneral}
270
+ onCheckedChange={(v) =>
271
+ setKbChecked((prev) => {
272
+ const next = new Set(prev);
273
+ if (v) next.add(kb.id);
274
+ else next.delete(kb.id);
275
+ return next;
276
+ })
277
+ }
278
+ />
279
+ <BookOpenText className="size-4 text-muted-foreground" />
280
+ <span className="flex-1">{kb.name}</span>
281
+ {isGeneral ? (
282
+ <span className="text-xs text-muted-foreground">
283
+ {t('settings.kbSection.generalDisabled')}
284
+ </span>
285
+ ) : (
286
+ <Badge variant="outline" className="text-xs">{t('knowledge:toolbar.team')}</Badge>
287
+ )}
288
+ </label>
289
+ );
290
+ })}
291
+ </div>
292
+ <Button onClick={saveKbs} disabled={savingKbs} className="mt-3">
293
+ {savingKbs ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
294
+ {t('settings.kbSection.save')}
295
+ </Button>
296
+ </CardContent>
297
+ </Card>
298
+
228
299
  <Card className="mb-6">
229
300
  <CardHeader>
230
301
  <CardTitle>{t('settings.members')}</CardTitle>
@@ -19,14 +19,18 @@ import { createFileRoute } from "@tanstack/react-router";
19
19
  import {
20
20
  getProjectDetailFn,
21
21
  listProjectMembersFn,
22
+ listProjectKbsFn,
22
23
  } from "@/server/serverFns/project";
24
+ import { listKnowledgeFn } from "@/server/serverFns/knowledge";
23
25
 
24
26
  export const Route = createFileRoute("/_nav/projects/$projectId/settings")({
25
27
  loader: async ({ params }) => {
26
- const [project, members] = await Promise.all([
28
+ const [project, members, linkedKbs, myKbs] = await Promise.all([
27
29
  getProjectDetailFn({ data: { projectId: params.projectId } }),
28
30
  listProjectMembersFn({ data: { projectId: params.projectId } }),
31
+ listProjectKbsFn({ data: { projectId: params.projectId } }),
32
+ listKnowledgeFn({ data: {} }),
29
33
  ]);
30
- return { project, members };
34
+ return { project, members, linkedKbIds: linkedKbs.map((k) => k.id), candidateKbs: myKbs };
31
35
  },
32
36
  });
@@ -23,7 +23,7 @@ import { StoryDetailBody } from "@/components/story/detail/story-detail-body";
23
23
  import { StoryPanelHeader } from "@/components/story/story-panel-header";
24
24
 
25
25
  function StoryDetailPage() {
26
- const { story, users, comments, activities, neighbors, projects, labels, stories, relationsData } = Route.useLoaderData();
26
+ const { story, users, comments, activities, neighbors, projects, labels, stories, relationsData, iterations } = Route.useLoaderData();
27
27
  const currentUserId = Route.useRouteContext().session?.user?.id;
28
28
  const [storyState, setStoryState] = useState<StoryDetail>(story);
29
29
  const [activitiesState, setActivitiesState] = useState<ActivityItem[]>(activities);
@@ -49,6 +49,7 @@ function StoryDetailPage() {
49
49
  labels={labels}
50
50
  stories={stories}
51
51
  relationsData={relationsState}
52
+ iterations={iterations}
52
53
  onStoryUpdate={(s) => setStoryState(s)}
53
54
  onAppendActivities={(items) => setActivitiesState((prev) => [...items, ...prev])}
54
55
  onRelationsUpdate={(next) => setRelationsState(typeof next === "function" ? next(relationsState) : next)}
@@ -68,6 +69,7 @@ function StoryDetailContent({
68
69
  labels,
69
70
  stories,
70
71
  relationsData,
72
+ iterations,
71
73
  onStoryUpdate,
72
74
  onAppendActivities,
73
75
  onRelationsUpdate,
@@ -82,6 +84,7 @@ function StoryDetailContent({
82
84
  labels: { id: string; name: string; color: string }[];
83
85
  stories: { id: string; identifier: string; name: string; state: StoryState }[];
84
86
  relationsData: RelationsData;
87
+ iterations: import("@/server/serverFns/iteration/schemas").IterationSummary[];
85
88
  onStoryUpdate: (s: StoryDetail) => void;
86
89
  onAppendActivities: (items: ActivityItem[]) => void;
87
90
  onRelationsUpdate: (next: RelationsData | ((prev: RelationsData) => RelationsData)) => void;
@@ -123,6 +126,7 @@ function StoryDetailContent({
123
126
  stories={stories}
124
127
  projects={projects}
125
128
  relationsData={relationsData}
129
+ iterations={iterations}
126
130
  onRelationsUpdate={onRelationsUpdate}
127
131
  onStoryUpdate={onStoryUpdate}
128
132
  onAppendActivities={onAppendActivities}
@@ -33,6 +33,7 @@ import {
33
33
  getLinkedDocsFn,
34
34
  } from "@/server/serverFns/story";
35
35
  import { listProjectsBriefFn } from "@/server/serverFns/project";
36
+ import { listIterationsFn } from "@/server/serverFns/iteration";
36
37
  import type { RelationsData, StoryListItem } from "@/components/story/types";
37
38
 
38
39
  function StoryDetailSkeleton() {
@@ -98,6 +99,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
98
99
  attachments,
99
100
  prds,
100
101
  docs,
102
+ iterationsResult,
101
103
  ] = await Promise.all([
102
104
  listUsersBriefFn({ data: story.projectId ? { projectId: story.projectId } : undefined }),
103
105
  listCommentsFn({ data: { storyId: params.storyId } }),
@@ -112,6 +114,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
112
114
  getAttachmentsFn({ data: { storyId: params.storyId } }),
113
115
  getLinkedPrdsFn({ data: { storyId: params.storyId } }),
114
116
  getLinkedDocsFn({ data: { storyId: params.storyId } }),
117
+ story.projectId ? listIterationsFn({ data: { projectId: story.projectId, includeArchived: false } }) : Promise.resolve({ items: [] }),
115
118
  ]);
116
119
 
117
120
  const relationsData: RelationsData = {
@@ -136,6 +139,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
136
139
  labels,
137
140
  stories: storiesResult.items,
138
141
  relationsData,
142
+ iterations: iterationsResult.items,
139
143
  };
140
144
  },
141
145
  pendingComponent: StoryDetailSkeleton,
@@ -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 { tags_route_2 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/$id')({
7
+ component: tags_route_2.Component as any,
8
+ })
@@ -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 { tags_route_1 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/')({
7
+ component: tags_route_1.Component as any,
8
+ })
@@ -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 { tags_route_0 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags')({
7
+ component: tags_route_0.Component as any,
8
+ })
@@ -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 { tags_route_3 } from '@allbluecn/plugins-core/tags/routes'
5
+
6
+ export const Route = createFileRoute('/_nav/tags/settings')({
7
+ loader: ((ctx: PluginRouteContext) => tags_route_3.loader(ctx)) as any,
8
+ component: tags_route_3.Component as any,
9
+ })
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
23
23
  import { Button } from "@/components/ui/button";
24
24
  import { Card } from "@/components/ui/card";
25
25
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
26
- import { ProjectIconDisplay } from "@/components/project/project-icon";
26
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
27
27
  import { CreateProjectDialog } from "@/components/project/create-project-dialog";
28
28
 
29
29
  type DetailData = ReturnType<typeof import("@/server/serverFns/workspace")["getWorkspaceDetailFn"]> extends Promise<infer T>
@@ -52,7 +52,7 @@ function WorkspaceDetailPage() {
52
52
  <div className="relative z-10 mx-auto max-w-6xl px-6 py-6">
53
53
  <div className="flex flex-col items-center text-center py-10 mb-8">
54
54
  <div className="flex size-16 items-center justify-center rounded-xl bg-primary/10 mb-4">
55
- <ProjectIconDisplay name={workspace.icon} size={32} className="text-primary" />
55
+ <LucideIconByName name={workspace.icon} size={32} className="text-primary" />
56
56
  </div>
57
57
  <div className="flex items-center justify-center gap-3">
58
58
  <h1 className="text-2xl font-semibold">{workspace.name}</h1>
@@ -105,7 +105,7 @@ function WorkspaceDetailPage() {
105
105
  />
106
106
  <div className="flex items-start justify-between">
107
107
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
108
- <ProjectIconDisplay name={project.icon} size={20} />
108
+ <LucideIconByName name={project.icon} size={20} />
109
109
  </div>
110
110
  </div>
111
111
  <h3 className="mt-4 font-medium text-lg">{project.name}</h3>
@@ -26,8 +26,8 @@ import { Input } from "@/components/ui/input";
26
26
  import { Textarea } from "@/components/ui/textarea";
27
27
  import { Label } from "@/components/ui/label";
28
28
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
29
- import { IconPicker } from "@/components/shared/icon-picker";
30
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
29
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
30
+
31
31
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
32
32
  import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
33
33
  import {
@@ -121,9 +121,9 @@ function WorkspaceSettingsPage() {
121
121
  <CardTitle>{t("settings.basicInfo")}</CardTitle>
122
122
  </CardHeader>
123
123
  <CardContent className="space-y-5">
124
- <div className="flex flex-col gap-3">
124
+ <div className="flex items-center gap-3">
125
125
  <Label>{t("settings.icon")}</Label>
126
- <IconPicker icons={PROJECT_ICONS} value={icon} onChange={setIcon} />
126
+ <LucideIconPicker value={icon} onChange={setIcon} />
127
127
  </div>
128
128
  <div className="grid gap-2">
129
129
  <Label htmlFor="ws-name">{t("settings.nameLabel")}</Label>
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
23
23
  import { Button } from "@/components/ui/button";
24
24
  import { Card } from "@/components/ui/card";
25
25
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
26
- import { ProjectIconDisplay } from "@/components/project/project-icon";
26
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
27
27
  import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog";
28
28
 
29
29
  type WorkspaceList = ReturnType<typeof import("@/server/serverFns/workspace")["listWorkspacesFn"]> extends Promise<infer T> ? T : never;
@@ -43,7 +43,7 @@ function WorkspacesIndexPage() {
43
43
  <div className="relative z-10 mx-auto max-w-6xl px-6 py-6">
44
44
  <div className="relative mb-10 mt-2 flex flex-col items-center py-10 text-center">
45
45
  <div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 mb-4">
46
- <ProjectIconDisplay name="folder-kanban" size={28} className="text-primary" />
46
+ <LucideIconByName name="folder-kanban" size={28} className="text-primary" />
47
47
  </div>
48
48
  <h1 className="text-2xl font-semibold">{t("list.title")}</h1>
49
49
  <p className="mt-2 max-w-2xl text-sm text-muted-foreground">
@@ -77,7 +77,7 @@ function WorkspacesIndexPage() {
77
77
  <div className="flex-1">
78
78
  <div className="flex items-start justify-between">
79
79
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
80
- <ProjectIconDisplay name={workspace.icon} size={20} />
80
+ <LucideIconByName name={workspace.icon} size={20} />
81
81
  </div>
82
82
  </div>
83
83
  <h3 className="mt-4 font-medium text-lg">{workspace.name}</h3>
@@ -0,0 +1,60 @@
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 } from "@tanstack/react-router";
19
+ import { prisma } from "@allbluecn/database";
20
+ import { requireAuth } from "@/server/auth-helpers";
21
+
22
+ // URL 路径中的文件名不可信,Content-Disposition 用 RFC 5987 filename* 编码
23
+ function contentDisposition(fileName: string): string {
24
+ const encoded = encodeURIComponent(fileName);
25
+ return `inline; filename*=UTF-8''${encoded}`;
26
+ }
27
+
28
+ export const Route = createFileRoute("/api/attachments/$id/content")({
29
+ server: {
30
+ handlers: {
31
+ GET: async ({ request, params }) => {
32
+ try {
33
+ await requireAuth(request);
34
+
35
+ const attachment = await prisma.storyAttachment.findUnique({
36
+ where: { id: params.id },
37
+ select: { fileName: true, mimeType: true, bytes: true },
38
+ });
39
+ if (!attachment || !attachment.bytes) {
40
+ return new Response("Not found", { status: 404 });
41
+ }
42
+
43
+ const body = new Uint8Array(attachment.bytes);
44
+ return new Response(body, {
45
+ headers: {
46
+ "Content-Type": attachment.mimeType || "application/octet-stream",
47
+ "Content-Length": String(body.byteLength),
48
+ "Content-Disposition": contentDisposition(attachment.fileName),
49
+ "Cache-Control": "private, max-age=31536000, immutable",
50
+ },
51
+ });
52
+ } catch (e) {
53
+ const message = e instanceof Error ? e.message : "读取失败";
54
+ const status = message === "UNAUTHORIZED" ? 401 : 500;
55
+ return new Response(message, { status });
56
+ }
57
+ },
58
+ },
59
+ },
60
+ });