@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
134
|
+
"@allbluecn/ai-gateway": "0.4.17",
|
|
131
135
|
"@allbluecn/kernel": "0.3.1",
|
|
132
|
-
"@allbluecn/
|
|
133
|
-
"@allbluecn/
|
|
134
|
-
"@allbluecn/
|
|
135
|
-
"@allbluecn/
|
|
136
|
-
"@allbluecn/
|
|
137
|
-
"@allbluecn/
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
<
|
|
46
|
-
<
|
|
77
|
+
<Popover>
|
|
78
|
+
<PopoverTrigger asChild>
|
|
47
79
|
<Button
|
|
48
|
-
|
|
80
|
+
aria-label={t('bell.label')}
|
|
81
|
+
className="relative rounded-full"
|
|
49
82
|
size="icon"
|
|
50
|
-
|
|
51
|
-
aria-label="Open notifications"
|
|
83
|
+
variant="ghost"
|
|
52
84
|
>
|
|
53
|
-
<
|
|
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
|
-
</
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|