@allbluecn/web-app 0.4.22 → 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 +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- 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 +14 -3
- 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 +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- 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/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- 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/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -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/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -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 +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- 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 +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- 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/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
20
|
import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
|
-
import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag } from "lucide-react";
|
|
22
|
+
import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag, Waypoints } from "lucide-react";
|
|
23
23
|
import { Badge } from "@/components/ui/badge";
|
|
24
24
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
25
25
|
import { ProjectIconDisplay } from "@/components/project/project-icon";
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
STATE_LABEL,
|
|
51
51
|
STORY_STATES,
|
|
52
52
|
STORY_PRIORITIES,
|
|
53
|
+
DEPENDENCY_TYPE_OPTIONS,
|
|
53
54
|
type StoryState,
|
|
55
|
+
type DependencyTypeOption,
|
|
54
56
|
} from "../constants";
|
|
55
57
|
import { PriorityIcon } from "../priority-icon";
|
|
56
58
|
|
|
@@ -71,7 +73,7 @@ interface StorySelectDialogProps {
|
|
|
71
73
|
initialProjectId?: string | null;
|
|
72
74
|
defaultSelectedParentId?: string | null;
|
|
73
75
|
defaultSelectedParentIds?: string[];
|
|
74
|
-
mode?: "parent" | "child";
|
|
76
|
+
mode?: "parent" | "child" | "dependency";
|
|
75
77
|
onSelected: (parentId: string | string[]) => void;
|
|
76
78
|
onActivities?: (activities: ActivityItem[]) => void;
|
|
77
79
|
skipTransfer?: boolean;
|
|
@@ -79,9 +81,15 @@ interface StorySelectDialogProps {
|
|
|
79
81
|
excludeProjectIds?: string[];
|
|
80
82
|
hideAllProjects?: boolean;
|
|
81
83
|
filteredTip?: string;
|
|
84
|
+
/** dependency 模式:额外排除的需求 id(已建立依赖的对方) */
|
|
85
|
+
excludeIds?: string[];
|
|
86
|
+
/** dependency 模式:当前选中的依赖类型(受控),由父组件持有以便提交时映射方向 */
|
|
87
|
+
dependencyType?: DependencyTypeOption["key"];
|
|
88
|
+
onDependencyTypeChange?: (key: DependencyTypeOption["key"]) => void;
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
const ALL_PROJECTS = "__all__";
|
|
92
|
+
const EMPTY_IDS: string[] = [];
|
|
85
93
|
const ALL_STATES = STORY_STATES.map((s) => s.value);
|
|
86
94
|
const ALL_PRIORITIES = STORY_PRIORITIES.map((p) => p.value);
|
|
87
95
|
|
|
@@ -101,10 +109,13 @@ export function StorySelectDialog({
|
|
|
101
109
|
excludeProjectIds = [],
|
|
102
110
|
hideAllProjects = false,
|
|
103
111
|
filteredTip,
|
|
112
|
+
excludeIds = EMPTY_IDS,
|
|
113
|
+
dependencyType,
|
|
114
|
+
onDependencyTypeChange,
|
|
104
115
|
}: StorySelectDialogProps) {
|
|
105
116
|
const { t } = useTranslation("story");
|
|
106
117
|
const resolvedTitle = title ?? t("select.defaultTitle");
|
|
107
|
-
const isMulti = mode === "child";
|
|
118
|
+
const isMulti = mode === "child" || mode === "dependency";
|
|
108
119
|
const [projectFilter, setProjectFilter] = useState<string>(ALL_PROJECTS);
|
|
109
120
|
const [items, setItems] = useState<ParentStoryItem[]>([]);
|
|
110
121
|
const [search, setSearch] = useState("");
|
|
@@ -117,6 +128,7 @@ export function StorySelectDialog({
|
|
|
117
128
|
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
|
|
118
129
|
const [statePopoverOpen, setStatePopoverOpen] = useState(false);
|
|
119
130
|
const [priorityPopoverOpen, setPriorityPopoverOpen] = useState(false);
|
|
131
|
+
const [depPopoverOpen, setDepPopoverOpen] = useState(false);
|
|
120
132
|
const [selectedStates, setSelectedStates] = useState<Set<string>>(
|
|
121
133
|
() => new Set(ALL_STATES),
|
|
122
134
|
);
|
|
@@ -209,6 +221,7 @@ export function StorySelectDialog({
|
|
|
209
221
|
projectId: projectFilter === ALL_PROJECTS ? undefined : projectFilter,
|
|
210
222
|
search: debouncedSearch || undefined,
|
|
211
223
|
excludeStoryId: storyId,
|
|
224
|
+
excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
|
|
212
225
|
mode,
|
|
213
226
|
},
|
|
214
227
|
})
|
|
@@ -226,7 +239,7 @@ export function StorySelectDialog({
|
|
|
226
239
|
if (reqId !== requestIdRef.current) return;
|
|
227
240
|
setLoading(false);
|
|
228
241
|
});
|
|
229
|
-
}, [open, projectFilter, debouncedSearch, storyId, mode]);
|
|
242
|
+
}, [open, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
|
|
230
243
|
|
|
231
244
|
const loadMore = useCallback(async () => {
|
|
232
245
|
if (loading || !hasMore || !nextCursor) return;
|
|
@@ -239,6 +252,7 @@ export function StorySelectDialog({
|
|
|
239
252
|
search: debouncedSearch || undefined,
|
|
240
253
|
cursor: nextCursor,
|
|
241
254
|
excludeStoryId: storyId,
|
|
255
|
+
excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
|
|
242
256
|
mode,
|
|
243
257
|
},
|
|
244
258
|
});
|
|
@@ -253,11 +267,17 @@ export function StorySelectDialog({
|
|
|
253
267
|
if (reqId !== requestIdRef.current) return;
|
|
254
268
|
setLoading(false);
|
|
255
269
|
}
|
|
256
|
-
}, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, mode]);
|
|
270
|
+
}, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
|
|
257
271
|
|
|
258
272
|
// 点击 Popover 外部时关闭(Dialog 嵌套下 Radix 事件被拦截)
|
|
259
273
|
useEffect(() => {
|
|
260
|
-
if (
|
|
274
|
+
if (
|
|
275
|
+
!projectPopoverOpen &&
|
|
276
|
+
!statePopoverOpen &&
|
|
277
|
+
!priorityPopoverOpen &&
|
|
278
|
+
!depPopoverOpen
|
|
279
|
+
)
|
|
280
|
+
return;
|
|
261
281
|
const handlePointerDown = (e: PointerEvent) => {
|
|
262
282
|
const target = e.target as Node;
|
|
263
283
|
const content = document.querySelector('[data-slot="popover-content"]');
|
|
@@ -271,11 +291,12 @@ export function StorySelectDialog({
|
|
|
271
291
|
setProjectPopoverOpen(false);
|
|
272
292
|
setStatePopoverOpen(false);
|
|
273
293
|
setPriorityPopoverOpen(false);
|
|
294
|
+
setDepPopoverOpen(false);
|
|
274
295
|
}
|
|
275
296
|
};
|
|
276
297
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
277
298
|
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
278
|
-
}, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen]);
|
|
299
|
+
}, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen, depPopoverOpen]);
|
|
279
300
|
|
|
280
301
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
281
302
|
const el = e.currentTarget;
|
|
@@ -288,8 +309,8 @@ export function StorySelectDialog({
|
|
|
288
309
|
const handleSubmit = async () => {
|
|
289
310
|
if (selectedIds.length === 0) return;
|
|
290
311
|
if (skipTransfer) {
|
|
312
|
+
// dependency 模式:关闭时机由回调方控制(部分失败时保留弹窗与已选项)
|
|
291
313
|
const parentId = isMulti ? selectedIds : selectedIds[0];
|
|
292
|
-
onOpenChange(false);
|
|
293
314
|
onSelected(parentId);
|
|
294
315
|
return;
|
|
295
316
|
}
|
|
@@ -384,7 +405,7 @@ export function StorySelectDialog({
|
|
|
384
405
|
)}
|
|
385
406
|
>
|
|
386
407
|
<Icon className={cn("size-3.5 shrink-0", STATE_ICON_COLOR[s.value])} />
|
|
387
|
-
<span className="flex-1">{s.label}</span>
|
|
408
|
+
<span className="flex-1">{t(s.label)}</span>
|
|
388
409
|
{checked && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
389
410
|
</button>
|
|
390
411
|
);
|
|
@@ -432,7 +453,7 @@ export function StorySelectDialog({
|
|
|
432
453
|
)}
|
|
433
454
|
>
|
|
434
455
|
<PriorityIcon priority={p.value} />
|
|
435
|
-
<span className="flex-1">{p.label}</span>
|
|
456
|
+
<span className="flex-1">{t(p.label)}</span>
|
|
436
457
|
{checked && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
437
458
|
</button>
|
|
438
459
|
);
|
|
@@ -541,8 +562,9 @@ export function StorySelectDialog({
|
|
|
541
562
|
const owner = projectById.get(story.projectId);
|
|
542
563
|
return (
|
|
543
564
|
<li key={story.id}>
|
|
544
|
-
<
|
|
545
|
-
|
|
565
|
+
<div
|
|
566
|
+
role="button"
|
|
567
|
+
tabIndex={0}
|
|
546
568
|
onClick={() => {
|
|
547
569
|
if (isMulti) {
|
|
548
570
|
setSelectedIds((prev) =>
|
|
@@ -554,7 +576,21 @@ export function StorySelectDialog({
|
|
|
554
576
|
setSelectedIds([story.id]);
|
|
555
577
|
}
|
|
556
578
|
}}
|
|
557
|
-
|
|
579
|
+
onKeyDown={(e) => {
|
|
580
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
581
|
+
e.preventDefault();
|
|
582
|
+
if (isMulti) {
|
|
583
|
+
setSelectedIds((prev) =>
|
|
584
|
+
prev.includes(story.id)
|
|
585
|
+
? prev.filter((id) => id !== story.id)
|
|
586
|
+
: [...prev, story.id],
|
|
587
|
+
);
|
|
588
|
+
} else {
|
|
589
|
+
setSelectedIds([story.id]);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}}
|
|
593
|
+
className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
|
558
594
|
isSelected
|
|
559
595
|
? "bg-accent ring-1 ring-primary/30"
|
|
560
596
|
: "hover:bg-muted"
|
|
@@ -579,7 +615,7 @@ export function StorySelectDialog({
|
|
|
579
615
|
STATE_COLOR[state] ?? STATE_COLOR.backlog
|
|
580
616
|
}`}
|
|
581
617
|
>
|
|
582
|
-
{STATE_LABEL[state] ?? state}
|
|
618
|
+
{t(STATE_LABEL[state] ?? state)}
|
|
583
619
|
</span>
|
|
584
620
|
<PriorityIcon priority={priority} />
|
|
585
621
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
@@ -596,7 +632,7 @@ export function StorySelectDialog({
|
|
|
596
632
|
{!isMulti && isSelected && (
|
|
597
633
|
<Check className="size-4 shrink-0 text-primary" />
|
|
598
634
|
)}
|
|
599
|
-
</
|
|
635
|
+
</div>
|
|
600
636
|
</li>
|
|
601
637
|
);
|
|
602
638
|
})}
|
|
@@ -638,6 +674,57 @@ export function StorySelectDialog({
|
|
|
638
674
|
<span className="text-xs text-muted-foreground">
|
|
639
675
|
{selectedCount > 0 ? t("select.selectedCount", { count: selectedCount }) : t("select.pleaseSelect")}
|
|
640
676
|
</span>
|
|
677
|
+
{mode === "dependency" && (
|
|
678
|
+
<Popover open={depPopoverOpen} onOpenChange={setDepPopoverOpen}>
|
|
679
|
+
<PopoverTrigger asChild>
|
|
680
|
+
<Button
|
|
681
|
+
variant="ghost"
|
|
682
|
+
size="sm"
|
|
683
|
+
className={cn(
|
|
684
|
+
"h-9 gap-1.5",
|
|
685
|
+
!dependencyType && "text-muted-foreground",
|
|
686
|
+
)}
|
|
687
|
+
>
|
|
688
|
+
<Waypoints className="size-3.5" />
|
|
689
|
+
{dependencyType
|
|
690
|
+
? t(
|
|
691
|
+
DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType)!
|
|
692
|
+
.optionLabelKey,
|
|
693
|
+
)
|
|
694
|
+
: t("dependency.typePlaceholder")}
|
|
695
|
+
</Button>
|
|
696
|
+
</PopoverTrigger>
|
|
697
|
+
<PopoverContent align="start" className="w-80 p-1">
|
|
698
|
+
{(["dependsOn", "dependents"] as const).map((group, gi) => (
|
|
699
|
+
<div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
|
|
700
|
+
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
701
|
+
{t(`dependency.optionGroups.${group}`)}
|
|
702
|
+
</p>
|
|
703
|
+
{DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
|
|
704
|
+
const checked = dependencyType === opt.key;
|
|
705
|
+
return (
|
|
706
|
+
<button
|
|
707
|
+
key={opt.key}
|
|
708
|
+
type="button"
|
|
709
|
+
onClick={() => {
|
|
710
|
+
onDependencyTypeChange?.(opt.key);
|
|
711
|
+
setDepPopoverOpen(false);
|
|
712
|
+
}}
|
|
713
|
+
className={cn(
|
|
714
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
|
|
715
|
+
checked && "font-medium",
|
|
716
|
+
)}
|
|
717
|
+
>
|
|
718
|
+
<span className="flex-1">{t(opt.optionLabelKey)}</span>
|
|
719
|
+
{checked && <Check className="size-3.5 shrink-0 text-primary" />}
|
|
720
|
+
</button>
|
|
721
|
+
);
|
|
722
|
+
})}
|
|
723
|
+
</div>
|
|
724
|
+
))}
|
|
725
|
+
</PopoverContent>
|
|
726
|
+
</Popover>
|
|
727
|
+
)}
|
|
641
728
|
</div>
|
|
642
729
|
)}
|
|
643
730
|
<div className="flex items-center gap-3">
|
|
@@ -650,7 +737,11 @@ export function StorySelectDialog({
|
|
|
650
737
|
</Button>
|
|
651
738
|
<Button
|
|
652
739
|
onClick={handleSubmit}
|
|
653
|
-
disabled={
|
|
740
|
+
disabled={
|
|
741
|
+
(isMulti ? selectedCount === 0 : selectedIds.length === 0) ||
|
|
742
|
+
submitting ||
|
|
743
|
+
(mode === "dependency" && !dependencyType)
|
|
744
|
+
}
|
|
654
745
|
>
|
|
655
746
|
{submitting ? t("select.loading") : isMulti ? t("select.confirmWithCount", { count: selectedCount }) : t("select.confirm")}
|
|
656
747
|
</Button>
|
|
@@ -246,7 +246,14 @@ export function StoryPeekPanel({
|
|
|
246
246
|
<>
|
|
247
247
|
<div
|
|
248
248
|
className="absolute inset-y-0 right-0 z-20 flex w-[min(100%,max(85vw,560px),1200px)] flex-col overflow-hidden border-l bg-popover rounded-r-2xl rounded-l-none shadow-xl ring-1 ring-foreground/10 @max-[1399px]:rounded-l-2xl"
|
|
249
|
-
onClick={(e) =>
|
|
249
|
+
onClick={(e) => {
|
|
250
|
+
// 仅拦截本容器 DOM 内的点击(防止冒泡到列表背景误关 peek)。
|
|
251
|
+
// Radix 弹层 portal 到 body、不在本容器 DOM 内,其合成事件冒泡会经过这里,
|
|
252
|
+
// 若 stopPropagation 会同时终止原生 click 冒泡到 document,
|
|
253
|
+
// 导致 DismissableLayer 的 outside-dismiss 判定失效(弹窗内 popover 无法关闭)。
|
|
254
|
+
if (!e.currentTarget.contains(e.target as Node)) return;
|
|
255
|
+
e.stopPropagation();
|
|
256
|
+
}}
|
|
250
257
|
>
|
|
251
258
|
{header}
|
|
252
259
|
{body}
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
// jsdom 缺少 ResizeObserver,Radix Dialog 依赖它
|
|
19
|
+
const MockResizeObserver = class ResizeObserver {
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
};
|
|
24
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
25
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
29
|
+
import { render, screen, fireEvent, waitFor, type RenderResult } from "@testing-library/react";
|
|
30
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
31
|
+
|
|
32
|
+
vi.mock("sonner", () => ({
|
|
33
|
+
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const fns = vi.hoisted(() => ({ uploadFetch: vi.fn() }));
|
|
37
|
+
|
|
38
|
+
// 上传走 /api/attachments/upload(multipart fetch),mock 全局 fetch
|
|
39
|
+
vi.stubGlobal("fetch", fns.uploadFetch);
|
|
40
|
+
|
|
41
|
+
function uploadResponse(body: Record<string, unknown>) {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
json: () => Promise.resolve(body),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
import { AttachmentManageDialog } from "../attachment-manage-dialog";
|
|
49
|
+
import type { StoryAttachmentItem } from "../../types";
|
|
50
|
+
|
|
51
|
+
const ATTS: StoryAttachmentItem[] = [
|
|
52
|
+
{ id: "a1", fileName: "设计稿.png", fileSize: 2048, mimeType: "image/png", storageUrl: "/mock-storage/1" },
|
|
53
|
+
{ id: "a2", fileName: "说明.pdf", fileSize: 1048576, mimeType: "application/pdf", storageUrl: "/mock-storage/2" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function setup(): RenderResult & { onUpdate: ReturnType<typeof vi.fn>; onOpenChange: ReturnType<typeof vi.fn> } {
|
|
57
|
+
const onUpdate = vi.fn();
|
|
58
|
+
const onOpenChange = vi.fn();
|
|
59
|
+
const view = render(
|
|
60
|
+
<TooltipProvider>
|
|
61
|
+
<AttachmentManageDialog
|
|
62
|
+
open
|
|
63
|
+
onOpenChange={onOpenChange}
|
|
64
|
+
storyId="s1"
|
|
65
|
+
data={ATTS}
|
|
66
|
+
onUpdate={onUpdate}
|
|
67
|
+
/>
|
|
68
|
+
</TooltipProvider>,
|
|
69
|
+
);
|
|
70
|
+
return { ...view, onUpdate, onOpenChange };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getFileInput() {
|
|
74
|
+
return document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stageFile(file: File) {
|
|
78
|
+
fireEvent.change(getFileInput(), { target: { files: [file] } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
fns.uploadFetch.mockResolvedValue(
|
|
84
|
+
uploadResponse({
|
|
85
|
+
id: "a3",
|
|
86
|
+
fileName: "新文件.txt",
|
|
87
|
+
fileSize: 5,
|
|
88
|
+
mimeType: "text/plain",
|
|
89
|
+
storageUrl: "/api/attachments/a3/content",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("AttachmentManageDialog", () => {
|
|
95
|
+
it("不渲染已有附件列表,仅保留上传区", () => {
|
|
96
|
+
setup();
|
|
97
|
+
expect(screen.queryByText("设计稿.png")).not.toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("点击或拖拽上传")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("选择文件后先在弹窗内预览(暂存),点击确认上传才发起 POST", async () => {
|
|
102
|
+
setup();
|
|
103
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
104
|
+
stageFile(file);
|
|
105
|
+
// 暂存预览:文件名 + 移除按钮出现
|
|
106
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
108
|
+
// 未确认前不发起上传
|
|
109
|
+
expect(fns.uploadFetch).not.toHaveBeenCalled();
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
111
|
+
await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
|
|
112
|
+
const [url, init] = fns.uploadFetch.mock.calls[0];
|
|
113
|
+
expect(url).toBe("/api/attachments/upload");
|
|
114
|
+
expect(init.method).toBe("POST");
|
|
115
|
+
const formData = init.body as FormData;
|
|
116
|
+
expect(formData.get("storyId")).toBe("s1");
|
|
117
|
+
expect(formData.get("file")).toBeInstanceOf(File);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("确认上传成功后回调 onUpdate(含内容路由 URL)并关闭弹窗", async () => {
|
|
121
|
+
const { onUpdate, onOpenChange } = setup();
|
|
122
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
123
|
+
stageFile(file);
|
|
124
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
125
|
+
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
|
126
|
+
const added = onUpdate.mock.calls[0][0][0];
|
|
127
|
+
expect(added.storageUrl).toBe("/api/attachments/a3/content");
|
|
128
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("描述随确认上传提交到 FormData", async () => {
|
|
132
|
+
setup();
|
|
133
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
134
|
+
stageFile(file);
|
|
135
|
+
fireEvent.change(screen.getByPlaceholderText("补充说明…"), { target: { value: "测试描述" } });
|
|
136
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
137
|
+
await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
|
|
138
|
+
const formData = fns.uploadFetch.mock.calls[0][1].body as FormData;
|
|
139
|
+
expect(formData.get("description")).toBe("测试描述");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("上传接口报错时 toast 提示且不回调 onUpdate", async () => {
|
|
143
|
+
const { toast } = await import("sonner");
|
|
144
|
+
fns.uploadFetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({ error: "文件大小需在 10MB 以内" }) });
|
|
145
|
+
const { onUpdate } = setup();
|
|
146
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
147
|
+
stageFile(file);
|
|
148
|
+
fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
|
|
149
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
150
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("超过 10MB 的文件被拒绝且不入暂存列表", async () => {
|
|
154
|
+
const { toast } = await import("sonner");
|
|
155
|
+
setup();
|
|
156
|
+
const big = new File([new ArrayBuffer(11 * 1024 * 1024)], "大文件.zip", { type: "application/zip" });
|
|
157
|
+
stageFile(big);
|
|
158
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
159
|
+
expect(fns.uploadFetch).not.toHaveBeenCalled();
|
|
160
|
+
expect(screen.queryByText("大文件.zip")).not.toBeInTheDocument();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("取消按钮关闭弹窗", () => {
|
|
164
|
+
const { onOpenChange } = setup();
|
|
165
|
+
const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
|
|
166
|
+
stageFile(file);
|
|
167
|
+
expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
|
|
168
|
+
fireEvent.click(screen.getByRole("button", { name: "取消" }));
|
|
169
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
20
|
+
|
|
21
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
22
|
+
useServerFn: (fn: any) => vi.fn().mockImplementation((args: any) => fn(args)),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const fns = vi.hoisted(() => ({
|
|
26
|
+
listKbs: vi.fn(),
|
|
27
|
+
search: vi.fn(),
|
|
28
|
+
link: vi.fn(),
|
|
29
|
+
unlink: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("@/server/serverFns/story", () => ({
|
|
33
|
+
listStoryAvailableKbsFn: fns.listKbs,
|
|
34
|
+
searchKbDocsFn: fns.search,
|
|
35
|
+
linkDocsFn: fns.link,
|
|
36
|
+
unlinkDocFn: fns.unlink,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("sonner", () => ({
|
|
40
|
+
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { KnowledgeSelectDialog } from "../knowledge-select-dialog";
|
|
44
|
+
import type { DocBrief } from "../../types";
|
|
45
|
+
|
|
46
|
+
const doc = (id: string, title: string, parentId: string | null = null): DocBrief => ({
|
|
47
|
+
id,
|
|
48
|
+
title,
|
|
49
|
+
parentId,
|
|
50
|
+
kbId: "kb1",
|
|
51
|
+
kbName: "团队库",
|
|
52
|
+
kbIcon: "notebook",
|
|
53
|
+
docIcon: null,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const LINKED: DocBrief[] = [doc("d1", "已关联页")];
|
|
57
|
+
|
|
58
|
+
function setup() {
|
|
59
|
+
const onUpdate = vi.fn();
|
|
60
|
+
render(
|
|
61
|
+
<KnowledgeSelectDialog
|
|
62
|
+
open
|
|
63
|
+
onOpenChange={() => {}}
|
|
64
|
+
storyId="s1"
|
|
65
|
+
linkedDocs={LINKED}
|
|
66
|
+
onUpdate={onUpdate}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
return { onUpdate };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.clearAllMocks();
|
|
74
|
+
fns.listKbs.mockResolvedValue([
|
|
75
|
+
{ id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
|
|
76
|
+
{ id: "kb2", name: "通用库", icon: "book", visibility: "general" },
|
|
77
|
+
]);
|
|
78
|
+
fns.search.mockResolvedValue({
|
|
79
|
+
items: [doc("d1", "已关联页"), doc("d2", "子页", "d1"), doc("d3", "普通页")],
|
|
80
|
+
nextCursor: null,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("KnowledgeSelectDialog", () => {
|
|
85
|
+
it("加载可用 KB 与页面列表,已关联项默认勾选且带 badge", async () => {
|
|
86
|
+
setup();
|
|
87
|
+
// KB 名称出现在 Popover 触发器和文档列表中,用含角色断言避免歧义
|
|
88
|
+
await waitFor(() => expect(screen.getByText("已关联页")).toBeInTheDocument());
|
|
89
|
+
expect(screen.getByText("已关联")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("切换勾选后确认:新增走 linkDocsFn、取消走 unlinkDocFn", async () => {
|
|
93
|
+
fns.link.mockResolvedValue({ linked: ["d2", "d3"], skipped: [], failed: [] });
|
|
94
|
+
fns.unlink.mockResolvedValue({ ok: true });
|
|
95
|
+
const { onUpdate } = setup();
|
|
96
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
97
|
+
// 勾选 d3(新增)
|
|
98
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
99
|
+
// d3 是第三个(已关联页、子页、普通页)
|
|
100
|
+
fireEvent.click(checkboxes[2]);
|
|
101
|
+
// 取消 d1
|
|
102
|
+
fireEvent.click(checkboxes[0]);
|
|
103
|
+
fireEvent.click(screen.getByRole("button", { name: "确认" }));
|
|
104
|
+
await waitFor(() => expect(fns.link).toHaveBeenCalledWith({
|
|
105
|
+
data: { storyId: "s1", docIds: expect.arrayContaining(["d3"]) },
|
|
106
|
+
}));
|
|
107
|
+
await waitFor(() => expect(fns.unlink).toHaveBeenCalledWith({
|
|
108
|
+
data: { storyId: "s1", docId: "d1" },
|
|
109
|
+
}));
|
|
110
|
+
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("linkDocsFn 返回 failed 时 toast warning 且不关弹窗", async () => {
|
|
114
|
+
fns.link.mockResolvedValue({ linked: [], skipped: [], failed: [{ docId: "d9", reason: "outOfScope" }] });
|
|
115
|
+
const { toast } = await import("sonner");
|
|
116
|
+
setup();
|
|
117
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
118
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
119
|
+
fireEvent.click(checkboxes[2]);
|
|
120
|
+
fireEvent.click(screen.getByRole("button", { name: "确认" }));
|
|
121
|
+
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("KB 选择弹层含我的知识库与通用知识库两个分组区(五个通用组)", async () => {
|
|
125
|
+
setup();
|
|
126
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
127
|
+
fireEvent.click(screen.getByRole("button", { name: /全部知识库/ }));
|
|
128
|
+
expect(screen.getByText("我的知识库")).toBeInTheDocument();
|
|
129
|
+
expect(screen.getByText("通用知识库")).toBeInTheDocument();
|
|
130
|
+
for (const label of ["法律法规", "行业合规", "词典词条", "网站收藏", "周报月报"]) {
|
|
131
|
+
expect(screen.getByText(label)).toBeInTheDocument();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("选择通用知识库分组后搜索带 generalGroup 参数", async () => {
|
|
136
|
+
setup();
|
|
137
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
138
|
+
fireEvent.click(screen.getByRole("button", { name: /全部知识库/ }));
|
|
139
|
+
fireEvent.click(screen.getByText("法律法规"));
|
|
140
|
+
await waitFor(() =>
|
|
141
|
+
expect(fns.search).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({ data: expect.objectContaining({ generalGroup: "legal" }) }),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("底部全选复选框支持全选与部分选中(indeterminate)状态", async () => {
|
|
148
|
+
setup();
|
|
149
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
150
|
+
const footerCheckbox = screen.getByRole("checkbox", { name: "全选" });
|
|
151
|
+
// 初始:仅 d1 已勾选(部分选中)→ indeterminate
|
|
152
|
+
expect(footerCheckbox.getAttribute("aria-checked")).toBe("mixed");
|
|
153
|
+
// 全选
|
|
154
|
+
fireEvent.click(footerCheckbox);
|
|
155
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
156
|
+
for (const cb of checkboxes) expect(cb.getAttribute("aria-checked")).toBe("true");
|
|
157
|
+
// 再次点击取消全选
|
|
158
|
+
fireEvent.click(footerCheckbox);
|
|
159
|
+
expect(footerCheckbox.getAttribute("aria-checked")).toBe("false");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("点击行任意位置切换选中(勾选框点击不重复触发)", async () => {
|
|
163
|
+
setup();
|
|
164
|
+
await waitFor(() => screen.getByText("普通页"));
|
|
165
|
+
// 点击行文本区域 → 选中
|
|
166
|
+
fireEvent.click(screen.getByText("普通页"));
|
|
167
|
+
const checkboxes = screen.getAllByRole("checkbox");
|
|
168
|
+
// d3(普通页)对应第三个行勾选框
|
|
169
|
+
expect(checkboxes[2].getAttribute("aria-checked")).toBe("true");
|
|
170
|
+
// 再次点击行 → 取消选中
|
|
171
|
+
fireEvent.click(screen.getByText("普通页"));
|
|
172
|
+
expect(checkboxes[2].getAttribute("aria-checked")).toBe("false");
|
|
173
|
+
});
|
|
174
|
+
});
|