@allbluecn/web-app 0.4.17 → 0.4.18

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 (67) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. package/vite.shared.ts +13 -0
@@ -1,21 +1,4 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- // AllBlue - 理想之海
3
- // Copyright (C) 2026 AllBlue Contributors
4
- //
5
- // This program is free software: you can redistribute it and/or modify
6
- // it under the terms of the GNU Affero General Public License as published by
7
- // the Free Software Foundation, either version 3 of the License, or
8
- // (at your option) any later version.
9
- //
10
- // This program is distributed in the hope that it will be useful,
11
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
- // GNU Affero General Public License for more details.
14
- //
15
- // You should have received a copy of the GNU Affero General Public License
16
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
-
18
- "use client";
1
+ "use client"
19
2
 
20
3
  import {
21
4
  Crown,
@@ -26,147 +9,138 @@ import {
26
9
  Trash2,
27
10
  User,
28
11
  UserCheck,
29
- } from "lucide-react";
30
- import { useState } from "react";
31
- import { useTranslation } from "react-i18next";
32
- import { cn } from "@/lib/utils";
33
- import { formatDate } from "@/lib/datetime";
34
- import { useTimezone } from "@/providers/timezone-provider";
35
- import {
36
- Avatar,
37
- AvatarFallback,
38
- AvatarImage,
39
- } from "@/components/ui/avatar";
40
- import { Badge } from "@/components/ui/badge";
41
- import { Button } from "@/components/ui/button";
12
+ } from "lucide-react"
13
+ import { useState } from "react"
14
+ import { useTranslation } from "react-i18next"
15
+ import { cn } from "@/lib/utils"
16
+ import { formatDate } from "@/lib/datetime"
17
+ import { useTimezone } from "@/providers/timezone-provider"
18
+ import { UserAvatar } from "@allbluecn/ui"
19
+ import type { AvatarConfig, ShapeType } from "@allbluecn/shared"
20
+ import { Badge } from "@/components/ui/badge"
21
+ import { Button } from "@/components/ui/button"
42
22
  import {
43
23
  Card,
44
24
  CardContent,
45
25
  CardDescription,
46
26
  CardHeader,
47
27
  CardTitle,
48
- } from "@/components/ui/card";
28
+ } from "@/components/ui/card"
49
29
  import {
50
30
  DropdownMenu,
51
31
  DropdownMenuContent,
52
32
  DropdownMenuItem,
53
33
  DropdownMenuSeparator,
54
34
  DropdownMenuTrigger,
55
- } from "@/components/ui/dropdown-menu";
35
+ } from "@/components/ui/dropdown-menu"
56
36
  import {
57
37
  Empty,
58
38
  EmptyDescription,
59
39
  EmptyHeader,
60
40
  EmptyMedia,
61
41
  EmptyTitle,
62
- } from "@/components/ui/empty";
42
+ } from "@/components/ui/empty"
63
43
  import {
64
44
  InputGroup,
65
45
  InputGroupAddon,
66
46
  InputGroupInput,
67
- } from "@/components/ui/input-group";
47
+ } from "@/components/ui/input-group"
68
48
  import {
69
49
  Select,
70
50
  SelectContent,
71
51
  SelectItem,
72
52
  SelectTrigger,
73
53
  SelectValue,
74
- } from "@/components/ui/select";
75
- import { Separator } from "@/components/ui/separator";
54
+ } from "@/components/ui/select"
55
+ import { Separator } from "@/components/ui/separator"
76
56
 
77
57
  export interface TeamMember {
78
- id: string;
79
- name: string;
80
- email: string;
81
- avatar?: string;
82
- role: "owner" | "admin" | "member" | "viewer" | "guest";
83
- status: "active" | "invited" | "suspended";
84
- lastActive?: Date;
85
- joinedAt: Date;
58
+ id: string
59
+ name: string
60
+ email: string
61
+ avatar?: string
62
+ avatarConfig?: AvatarConfig | null
63
+ bgShape?: ShapeType | null
64
+ bgColor?: string | null
65
+ role: "owner" | "admin" | "member" | "viewer" | "guest"
66
+ status: "active" | "invited" | "suspended"
67
+ lastActive?: Date
68
+ joinedAt: Date
86
69
  aiUsage?: {
87
- tokens: number;
88
- sessions: number;
89
- };
70
+ tokens: number
71
+ sessions: number
72
+ }
90
73
  }
91
74
 
92
75
  export interface TeamMemberListProps {
93
- members?: TeamMember[];
94
- currentUserId?: string;
95
- onPromote?: (memberId: string) => Promise<void>;
96
- onDemote?: (memberId: string) => Promise<void>;
97
- onRemove?: (memberId: string) => Promise<void>;
98
- onResendInvite?: (memberId: string) => Promise<void>;
99
- className?: string;
100
- showSearch?: boolean;
101
- showFilters?: boolean;
102
- showUsage?: boolean;
76
+ members?: TeamMember[]
77
+ currentUserId?: string
78
+ onPromote?: (memberId: string) => Promise<void>
79
+ onDemote?: (memberId: string) => Promise<void>
80
+ onRemove?: (memberId: string) => Promise<void>
81
+ onResendInvite?: (memberId: string) => Promise<void>
82
+ className?: string
83
+ showSearch?: boolean
84
+ showFilters?: boolean
85
+ showUsage?: boolean
103
86
  }
104
87
 
105
88
  function formatLastActive(date: Date | undefined, timezone: string, t: (key: string, params?: Record<string, unknown>) => string): string {
106
- if (!date) return t("team.never");
107
- const now = Date.now();
108
- const diff = now - date.getTime();
109
- const minutes = Math.floor(diff / 60_000);
110
- const hours = Math.floor(minutes / 60);
111
- const days = Math.floor(hours / 24);
89
+ if (!date) return t("team.never")
90
+ const now = Date.now()
91
+ const diff = now - date.getTime()
92
+ const minutes = Math.floor(diff / 60_000)
93
+ const hours = Math.floor(minutes / 60)
94
+ const days = Math.floor(hours / 24)
112
95
 
113
- if (minutes < 1) return t("team.justNow");
114
- if (minutes < 60) return t("team.minutesAgo", { count: minutes });
115
- if (hours < 24) return t("team.hoursAgo", { count: hours });
116
- if (days < 7) return t("team.daysAgo", { count: days });
117
- return formatDate(date, timezone, 'MMM d, yyyy');
96
+ if (minutes < 1) return t("team.justNow")
97
+ if (minutes < 60) return t("team.minutesAgo", { count: minutes })
98
+ if (hours < 24) return t("team.hoursAgo", { count: hours })
99
+ if (days < 7) return t("team.daysAgo", { count: days })
100
+ return formatDate(date, timezone, "MMM d, yyyy")
118
101
  }
119
102
 
120
103
  function getRoleLabel(role: TeamMember["role"], t: (key: string) => string): string {
121
- return t(`team.role.${role}`);
104
+ return t(`team.role.${role}`)
122
105
  }
123
106
 
124
107
  function formatNumber(num: number): string {
125
108
  if (num >= 1_000_000) {
126
- return `${(num / 1_000_000).toFixed(1)}M`;
109
+ return `${(num / 1_000_000).toFixed(1)}M`
127
110
  }
128
111
  if (num >= 1000) {
129
- return `${(num / 1000).toFixed(1)}K`;
112
+ return `${(num / 1000).toFixed(1)}K`
130
113
  }
131
- return num.toString();
114
+ return num.toString()
132
115
  }
133
116
 
134
117
  function getRoleIcon(role: TeamMember["role"]) {
135
118
  switch (role) {
136
119
  case "owner":
137
- return Crown;
120
+ return Crown
138
121
  case "admin":
139
- return Shield;
122
+ return Shield
140
123
  case "member":
141
- return UserCheck;
124
+ return UserCheck
142
125
  case "viewer":
143
126
  case "guest":
144
- return User;
127
+ return User
145
128
  default:
146
- return User;
129
+ return User
147
130
  }
148
131
  }
149
132
 
150
133
  function getRoleBadgeVariant(role: TeamMember["role"]) {
151
134
  switch (role) {
152
135
  case "owner":
153
- return "default";
136
+ return "default"
154
137
  case "admin":
155
- return "secondary";
138
+ return "secondary"
156
139
  default:
157
- return "outline";
140
+ return "outline"
158
141
  }
159
142
  }
160
143
 
161
- function getInitials(name: string): string {
162
- return name
163
- .split(" ")
164
- .map((n) => n[0])
165
- .join("")
166
- .toUpperCase()
167
- .slice(0, 2);
168
- }
169
-
170
144
  export default function TeamMemberList({
171
145
  members = [],
172
146
  currentUserId,
@@ -179,40 +153,40 @@ export default function TeamMemberList({
179
153
  showFilters = true,
180
154
  showUsage = false,
181
155
  }: TeamMemberListProps) {
182
- const [searchQuery, setSearchQuery] = useState("");
183
- const [roleFilter, setRoleFilter] = useState<string>("all");
184
- const [statusFilter, setStatusFilter] = useState<string>("all");
185
- const [actionLoading, setActionLoading] = useState<string | null>(null);
186
- const timezone = useTimezone();
187
- const { t } = useTranslation("settings");
156
+ const { t } = useTranslation("settings")
157
+ const timezone = useTimezone()
158
+ const [searchQuery, setSearchQuery] = useState("")
159
+ const [roleFilter, setRoleFilter] = useState<string>("all")
160
+ const [statusFilter, setStatusFilter] = useState<string>("all")
161
+ const [actionLoading, setActionLoading] = useState<string | null>(null)
188
162
 
189
163
  const filteredMembers = members.filter((member) => {
190
164
  const matchesSearch =
191
165
  !searchQuery.trim() ||
192
166
  member.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
193
- member.email.toLowerCase().includes(searchQuery.toLowerCase());
167
+ member.email.toLowerCase().includes(searchQuery.toLowerCase())
194
168
 
195
- const matchesRole = roleFilter === "all" || member.role === roleFilter;
169
+ const matchesRole = roleFilter === "all" || member.role === roleFilter
196
170
  const matchesStatus =
197
- statusFilter === "all" || member.status === statusFilter;
171
+ statusFilter === "all" || member.status === statusFilter
198
172
 
199
- return matchesSearch && matchesRole && matchesStatus;
200
- });
173
+ return matchesSearch && matchesRole && matchesStatus
174
+ })
201
175
 
202
176
  const handleAction = async (
203
177
  action: () => Promise<void>,
204
- memberId: string
178
+ memberId: string,
205
179
  ) => {
206
- setActionLoading(memberId);
180
+ setActionLoading(memberId)
207
181
  try {
208
- await action();
182
+ await action()
209
183
  } finally {
210
- setActionLoading(null);
184
+ setActionLoading(null)
211
185
  }
212
- };
186
+ }
213
187
 
214
188
  return (
215
- <Card className={cn("w-full shadow-xs border", className)}>
189
+ <Card className={cn("w-full border shadow-xs", className)}>
216
190
  <CardHeader>
217
191
  <div className="flex flex-col gap-4">
218
192
  <div className="flex flex-col gap-1">
@@ -287,12 +261,16 @@ export default function TeamMemberList({
287
261
  <User className="size-6" />
288
262
  </EmptyMedia>
289
263
  <EmptyTitle>
290
- {searchQuery || roleFilter !== "all" || statusFilter !== "all"
264
+ {searchQuery ||
265
+ roleFilter !== "all" ||
266
+ statusFilter !== "all"
291
267
  ? t("team.noMatch")
292
268
  : t("team.noMembers")}
293
269
  </EmptyTitle>
294
270
  <EmptyDescription>
295
- {searchQuery || roleFilter !== "all" || statusFilter !== "all"
271
+ {searchQuery ||
272
+ roleFilter !== "all" ||
273
+ statusFilter !== "all"
296
274
  ? t("team.tryAdjustFilters")
297
275
  : t("team.inviteMembers")}
298
276
  </EmptyDescription>
@@ -301,9 +279,9 @@ export default function TeamMemberList({
301
279
  ) : (
302
280
  <div className="flex flex-col gap-0 overflow-hidden rounded-lg border">
303
281
  {filteredMembers.map((member, idx) => {
304
- const RoleIcon = getRoleIcon(member.role);
305
- const isCurrentUser = member.id === currentUserId;
306
- const canModify = !isCurrentUser && member.role !== "owner";
282
+ const RoleIcon = getRoleIcon(member.role)
283
+ const isCurrentUser = member.id === currentUserId
284
+ const canModify = !isCurrentUser && member.role !== "owner"
307
285
 
308
286
  return (
309
287
  <div key={member.id}>
@@ -312,15 +290,14 @@ export default function TeamMemberList({
312
290
  <div className="flex items-center gap-2">
313
291
  <span className="flex items-center gap-2 font-medium text-sm">
314
292
  {" "}
315
- <Avatar className="size-8">
316
- <AvatarImage
317
- alt={member.name}
318
- src={member.avatar}
319
- />
320
- <AvatarFallback>
321
- {getInitials(member.name)}
322
- </AvatarFallback>
323
- </Avatar>
293
+ <UserAvatar
294
+ avatarConfig={member.avatarConfig}
295
+ bgShape={member.bgShape}
296
+ bgColor={member.bgColor}
297
+ className="shrink-0"
298
+ name={member.name}
299
+ size="md"
300
+ />
324
301
  {member.name}
325
302
  </span>
326
303
  {isCurrentUser && (
@@ -346,16 +323,20 @@ export default function TeamMemberList({
346
323
  </Badge>
347
324
  )}
348
325
  </div>
349
- <p className="text-muted-foreground text-sm">
350
- {member.email}
326
+ <p className="flex flex-wrap items-center gap-x-2 text-muted-foreground text-sm">
327
+ <span>{member.email}</span>
328
+ <span className="text-xs">
329
+ {t("team.joinedAt")}{" "}
330
+ {formatDate(member.joinedAt, timezone, "MMM d, yyyy")}
331
+ </span>
351
332
  </p>
352
333
  <div className="flex flex-wrap items-center gap-1 text-muted-foreground text-xs">
353
- <span>{t("team.joinedAt")} {formatDate(member.joinedAt, timezone, 'MMM d, yyyy')}</span>
354
334
  {member.lastActive && (
355
335
  <>
356
336
  <span aria-hidden="true">•</span>
357
337
  <span>
358
- {t("team.lastActiveAt")} {formatLastActive(member.lastActive, timezone, t)}
338
+ {t("team.lastActiveAt")}{" "}
339
+ {formatLastActive(member.lastActive, timezone, t)}
359
340
  </span>
360
341
  </>
361
342
  )}
@@ -363,11 +344,16 @@ export default function TeamMemberList({
363
344
  <>
364
345
  <span aria-hidden="true">•</span>
365
346
  <span>
366
- {formatNumber(member.aiUsage.tokens)} Token
347
+ {formatNumber(member.aiUsage.tokens)}{" "}
348
+ {t("team.tokens", {
349
+ count: formatNumber(member.aiUsage.tokens),
350
+ })}
367
351
  </span>
368
352
  <span aria-hidden="true">•</span>
369
353
  <span>
370
- {member.aiUsage.sessions} {t("team.sessions", { count: member.aiUsage.sessions })}
354
+ {t("team.sessions", {
355
+ count: member.aiUsage.sessions,
356
+ })}
371
357
  </span>
372
358
  </>
373
359
  )}
@@ -377,7 +363,7 @@ export default function TeamMemberList({
377
363
  <DropdownMenu>
378
364
  <DropdownMenuTrigger asChild>
379
365
  <Button
380
- aria-label={`${member.name} ${t("team.moreActions")}`}
366
+ aria-label={t("team.moreActions")}
381
367
  size="icon"
382
368
  type="button"
383
369
  variant="ghost"
@@ -399,7 +385,7 @@ export default function TeamMemberList({
399
385
  onSelect={() =>
400
386
  handleAction(
401
387
  () => onPromote(member.id),
402
- member.id
388
+ member.id,
403
389
  )
404
390
  }
405
391
  >
@@ -412,7 +398,7 @@ export default function TeamMemberList({
412
398
  onSelect={() =>
413
399
  handleAction(
414
400
  () => onDemote(member.id),
415
- member.id
401
+ member.id,
416
402
  )
417
403
  }
418
404
  >
@@ -425,7 +411,7 @@ export default function TeamMemberList({
425
411
  onSelect={() =>
426
412
  handleAction(
427
413
  () => onResendInvite(member.id),
428
- member.id
414
+ member.id,
429
415
  )
430
416
  }
431
417
  >
@@ -433,7 +419,7 @@ export default function TeamMemberList({
433
419
  {t("team.resendInvite")}
434
420
  </DropdownMenuItem>
435
421
  )}
436
- {member.role !== "viewer" && (
422
+ {member.role !== "viewer" && member.role !== "guest" && (
437
423
  <DropdownMenuSeparator />
438
424
  )}
439
425
  {onRemove && (
@@ -442,7 +428,7 @@ export default function TeamMemberList({
442
428
  onSelect={() =>
443
429
  handleAction(
444
430
  () => onRemove(member.id),
445
- member.id
431
+ member.id,
446
432
  )
447
433
  }
448
434
  variant="destructive"
@@ -458,11 +444,11 @@ export default function TeamMemberList({
458
444
  </div>
459
445
  {idx < filteredMembers.length - 1 && <Separator />}
460
446
  </div>
461
- );
447
+ )
462
448
  })}
463
449
  </div>
464
450
  )}
465
451
  </CardContent>
466
452
  </Card>
467
- );
468
- }
453
+ )
454
+ }
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { describe, it, expect } from 'vitest'
3
+ import { parseNotificationEvent } from '../use-notifications'
4
+
5
+ describe('parseNotificationEvent', () => {
6
+ it('解析合法 notification 事件', () => {
7
+ const raw = JSON.stringify({
8
+ type: 'CUSTOM',
9
+ name: 'notifications',
10
+ value: {
11
+ kind: 'notification',
12
+ notification: {
13
+ id: 'n1',
14
+ category: 'team',
15
+ type: 'team.invite_request',
16
+ title: 't',
17
+ body: null,
18
+ link: null,
19
+ readAt: null,
20
+ createdAt: '2026-08-26T00:00:00Z',
21
+ },
22
+ },
23
+ })
24
+ const parsed = parseNotificationEvent(raw)
25
+ expect(parsed?.kind).toBe('notification')
26
+ })
27
+
28
+ it('解析 unread_snapshot 事件', () => {
29
+ const raw = JSON.stringify({ type: 'CUSTOM', name: 'notifications', value: { kind: 'unread_snapshot', count: 5 } })
30
+ expect(parseNotificationEvent(raw)).toEqual({ kind: 'unread_snapshot', count: 5 })
31
+ })
32
+
33
+ it('非法 JSON 返回 null', () => {
34
+ expect(parseNotificationEvent('not json')).toBeNull()
35
+ })
36
+
37
+ it('非 notifications 通道的 CUSTOM 事件返回 null', () => {
38
+ const raw = JSON.stringify({ type: 'CUSTOM', name: 'other', value: { kind: 'unread_snapshot', count: 5 } })
39
+ expect(parseNotificationEvent(raw)).toBeNull()
40
+ })
41
+ })
@@ -0,0 +1,119 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { useCallback, useEffect } from 'react'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useRouter } from '@tanstack/react-router'
5
+ import { toast } from 'sonner'
6
+ import { useTranslation } from 'react-i18next'
7
+ import type { NotificationSseValue, NotificationItem } from '@allbluecn/shared'
8
+ import { isInDndWindow } from '@allbluecn/shared'
9
+ import { getUnreadCountFn, getNotificationPreferencesFn } from '@/server/serverFns/notifications'
10
+
11
+ export const NOTIFICATION_LIST_KEY = ['notifications', 'list'] as const
12
+ export const NOTIFICATION_UNREAD_KEY = ['notifications', 'unread'] as const
13
+ export const NOTIFICATION_PREFERENCES_KEY = ['notifications', 'preferences'] as const
14
+
15
+ export function parseNotificationEvent(raw: string): NotificationSseValue | null {
16
+ try {
17
+ const parsed = JSON.parse(raw)
18
+ if (parsed?.type === 'CUSTOM' && parsed?.name === 'notifications' && parsed?.value?.kind) {
19
+ return parsed.value as NotificationSseValue
20
+ }
21
+ return null
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ function toastNotification(item: NotificationItem, t: (key: string) => string, navigate: (opts: { to: string }) => void) {
28
+ toast(item.title, {
29
+ description: item.body ?? undefined,
30
+ action: item.link
31
+ ? {
32
+ label: t('actions.view'),
33
+ onClick: () => {
34
+ navigate({ to: item.link! })
35
+ },
36
+ }
37
+ : undefined,
38
+ })
39
+ }
40
+
41
+ let source: EventSource | null = null
42
+ let refCount = 0
43
+
44
+ export function useNotifications(options?: { toastEnabled?: boolean }) {
45
+ const { t } = useTranslation('notifications')
46
+ const queryClient = useQueryClient()
47
+ const router = useRouter()
48
+ const toastEnabled = options?.toastEnabled !== false
49
+
50
+ const { data: unreadData } = useQuery({
51
+ queryKey: NOTIFICATION_UNREAD_KEY,
52
+ queryFn: () => getUnreadCountFn({ data: {} }),
53
+ })
54
+
55
+ const unreadCount = unreadData?.count ?? 0
56
+
57
+ // N2 修复:预加载偏好数据到缓存,确保 DND 判断在任何页面均可用
58
+ useQuery({
59
+ queryKey: NOTIFICATION_PREFERENCES_KEY,
60
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
61
+ staleTime: 60000,
62
+ })
63
+
64
+ const setUnreadCount = useCallback(
65
+ (updater: number | ((c: number) => number)) => {
66
+ if (typeof updater === 'function') {
67
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
68
+ const count = (old as { count?: number } | undefined)?.count ?? 0
69
+ return { count: updater(count) }
70
+ })
71
+ } else {
72
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: updater })
73
+ }
74
+ },
75
+ [queryClient]
76
+ )
77
+
78
+ useEffect(() => {
79
+ refCount++
80
+ if (!source) {
81
+ source = new EventSource('/api/notifications/stream')
82
+ }
83
+ const es = source
84
+
85
+ const handleMessage = (event: MessageEvent) => {
86
+ const value = parseNotificationEvent(event.data)
87
+ if (!value) return
88
+ if (value.kind === 'unread_snapshot') {
89
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: value.count })
90
+ } else {
91
+ queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
92
+ const count = (old as { count?: number } | undefined)?.count ?? 0
93
+ return { count: count + 1 }
94
+ })
95
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
96
+ if (toastEnabled) {
97
+ const prefs = queryClient.getQueryData<{ dndEnabled: boolean; dndStart: string; dndEnd: string }>(NOTIFICATION_PREFERENCES_KEY)
98
+ const now = new Date()
99
+ const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
100
+ const inDnd = !!prefs && isInDndWindow(prefs, hhmm)
101
+ if (!inDnd) toastNotification(value.notification, t, (opts) => router.navigate({ to: opts.to as never }))
102
+ }
103
+ }
104
+ }
105
+
106
+ es.addEventListener('message', handleMessage as EventListener)
107
+ return () => {
108
+ es.removeEventListener('message', handleMessage as EventListener)
109
+ refCount--
110
+ if (refCount === 0) {
111
+ source?.close()
112
+ source = null
113
+ }
114
+ }
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, [toastEnabled, t, router])
117
+
118
+ return { unreadCount, setUnreadCount }
119
+ }
@@ -15,6 +15,9 @@
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
- export const APP_VERSION = '0.5.0'
18
+ // 版本号由 vite.shared.ts 构建时注入(__APP_VERSION__),无需运行时读取 package.json
19
+ declare const __APP_VERSION__: string
20
+ const APP_VERSION: string = __APP_VERSION__
21
+
19
22
  export const APP_VERSION_DISPLAY = `v${APP_VERSION}`
20
23
  export const APP_FULL_VERSION = `AllBlue v${APP_VERSION}`
@@ -139,8 +139,8 @@ export const authConfig: StartAuthJSConfig = {
139
139
  // 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
140
140
  let ip: string | null = null
141
141
  let userAgent: string | null = null
142
+ const headers = request?.headers as { get?: (name: string) => string | null } | undefined
142
143
  try {
143
- const headers = request?.headers as { get?: (name: string) => string | null } | undefined
144
144
  if (headers && typeof headers.get === 'function') {
145
145
  ip = getClientIp(request as Request)
146
146
  userAgent = headers.get('user-agent') || null
@@ -194,6 +194,22 @@ export const authConfig: StartAuthJSConfig = {
194
194
 
195
195
  const remember = creds.remember === 'true'
196
196
  const stayLoggedIn = creds.stayLoggedIn === 'true'
197
+
198
+ const isNewDevice = (() => {
199
+ const cookieHeader = headers?.get?.('cookie') || ''
200
+ return !cookieHeader.includes('allblue_device_seen=')
201
+ })()
202
+ void (async () => {
203
+ if (!isNewDevice) return
204
+ const { createNotification } = await import('@/server/notifications/notification-service')
205
+ await createNotification({
206
+ userId: user.id,
207
+ category: 'security',
208
+ type: 'security.new_device_login',
209
+ title: '检测到新设备登录',
210
+ body: `登录时间 ${new Date().toLocaleString('zh-CN')}`,
211
+ })
212
+ })()
197
213
  return {
198
214
  id: user.id,
199
215
  email: user.email,
@@ -1,5 +1,6 @@
1
1
  export interface SdkVersion {
2
2
  name: string
3
+ displayName?: string
3
4
  version: string
4
5
  lastUpdate: string
5
6
  source: string