@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.
Files changed (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.22",
3
+ "version": "0.5.1",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -28,6 +28,8 @@
28
28
  "@fontsource-variable/geist-mono": "^5.3.0",
29
29
  "@iconify/react": "^6.0.2",
30
30
  "@lobehub/icons": "^5.16.0",
31
+ "@open-file-viewer/core": "^0.1.43",
32
+ "@open-file-viewer/react": "^0.1.43",
31
33
  "@prisma/client": "^7.8.0",
32
34
  "@radix-ui/react-dialog": "^1.1.23",
33
35
  "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -108,6 +110,8 @@
108
110
  "next-themes": "^0.4.6",
109
111
  "nitro": "3.0.260603-beta",
110
112
  "ogl": "^1.0.11",
113
+ "open-file-viewer-workspace": "github:xushanpei/open-file-viewer",
114
+ "pdfjs-dist": "^6.3.289",
111
115
  "prisma": "^7.8.0",
112
116
  "radix-ui": "^1.4.3",
113
117
  "react": "19.2.4",
@@ -127,14 +131,14 @@
127
131
  "three": "^0.180.0",
128
132
  "tw-animate-css": "^1.4.0",
129
133
  "zod": "^4.4.3",
130
- "@allbluecn/database": "0.4.14",
134
+ "@allbluecn/ai-gateway": "0.4.17",
131
135
  "@allbluecn/kernel": "0.3.1",
132
- "@allbluecn/shared": "0.4.15",
133
- "@allbluecn/ai-gateway": "0.4.15",
134
- "@allbluecn/tiptap": "0.4.15",
135
- "@allbluecn/ui": "0.4.15",
136
- "@allbluecn/plugins-core": "0.4.14",
137
- "@allbluecn/prototype": "0.1.1"
136
+ "@allbluecn/plugins-core": "0.4.16",
137
+ "@allbluecn/shared": "0.4.17",
138
+ "@allbluecn/ui": "0.4.17",
139
+ "@allbluecn/prototype": "0.1.1",
140
+ "@allbluecn/tiptap": "0.4.17",
141
+ "@allbluecn/database": "0.4.16"
138
142
  },
139
143
  "devDependencies": {
140
144
  "@babel/parser": "^8.0.4",
@@ -72,6 +72,10 @@ import { toast } from '@/components/ui/sonner'
72
72
  import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
73
73
  import { cn } from '@/lib/utils'
74
74
  import { useTranslation } from 'react-i18next'
75
+ import { Checkbox } from '@/components/ui/checkbox'
76
+ import { Link as LinkIcon } from 'lucide-react'
77
+ import { listKbProjectsFn, setKbProjectsFn } from '@/server/serverFns/knowledge'
78
+ import { listProjectsBriefFn } from '@/server/serverFns/project'
75
79
 
76
80
  interface KbData {
77
81
  id: string
@@ -116,6 +120,10 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
116
120
  const [createPopover, setCreatePopover] = useState(false)
117
121
  const [createType, setCreateType] = useState('doc')
118
122
  const [createTitle, setCreateTitle] = useState('')
123
+ const [projectsDialogOpen, setProjectsDialogOpen] = useState(false)
124
+ const [projectCandidates, setProjectCandidates] = useState<{ id: string; name: string; icon: string }[]>([])
125
+ const [projectChecked, setProjectChecked] = useState<Set<string>>(new Set())
126
+ const [savingProjects, setSavingProjects] = useState(false)
119
127
  const createInputRef = useRef<HTMLInputElement>(null)
120
128
 
121
129
  useEffect(() => {
@@ -148,13 +156,40 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
148
156
  if (value === localVisibility) return
149
157
  setLocalVisibility(value)
150
158
  try {
151
- await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' } })
159
+ await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' | 'general' } })
152
160
  onUpdate?.()
153
161
  } catch (e) {
154
162
  console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
155
163
  }
156
164
  }
157
165
 
166
+ const openProjectsDialog = async () => {
167
+ setProjectsDialogOpen(true);
168
+ try {
169
+ const [linked, all] = await Promise.all([
170
+ listKbProjectsFn({ data: { kbId: kb.id } }),
171
+ listProjectsBriefFn(),
172
+ ]);
173
+ setProjectCandidates(all);
174
+ setProjectChecked(new Set(linked.map((p) => p.id)));
175
+ } catch {
176
+ toast.error('加载项目列表失败');
177
+ }
178
+ };
179
+
180
+ const saveProjects = async () => {
181
+ setSavingProjects(true);
182
+ try {
183
+ await setKbProjectsFn({ data: { kbId: kb.id, projectIds: [...projectChecked] } });
184
+ toast.success('项目关联已更新');
185
+ setProjectsDialogOpen(false);
186
+ } catch (err) {
187
+ toast.error(err instanceof Error ? err.message : '保存失败');
188
+ } finally {
189
+ setSavingProjects(false);
190
+ }
191
+ };
192
+
158
193
  const handleDelete = async () => {
159
194
  if (deleteConfirm !== kb.name) return
160
195
  setDeleting(true)
@@ -426,6 +461,56 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
426
461
  {localVisibility === 'team' ? t('toolbar.team') : t('toolbar.private')}
427
462
  </Badge>
428
463
 
464
+ {localVisibility === 'team' && (
465
+ <Button
466
+ variant="ghost"
467
+ size="sm"
468
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
469
+ onClick={openProjectsDialog}
470
+ >
471
+ <LinkIcon className="size-3.5 text-muted-foreground" />
472
+ {t('toolbar.linkProjects')}
473
+ </Button>
474
+ )}
475
+
476
+ {localVisibility === 'general' && (
477
+ <Tooltip>
478
+ <TooltipTrigger asChild>
479
+ <Button
480
+ variant="ghost"
481
+ size="sm"
482
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
483
+ disabled
484
+ >
485
+ <LinkIcon className="size-3.5 text-muted-foreground" />
486
+ {t('toolbar.linkProjects')}
487
+ </Button>
488
+ </TooltipTrigger>
489
+ <TooltipContent>
490
+ <p>{t('toolbar.generalLinkDisabled')}</p>
491
+ </TooltipContent>
492
+ </Tooltip>
493
+ )}
494
+
495
+ {localVisibility === 'private' && (
496
+ <Tooltip>
497
+ <TooltipTrigger asChild>
498
+ <Button
499
+ variant="ghost"
500
+ size="sm"
501
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
502
+ disabled
503
+ >
504
+ <LinkIcon className="size-3.5 text-muted-foreground" />
505
+ {t('toolbar.linkProjects')}
506
+ </Button>
507
+ </TooltipTrigger>
508
+ <TooltipContent>
509
+ <p>{t('toolbar.privateLinkDisabled')}</p>
510
+ </TooltipContent>
511
+ </Tooltip>
512
+ )}
513
+
429
514
  <div className="ml-auto">
430
515
  <DropdownMenu>
431
516
  <DropdownMenuTrigger asChild>
@@ -544,6 +629,44 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
544
629
  kb={{ id: kb.id, name: kb.name, description: kb.description ?? null, icon: kb.icon ?? 'notebook' }}
545
630
  onSuccess={onUpdate}
546
631
  />
632
+
633
+ <Dialog open={projectsDialogOpen} onOpenChange={setProjectsDialogOpen}>
634
+ <DialogContent className="sm:max-w-260 -mt-50">
635
+ <DialogHeader>
636
+ <DialogTitle>{t('toolbar.linkProjects')}</DialogTitle>
637
+ </DialogHeader>
638
+ <div className="flex flex-col gap-2 max-h-96 overflow-y-auto">
639
+ {projectCandidates.length === 0 ? (
640
+ <p className="text-sm text-muted-foreground">暂无项目</p>
641
+ ) : (
642
+ projectCandidates.map((proj) => (
643
+ <label key={proj.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm cursor-pointer">
644
+ <Checkbox
645
+ checked={projectChecked.has(proj.id)}
646
+ onCheckedChange={(v) =>
647
+ setProjectChecked((prev) => {
648
+ const next = new Set(prev);
649
+ if (v) next.add(proj.id);
650
+ else next.delete(proj.id);
651
+ return next;
652
+ })
653
+ }
654
+ />
655
+ <span className="flex-1">{proj.name}</span>
656
+ </label>
657
+ ))
658
+ )}
659
+ </div>
660
+ <DialogFooter>
661
+ <Button variant="outline" onClick={() => setProjectsDialogOpen(false)}>
662
+ {t('knowledge:toolbar.cancel')}
663
+ </Button>
664
+ <Button onClick={saveProjects} disabled={savingProjects}>
665
+ {savingProjects ? '保存中...' : t('settings.kbSection.save')}
666
+ </Button>
667
+ </DialogFooter>
668
+ </DialogContent>
669
+ </Dialog>
547
670
  </div>
548
671
  )
549
672
  }
@@ -27,7 +27,7 @@ import {
27
27
  import { cn } from "@/lib/utils";
28
28
  import { m } from "motion/react";
29
29
  import DashboardNavigation from "@/components/layout/sidebar-03/nav-main";
30
- import { NotificationsPopover } from "@/components/layout/sidebar-03/nav-notifications";
30
+ import { NotificationsBell } from "@/components/layout/sidebar-03/nav-notifications";
31
31
  import { NavAnyHub } from "@/components/layout/sidebar-03/nav-anyhub";
32
32
  import { NavKnowledge } from "@/components/layout/sidebar-03/nav-knowledge";
33
33
  import { NavWorkspace } from "@/components/layout/sidebar-03/nav-workspace";
@@ -37,7 +37,6 @@ import { ThemeToggle } from "@/components/shared/theme-toggle";
37
37
  import { AnimatedThemeToggler } from '@/components/magicui/animated-theme-toggler'
38
38
  import { Badge } from "@/components/ui/badge"
39
39
  import { workspaceRoutes } from "@/config/sidebar-routes";
40
- import { notifications } from "@/config/sidebar-notifications";
41
40
  import type { Route } from "@/config/sidebar-routes";
42
41
  import { SidebarSearchTrigger } from "@/components/layout/sidebar-03/nav-search-trigger"
43
42
  import { NavSecondary } from "@/components/layout/sidebar-03/nav-secondary"
@@ -65,7 +64,7 @@ export function DashboardSidebar() {
65
64
  animate={{ opacity: 1 }}
66
65
  transition={{ duration: 0.8 }}
67
66
  >
68
- <NotificationsPopover notifications={notifications} />
67
+ <NotificationsBell />
69
68
  <SidebarTrigger />
70
69
  </m.div>
71
70
  </div>
@@ -86,7 +85,7 @@ export function DashboardSidebar() {
86
85
  animate={{ opacity: 1 }}
87
86
  transition={{ duration: 0.8 }}
88
87
  >
89
- <NotificationsPopover notifications={notifications} />
88
+ <NotificationsBell />
90
89
  <AnimatedThemeToggler />
91
90
 
92
91
  <SidebarTrigger />
@@ -15,64 +15,147 @@
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
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
20
- import { Button } from "@/components/ui/button";
18
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
19
+ import { Link, useNavigate } from '@tanstack/react-router'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { Bell, CheckCheck } from 'lucide-react'
22
+ import { Button } from '@/components/ui/button'
23
+ import {
24
+ Empty,
25
+ EmptyHeader,
26
+ EmptyMedia,
27
+ EmptyTitle,
28
+ } from '@/components/ui/empty'
29
+ import {
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ } from '@/components/ui/popover'
34
+ import { Separator } from '@/components/ui/separator'
35
+ import { cn } from '@/lib/utils'
36
+ import { CATEGORY_ICONS, formatRelativeTime } from '@/components/notifications/team-notifications'
21
37
  import {
22
- DropdownMenu,
23
- DropdownMenuContent,
24
- DropdownMenuItem,
25
- DropdownMenuLabel,
26
- DropdownMenuSeparator,
27
- DropdownMenuTrigger,
28
- } from "@/components/ui/dropdown-menu";
29
- import { BellIcon } from "lucide-react";
38
+ useNotifications,
39
+ NOTIFICATION_POPOVER_KEY,
40
+ NOTIFICATION_ROOT_KEY,
41
+ } from '@/hooks/use-notifications'
42
+ import { listNotificationsFn, markAllNotificationsReadFn, markNotificationReadFn } from '@/server/serverFns/notifications'
43
+ import type { NotificationItem } from '@allbluecn/shared'
44
+
45
+ const POPOVER_ITEM_LIMIT = 8
46
+
47
+ export function NotificationsBell() {
48
+ const { t } = useTranslation('notifications')
49
+ const navigate = useNavigate()
50
+ const queryClient = useQueryClient()
51
+ const { unreadCount } = useNotifications()
30
52
 
31
- type Notification = {
32
- id: string;
33
- avatar: string;
34
- fallback: string;
35
- text: string;
36
- time: string;
37
- };
53
+ // 与详情页 useInfiniteQuery 的分页缓存形态不同,必须使用独立 queryKey
54
+ const { data } = useQuery({
55
+ queryKey: NOTIFICATION_POPOVER_KEY,
56
+ queryFn: () => listNotificationsFn({ data: {} }),
57
+ })
58
+ const items = (data?.items ?? []).slice(0, POPOVER_ITEM_LIMIT)
59
+
60
+ const invalidateNotificationData = () => {
61
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_ROOT_KEY })
62
+ }
63
+
64
+ const handleOpenItem = (item: NotificationItem) => {
65
+ if (!item.readAt) {
66
+ void markNotificationReadFn({ data: { id: item.id } }).then(invalidateNotificationData)
67
+ }
68
+ if (item.link) navigate({ to: item.link as never })
69
+ }
70
+
71
+ const handleMarkAllRead = async () => {
72
+ await markAllNotificationsReadFn({ data: {} })
73
+ invalidateNotificationData()
74
+ }
38
75
 
39
- export function NotificationsPopover({
40
- notifications,
41
- }: {
42
- notifications: Notification[];
43
- }) {
44
76
  return (
45
- <DropdownMenu>
46
- <DropdownMenuTrigger asChild>
77
+ <Popover>
78
+ <PopoverTrigger asChild>
47
79
  <Button
48
- variant="ghost"
80
+ aria-label={t('bell.label')}
81
+ className="relative rounded-full"
49
82
  size="icon"
50
- className="rounded-full"
51
- aria-label="Open notifications"
83
+ variant="ghost"
52
84
  >
53
- <BellIcon className="size-5" />
85
+ <Bell className="size-5" />
86
+ {unreadCount > 0 && (
87
+ <span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
88
+ {unreadCount > 9 ? '9+' : unreadCount}
89
+ </span>
90
+ )}
54
91
  </Button>
55
- </DropdownMenuTrigger>
56
- <DropdownMenuContent side="right" className="w-80 my-6">
57
- <DropdownMenuLabel>Notifications</DropdownMenuLabel>
58
- <DropdownMenuSeparator />
59
- {notifications.map(({ id, avatar, fallback, text, time }) => (
60
- <DropdownMenuItem key={id} className="flex items-start gap-3">
61
- <Avatar className="size-8">
62
- <AvatarImage src={avatar} alt="Avatar" />
63
- <AvatarFallback>{fallback}</AvatarFallback>
64
- </Avatar>
65
- <div className="flex flex-col">
66
- <span className="text-sm font-medium">{text}</span>
67
- <span className="text-xs text-muted-foreground">{time}</span>
68
- </div>
69
- </DropdownMenuItem>
70
- ))}
71
- <DropdownMenuSeparator />
72
- <DropdownMenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
73
- View all notifications
74
- </DropdownMenuItem>
75
- </DropdownMenuContent>
76
- </DropdownMenu>
77
- );
92
+ </PopoverTrigger>
93
+ <PopoverContent align="start" className="w-80 p-0">
94
+ <div className="flex h-6 mt-3 items-center justify-between px-4">
95
+ <span className="flex h-full items-center text-sm font-semibold">{t('bell.recentTitle')}</span>
96
+ {unreadCount > 0 && (
97
+ <Button onClick={handleMarkAllRead} size="sm" type="button" variant="ghost">
98
+ <CheckCheck className="size-4" />
99
+ {t('actions.markAllRead')}
100
+ </Button>
101
+ )}
102
+ </div>
103
+ {/* <Separator /> */}
104
+ {items.length === 0 ? (
105
+ <Empty>
106
+ <EmptyHeader>
107
+ <EmptyMedia variant="icon">
108
+ <Bell className="size-6" />
109
+ </EmptyMedia>
110
+ <EmptyTitle>{t('empty')}</EmptyTitle>
111
+ </EmptyHeader>
112
+ </Empty>
113
+ ) : (
114
+ <div className="max-h-96 overflow-y-auto border-t">
115
+ {items.map((item) => {
116
+ const Icon = CATEGORY_ICONS[item.category]
117
+ const isUnread = !item.readAt
118
+ return (
119
+ <div
120
+ key={item.id}
121
+ className={cn(
122
+ 'flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50',
123
+ isUnread && 'bg-primary/5'
124
+ )}
125
+ onClick={() => handleOpenItem(item)}
126
+ >
127
+ <div
128
+ className={cn(
129
+ 'flex size-8 shrink-0 items-center justify-center rounded-full',
130
+ isUnread ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
131
+ )}
132
+ >
133
+ <Icon className="size-4" />
134
+ </div>
135
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
136
+ <span className={cn('truncate text-sm', isUnread ? 'font-medium' : 'text-muted-foreground')}>
137
+ {item.title}
138
+ </span>
139
+ <span className="text-muted-foreground text-xs">
140
+ {formatRelativeTime(item.createdAt, t)}
141
+ </span>
142
+ </div>
143
+ {isUnread && <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />}
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ )}
149
+ {/* <Separator /> */}
150
+ <div className="p-1 border-t">
151
+ <Link
152
+ className="flex h-10 w-full items-center justify-center rounded-md text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-primary"
153
+ to="/notifications"
154
+ >
155
+ {t('actions.viewAll')}
156
+ </Link>
157
+ </div>
158
+ </PopoverContent>
159
+ </Popover>
160
+ )
78
161
  }
@@ -46,7 +46,7 @@ import {
46
46
  import { signOut, useSession } from "@/lib/auth/auth-client";
47
47
  import { useRouter, useMatches } from "@tanstack/react-router";
48
48
  import { useTranslation } from "react-i18next";
49
- import { useQueryClient } from "@tanstack/react-query";
49
+ import { getQueryClient } from "@allbluecn/shared/lib/query-client-seam";
50
50
  import { UserAvatar } from "@allbluecn/ui";
51
51
  import type { AvatarConfig, ShapeType } from "@allbluecn/shared";
52
52
  import { SparklesText } from "@/components/magicui/sparkles-text";
@@ -55,7 +55,9 @@ export function UserMenu() {
55
55
  const { t } = useTranslation('common');
56
56
  const { isMobile } = useSidebar();
57
57
  const { data: clientSession } = useSession();
58
- const queryClient = useQueryClient();
58
+ // seam 单例替代 React Context 读取:SSR 下 npm 包内 react-query 可能与宿主
59
+ // 解析到不同物理实例,Context 分裂会抛 "No QueryClient set"
60
+ const queryClient = getQueryClient();
59
61
  const router = useRouter();
60
62
  const matches = useMatches();
61
63
 
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Adapted from @hextaui/settings-notifications (https://www.hextaui.com)
3
+ import { useEffect, useState } from 'react'
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { toast } from 'sonner'
7
+ import { Bell } from 'lucide-react'
8
+ import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
9
+ import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@/components/ui/select'
17
+ import { Separator } from '@/components/ui/separator'
18
+ import { Switch } from '@/components/ui/switch'
19
+ import { useDebouncedCallback } from '@/lib/pacer'
20
+ import { getNotificationPreferencesFn, updateNotificationPreferencesFn } from '@/server/serverFns/notifications'
21
+ import type {
22
+ CategoryFrequencies,
23
+ NotificationCategory,
24
+ NotificationFrequency,
25
+ NotificationPreferences,
26
+ } from '@allbluecn/shared'
27
+ import { NOTIFICATION_FREQUENCIES } from '@allbluecn/shared'
28
+
29
+ const FREQUENCY_OPTIONS = NOTIFICATION_FREQUENCIES as readonly NotificationFrequency[]
30
+
31
+ const FREQUENCY_LABEL_KEYS: Record<NotificationFrequency, string> = {
32
+ realtime: 'preferences.frequency.realtime',
33
+ 'digest-daily': 'preferences.frequency.daily',
34
+ 'digest-weekly': 'preferences.frequency.weekly',
35
+ off: 'preferences.frequency.off',
36
+ }
37
+
38
+ const CATEGORY_META: { key: NotificationCategory; labelKey: string; descKey: string }[] = [
39
+ { key: 'team', labelKey: 'preferences.categories.team', descKey: 'preferences.categories.teamDesc' },
40
+ { key: 'collaboration', labelKey: 'preferences.categories.collaboration', descKey: 'preferences.categories.collaborationDesc' },
41
+ { key: 'system', labelKey: 'preferences.categories.system', descKey: 'preferences.categories.systemDesc' },
42
+ { key: 'billing', labelKey: 'preferences.categories.billing', descKey: 'preferences.categories.billingDesc' },
43
+ { key: 'security', labelKey: 'preferences.categories.security', descKey: 'preferences.categories.securityDesc' },
44
+ ]
45
+
46
+ const PREFERENCES_SAVE_DEBOUNCE_MS = 500
47
+
48
+ export function SettingsNotifications() {
49
+ const { t } = useTranslation('notifications')
50
+ const queryClient = useQueryClient()
51
+
52
+ const { data: prefsData, isLoading } = useQuery({
53
+ queryKey: ['notifications', 'preferences'],
54
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
55
+ })
56
+
57
+ const [prefs, setPrefs] = useState<NotificationPreferences | null>(null)
58
+
59
+ useEffect(() => {
60
+ if (prefsData) setPrefs(prefsData)
61
+ }, [prefsData])
62
+
63
+ const debouncedSave = useDebouncedCallback(async (next: NotificationPreferences) => {
64
+ try {
65
+ await updateNotificationPreferencesFn({ data: next })
66
+ queryClient.setQueryData(['notifications', 'preferences'], next)
67
+ } catch {
68
+ toast.error(t('preferences.saveFailed'))
69
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] })
70
+ }
71
+ }, { wait: PREFERENCES_SAVE_DEBOUNCE_MS })
72
+
73
+ const patch = (partial: Partial<NotificationPreferences>) => {
74
+ if (!prefs) return
75
+ const next = { ...prefs, ...partial }
76
+ setPrefs(next)
77
+ debouncedSave(next)
78
+ }
79
+
80
+ const setCategoryFrequency = (key: NotificationCategory, frequency: NotificationFrequency) => {
81
+ if (!prefs) return
82
+ patch({ categories: { ...prefs.categories, [key]: frequency } as CategoryFrequencies })
83
+ }
84
+
85
+ if (isLoading || !prefs) {
86
+ return (
87
+ <div className="flex flex-col gap-4">
88
+ {Array.from({ length: 4 }).map((_, i) => (
89
+ <div key={i} className="h-16 rounded-lg border bg-muted/30" />
90
+ ))}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <div className="flex flex-col gap-6">
97
+ <div className="flex flex-col gap-4">
98
+ {CATEGORY_META.map((meta) => {
99
+ const frequency = prefs.categories[meta.key]
100
+ return (
101
+ <div className="flex flex-col gap-4 rounded-lg border p-4" key={meta.key}>
102
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
103
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
104
+ <h4 className="font-medium text-sm">{t(meta.labelKey)}</h4>
105
+ <p className="text-muted-foreground text-xs">{t(meta.descKey)}</p>
106
+ </div>
107
+ <Select
108
+ onValueChange={(value) => setCategoryFrequency(meta.key, value as NotificationFrequency)}
109
+ value={frequency}
110
+ >
111
+ <SelectTrigger className="w-full shrink-0 sm:w-[140px]">
112
+ <SelectValue />
113
+ </SelectTrigger>
114
+ <SelectContent>
115
+ {FREQUENCY_OPTIONS.map((option) => (
116
+ <SelectItem key={option} value={option}>
117
+ {t(FREQUENCY_LABEL_KEYS[option])}
118
+ </SelectItem>
119
+ ))}
120
+ </SelectContent>
121
+ </Select>
122
+ </div>
123
+
124
+ <Separator />
125
+
126
+ <Field>
127
+ <div className="flex items-center justify-between">
128
+ <div className="flex items-center gap-2">
129
+ <Bell className="size-4 text-muted-foreground" />
130
+ <FieldLabel htmlFor={`${meta.key}-enable`}>
131
+ {t('preferences.enable')}
132
+ </FieldLabel>
133
+ </div>
134
+ <Switch
135
+ checked={frequency !== 'off'}
136
+ id={`${meta.key}-enable`}
137
+ onCheckedChange={(checked) => setCategoryFrequency(meta.key, checked ? 'realtime' : 'off')}
138
+ />
139
+ </div>
140
+ </Field>
141
+ </div>
142
+ )
143
+ })}
144
+ </div>
145
+
146
+ <Separator />
147
+
148
+ <div className="flex flex-col gap-4">
149
+ <h3 className="font-semibold text-base">{t('preferences.dnd.title')}</h3>
150
+ <Field>
151
+ <div className="flex items-center justify-between">
152
+ <div className="flex flex-col gap-1">
153
+ <FieldLabel htmlFor="dnd-enabled">{t('preferences.dnd.enabled')}</FieldLabel>
154
+ <FieldDescription>{t('preferences.dnd.desc')}</FieldDescription>
155
+ </div>
156
+ <Switch
157
+ checked={prefs.dndEnabled}
158
+ id="dnd-enabled"
159
+ onCheckedChange={(checked) => patch({ dndEnabled: checked })}
160
+ />
161
+ </div>
162
+ </Field>
163
+
164
+ {prefs.dndEnabled && (
165
+ <div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
166
+ <Field>
167
+ <FieldLabel htmlFor="dnd-start">{t('preferences.dnd.start')}</FieldLabel>
168
+ <FieldContent>
169
+ <InputGroup>
170
+ <InputGroupInput
171
+ id="dnd-start"
172
+ onChange={(e) => patch({ dndStart: e.target.value })}
173
+ type="time"
174
+ value={prefs.dndStart}
175
+ />
176
+ </InputGroup>
177
+ </FieldContent>
178
+ </Field>
179
+
180
+ <Field>
181
+ <FieldLabel htmlFor="dnd-end">{t('preferences.dnd.end')}</FieldLabel>
182
+ <FieldContent>
183
+ <InputGroup>
184
+ <InputGroupInput
185
+ id="dnd-end"
186
+ onChange={(e) => patch({ dndEnd: e.target.value })}
187
+ type="time"
188
+ value={prefs.dndEnd}
189
+ />
190
+ </InputGroup>
191
+ </FieldContent>
192
+ </Field>
193
+ </div>
194
+ )}
195
+ </div>
196
+ </div>
197
+ )
198
+ }