@allbluecn/web-app 0.5.1 → 0.6.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 +230 -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/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 +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- 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 +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- 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 +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- 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 +1 -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/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- 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 +4 -4
- 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/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/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/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- 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-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- 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 +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- 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/lib/project-mock.ts +0 -156
|
@@ -47,9 +47,12 @@ vi.mock("@tanstack/react-start/server", () => ({
|
|
|
47
47
|
const mocks = vi.hoisted(() => ({
|
|
48
48
|
depFindMany: vi.fn(),
|
|
49
49
|
depFindFirst: vi.fn(),
|
|
50
|
+
depFindUnique: vi.fn(),
|
|
50
51
|
depCreate: vi.fn(),
|
|
52
|
+
depUpdate: vi.fn(),
|
|
51
53
|
depDelete: vi.fn(),
|
|
52
54
|
storyFindMany: vi.fn(),
|
|
55
|
+
storyFindUnique: vi.fn(),
|
|
53
56
|
activityCreate: vi.fn(),
|
|
54
57
|
currentSession: vi.fn(),
|
|
55
58
|
}));
|
|
@@ -59,10 +62,12 @@ vi.mock("@allbluecn/database", () => {
|
|
|
59
62
|
storyDependency: {
|
|
60
63
|
findMany: mocks.depFindMany,
|
|
61
64
|
findFirst: mocks.depFindFirst,
|
|
65
|
+
findUnique: mocks.depFindUnique,
|
|
62
66
|
create: mocks.depCreate,
|
|
67
|
+
update: mocks.depUpdate,
|
|
63
68
|
delete: mocks.depDelete,
|
|
64
69
|
},
|
|
65
|
-
story: { findMany: mocks.storyFindMany },
|
|
70
|
+
story: { findMany: mocks.storyFindMany, findUnique: mocks.storyFindUnique },
|
|
66
71
|
storyActivity: { create: mocks.activityCreate },
|
|
67
72
|
};
|
|
68
73
|
return { prisma, Prisma: {} };
|
|
@@ -74,6 +79,7 @@ vi.mock("@/server/auth-helpers", () => ({
|
|
|
74
79
|
|
|
75
80
|
import {
|
|
76
81
|
addDependenciesFn,
|
|
82
|
+
updateDependencyFn,
|
|
77
83
|
removeDependencyFn,
|
|
78
84
|
getDependenciesFn,
|
|
79
85
|
} from "../story-dependencies";
|
|
@@ -225,12 +231,110 @@ describe("addDependenciesFn", () => {
|
|
|
225
231
|
});
|
|
226
232
|
});
|
|
227
233
|
|
|
234
|
+
describe("updateDependencyFn", () => {
|
|
235
|
+
const EXISTING = { id: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" };
|
|
236
|
+
const UPDATE_ACTIVITY = {
|
|
237
|
+
id: "act1",
|
|
238
|
+
action: "dependency_updated",
|
|
239
|
+
field: null,
|
|
240
|
+
oldValue: null,
|
|
241
|
+
newValue: "B-1 story b",
|
|
242
|
+
isAI: false,
|
|
243
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
244
|
+
storyId: "a",
|
|
245
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
it("同向改类型:仅更新 type,两端各记一条活动", async () => {
|
|
249
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
250
|
+
mocks.depFindMany.mockResolvedValue([]); // 排除自身后无其他边
|
|
251
|
+
mocks.depUpdate.mockResolvedValue({ ...EXISTING, type: "STARTS_BEFORE" });
|
|
252
|
+
mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
|
|
253
|
+
const res = await updateDependencyFn({
|
|
254
|
+
data: { depId: "d1", sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
|
|
255
|
+
});
|
|
256
|
+
expect(res.ok).toBe(true);
|
|
257
|
+
expect(mocks.depUpdate).toHaveBeenCalledWith({
|
|
258
|
+
where: { id: "d1" },
|
|
259
|
+
data: { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
|
|
260
|
+
});
|
|
261
|
+
expect(res.activities).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("方向反转合法时更新为新的 source/target", async () => {
|
|
265
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
266
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
267
|
+
mocks.depUpdate.mockResolvedValue({ ...EXISTING, sourceId: "b", targetId: "a" });
|
|
268
|
+
mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
|
|
269
|
+
const res = await updateDependencyFn({
|
|
270
|
+
data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
271
|
+
});
|
|
272
|
+
expect(res.ok).toBe(true);
|
|
273
|
+
expect(mocks.depUpdate).toHaveBeenCalledWith({
|
|
274
|
+
where: { id: "d1" },
|
|
275
|
+
data: { sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("方向反转成环时拒绝写入", async () => {
|
|
280
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
281
|
+
// 现边 a -> b;另已存在路径 a -> c -> b。请求反转成 b -> a 后,
|
|
282
|
+
// 从新 target(a) 出发可直达新 source(b) ⇒ 成环,必须拒绝
|
|
283
|
+
mocks.depFindMany.mockResolvedValue([
|
|
284
|
+
{ sourceId: "a", targetId: "c" },
|
|
285
|
+
{ sourceId: "c", targetId: "b" },
|
|
286
|
+
]);
|
|
287
|
+
const res = await updateDependencyFn({
|
|
288
|
+
data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
289
|
+
});
|
|
290
|
+
expect(res).toEqual({ ok: false, reason: "CYCLE" });
|
|
291
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("三元组与现状一致时短路返回且不写库", async () => {
|
|
295
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
296
|
+
const res = await updateDependencyFn({
|
|
297
|
+
data: { depId: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
298
|
+
});
|
|
299
|
+
expect(res).toEqual({ ok: true, activities: [] });
|
|
300
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
301
|
+
expect(mocks.depFindMany).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("两端不属于原边时抛错", async () => {
|
|
305
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
306
|
+
await expect(
|
|
307
|
+
updateDependencyFn({
|
|
308
|
+
data: { depId: "d1", sourceId: "a", targetId: "c", type: "BLOCKS" },
|
|
309
|
+
}),
|
|
310
|
+
).rejects.toMatchObject({ message: expect.stringContaining("依赖关系两端不匹配") });
|
|
311
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("depId 不存在时抛错", async () => {
|
|
315
|
+
mocks.depFindUnique.mockResolvedValue(null);
|
|
316
|
+
await expect(
|
|
317
|
+
updateDependencyFn({
|
|
318
|
+
data: { depId: "missing", sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
319
|
+
}),
|
|
320
|
+
).rejects.toMatchObject({ message: expect.stringContaining("依赖关系不存在") });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
228
324
|
describe("removeDependencyFn", () => {
|
|
229
325
|
it("按 depId 删除", async () => {
|
|
326
|
+
mocks.depFindUnique.mockResolvedValue({ id: "d1", sourceId: "s1", targetId: "t1" });
|
|
327
|
+
mocks.storyFindUnique.mockImplementation(({ where }: any) => {
|
|
328
|
+
if (where.id === "s1") return Promise.resolve({ identifier: "A-1", name: "story a" });
|
|
329
|
+
if (where.id === "t1") return Promise.resolve({ identifier: "B-1", name: "story b" });
|
|
330
|
+
return Promise.resolve(null);
|
|
331
|
+
});
|
|
230
332
|
mocks.depDelete.mockResolvedValue({ id: "d1" });
|
|
333
|
+
mocks.activityCreate.mockResolvedValue({ id: "act1", action: "dependency_removed", field: null, oldValue: null, newValue: "B-1", isAI: false, createdAt: new Date(), user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null } });
|
|
231
334
|
const res = await removeDependencyFn({ data: { depId: "d1" } });
|
|
232
|
-
expect(res.id).toBe("d1");
|
|
335
|
+
expect(res?.dep.id).toBe("d1");
|
|
233
336
|
expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
|
|
337
|
+
expect(mocks.activityCreate).toHaveBeenCalledTimes(2);
|
|
234
338
|
});
|
|
235
339
|
});
|
|
236
340
|
|
|
@@ -21,6 +21,8 @@ export {
|
|
|
21
21
|
createLabelFn,
|
|
22
22
|
} from "./story-crud/labels";
|
|
23
23
|
|
|
24
|
+
export { suggestStoryLabelsFn } from "./story-crud/label-suggest";
|
|
25
|
+
|
|
24
26
|
export {
|
|
25
27
|
listActivitiesFn,
|
|
26
28
|
} from "./story-crud/activities";
|
|
@@ -82,6 +84,7 @@ export {
|
|
|
82
84
|
|
|
83
85
|
export {
|
|
84
86
|
addDependenciesFn,
|
|
87
|
+
updateDependencyFn,
|
|
85
88
|
removeDependencyFn,
|
|
86
89
|
getDependenciesFn,
|
|
87
90
|
} from "./story-dependencies";
|
|
@@ -113,12 +113,29 @@ export const unlinkPrdFn = createServerFn({ method: "POST" })
|
|
|
113
113
|
.handler(async ({ data }) => {
|
|
114
114
|
const request = getRequest();
|
|
115
115
|
const session = await requireAuth(request);
|
|
116
|
+
const userId = session.user!.id!;
|
|
117
|
+
|
|
118
|
+
const prd = await prisma.pRD.findUnique({ where: { id: data.prdId }, select: { name: true } });
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
await prisma.storyPrdLink.delete({
|
|
118
121
|
where: { storyId_prdId: { storyId: data.storyId, prdId: data.prdId } },
|
|
119
122
|
});
|
|
120
123
|
|
|
121
|
-
|
|
124
|
+
let activity = null;
|
|
125
|
+
if (prd) {
|
|
126
|
+
const created = await prisma.storyActivity.create({
|
|
127
|
+
data: {
|
|
128
|
+
storyId: data.storyId,
|
|
129
|
+
userId,
|
|
130
|
+
action: "prd_unlinked",
|
|
131
|
+
newValue: prd.name,
|
|
132
|
+
},
|
|
133
|
+
select: ACTIVITY_SELECT,
|
|
134
|
+
});
|
|
135
|
+
activity = toActivityItem(created);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { storyId: data.storyId, prdId: data.prdId, activity };
|
|
122
139
|
});
|
|
123
140
|
|
|
124
141
|
export const getLinkedPrdsFn = createServerFn({ method: "GET" })
|
|
@@ -415,17 +432,32 @@ export const unlinkDocFn = createServerFn({ method: "POST" })
|
|
|
415
432
|
.handler(async ({ data }) => {
|
|
416
433
|
const request = getRequest();
|
|
417
434
|
const session = await requireAuth(request);
|
|
435
|
+
const userId = session.user!.id!;
|
|
436
|
+
|
|
418
437
|
const parsed = parseExternalDocId(data.docId);
|
|
419
438
|
if (parsed) {
|
|
420
439
|
await prisma.storyExternalLink.deleteMany({
|
|
421
440
|
where: { storyId: data.storyId, group: parsed.group, externalId: parsed.externalId },
|
|
422
441
|
});
|
|
423
|
-
|
|
442
|
+
const created = await prisma.storyActivity.create({
|
|
443
|
+
data: { storyId: data.storyId, userId, action: "kb_unlinked", newValue: `${parsed.group}:${parsed.externalId}` },
|
|
444
|
+
select: ACTIVITY_SELECT,
|
|
445
|
+
});
|
|
446
|
+
return { ok: true, activity: toActivityItem(created) };
|
|
424
447
|
}
|
|
448
|
+
const doc = await prisma.knowledgeDocument.findUnique({ where: { id: data.docId }, select: { title: true } });
|
|
425
449
|
await prisma.storyDocLink.delete({
|
|
426
450
|
where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
|
|
427
451
|
});
|
|
428
|
-
|
|
452
|
+
let activity: ReturnType<typeof toActivityItem> | null = null;
|
|
453
|
+
if (doc) {
|
|
454
|
+
const created = await prisma.storyActivity.create({
|
|
455
|
+
data: { storyId: data.storyId, userId, action: "kb_unlinked", newValue: doc.title },
|
|
456
|
+
select: ACTIVITY_SELECT,
|
|
457
|
+
});
|
|
458
|
+
activity = toActivityItem(created);
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, activity };
|
|
429
461
|
});
|
|
430
462
|
|
|
431
463
|
export const getLinkedDocsFn = createServerFn({ method: "GET" })
|
|
@@ -20,18 +20,36 @@ 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 removeAttachmentFn = createServerFn({ method: "POST" })
|
|
25
26
|
.validator(z.object({ attachmentId: z.string() }))
|
|
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
|
-
const attachment = await prisma.storyAttachment.
|
|
32
|
+
const attachment = await prisma.storyAttachment.findUnique({ where: { id: data.attachmentId } });
|
|
33
|
+
await prisma.storyAttachment.delete({
|
|
31
34
|
where: { id: data.attachmentId },
|
|
32
35
|
});
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
// 结果含 BigInt fileSize 不可序列化,只返回活动记录(Seroval 安全的纯对象)
|
|
38
|
+
let activity = null;
|
|
39
|
+
if (attachment) {
|
|
40
|
+
const created = await prisma.storyActivity.create({
|
|
41
|
+
data: {
|
|
42
|
+
storyId: attachment.storyId,
|
|
43
|
+
userId,
|
|
44
|
+
action: "attachment_removed",
|
|
45
|
+
newValue: attachment.fileName,
|
|
46
|
+
},
|
|
47
|
+
select: ACTIVITY_SELECT,
|
|
48
|
+
});
|
|
49
|
+
activity = toActivityItem(created);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { ok: true, activity };
|
|
35
53
|
});
|
|
36
54
|
|
|
37
55
|
export const getAttachmentsFn = createServerFn({ method: "GET" })
|
|
@@ -15,24 +15,21 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
export const LABEL_PALETTE = [
|
|
19
|
+
"#ef4444",
|
|
20
|
+
"#f97316",
|
|
21
|
+
"#f59e0b",
|
|
22
|
+
"#10b981",
|
|
23
|
+
"#06b6d4",
|
|
24
|
+
"#3b82f6",
|
|
25
|
+
"#8b5cf6",
|
|
26
|
+
"#ec4899",
|
|
27
|
+
] as const;
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
size?: number;
|
|
28
|
-
className?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function ProjectIconDisplay({
|
|
32
|
-
name,
|
|
33
|
-
size = 24,
|
|
34
|
-
className,
|
|
35
|
-
}: ProjectIconDisplayProps) {
|
|
36
|
-
const Icon = ICON_MAP.get(name ?? "") ?? PROJECT_ICONS[0].icon;
|
|
37
|
-
return <Icon size={size} className={cn("text-current", className)} />;
|
|
29
|
+
export function pickLabelColor(name: string): string {
|
|
30
|
+
let hash = 0;
|
|
31
|
+
for (const ch of name) {
|
|
32
|
+
hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) | 0;
|
|
33
|
+
}
|
|
34
|
+
return LABEL_PALETTE[Math.abs(hash) % LABEL_PALETTE.length];
|
|
38
35
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { prisma } from "@allbluecn/database"
|
|
19
|
+
import {
|
|
20
|
+
hasAiKey,
|
|
21
|
+
runChat,
|
|
22
|
+
buildLabelSuggestSystemPrompt,
|
|
23
|
+
buildLabelSuggestUserMessage,
|
|
24
|
+
parseLabelSuggestions,
|
|
25
|
+
} from "@allbluecn/ai-gateway"
|
|
26
|
+
|
|
27
|
+
const MAX_LABELS_IN_PROMPT = 100
|
|
28
|
+
|
|
29
|
+
export interface SuggestLabelsInput {
|
|
30
|
+
name: string
|
|
31
|
+
description?: string
|
|
32
|
+
currentLabelNames?: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SuggestLabelsResult {
|
|
36
|
+
existingLabels: { id: string; name: string; color: string }[]
|
|
37
|
+
newNames: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function suggestLabelsCore(
|
|
41
|
+
input: SuggestLabelsInput,
|
|
42
|
+
userId: string,
|
|
43
|
+
): Promise<SuggestLabelsResult> {
|
|
44
|
+
if (!(await hasAiKey(userId))) {
|
|
45
|
+
throw new Error("AI 未配置,请先前往 AI 设置页配置 API Key")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const allLabels = await prisma.label.findMany({
|
|
49
|
+
select: { id: true, name: true, color: true },
|
|
50
|
+
orderBy: { name: "asc" },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
let candidates = allLabels
|
|
54
|
+
if (allLabels.length > MAX_LABELS_IN_PROMPT) {
|
|
55
|
+
const usage = await prisma.storyLabel.groupBy({
|
|
56
|
+
by: ["labelId"],
|
|
57
|
+
_count: { _all: true },
|
|
58
|
+
orderBy: { _count: { labelId: "desc" } },
|
|
59
|
+
take: MAX_LABELS_IN_PROMPT,
|
|
60
|
+
})
|
|
61
|
+
const topIds = new Set(usage.map((u) => u.labelId))
|
|
62
|
+
candidates = allLabels.filter((l) => topIds.has(l.id))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const names = candidates.map((l) => l.name)
|
|
66
|
+
const raw = await runChat(
|
|
67
|
+
[{ role: "user", content: buildLabelSuggestUserMessage(input) }],
|
|
68
|
+
{
|
|
69
|
+
systemPrompts: [buildLabelSuggestSystemPrompt(names)],
|
|
70
|
+
modelOptions: { temperature: 0.2 },
|
|
71
|
+
userId,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const parsed = parseLabelSuggestions(raw, names)
|
|
76
|
+
const byName = new Map(candidates.map((l) => [l.name, l]))
|
|
77
|
+
const existingLabels: { id: string; name: string; color: string }[] = []
|
|
78
|
+
const unmatchedNames: string[] = []
|
|
79
|
+
for (const n of parsed.existing) {
|
|
80
|
+
const l = byName.get(n)
|
|
81
|
+
if (l) existingLabels.push(l)
|
|
82
|
+
else unmatchedNames.push(n)
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
existingLabels,
|
|
86
|
+
newNames: [...parsed.newNames, ...unmatchedNames],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { createServerFn } from "@tanstack/react-start"
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server"
|
|
20
|
+
import { z } from "zod"
|
|
21
|
+
import { requireAuth } from "@/server/auth-helpers"
|
|
22
|
+
import { suggestLabelsCore } from "./label-suggest-core"
|
|
23
|
+
|
|
24
|
+
const suggestSchema = z.object({
|
|
25
|
+
name: z.string().min(1).max(200),
|
|
26
|
+
description: z.string().max(4000).optional(),
|
|
27
|
+
currentLabelNames: z.array(z.string().min(1).max(10)).max(20).optional(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const suggestStoryLabelsFn = createServerFn({ method: "POST" })
|
|
31
|
+
.validator(suggestSchema)
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const request = getRequest()
|
|
34
|
+
const session = await requireAuth(request)
|
|
35
|
+
const userId = session.user?.id
|
|
36
|
+
if (!userId) throw new Error("用户未登录")
|
|
37
|
+
return suggestLabelsCore(data, userId)
|
|
38
|
+
})
|
|
@@ -15,24 +15,18 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
18
|
+
import { prisma } from "@allbluecn/database";
|
|
19
|
+
import { pickLabelColor } from "./label-palette";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
name,
|
|
33
|
-
size = 24,
|
|
34
|
-
className,
|
|
35
|
-
}: KnowledgeIconDisplayProps) {
|
|
36
|
-
const Icon = ICON_MAP.get(name ?? "") ?? KNOWLEDGE_ICONS[0].icon;
|
|
37
|
-
return <Icon size={size} className={cn("text-primary", className)} />;
|
|
21
|
+
export async function upsertLabel(name: string, createdById: string, color?: string) {
|
|
22
|
+
return prisma.label.upsert({
|
|
23
|
+
where: { name },
|
|
24
|
+
update: {},
|
|
25
|
+
create: {
|
|
26
|
+
name,
|
|
27
|
+
color: color ?? pickLabelColor(name),
|
|
28
|
+
createdById,
|
|
29
|
+
},
|
|
30
|
+
select: { id: true, name: true, color: true },
|
|
31
|
+
});
|
|
38
32
|
}
|
|
@@ -21,10 +21,11 @@ import { z } from "zod";
|
|
|
21
21
|
import { prisma } from "@allbluecn/database";
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
23
|
import { USER_BRIEF_SELECT } from "../types";
|
|
24
|
+
import { upsertLabel } from "./label-upsert";
|
|
24
25
|
|
|
25
26
|
const labelCreateSchema = z.object({
|
|
26
27
|
name: z.string().min(1).max(30),
|
|
27
|
-
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).
|
|
28
|
+
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
export const listUsersBriefFn = createServerFn({ method: "GET" })
|
|
@@ -98,8 +99,5 @@ export const createLabelFn = createServerFn({ method: "POST" })
|
|
|
98
99
|
.handler(async ({ data }) => {
|
|
99
100
|
const request = getRequest();
|
|
100
101
|
const session = await requireAuth(request);
|
|
101
|
-
return
|
|
102
|
-
data: { name: data.name, color: data.color, createdById: session.user!.id! },
|
|
103
|
-
select: { id: true, name: true, color: true },
|
|
104
|
-
});
|
|
102
|
+
return upsertLabel(data.name, session.user!.id!, data.color);
|
|
105
103
|
});
|
|
@@ -133,8 +133,8 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
133
133
|
const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
|
|
134
134
|
if (isIndependent) {
|
|
135
135
|
const counter = await tx.sequenceCounter.upsert({
|
|
136
|
-
where: { scope:
|
|
137
|
-
create: { scope:
|
|
136
|
+
where: { scope: projectId },
|
|
137
|
+
create: { scope: projectId, seq: 1 },
|
|
138
138
|
update: { seq: { increment: 1 } },
|
|
139
139
|
});
|
|
140
140
|
sequenceId = counter.seq;
|
|
@@ -222,6 +222,7 @@ const updateSchema = z.object({
|
|
|
222
222
|
targetDate: z.string().nullable().optional(),
|
|
223
223
|
labelIds: z.array(z.string()).optional(),
|
|
224
224
|
parentId: z.string().nullable().optional(),
|
|
225
|
+
iterationId: z.string().nullable().optional(),
|
|
225
226
|
isAI: z.boolean().default(false),
|
|
226
227
|
});
|
|
227
228
|
|
|
@@ -352,6 +353,35 @@ export const updateStoryFn = createServerFn({ method: "POST" })
|
|
|
352
353
|
}
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
if (data.iterationId !== undefined) {
|
|
357
|
+
const newIterationId = data.iterationId;
|
|
358
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
359
|
+
const oldIterationId = (existing as any).iteration?.id ?? null;
|
|
360
|
+
if (newIterationId !== oldIterationId) {
|
|
361
|
+
let oldName = "";
|
|
362
|
+
let newName = "";
|
|
363
|
+
if (oldIterationId) {
|
|
364
|
+
const oldIt = await tx.iteration.findUnique({ where: { id: oldIterationId }, select: { name: true } });
|
|
365
|
+
oldName = oldIt?.name ?? "";
|
|
366
|
+
}
|
|
367
|
+
if (newIterationId) {
|
|
368
|
+
const newIt = await tx.iteration.findUnique({ where: { id: newIterationId }, select: { name: true } });
|
|
369
|
+
newName = newIt?.name ?? "";
|
|
370
|
+
}
|
|
371
|
+
if (newIterationId) {
|
|
372
|
+
updates.iteration = { connect: { id: newIterationId } };
|
|
373
|
+
} else {
|
|
374
|
+
updates.iteration = { disconnect: true };
|
|
375
|
+
}
|
|
376
|
+
activities.push({
|
|
377
|
+
action: "iteration_changed",
|
|
378
|
+
field: "iterationId",
|
|
379
|
+
oldValue: oldName,
|
|
380
|
+
newValue: newName,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
355
385
|
updates.updatedBy = { connect: { id: userId } };
|
|
356
386
|
|
|
357
387
|
const updated = await tx.story.update({
|