@allbluecn/web-app 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +11 -7
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/story/constants.ts +11 -1
  4. package/src/components/story/create-story-dialog.tsx +12 -6
  5. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  6. package/src/components/story/detail/activity-timeline.tsx +15 -0
  7. package/src/components/story/detail/story-detail-body.tsx +1 -0
  8. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  9. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  10. package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
  11. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
  12. package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
  13. package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
  14. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  15. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  16. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  17. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  18. package/src/components/story/relations/file-preview.tsx +173 -0
  19. package/src/components/story/relations/format.ts +120 -0
  20. package/src/components/story/relations/general-groups.ts +32 -0
  21. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  22. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  23. package/src/components/story/types.ts +13 -0
  24. package/src/lib/changelog/sdk-versions.ts +19 -19
  25. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  26. package/src/lib/story/video-embed.ts +59 -0
  27. package/src/plugins/.gen-manifest.json +13 -0
  28. package/src/plugins/modules.gen.ts +20 -0
  29. package/src/plugins/plugins.gen.ts +20 -0
  30. package/src/plugins/ui.gen.ts +8 -0
  31. package/src/routeTree.gen.ts +42 -0
  32. package/src/routes/_nav/inspiration/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  37. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  38. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  39. package/src/routes/_nav/tags/index.tsx +8 -0
  40. package/src/routes/_nav/tags/route.tsx +8 -0
  41. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  42. package/src/routes/api/attachments/$id/content.ts +60 -0
  43. package/src/routes/api/attachments/upload.ts +148 -0
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  46. package/src/server/serverFns/knowledge.ts +50 -4
  47. package/src/server/serverFns/project.ts +43 -0
  48. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  49. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  50. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
  51. package/src/server/serverFns/story/index.ts +6 -4
  52. package/src/server/serverFns/story/kb-general.ts +39 -0
  53. package/src/server/serverFns/story/kb-scope.ts +52 -0
  54. package/src/server/serverFns/story/link-preview.ts +115 -0
  55. package/src/server/serverFns/story/story-assoc.ts +339 -50
  56. package/src/server/serverFns/story/story-attachments.ts +12 -43
  57. package/src/server/serverFns/story/story-crud.ts +21 -4
  58. package/src/server/serverFns/story/story-dependencies.ts +35 -3
  59. package/src/server/serverFns/story/story-links.ts +13 -1
  60. package/src/server/serverFns/story/story-tree.ts +28 -1
  61. package/src/server/serverFns/story/types.ts +37 -0
  62. package/src/styles/globals.css +25 -0
  63. package/vite.shared.ts +2 -0
  64. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  65. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  66. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  67. package/src/components/story/relations/attachments-panel.tsx +0 -179
  68. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  69. package/src/components/story/relations/links-panel.tsx +0 -240
@@ -25,7 +25,7 @@ import { isUserInTeam } from "@/server/team-access";
25
25
  import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
26
26
  import type { ActivityItem } from "@/components/story/types";
27
27
  import { diffDescriptionBlocks } from "@/lib/story/description-diff";
28
- import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly } from "./types";
28
+ import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly, ACTIVITY_SELECT, toActivityItem } from "./types";
29
29
  import { buildListWhere } from "./story-list-query";
30
30
 
31
31
  async function validateAssignee(projectId: string, assigneeId: string | null | undefined) {
@@ -116,9 +116,10 @@ export const createStoryFn = createServerFn({ method: "POST" })
116
116
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
117
117
  try {
118
118
  return await prisma.$transaction(async (tx) => {
119
+ let parentStory: { id: string; identifier: string; name: string } | null = null;
119
120
  if (data.parentId) {
120
- const parent = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true } });
121
- if (!parent) throw new Error("父需求不存在");
121
+ parentStory = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true, identifier: true, name: true } });
122
+ if (!parentStory) throw new Error("父需求不存在");
122
123
  }
123
124
  let sequenceId: number;
124
125
  let identifier: string;
@@ -177,7 +178,23 @@ export const createStoryFn = createServerFn({ method: "POST" })
177
178
  data: { storyId: story.id, userId, action: "created", isAI: data.isAI },
178
179
  });
179
180
 
180
- return castStoryUsers(story);
181
+ // 创建时带父需求:父需求侧补「添加子需求」活动
182
+ let parentActivity: ReturnType<typeof toActivityItem> | null = null;
183
+ if (parentStory) {
184
+ const pa = await tx.storyActivity.create({
185
+ data: {
186
+ storyId: parentStory.id,
187
+ userId,
188
+ action: "child_added",
189
+ newValue: `${story.identifier} ${story.name}`.trim(),
190
+ isAI: data.isAI,
191
+ },
192
+ select: ACTIVITY_SELECT,
193
+ });
194
+ parentActivity = toActivityItem(pa);
195
+ }
196
+
197
+ return { ...castStoryUsers(story), activities: parentActivity ? [parentActivity] : [] };
181
198
  });
182
199
  } catch (e: unknown) {
183
200
  const isUniqueViolation =
@@ -20,7 +20,7 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
- import { STORY_LIST_SELECT, castStoryUsers } from "./types";
23
+ import { STORY_LIST_SELECT, castStoryUsers, ACTIVITY_SELECT, toActivityItem } from "./types";
24
24
  import type { DependencyRow } from "@/components/story/types";
25
25
 
26
26
  const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
@@ -41,7 +41,8 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
41
41
  )
42
42
  .handler(async ({ data }) => {
43
43
  const request = getRequest();
44
- await requireAuth(request);
44
+ const session = await requireAuth(request);
45
+ const userId = session.user!.id!;
45
46
 
46
47
  // 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
47
48
  // 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
@@ -111,7 +112,38 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
111
112
  batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
112
113
  }
113
114
 
114
- return { created, failed };
115
+ // 活动记录:依赖两端各记一条「添加依赖关系」(newValue 为对方需求描述),
116
+ // 返回带 storyId 供 UI 过滤当前需求侧的记录
117
+ let activities: Array<ReturnType<typeof toActivityItem> & { storyId: string }> = [];
118
+ if (created.length > 0) {
119
+ const endpointIds = [...new Set(batchEdges.flatMap((e) => [e.sourceId, e.targetId]))];
120
+ const endpoints = await prisma.story.findMany({
121
+ where: { id: { in: endpointIds } },
122
+ select: { id: true, identifier: true, name: true },
123
+ });
124
+ const labelById = new Map(
125
+ endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
126
+ );
127
+ const createdActivities = await Promise.all(
128
+ batchEdges.flatMap(({ sourceId, targetId }) => {
129
+ const sourceLabel = labelById.get(sourceId) ?? "";
130
+ const targetLabel = labelById.get(targetId) ?? "";
131
+ return [
132
+ prisma.storyActivity.create({
133
+ data: { storyId: sourceId, userId, action: "dependency_added", newValue: targetLabel },
134
+ select: { ...ACTIVITY_SELECT, storyId: true },
135
+ }),
136
+ prisma.storyActivity.create({
137
+ data: { storyId: targetId, userId, action: "dependency_added", newValue: sourceLabel },
138
+ select: { ...ACTIVITY_SELECT, storyId: true },
139
+ }),
140
+ ];
141
+ }),
142
+ );
143
+ activities = createdActivities.map((a) => ({ ...toActivityItem(a), storyId: a.storyId }));
144
+ }
145
+
146
+ return { created, failed, activities };
115
147
  });
116
148
 
117
149
  export const removeDependencyFn = createServerFn({ method: "POST" })
@@ -20,12 +20,14 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
+ import { ACTIVITY_SELECT, toActivityItem } from "./types";
23
24
 
24
25
  export const addLinkFn = createServerFn({ method: "POST" })
25
26
  .validator(z.object({ storyId: z.string(), url: z.string().url(), title: z.string().optional(), description: z.string().optional() }))
26
27
  .handler(async ({ data }) => {
27
28
  const request = getRequest();
28
29
  const session = await requireAuth(request);
30
+ const userId = session.user!.id!;
29
31
 
30
32
  const link = await prisma.storyLink.create({
31
33
  data: {
@@ -36,7 +38,17 @@ export const addLinkFn = createServerFn({ method: "POST" })
36
38
  },
37
39
  });
38
40
 
39
- return link;
41
+ const activity = await prisma.storyActivity.create({
42
+ data: {
43
+ storyId: data.storyId,
44
+ userId,
45
+ action: "link_added",
46
+ newValue: data.title || data.url,
47
+ },
48
+ select: ACTIVITY_SELECT,
49
+ });
50
+
51
+ return { ...link, activity: toActivityItem(activity) };
40
52
  });
41
53
 
42
54
  export const updateLinkFn = createServerFn({ method: "POST" })
@@ -54,7 +54,8 @@ export const addChildFn = createServerFn({ method: "POST" })
54
54
  .validator(z.object({ parentId: z.string(), childId: z.string() }))
55
55
  .handler(async ({ data }) => {
56
56
  const request = getRequest();
57
- await requireAuth(request);
57
+ const session = await requireAuth(request);
58
+ const userId = session.user!.id!;
58
59
 
59
60
  const existingChild = await prisma.story.findUnique({
60
61
  where: { id: data.childId },
@@ -68,11 +69,37 @@ export const addChildFn = createServerFn({ method: "POST" })
68
69
  throw new Error("不能将子需求设为其父需求的子需求(循环依赖)");
69
70
  }
70
71
 
72
+ const parent = await prisma.story.findUnique({
73
+ where: { id: data.parentId },
74
+ select: { id: true, identifier: true, name: true },
75
+ });
76
+
71
77
  const child = await prisma.story.update({
72
78
  where: { id: data.childId },
73
79
  data: { parentId: data.parentId },
74
80
  });
75
81
 
82
+ // 活动记录:父需求记「添加子需求」,子需求记「变更父需求」(与 updateStoryFn 格式一致)
83
+ if (parent) {
84
+ const childLabel = `${existingChild?.identifier ?? ""} ${existingChild?.name ?? ""}`.trim();
85
+ const parentLabel = `${parent.identifier} ${parent.name}`.trim();
86
+ await prisma.$transaction([
87
+ prisma.storyActivity.create({
88
+ data: { storyId: parent.id, userId, action: "child_added", newValue: childLabel },
89
+ }),
90
+ prisma.storyActivity.create({
91
+ data: {
92
+ storyId: child.id,
93
+ userId,
94
+ action: "parent_changed",
95
+ field: "parentId",
96
+ oldValue: "",
97
+ newValue: parentLabel,
98
+ },
99
+ }),
100
+ ]);
101
+ }
102
+
76
103
  return child;
77
104
  });
78
105
 
@@ -21,6 +21,7 @@ import type { StoryState, StoryPriority } from "@/components/story/constants";
21
21
  import type {
22
22
  UserBrief,
23
23
  ReactionGroup,
24
+ ActivityItem,
24
25
  } from "@/components/story/types";
25
26
 
26
27
  export const USER_BRIEF_SELECT = {
@@ -140,6 +141,42 @@ export function castUserBrief(u: {
140
141
  };
141
142
  }
142
143
 
144
+ /** 活动记录 select(含 user brief),供 serverFn 返回新创建的活动给 UI 即时追加 */
145
+ export const ACTIVITY_SELECT = {
146
+ id: true,
147
+ action: true,
148
+ field: true,
149
+ oldValue: true,
150
+ newValue: true,
151
+ isAI: true,
152
+ createdAt: true,
153
+ user: { select: USER_BRIEF_SELECT },
154
+ } as const;
155
+
156
+ export interface ActivityRow {
157
+ id: string;
158
+ action: string;
159
+ field: string | null;
160
+ oldValue: string | null;
161
+ newValue: string | null;
162
+ isAI: boolean;
163
+ createdAt: Date;
164
+ user: { id: string; username: string; email: string; avatarConfig: unknown; bgShape: string | null; bgColor: string | null };
165
+ }
166
+
167
+ export function toActivityItem(a: ActivityRow): ActivityItem {
168
+ return {
169
+ id: a.id,
170
+ action: a.action,
171
+ field: a.field,
172
+ oldValue: a.oldValue,
173
+ newValue: a.newValue,
174
+ isAI: a.isAI,
175
+ createdAt: a.createdAt.toISOString(),
176
+ user: castUserBrief(a.user),
177
+ };
178
+ }
179
+
143
180
  export function aggregateReactions(
144
181
  reactions: { emoji: string; userId: string }[],
145
182
  currentUserId: string,
@@ -493,3 +493,28 @@ html.theme-transitioning *::after {
493
493
  height: 40px !important;
494
494
  border-radius: 20px !important;
495
495
  }
496
+
497
+ /* 关联附件预览网格:笔记本4列,大屏(≥1536px)5列;4列时预览高度较矮 */
498
+ .attachment-preview-grid {
499
+ grid-template-columns: repeat(4, minmax(0, 1fr));
500
+ --attachment-preview-h: 18rem;
501
+ --attachment-preview-h-sub: 9rem;
502
+ }
503
+ @media (min-width: 1536px) {
504
+ .attachment-preview-grid {
505
+ grid-template-columns: repeat(5, minmax(0, 1fr));
506
+ /* 5列(大屏)保持原高度(h-96 / h-48) */
507
+ --attachment-preview-h: 24rem;
508
+ --attachment-preview-h-sub: 12rem;
509
+ }
510
+ }
511
+
512
+ /* open-file-viewer 预览(FilePreview):插件不支持的命令按钮处于 disabled 态,直接隐藏 */
513
+ .ofv-preview-scope .ofv-toolbar button:disabled {
514
+ display: none;
515
+ }
516
+ /* open-file-viewer 预览(FilePreview):默认隐藏 text/code 顶部标题栏,由组件切换类名显隐;
517
+ 音频等类型的标题不隐藏 */
518
+ .ofv-preview-title-hidden .ofv-code-header {
519
+ display: none;
520
+ }
package/vite.shared.ts CHANGED
@@ -160,6 +160,8 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
160
160
  '@allblue/devtools',
161
161
  '@allbluecn/plugins-core',
162
162
  '@allbluecn/tiptap',
163
+ '@open-file-viewer/core',
164
+ '@open-file-viewer/react',
163
165
  ],
164
166
  },
165
167
  }
@@ -1,73 +0,0 @@
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 { Paperclip } from "lucide-react";
19
- import { useTranslation } from "react-i18next";
20
- import { Button } from "@/components/ui/button";
21
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
22
- import { AttachmentsPanel } from "../../relations/attachments-panel";
23
- import type { RelationsData } from "../../types";
24
-
25
- export function StoryDetailAttachments({
26
- storyId,
27
- data,
28
- onRefresh,
29
- }: {
30
- storyId: string;
31
- data: RelationsData;
32
- onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
33
- }) {
34
- const { t } = useTranslation("story");
35
- const count = data.attachments.length;
36
-
37
- return (
38
- <Popover>
39
- <PopoverTrigger asChild>
40
- <Button
41
- variant="ghost"
42
- size="sm"
43
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
44
- title={t("relations.uploadAttachment")}
45
- >
46
- <Paperclip className="size-3.5 text-muted-foreground" />
47
- {t("relations.attachment")}
48
- {count > 0 && (
49
- <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
50
- {count}
51
- </span>
52
- )}
53
- </Button>
54
- </PopoverTrigger>
55
- <PopoverContent align="start" className="w-[380px] p-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-sm font-semibold">{t("relations.attachment")}</span>
58
- {count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
59
- </div>
60
- <AttachmentsPanel
61
- storyId={storyId}
62
- data={data.attachments}
63
- onUpdate={(newAttachments) =>
64
- onRefresh((prev) => ({
65
- ...prev,
66
- attachments: newAttachments,
67
- }))
68
- }
69
- />
70
- </PopoverContent>
71
- </Popover>
72
- );
73
- }
@@ -1,73 +0,0 @@
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 { BookOpenText } from "lucide-react";
19
- import { useTranslation } from "react-i18next";
20
- import { Button } from "@/components/ui/button";
21
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
22
- import { KnowledgePanel } from "../../relations/knowledge-panel";
23
- import type { RelationsData } from "../../types";
24
-
25
- export function StoryDetailKnowledge({
26
- storyId,
27
- data,
28
- onRefresh,
29
- }: {
30
- storyId: string;
31
- data: RelationsData;
32
- onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
33
- }) {
34
- const { t } = useTranslation("story");
35
- const count = data.knowledge.prds.length + data.knowledge.docs.length;
36
-
37
- return (
38
- <Popover>
39
- <PopoverTrigger asChild>
40
- <Button
41
- variant="ghost"
42
- size="sm"
43
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
44
- title={t("relations.knowledgeTitle")}
45
- >
46
- <BookOpenText className="size-3.5 text-muted-foreground" />
47
- {t("relations.knowledge")}
48
- {count > 0 && (
49
- <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
50
- {count}
51
- </span>
52
- )}
53
- </Button>
54
- </PopoverTrigger>
55
- <PopoverContent align="start" className="w-[380px] p-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-sm font-semibold">{t("relations.knowledge")}</span>
58
- {count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
59
- </div>
60
- <KnowledgePanel
61
- storyId={storyId}
62
- data={data.knowledge}
63
- onUpdate={(updates) =>
64
- onRefresh((prev) => ({
65
- ...prev,
66
- knowledge: { ...prev.knowledge, ...updates },
67
- }))
68
- }
69
- />
70
- </PopoverContent>
71
- </Popover>
72
- );
73
- }
@@ -1,73 +0,0 @@
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 { Link2 } from "lucide-react";
19
- import { useTranslation } from "react-i18next";
20
- import { Button } from "@/components/ui/button";
21
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
22
- import { LinksPanel } from "../../relations/links-panel";
23
- import type { RelationsData } from "../../types";
24
-
25
- export function StoryDetailLinks({
26
- storyId,
27
- data,
28
- onRefresh,
29
- }: {
30
- storyId: string;
31
- data: RelationsData;
32
- onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
33
- }) {
34
- const { t } = useTranslation("story");
35
- const count = data.links.length;
36
-
37
- return (
38
- <Popover>
39
- <PopoverTrigger asChild>
40
- <Button
41
- variant="ghost"
42
- size="sm"
43
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
44
- title={t("relations.linksTitle")}
45
- >
46
- <Link2 className="size-3.5 text-muted-foreground" />
47
- {t("relations.links")}
48
- {count > 0 && (
49
- <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
50
- {count}
51
- </span>
52
- )}
53
- </Button>
54
- </PopoverTrigger>
55
- <PopoverContent align="start" className="w-[380px] p-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-sm font-semibold">{t("relations.links")}</span>
58
- {count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
59
- </div>
60
- <LinksPanel
61
- storyId={storyId}
62
- data={data.links}
63
- onUpdate={(newLinks) =>
64
- onRefresh((prev) => ({
65
- ...prev,
66
- links: newLinks,
67
- }))
68
- }
69
- />
70
- </PopoverContent>
71
- </Popover>
72
- );
73
- }
@@ -1,179 +0,0 @@
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 * as React from "react";
19
- import { Plus, Upload, FileText, Trash2 } from "lucide-react";
20
- import { useTranslation } from "react-i18next";
21
- import { Button } from "@/components/ui/button";
22
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
23
- import { useServerFn } from "@tanstack/react-start";
24
- import { getAttachmentsFn, removeAttachmentFn } from "@/server/serverFns/story";
25
- import { toast } from "sonner";
26
- import { type StoryAttachmentItem } from "../types";
27
-
28
- export interface AttachmentsPanelProps {
29
- storyId: string;
30
- data: StoryAttachmentItem[];
31
- onUpdate: (newAttachments: StoryAttachmentItem[]) => void;
32
- }
33
-
34
- export function AttachmentsPanel({ storyId, data, onUpdate }: AttachmentsPanelProps) {
35
- const { t } = useTranslation("story");
36
- const [open, setOpen] = React.useState(false);
37
- const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
38
- const [uploading, setUploading] = React.useState(false);
39
-
40
- const removeAttachment = useServerFn(removeAttachmentFn);
41
-
42
- const handleUpload = async () => {
43
- if (!selectedFile) {
44
- toast.error(t("attachments.selectFile"));
45
- return;
46
- }
47
-
48
- setUploading(true);
49
- try {
50
- // 使用原生 fetch 调用 serverFn 端点上传(支持 FormData)
51
- const formData = new FormData();
52
- formData.append("storyId", storyId);
53
- formData.append("file", selectedFile);
54
-
55
- // 注意:serverFn 不支持 FormData,这里使用 Mock 实现
56
- // 实际应用中应调用专用上传 API
57
- const mockUrl = `/mock-storage/${Date.now()}-${selectedFile.name}`;
58
- const newAttachment: StoryAttachmentItem = {
59
- id: `mock-${Date.now()}`,
60
- fileName: selectedFile.name,
61
- fileSize: selectedFile.size,
62
- mimeType: selectedFile.type || "application/octet-stream",
63
- storageUrl: mockUrl,
64
- };
65
-
66
- // 乐观更新
67
- onUpdate([newAttachment, ...data]);
68
- toast.success(t("attachments.uploaded"));
69
- setOpen(false);
70
- setSelectedFile(null);
71
- } catch (error: any) {
72
- console.error(t("attachments.uploadFailed"), error);
73
- toast.error(error.message || t("attachments.uploadFailed"));
74
- } finally {
75
- setUploading(false);
76
- }
77
- };
78
-
79
- const handleRemove = async (attachmentId: string) => {
80
- try {
81
- // Mock 附件用本地移除
82
- if (attachmentId.startsWith("mock-")) {
83
- onUpdate(data.filter((att) => att.id !== attachmentId));
84
- toast.success(t("attachments.removed"));
85
- return;
86
- }
87
-
88
- await removeAttachment({ data: { attachmentId } });
89
- toast.success(t("attachments.removed"));
90
- onUpdate(data.filter((att) => att.id !== attachmentId));
91
- } catch (error: any) {
92
- console.error(t("attachments.removeFailed"), error);
93
- toast.error(error.message || t("attachments.removeFailed"));
94
- }
95
- };
96
-
97
- const formatFileSize = (bytes: number) => {
98
- if (bytes === 0) return "0 B";
99
- const k = 1024;
100
- const sizes = ["B", "KB", "MB", "GB"];
101
- const i = Math.floor(Math.log(bytes) / Math.log(k));
102
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
103
- };
104
-
105
- return (
106
- <div className="space-y-4">
107
- <div className="flex items-center justify-between">
108
- <div className="text-sm font-medium">{t("attachments.title")}</div>
109
- <Dialog open={open} onOpenChange={setOpen}>
110
- <DialogTrigger asChild>
111
- <Button type="button" variant="outline" size="sm">
112
- <Plus className="h-4 w-4 mr-2" />
113
- {t("attachments.add")}
114
- </Button>
115
- </DialogTrigger>
116
- <DialogContent>
117
- <DialogHeader>
118
- <DialogTitle>{t("attachments.uploadTitle")}</DialogTitle>
119
- </DialogHeader>
120
- <div className="space-y-4">
121
- <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-md cursor-pointer hover:bg-muted transition-colors">
122
- <Upload className="h-8 w-8 mb-2 text-muted-foreground" />
123
- <span className="text-sm text-muted-foreground">
124
- {selectedFile ? selectedFile.name : t("attachments.clickToSelect")}
125
- </span>
126
- <input
127
- type="file"
128
- className="hidden"
129
- onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
130
- />
131
- </label>
132
- <div className="flex justify-end gap-2">
133
- <Button type="button" variant="outline" onClick={() => setOpen(false)}>
134
- {t("attachments.cancel")}
135
- </Button>
136
- <Button type="button" onClick={handleUpload} disabled={uploading || !selectedFile}>
137
- {uploading ? t("attachments.uploading") : t("attachments.upload")}
138
- </Button>
139
- </div>
140
- </div>
141
- </DialogContent>
142
- </Dialog>
143
- </div>
144
-
145
- {data.length > 0 ? (
146
- <div className="space-y-2">
147
- {data.map((att) => (
148
- <div
149
- key={att.id}
150
- className="flex items-center justify-between p-3 bg-muted rounded-md"
151
- >
152
- <div className="flex items-center gap-2 flex-1 min-w-0">
153
- <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
154
- <div className="flex-1 min-w-0">
155
- <div className="text-sm font-medium truncate">{att.fileName}</div>
156
- <div className="text-xs text-muted-foreground">
157
- {formatFileSize(att.fileSize)} · {att.mimeType}
158
- </div>
159
- </div>
160
- </div>
161
- <Button
162
- type="button"
163
- variant="ghost"
164
- size="icon"
165
- onClick={() => handleRemove(att.id)}
166
- >
167
- <Trash2 className="h-4 w-4" />
168
- </Button>
169
- </div>
170
- ))}
171
- </div>
172
- ) : (
173
- <div className="text-sm text-muted-foreground text-center py-4">
174
- {t("attachments.empty")}
175
- </div>
176
- )}
177
- </div>
178
- );
179
- }