@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.
- package/package.json +11 -7
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/story/constants.ts +11 -1
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +1 -0
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
- package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
- package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
- 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 +191 -0
- package/src/components/story/types.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- 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/routeTree.gen.ts +42 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- 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/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- 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/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/knowledge.ts +50 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
- package/src/server/serverFns/story/index.ts +6 -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 +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +35 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- 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
|
@@ -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
|
-
|
|
121
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/styles/globals.css
CHANGED
|
@@ -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
|
@@ -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
|
-
}
|