@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.
Files changed (78) 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 +5 -4
  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/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
  15. package/src/components/story/display/display-dropdown.tsx +6 -7
  16. package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
  17. package/src/components/story/story-list-view.tsx +13 -2
  18. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  19. package/src/hooks/use-notifications.ts +119 -0
  20. package/src/lib/app-version.ts +4 -1
  21. package/src/lib/auth/auth.config.ts +17 -1
  22. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  23. package/src/lib/changelog/sdk-versions.ts +29 -19
  24. package/src/lib/collections/ai/index.ts +0 -1
  25. package/src/lib/collections/index.ts +0 -1
  26. package/src/plugins/.gen-manifest.json +13 -0
  27. package/src/plugins/modules.gen.ts +20 -0
  28. package/src/plugins/plugins.gen.ts +20 -0
  29. package/src/plugins/ui.gen.ts +10 -0
  30. package/src/routeTree.gen.ts +70 -0
  31. package/src/routes/_login/login/route.tsx +10 -1
  32. package/src/routes/_login/register/route.lazy.tsx +3 -2
  33. package/src/routes/_login/register/route.tsx +5 -0
  34. package/src/routes/_nav/inspiration/route.tsx +8 -0
  35. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  36. package/src/routes/_nav/notifications/route.tsx +8 -0
  37. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  38. package/src/routes/_nav/prd/index.tsx +9 -0
  39. package/src/routes/_nav/prd/route.tsx +8 -0
  40. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  41. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  43. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  44. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  45. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  46. package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
  47. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  48. package/src/routes/_nav/tags/index.tsx +8 -0
  49. package/src/routes/_nav/tags/route.tsx +8 -0
  50. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  51. package/src/routes/api/notifications/stream.ts +88 -0
  52. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  53. package/src/routes/invite/$token/route.tsx +16 -8
  54. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  55. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  56. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  57. package/src/server/notifications/event-bus.ts +16 -0
  58. package/src/server/notifications/notification-service.ts +86 -0
  59. package/src/server/resources.ts +10 -2
  60. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  61. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  62. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  63. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  64. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  65. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  66. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  67. package/src/server/serverFns/billing.ts +23 -2
  68. package/src/server/serverFns/notifications.ts +155 -0
  69. package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
  70. package/src/server/serverFns/story/story-comments.ts +42 -0
  71. package/src/server/serverFns/story/story-crud/labels.ts +45 -6
  72. package/src/server/serverFns/story/story-crud.ts +8 -2
  73. package/src/server/serverFns/story/story-groups.ts +26 -7
  74. package/src/server/serverFns/story/story-search.ts +4 -0
  75. package/src/server/serverFns/story-display-options.ts +15 -4
  76. package/src/server/serverFns/team.ts +573 -53
  77. package/src/start.ts +23 -1
  78. 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
+ }
@@ -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("大分页未解锁——500/1000 禁用且点击不触发 onChange", () => {
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.getByTestId("upgrade-prompt")).toBeInTheDocument();
249
+ expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
233
250
  });
234
251
 
235
- it("大分页已解锁——点击 500 触发 onChange({ pageSize: 500 })", () => {
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="border-t px-3 py-2">
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
- const sizeOptions = PAGE_SIZE_OPTIONS as readonly PageSize[];
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
  );