@allbluecn/web-app 0.4.21 → 0.5.0

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 (84) hide show
  1. package/package.json +25 -33
  2. package/src/components/command/command-palette.tsx +1 -1
  3. package/src/components/knowledge/knowledge-toolbar.tsx +29 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  6. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  7. package/src/components/notifications/settings-notifications.tsx +198 -0
  8. package/src/components/notifications/team-notifications.tsx +343 -0
  9. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  10. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  11. package/src/components/shared/wave-physics-loader.tsx +155 -0
  12. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  13. package/src/components/story/constants.ts +114 -51
  14. package/src/components/story/detail/story-detail-body.tsx +13 -3
  15. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  16. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  17. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  18. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  19. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  20. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  21. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  22. package/src/components/story/peek-panel.tsx +8 -1
  23. package/src/components/story/relations/story-ref-row.tsx +319 -0
  24. package/src/components/story/story-list-view.tsx +3 -1
  25. package/src/components/story/story-panel-header.tsx +4 -1
  26. package/src/components/story/types.ts +17 -18
  27. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  28. package/src/components/story/views/story-list/index.tsx +16 -5
  29. package/src/components/story/views/story-list/row.tsx +5 -4
  30. package/src/components/ui/separator.tsx +1 -1
  31. package/src/hooks/use-notifications.ts +4 -1
  32. package/src/lib/__tests__/datetime.test.ts +16 -1
  33. package/src/lib/__tests__/rag.test.ts +37 -2
  34. package/src/lib/changelog/sdk-versions.ts +30 -30
  35. package/src/lib/commands/registry.ts +1 -1
  36. package/src/lib/commands/types.ts +3 -0
  37. package/src/lib/datetime.ts +1 -0
  38. package/src/lib/rag.ts +9 -2
  39. package/src/routeTree.gen.ts +23 -0
  40. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  41. package/src/routes/_nav/notifications/route.tsx +30 -0
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  43. package/src/routes/api/notifications/stream.ts +4 -0
  44. package/src/server/ai-ports.ts +1 -1
  45. package/src/server/knowledge-public/github-sync.ts +1 -1
  46. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  47. package/src/server/notifications/notification-service.ts +137 -27
  48. package/src/server/serverFns/__tests__/knowledge-index.test.ts +178 -0
  49. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  50. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  51. package/src/server/serverFns/knowledge-index-utils.ts +104 -0
  52. package/src/server/serverFns/knowledge-index.ts +6 -67
  53. package/src/server/serverFns/knowledge.ts +8 -2
  54. package/src/server/serverFns/locale.ts +15 -3
  55. package/src/server/serverFns/notifications.ts +18 -4
  56. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  57. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  58. package/src/server/serverFns/story/index.ts +1 -2
  59. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  60. package/src/server/serverFns/story/story-groups.ts +23 -3
  61. package/src/server/serverFns/story/story-transfer.ts +5 -3
  62. package/src/server/serverFns/team.ts +51 -2
  63. package/src/components/knowledge/knowledge-settings-panel.tsx +0 -148
  64. package/src/components/notifications/notification-list.tsx +0 -232
  65. package/src/components/notifications/notification-preferences.tsx +0 -156
  66. package/src/components/story/relation-badge.tsx +0 -57
  67. package/src/components/story/relations/dependency-panel.tsx +0 -190
  68. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  69. package/src/components/story/relations/tab.tsx +0 -212
  70. package/src/config/sidebar-notifications.ts +0 -50
  71. package/src/lib/openai-client.ts +0 -62
  72. package/src/plugins/.gen-manifest.json +0 -13
  73. package/src/plugins/modules.gen.ts +0 -20
  74. package/src/plugins/plugins.gen.ts +0 -20
  75. package/src/plugins/ui.gen.ts +0 -8
  76. package/src/routes/_nav/inspiration/route.tsx +0 -8
  77. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  78. package/src/routes/_nav/prd/index.tsx +0 -9
  79. package/src/routes/_nav/prd/route.tsx +0 -8
  80. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  81. package/src/routes/_nav/tags/index.tsx +0 -8
  82. package/src/routes/_nav/tags/route.tsx +0 -8
  83. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  84. package/src/routes/share/prd/$prdId/route.tsx +0 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.21",
3
+ "version": "0.5.0",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -34,31 +34,24 @@
34
34
  "@radix-ui/react-popover": "^1.1.15",
35
35
  "@radix-ui/react-slot": "^1.2.4",
36
36
  "@radix-ui/react-visually-hidden": "^1.2.11",
37
- "@tanstack/ai": "^0.43.0",
38
- "@tanstack/ai-anthropic": "^0.16.3",
39
- "@tanstack/ai-gemini": "^0.20.1",
40
- "@tanstack/ai-grok": "^0.14.9",
41
- "@tanstack/ai-groq": "^0.5.3",
42
- "@tanstack/ai-ollama": "^0.8.16",
43
- "@tanstack/ai-openai": "^0.17.1",
44
- "@tanstack/ai-openrouter": "^0.15.10",
45
- "@tanstack/ai-persistence": "^0.1.0",
46
- "@tanstack/ai-react": "^0.18.1",
47
- "@tanstack/db": "0.6.17",
37
+ "@tanstack/ai": "^0.52.0",
38
+ "@tanstack/ai-persistence": "^0.5.4",
39
+ "@tanstack/ai-react": "^0.22.4",
40
+ "@tanstack/db": "0.8.6",
48
41
  "@tanstack/hotkeys": "^0.8.0",
49
- "@tanstack/pacer": "^0.21.1",
50
- "@tanstack/query-db-collection": "1.2.1",
51
- "@tanstack/react-db": "0.1.95",
52
- "@tanstack/react-form": "2.0.0-alpha.0",
42
+ "@tanstack/pacer": "^0.22.0",
43
+ "@tanstack/query-db-collection": "1.2.11",
44
+ "@tanstack/react-db": "0.3.6",
45
+ "@tanstack/react-form": "2.0.0-alpha.2",
53
46
  "@tanstack/react-hotkeys": "^0.10.0",
54
- "@tanstack/react-pacer": "^0.22.1",
55
- "@tanstack/react-query": "^5.101.2",
56
- "@tanstack/react-router": "1.170.29",
57
- "@tanstack/react-router-ssr-query": "^1.167.1",
58
- "@tanstack/react-start": "^1.168.46",
59
- "@tanstack/react-store": "^0.11.0",
60
- "@tanstack/react-table": "^9.1.0",
61
- "@tanstack/react-virtual": "^3.14.9",
47
+ "@tanstack/react-pacer": "^0.23.0",
48
+ "@tanstack/react-query": "^5.102.8",
49
+ "@tanstack/react-router": "1.170.32",
50
+ "@tanstack/react-router-ssr-query": "^1.167.2",
51
+ "@tanstack/react-start": "^1.168.49",
52
+ "@tanstack/react-store": "^0.11.1",
53
+ "@tanstack/react-table": "^9.2.3",
54
+ "@tanstack/react-virtual": "^3.14.10",
62
55
  "@tanstack/store": "^0.11.1",
63
56
  "@tiptap/core": "^3.27.4",
64
57
  "@tiptap/extension-code-block-lowlight": "^3.27.4",
@@ -115,7 +108,6 @@
115
108
  "next-themes": "^0.4.6",
116
109
  "nitro": "3.0.260603-beta",
117
110
  "ogl": "^1.0.11",
118
- "openai": "^6.39.1",
119
111
  "prisma": "^7.8.0",
120
112
  "radix-ui": "^1.4.3",
121
113
  "react": "19.2.4",
@@ -135,14 +127,14 @@
135
127
  "three": "^0.180.0",
136
128
  "tw-animate-css": "^1.4.0",
137
129
  "zod": "^4.4.3",
138
- "@allbluecn/ai-gateway": "0.4.14",
139
- "@allbluecn/kernel": "0.3.0",
140
- "@allbluecn/database": "0.4.14",
141
- "@allbluecn/plugins-core": "0.4.13",
130
+ "@allbluecn/database": "0.4.15",
131
+ "@allbluecn/kernel": "0.3.1",
132
+ "@allbluecn/plugins-core": "0.4.15",
142
133
  "@allbluecn/prototype": "0.1.1",
143
- "@allbluecn/tiptap": "0.4.13",
144
- "@allbluecn/ui": "0.4.14",
145
- "@allbluecn/shared": "0.4.14"
134
+ "@allbluecn/ai-gateway": "0.4.16",
135
+ "@allbluecn/shared": "0.4.16",
136
+ "@allbluecn/tiptap": "0.4.16",
137
+ "@allbluecn/ui": "0.4.16"
146
138
  },
147
139
  "devDependencies": {
148
140
  "@babel/parser": "^8.0.4",
@@ -152,7 +144,7 @@
152
144
  "@playwright/mcp": "^0.0.75",
153
145
  "@playwright/test": "^1.60.0",
154
146
  "@tailwindcss/vite": "^4.3.2",
155
- "@tanstack/react-query-devtools": "5.101.4",
147
+ "@tanstack/react-query-devtools": "5.102.8",
156
148
  "@tanstack/react-router-devtools": "1.167.1",
157
149
  "@testing-library/jest-dom": "^6.9.1",
158
150
  "@testing-library/react": "^16.3.2",
@@ -195,7 +195,7 @@ function CommandPaletteList({
195
195
  key={cmd.id}
196
196
  index={idx}
197
197
  onSelect={() => onRun(cmd)}
198
- shortcut={cmd.shortcut ? resolveShortcut(cmd.shortcut) : undefined}
198
+ shortcut={cmd.shortcut ?? cmd.chord ? resolveShortcut(cmd.shortcut ?? cmd.chord!) : undefined}
199
199
  icon={Icon && <Icon className="size-4 shrink-0" />}
200
200
  >
201
201
  <span className="truncate">{cmd.title}</span>
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { useState, useEffect, useRef } from 'react'
19
19
  import { useNavigate } from '@tanstack/react-router'
20
- import { Settings, Globe, Lock, Trash2, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
20
+ import { Settings, Globe, Lock, Trash2, RefreshCw, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
21
21
  import { Input } from '@/components/ui/input'
22
22
  import { Badge } from '@/components/ui/badge'
23
23
  import {
@@ -67,6 +67,8 @@ import { KnowledgeTagInput } from './knowledge-tag-input'
67
67
  import { useEditorWidth } from './editor/editor-width-context'
68
68
  import type { EditorWidth } from './editor/editor-width-context'
69
69
  import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
70
+ import { reindexKnowledgeBaseFn } from '@/server/serverFns/knowledge-index'
71
+ import { toast } from '@/components/ui/sonner'
70
72
  import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
71
73
  import { cn } from '@/lib/utils'
72
74
  import { useTranslation } from 'react-i18next'
@@ -109,6 +111,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
109
111
  const [deleteConfirm, setDeleteConfirm] = useState('')
110
112
  const [editOpen, setEditOpen] = useState(false)
111
113
  const [deleting, setDeleting] = useState(false)
114
+ const [reindexing, setReindexing] = useState(false)
112
115
  const { effectiveWidth, setWidth } = useEditorWidth()
113
116
  const [createPopover, setCreatePopover] = useState(false)
114
117
  const [createType, setCreateType] = useState('doc')
@@ -166,6 +169,21 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
166
169
  setDeleting(false)
167
170
  }
168
171
 
172
+ const handleReindex = async () => {
173
+ setReindexing(true)
174
+ try {
175
+ const { indexed, documentCount } = await reindexKnowledgeBaseFn({ data: { kbId: kb.id } })
176
+ if (documentCount === 0) {
177
+ toast.success(t('toolbar.noDocs'))
178
+ } else {
179
+ toast.success(t('toolbar.reindexDone', { indexed, documentCount }))
180
+ }
181
+ } catch (e) {
182
+ toast.error(`${t('toolbar.reindexFailed')}:${e instanceof Error ? e.message : String(e)}`)
183
+ }
184
+ setReindexing(false)
185
+ }
186
+
169
187
  return (
170
188
  <div className="flex items-center gap-4 px-4 py-2 border-b bg-background shrink-0">
171
189
  <Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={onToggleTree}>
@@ -466,6 +484,16 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
466
484
  </DropdownMenuSub>
467
485
 
468
486
 
487
+ <DropdownMenuSeparator />
488
+
489
+ <DropdownMenuItem
490
+ onSelect={(e) => { e.preventDefault(); if (!reindexing) handleReindex() }}
491
+ disabled={reindexing}
492
+ >
493
+ <RefreshCw className={`size-3.5 mr-2 ${reindexing ? 'animate-spin' : ''}`} />
494
+ {reindexing ? t('toolbar.reindexing') : t('toolbar.rebuildIndex')}
495
+ </DropdownMenuItem>
496
+
469
497
  <DropdownMenuSeparator />
470
498
 
471
499
  <DropdownMenuItem
@@ -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
+ }