@allbluecn/web-app 0.4.16 → 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.
- package/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
- 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/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
- package/src/components/story/display/display-dropdown.tsx +6 -7
- package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
- package/src/components/story/story-list-view.tsx +13 -2
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -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 +10 -0
- package/src/routeTree.gen.ts +70 -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/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/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/stories/$storyId/route.tsx +2 -3
- 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/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -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__/notifications.test.ts +63 -0
- 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/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/story/story-crud/labels.ts +45 -6
- package/src/server/serverFns/story/story-crud.ts +8 -2
- package/src/server/serverFns/story/story-groups.ts +26 -7
- package/src/server/serverFns/story/story-search.ts +4 -0
- package/src/server/serverFns/story-display-options.ts +15 -4
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- 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
|
+
}
|
|
@@ -29,10 +29,6 @@ vi.mock("@/components/billing/use-feature", () => ({
|
|
|
29
29
|
useFeature: vi.fn(),
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
-
vi.mock("@/components/billing/upgrade-prompt", () => ({
|
|
33
|
-
UpgradePrompt: () => <div data-testid="upgrade-prompt">升级提示</div>,
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
32
|
import { useFeature } from "@/components/billing/use-feature";
|
|
37
33
|
|
|
38
34
|
const mockUseFeature = useFeature as ReturnType<typeof vi.fn>;
|
|
@@ -214,7 +210,28 @@ describe("DisplayDropdown", () => {
|
|
|
214
210
|
expect(patch.showEmptyGroups).toBe(true);
|
|
215
211
|
});
|
|
216
212
|
|
|
217
|
-
it("
|
|
213
|
+
it("OSS 环境——只显示 100/200,不显示 500/1000", () => {
|
|
214
|
+
mockUseFeature.mockReturnValue({ enabled: false, loading: false });
|
|
215
|
+
const onChange = vi.fn();
|
|
216
|
+
renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
|
|
217
|
+
fireEvent.click(screen.getByRole("button", { name: /显示/ }));
|
|
218
|
+
const options = document.querySelector('[data-section="options"]') as HTMLElement;
|
|
219
|
+
const sizeButtons = within(options).getAllByRole("button").filter((b) => /^\d+$/.test(b.textContent ?? ""));
|
|
220
|
+
const labels = sizeButtons.map((b) => b.textContent);
|
|
221
|
+
expect(labels).toContain("100");
|
|
222
|
+
expect(labels).toContain("200");
|
|
223
|
+
expect(labels).not.toContain("500");
|
|
224
|
+
expect(labels).not.toContain("1000");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("Pro 环境且 free 用户——500/1000 锁定但可见", async () => {
|
|
228
|
+
// mock implementedModules.billing = true(Pro 环境)
|
|
229
|
+
const modules = await import("@/plugins/modules.gen");
|
|
230
|
+
vi.spyOn(modules, "implementedModules", "get").mockReturnValue({
|
|
231
|
+
...modules.implementedModules,
|
|
232
|
+
billing: true,
|
|
233
|
+
} as any);
|
|
234
|
+
|
|
218
235
|
mockUseFeature.mockReturnValue({ enabled: false, loading: false });
|
|
219
236
|
const onChange = vi.fn();
|
|
220
237
|
renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
|
|
@@ -229,10 +246,16 @@ describe("DisplayDropdown", () => {
|
|
|
229
246
|
expect(btn1000).toBeDisabled();
|
|
230
247
|
fireEvent.click(btn500 as HTMLElement);
|
|
231
248
|
expect(onChange).not.toHaveBeenCalled();
|
|
232
|
-
expect(screen.
|
|
249
|
+
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
|
233
250
|
});
|
|
234
251
|
|
|
235
|
-
it("
|
|
252
|
+
it("Pro 环境且 pro 用户——点击 500 触发 onChange", async () => {
|
|
253
|
+
const modules = await import("@/plugins/modules.gen");
|
|
254
|
+
vi.spyOn(modules, "implementedModules", "get").mockReturnValue({
|
|
255
|
+
...modules.implementedModules,
|
|
256
|
+
billing: true,
|
|
257
|
+
} as any);
|
|
258
|
+
|
|
236
259
|
mockUseFeature.mockReturnValue({ enabled: true, loading: false });
|
|
237
260
|
const onChange = vi.fn();
|
|
238
261
|
renderWithTooltip(<DisplayDropdown filters={makeFilters()} onChange={onChange} layout="list" {...viewProps} />);
|
|
@@ -22,7 +22,6 @@ import { Button } from "@/components/ui/button";
|
|
|
22
22
|
import { Switch } from "@/components/ui/switch";
|
|
23
23
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
24
24
|
import { useFeature } from "@/components/billing/use-feature";
|
|
25
|
-
import { UpgradePrompt } from "@/components/billing/upgrade-prompt";
|
|
26
25
|
import {
|
|
27
26
|
Popover,
|
|
28
27
|
PopoverContent,
|
|
@@ -34,6 +33,7 @@ import type { SavedView, ViewProjectInfo } from "../views/view-types";
|
|
|
34
33
|
import type { StoryView } from "../views/view-switcher";
|
|
35
34
|
import { DEFAULT_DISPLAY_OPTIONS, GROUP_KEYS, ORDER_KEYS, PAGE_SIZE_OPTIONS, type DisplayOptions, type DisplayProperty, type GroupKey, type OrderDirection, type OrderKey, type PageSize } from "./types";
|
|
36
35
|
import { GROUP_LABELS, ORDER_LABELS, PROPERTY_LABELS, LAYOUT_SUPPORT, LAYOUT_PROPERTIES, GROUP_ICONS, ORDER_ICONS } from "./constants";
|
|
36
|
+
import { implementedModules } from "@/plugins/modules.gen";
|
|
37
37
|
|
|
38
38
|
/** 分组选项:布局不支持分组时返回空数组;选中特定项目时隐藏"项目"分组 */
|
|
39
39
|
export function getGroupOptions(layout: StoryView, projectId?: string): GroupKey[] {
|
|
@@ -377,7 +377,7 @@ function PropertiesSection({ selected, onChange, layout, t }: PropertiesSectionP
|
|
|
377
377
|
};
|
|
378
378
|
|
|
379
379
|
return (
|
|
380
|
-
<div data-section="properties" className="
|
|
380
|
+
<div data-section="properties" className=" px-3 py-2">
|
|
381
381
|
<button
|
|
382
382
|
type="button"
|
|
383
383
|
onClick={() => setCollapsed(!collapsed)}
|
|
@@ -432,13 +432,15 @@ function OptionsSection({
|
|
|
432
432
|
onPageSizeChange,
|
|
433
433
|
}: OptionsSectionProps) {
|
|
434
434
|
const { t } = useTranslation("display");
|
|
435
|
-
|
|
435
|
+
// OSS 环境(billing 模块未实现)只显示 100/200;Pro 环境显示全部
|
|
436
|
+
const sizeOptions = implementedModules.billing
|
|
437
|
+
? (PAGE_SIZE_OPTIONS as readonly PageSize[])
|
|
438
|
+
: ([100, 200] as readonly PageSize[]);
|
|
436
439
|
const { enabled: largePageSizeEnabled, loading: largePageSizeLoading } =
|
|
437
440
|
useFeature("largePageSize");
|
|
438
441
|
|
|
439
442
|
const isPageSizeLocked = (v: PageSize) =>
|
|
440
443
|
v > 200 && !largePageSizeEnabled && !largePageSizeLoading;
|
|
441
|
-
const showPageSizeUpgrade = !largePageSizeEnabled && !largePageSizeLoading;
|
|
442
444
|
|
|
443
445
|
return (
|
|
444
446
|
<div data-section="options" className="border-t px-3 py-2">
|
|
@@ -484,9 +486,6 @@ function OptionsSection({
|
|
|
484
486
|
})}
|
|
485
487
|
</div>
|
|
486
488
|
</div>
|
|
487
|
-
{showPageSizeUpgrade && (
|
|
488
|
-
<UpgradePrompt feature="largePageSize" />
|
|
489
|
-
)}
|
|
490
489
|
</div>
|
|
491
490
|
</div>
|
|
492
491
|
);
|