@allbluecn/web-app 0.4.17 → 0.4.19
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 +11 -11
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/config/sidebar-routes.tsx +16 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/routeTree.gen.ts +47 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/team.ts +521 -53
- package/vite.shared.ts +13 -0
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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
|
|
183
|
-
const
|
|
184
|
-
const [
|
|
185
|
-
const [
|
|
186
|
-
const
|
|
187
|
-
const
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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")}
|
|
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)}
|
|
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
|
-
{
|
|
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={
|
|
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
|
+
}
|
|
@@ -19,7 +19,9 @@ import type React from "react";
|
|
|
19
19
|
import {
|
|
20
20
|
Sparkles,
|
|
21
21
|
BotMessageSquare,
|
|
22
|
+
Lightbulb,
|
|
22
23
|
ClipboardList,
|
|
24
|
+
Package2,
|
|
23
25
|
FlaskConical,
|
|
24
26
|
Bug,
|
|
25
27
|
CircleCheckBig,
|
|
@@ -59,6 +61,13 @@ export const workspaceRoutes: Route[] = [
|
|
|
59
61
|
badge: " ",
|
|
60
62
|
link: "/chat",
|
|
61
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: "inspiration",
|
|
66
|
+
title: "nav.inspiration",
|
|
67
|
+
icon: <Lightbulb className="size-4" />,
|
|
68
|
+
badge: " ",
|
|
69
|
+
link: "/inspiration",
|
|
70
|
+
},
|
|
62
71
|
{
|
|
63
72
|
id: "story",
|
|
64
73
|
title: "nav.story",
|
|
@@ -66,6 +75,13 @@ export const workspaceRoutes: Route[] = [
|
|
|
66
75
|
badge: " ",
|
|
67
76
|
link: "/stories",
|
|
68
77
|
},
|
|
78
|
+
{
|
|
79
|
+
id: "prd",
|
|
80
|
+
title: "nav.prd",
|
|
81
|
+
icon: <Package2 className="size-4" />,
|
|
82
|
+
badge: "原型画板",
|
|
83
|
+
link: "/prd",
|
|
84
|
+
},
|
|
69
85
|
{
|
|
70
86
|
id: "design",
|
|
71
87
|
title: "nav.design",
|
|
@@ -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
|
+
}
|
package/src/lib/app-version.ts
CHANGED
|
@@ -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
|
-
|
|
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}`
|