@allbluecn/web-app 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +239 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +14 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/lib/project-mock.ts +0 -156
|
@@ -0,0 +1,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 {
|
|
30
|
-
import {
|
|
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
|
|
152
|
+
<div className="flex items-center gap-3">
|
|
137
153
|
<Label>{t('settings.projectIcon')}</Label>
|
|
138
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
30
|
-
|
|
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
|
|
124
|
+
<div className="flex items-center gap-3">
|
|
125
125
|
<Label>{t("settings.icon")}</Label>
|
|
126
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
+
});
|