@ploykit/ui 0.5.0 → 0.6.2

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 (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,568 @@
1
+
2
+ import { useState } from 'react'
3
+ import { useSearchParams } from 'react-router'
4
+ import { z } from 'zod'
5
+ import { UserPlus, Link2 } from 'lucide-react'
6
+ import {
7
+ PageHeader, PageLoading, PageError, PageEmpty,
8
+ } from '../../components/Page'
9
+ import { DataTable, type Column } from '../../components/DataTable'
10
+ import { FormField } from '../../components/FormField'
11
+ import { SecretModal, ImpactConfirmation } from '../../components/SecretModal'
12
+ import { useConfirm } from '../../components/ConfirmDialog'
13
+ import { toast } from '../../components/toast'
14
+ import { Button } from '../../components/ui/button'
15
+ import { Input } from '../../components/ui/input'
16
+ import { Badge } from '../../components/ui/badge'
17
+ import { StatusBadge } from '../../components/StatusBadge'
18
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'
19
+ import {
20
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
21
+ } from '../../components/ui/select'
22
+ import {
23
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
24
+ } from '../../components/ui/dialog'
25
+ import { useZodForm } from '@ploykit/hooks'
26
+ import {
27
+ useMembers, useMemberMutations, ASSIGNABLE_ROLES, LIST_PAGE_SIZE,
28
+ type PKMember, type PKInvitation, type PKShareLink,
29
+ } from '@ploykit/hooks'
30
+ import { formatDateTime } from '../../lib/utils'
31
+ import { apiErrorMessage } from '../../lib/api-error'
32
+ import { roleSatisfies } from '@ploykit/hooks'
33
+
34
+
35
+
36
+ const inviteSchema = z.object({
37
+ email: z.string().min(1, '请输入邮箱').email('邮箱格式不正确'),
38
+ role: z.enum(['admin', 'member']),
39
+ })
40
+ type InviteForm = z.infer<typeof inviteSchema>
41
+
42
+ const shareLinkSchema = z.object({
43
+ role: z.enum(['admin', 'member']),
44
+ max_uses: z.number().int('须为整数').min(1, '至少 1 次').max(10000),
45
+ ttl_hours: z.number().int('须为整数').min(1, '至少 1 小时').max(24 * 30),
46
+ })
47
+ type ShareLinkForm = z.infer<typeof shareLinkSchema>
48
+
49
+ const ROLE_LABEL: Record<string, string> = { owner: '所有者', admin: '管理员', member: '成员' }
50
+
51
+
52
+
53
+ const MEMBER_TABS = ['members', 'invitations', 'share-links'] as const
54
+ type MembersTab = (typeof MEMBER_TABS)[number]
55
+
56
+
57
+ function pageFromParams(v: string | null): number {
58
+ const n = Number.parseInt(v ?? '', 10)
59
+ return Number.isInteger(n) && n >= 1 ? n : 1
60
+ }
61
+
62
+ export interface MembersPageProps {
63
+ /** 无需 props:工作区上下文来自 PloykitProvider。保留 prop 通道便于产品扩展。 */
64
+ }
65
+
66
+ export function MembersPage(_props: MembersPageProps = {}) {
67
+
68
+
69
+
70
+
71
+ const [searchParams, setSearchParams] = useSearchParams()
72
+ const tabParam = searchParams.get('tab')
73
+ const tab: MembersTab =
74
+ tabParam !== null && (MEMBER_TABS as readonly string[]).includes(tabParam)
75
+ ? (tabParam as MembersTab)
76
+ : 'members'
77
+ const memberPage = pageFromParams(searchParams.get('mpage'))
78
+ const invitePage = pageFromParams(searchParams.get('ipage'))
79
+ const linkPage = pageFromParams(searchParams.get('lpage'))
80
+
81
+ const setTab = (t: string) => {
82
+ setSearchParams((prev) => {
83
+ const next = new URLSearchParams(prev)
84
+ next.set('tab', t)
85
+ return next
86
+ })
87
+ }
88
+ const setPageParam = (key: 'mpage' | 'ipage' | 'lpage', page: number) => {
89
+ setSearchParams(
90
+ (prev) => {
91
+ const next = new URLSearchParams(prev)
92
+ next.set(key, String(page))
93
+ return next
94
+ },
95
+ { replace: true },
96
+ )
97
+ }
98
+ const {
99
+ wsId, myRole, members, membersTotal, invitations, invitationsTotal,
100
+ shareLinks, shareLinksTotal, loading, membersError, invitationsError,
101
+ shareLinksError, refetchAll,
102
+ } = useMembers({ members: memberPage, invitations: invitePage, shareLinks: linkPage })
103
+ const mut = useMemberMutations()
104
+ const confirm = useConfirm()
105
+
106
+ const [inviteOpen, setInviteOpen] = useState(false)
107
+ const [shareOpen, setShareOpen] = useState(false)
108
+ const [secret, setSecret] = useState<string | null>(null)
109
+
110
+
111
+ const [transferTarget, setTransferTarget] = useState<PKMember | null>(null)
112
+
113
+ const inviteForm = useZodForm<InviteForm>({
114
+ schema: inviteSchema,
115
+ defaultValues: { email: '', role: 'member' },
116
+ })
117
+ const linkForm = useZodForm<ShareLinkForm>({
118
+ schema: shareLinkSchema,
119
+ defaultValues: { role: 'member', max_uses: 10, ttl_hours: 72 },
120
+ })
121
+
122
+ if (!wsId) {
123
+ return (
124
+ <div>
125
+ <PageHeader title="成员" description="管理当前工作区的成员、邀请与分享链接" />
126
+ <PageEmpty label="请先创建并选择一个工作区" />
127
+ </div>
128
+ )
129
+ }
130
+ if (loading) return <PageLoading label="加载成员数据…" />
131
+ if (membersError) {
132
+ return (
133
+ <PageError
134
+ message={apiErrorMessage(membersError, '成员数据加载失败')}
135
+ onRetry={() => refetchAll()}
136
+ />
137
+ )
138
+ }
139
+
140
+
141
+
142
+ const canManage = roleSatisfies(myRole ?? undefined, 'admin')
143
+
144
+
145
+ const canTransfer = myRole === 'owner'
146
+
147
+
148
+
149
+ const onRoleChange = async (m: PKMember, role: string) => {
150
+ if (role === m.role) return
151
+ try {
152
+ await mut.updateRole.mutateAsync({ userId: m.user_id, role })
153
+ toast.success(`已将 ${m.email} 的角色改为 ${ROLE_LABEL[role] ?? role}`)
154
+ } catch (e) {
155
+ toast.error(apiErrorMessage(e, '角色变更失败'))
156
+ }
157
+ }
158
+
159
+ const onRemove = async (m: PKMember) => {
160
+ if (!(await confirm({
161
+ title: '移除成员',
162
+ description: `确定将 ${m.email} 移出本工作区?其将立即失去访问权限。`,
163
+ confirmText: '移除', danger: true,
164
+ }))) return
165
+ try {
166
+ await mut.removeMember.mutateAsync(m.user_id)
167
+ toast.success('成员已移除')
168
+ } catch (e) {
169
+
170
+ toast.error(apiErrorMessage(e, '移除失败') + (isConflict(e) ? '(请先转移或删除工作区)' : ''))
171
+ }
172
+ }
173
+
174
+ const onRevokeInvite = async (inv: PKInvitation) => {
175
+ if (!(await confirm({
176
+ title: '撤销邀请',
177
+ description: `撤销发给 ${inv.email} 的邀请?对方将无法再接受。`,
178
+ confirmText: '撤销', danger: true,
179
+ }))) return
180
+ try {
181
+ await mut.revokeInvitation.mutateAsync(inv.id)
182
+ toast.success('邀请已撤销')
183
+ } catch (e) {
184
+ toast.error(apiErrorMessage(e, '撤销失败'))
185
+ }
186
+ }
187
+
188
+ const onRevokeLink = async (link: PKShareLink) => {
189
+ if (!(await confirm({
190
+ title: '撤销分享链接',
191
+ description: `撤销前缀为 ${link.code_prefix}… 的分享链接?已兑换的成员不受影响。`,
192
+ confirmText: '撤销', danger: true,
193
+ }))) return
194
+ try {
195
+ await mut.revokeShareLink.mutateAsync(link.id)
196
+ toast.success('分享链接已撤销')
197
+ } catch (e) {
198
+ toast.error(apiErrorMessage(e, '撤销失败'))
199
+ }
200
+ }
201
+
202
+
203
+
204
+
205
+
206
+
207
+ const onTransfer = async () => {
208
+ const m = transferTarget
209
+ if (!m) return
210
+ try {
211
+ await mut.transferOwnership.mutateAsync(m.user_id)
212
+ toast.success(`所有权已转让给 ${m.email}(你已降为成员)`)
213
+ setTransferTarget(null)
214
+ } catch (e) {
215
+ toast.error(apiErrorMessage(e, '所有权转让失败'))
216
+ }
217
+ }
218
+
219
+ const submitInvite = inviteForm.handleSubmit(async (values) => {
220
+ try {
221
+ await mut.invite.mutateAsync(values)
222
+ toast.success(`邀请已发送至 ${values.email}`)
223
+ inviteForm.reset()
224
+ setInviteOpen(false)
225
+ } catch (e) {
226
+ toast.error(apiErrorMessage(e, '邀请发送失败'))
227
+ }
228
+ })
229
+
230
+ const submitLink = linkForm.handleSubmit(async (values) => {
231
+ try {
232
+ const res = await mut.createShareLink.mutateAsync(values)
233
+ toast.success('分享链接已创建')
234
+ setSecret(res.code)
235
+ linkForm.reset()
236
+ setShareOpen(false)
237
+ } catch (e) {
238
+ toast.error(apiErrorMessage(e, '创建分享链接失败'))
239
+ }
240
+ })
241
+
242
+
243
+
244
+ const memberColumns: Column<PKMember>[] = [
245
+ {
246
+ key: 'email',
247
+ header: '邮箱',
248
+ className: 'w-[28%]',
249
+ render: (m) => (
250
+ <div className="min-w-0">
251
+ <p className="truncate font-medium text-foreground">{m.email}</p>
252
+ {m.display_name && <p className="truncate text-xs text-muted-foreground">{m.display_name}</p>}
253
+ </div>
254
+ ),
255
+ },
256
+ {
257
+ key: 'role',
258
+ header: '角色',
259
+ className: 'w-[20%]',
260
+ render: (m) =>
261
+ m.role === 'owner' ? (
262
+ <Badge variant="outline">{ROLE_LABEL.owner}</Badge>
263
+ ) : canManage ? (
264
+ <Select value={m.role} onValueChange={(v) => typeof v === 'string' && onRoleChange(m, v)}>
265
+ <SelectTrigger size="sm" aria-label={`变更 ${m.email} 的角色`}>
266
+ <SelectValue>{ROLE_LABEL[m.role] ?? m.role}</SelectValue>
267
+ </SelectTrigger>
268
+ <SelectContent>
269
+ {ASSIGNABLE_ROLES.map((r) => (
270
+ <SelectItem key={r} value={r}>{ROLE_LABEL[r]}</SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ ) : (
275
+ <Badge variant="secondary">{ROLE_LABEL[m.role] ?? m.role}</Badge>
276
+ ),
277
+ },
278
+ {
279
+ key: 'created_at',
280
+ header: '加入时间',
281
+ className: 'w-[22%]',
282
+ render: (m) => <span className="text-muted-foreground">{formatDateTime(m.created_at)}</span>,
283
+ },
284
+ {
285
+ key: 'actions',
286
+ header: '操作',
287
+ className: 'w-[22%] text-right',
288
+ render: (m) => (
289
+ <div className="flex items-center justify-end gap-1.5">
290
+ {canTransfer && m.role !== 'owner' && (
291
+ <Button
292
+ size="sm"
293
+ variant="outline"
294
+ disabled={mut.transferOwnership.isPending}
295
+ title="目标升为 owner,你降为 member(单事务原子)"
296
+ onClick={() => setTransferTarget(m)}
297
+ >
298
+ 转让所有权
299
+ </Button>
300
+ )}
301
+ {m.role !== 'owner' && canManage ? (
302
+ <Button
303
+ size="sm"
304
+ variant="destructive"
305
+ disabled={mut.removeMember.isPending}
306
+ onClick={() => onRemove(m)}
307
+ >
308
+ 移除
309
+ </Button>
310
+ ) : null}
311
+ </div>
312
+ ),
313
+ },
314
+ ]
315
+
316
+ const invitationColumns: Column<PKInvitation>[] = [
317
+ { key: 'email', header: '邮箱', render: (i) => <span className="font-medium">{i.email}</span> },
318
+ { key: 'role', header: '角色', render: (i) => <Badge variant="secondary">{ROLE_LABEL[i.role] ?? i.role}</Badge> },
319
+ { key: 'status', header: '状态', render: (i) => <StatusBadge status={i.status} /> },
320
+ {
321
+ key: 'expires_at', header: '过期时间',
322
+ render: (i) => <span className="text-muted-foreground">{formatDateTime(i.expires_at)}</span>,
323
+ },
324
+ {
325
+ key: 'actions', header: '操作', className: 'text-right',
326
+ render: (i) =>
327
+ i.status === 'pending' && canManage ? (
328
+ <Button size="sm" variant="outline" disabled={mut.revokeInvitation.isPending} onClick={() => onRevokeInvite(i)}>
329
+ 撤销
330
+ </Button>
331
+ ) : null,
332
+ },
333
+ ]
334
+
335
+ const shareLinkColumns: Column<PKShareLink>[] = [
336
+ {
337
+ key: 'code_prefix', header: '兑换码前缀',
338
+ render: (l) => <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{l.code_prefix}…</code>,
339
+ },
340
+ { key: 'role', header: '授予角色', render: (l) => <Badge variant="secondary">{ROLE_LABEL[l.role] ?? l.role}</Badge> },
341
+ {
342
+ key: 'uses', header: '兑换 / 上限',
343
+ render: (l) => (
344
+ <span className="tabular-nums">
345
+ {l.uses} / {l.max_uses === -1 ? '不限' : l.max_uses}
346
+ </span>
347
+ ),
348
+ },
349
+ {
350
+ key: 'expires_at', header: '过期时间',
351
+ render: (l) => <span className="text-muted-foreground">{formatDateTime(l.expires_at)}</span>,
352
+ },
353
+ {
354
+ key: 'actions', header: '操作', className: 'text-right',
355
+ render: (l) =>
356
+ canManage ? (
357
+ <Button size="sm" variant="outline" disabled={mut.revokeShareLink.isPending} onClick={() => onRevokeLink(l)}>
358
+ 撤销
359
+ </Button>
360
+ ) : null,
361
+ },
362
+ ]
363
+
364
+ return (
365
+ <div>
366
+ <PageHeader
367
+ title="成员"
368
+ description="管理当前工作区的成员、邀请与分享链接"
369
+ actions={
370
+ canManage ? (
371
+ <>
372
+ <Button variant="outline" onClick={() => setShareOpen(true)}>
373
+ <Link2 /> 创建分享链接
374
+ </Button>
375
+ <Button onClick={() => setInviteOpen(true)}>
376
+ <UserPlus /> 邀请成员
377
+ </Button>
378
+ </>
379
+ ) : undefined
380
+ }
381
+ />
382
+
383
+ <Tabs value={tab} onValueChange={setTab}>
384
+ <TabsList>
385
+ <TabsTrigger value="members">成员({membersTotal})</TabsTrigger>
386
+ <TabsTrigger value="invitations">邀请({invitationsTotal})</TabsTrigger>
387
+ <TabsTrigger value="share-links">分享链接({shareLinksTotal})</TabsTrigger>
388
+ </TabsList>
389
+
390
+ <TabsContent value="members" className="mt-4">
391
+ <DataTable
392
+ columns={memberColumns}
393
+ rows={members}
394
+ rowKey={(m) => m.user_id}
395
+ empty={<PageEmpty label="暂无成员" />}
396
+ pagination={{
397
+ page: memberPage,
398
+ pageSize: LIST_PAGE_SIZE,
399
+ total: membersTotal,
400
+ onPageChange: (p) => setPageParam('mpage', p),
401
+ }}
402
+ />
403
+ </TabsContent>
404
+
405
+ <TabsContent value="invitations" className="mt-4">
406
+ {invitationsError ? (
407
+
408
+ <PageError
409
+ message={apiErrorMessage(invitationsError, '邀请列表加载失败')}
410
+ onRetry={() => refetchAll()}
411
+ />
412
+ ) : (
413
+ <DataTable
414
+ columns={invitationColumns}
415
+ rows={invitations}
416
+ rowKey={(i) => i.id}
417
+ empty={<PageEmpty label="暂无邀请" hint="点击右上角“邀请成员”发送新邀请" />}
418
+ pagination={{
419
+ page: invitePage,
420
+ pageSize: LIST_PAGE_SIZE,
421
+ total: invitationsTotal,
422
+ onPageChange: (p) => setPageParam('ipage', p),
423
+ }}
424
+ />
425
+ )}
426
+ </TabsContent>
427
+
428
+ <TabsContent value="share-links" className="mt-4">
429
+ {shareLinksError ? (
430
+
431
+ <PageError
432
+ message={apiErrorMessage(shareLinksError, '分享链接加载失败')}
433
+ onRetry={() => refetchAll()}
434
+ />
435
+ ) : (
436
+ <DataTable
437
+ columns={shareLinkColumns}
438
+ rows={shareLinks}
439
+ rowKey={(l) => l.id}
440
+ empty={
441
+
442
+ <PageEmpty label="暂无分享链接" hint="分享链接可让用户自助加入工作区;点击右上角“创建分享链接”生成兑换码" />
443
+ }
444
+ pagination={{
445
+ page: linkPage,
446
+ pageSize: LIST_PAGE_SIZE,
447
+ total: shareLinksTotal,
448
+ onPageChange: (p) => setPageParam('lpage', p),
449
+ }}
450
+ />
451
+ )}
452
+ </TabsContent>
453
+ </Tabs>
454
+
455
+ {/* 邀请对话框 */}
456
+ <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
457
+ <DialogContent>
458
+ <DialogHeader>
459
+ <DialogTitle>邀请成员</DialogTitle>
460
+ <DialogDescription>向对方邮箱发送加入本工作区的邀请。</DialogDescription>
461
+ </DialogHeader>
462
+ {/* B7:noValidate 关掉浏览器原生校验气泡——错误文案唯一出口仍是下方
463
+ zod(inviteSchema 兜底保留:字段级中文文案 + aria 关联不随浏览器
464
+ 语言漂移,jsdom/真实浏览器行为一致)。 */}
465
+ <form className="space-y-4" onSubmit={submitInvite} noValidate>
466
+ <FormField label="邮箱" error={inviteForm.formState.errors.email?.message} htmlFor="invite-email">
467
+ {/* B7:语义 type="email"——移动端唤出邮箱键盘、桌面端自动填充可识别;
468
+ 输入法兼容:仅影响软键盘倾向 ASCII 布局,中文输入法粘贴/切英文
469
+ 输入域名段不受影响;校验仍由 zod 统一裁决(见上 noValidate)。 */}
470
+ <Input id="invite-email" type="email" autoComplete="email" placeholder="name@example.com" {...inviteForm.register('email')} />
471
+ </FormField>
472
+ <FormField label="角色" error={inviteForm.formState.errors.role?.message}>
473
+ <Select
474
+ value={inviteForm.watch('role')}
475
+ onValueChange={(v) => typeof v === 'string' && inviteForm.setValue('role', v as 'admin' | 'member')}
476
+ >
477
+ <SelectTrigger className="w-full" aria-label="邀请角色">
478
+ <SelectValue>{ROLE_LABEL[inviteForm.watch('role')]}</SelectValue>
479
+ </SelectTrigger>
480
+ <SelectContent>
481
+ {ASSIGNABLE_ROLES.map((r) => (
482
+ <SelectItem key={r} value={r}>{ROLE_LABEL[r]}</SelectItem>
483
+ ))}
484
+ </SelectContent>
485
+ </Select>
486
+ </FormField>
487
+ <DialogFooter>
488
+ <Button type="button" variant="outline" onClick={() => setInviteOpen(false)}>取消</Button>
489
+ <Button type="submit" disabled={mut.invite.isPending}>
490
+ {mut.invite.isPending ? '发送中…' : '发送邀请'}
491
+ </Button>
492
+ </DialogFooter>
493
+ </form>
494
+ </DialogContent>
495
+ </Dialog>
496
+
497
+ {/* 创建分享链接对话框 */}
498
+ <Dialog open={shareOpen} onOpenChange={setShareOpen}>
499
+ <DialogContent>
500
+ <DialogHeader>
501
+ <DialogTitle>创建分享链接</DialogTitle>
502
+ <DialogDescription>
503
+ 生成可分享的兑换码;持有者可按所选角色自助加入工作区。完整兑换码仅创建时展示一次。
504
+ </DialogDescription>
505
+ </DialogHeader>
506
+ <form className="space-y-4" onSubmit={submitLink}>
507
+ <FormField label="授予角色" error={linkForm.formState.errors.role?.message}>
508
+ <Select
509
+ value={linkForm.watch('role')}
510
+ onValueChange={(v) => typeof v === 'string' && linkForm.setValue('role', v as 'admin' | 'member')}
511
+ >
512
+ <SelectTrigger className="w-full" aria-label="分享链接角色">
513
+ <SelectValue>{ROLE_LABEL[linkForm.watch('role')]}</SelectValue>
514
+ </SelectTrigger>
515
+ <SelectContent>
516
+ {ASSIGNABLE_ROLES.map((r) => (
517
+ <SelectItem key={r} value={r}>{ROLE_LABEL[r]}</SelectItem>
518
+ ))}
519
+ </SelectContent>
520
+ </Select>
521
+ </FormField>
522
+ <FormField label="最大兑换次数" error={linkForm.formState.errors.max_uses?.message} htmlFor="link-max">
523
+ <Input id="link-max" type="number" min={1} {...linkForm.register('max_uses', { valueAsNumber: true })} />
524
+ </FormField>
525
+ <FormField label="有效期(小时)" error={linkForm.formState.errors.ttl_hours?.message} htmlFor="link-ttl">
526
+ <Input id="link-ttl" type="number" min={1} {...linkForm.register('ttl_hours', { valueAsNumber: true })} />
527
+ </FormField>
528
+ <DialogFooter>
529
+ <Button type="button" variant="outline" onClick={() => setShareOpen(false)}>取消</Button>
530
+ <Button type="submit" disabled={mut.createShareLink.isPending}>
531
+ {mut.createShareLink.isPending ? '创建中…' : '创建'}
532
+ </Button>
533
+ </DialogFooter>
534
+ </form>
535
+ </DialogContent>
536
+ </Dialog>
537
+
538
+ {/* 一次性兑换码 */}
539
+ <SecretModal
540
+ open={secret !== null}
541
+ onOpenChange={(o) => !o && setSecret(null)}
542
+ secretName="分享链接兑换码"
543
+ secretValue={secret ?? ''}
544
+ />
545
+ {transferTarget && (
546
+ <ImpactConfirmation
547
+ open
548
+ onOpenChange={(o) => {
549
+ if (!o) setTransferTarget(null)
550
+ }}
551
+ onConfirm={() => void onTransfer()}
552
+ title={`转让所有权给 ${transferTarget.email}`}
553
+ confirmText="确认转让"
554
+ danger
555
+ impacts={[
556
+ `${transferTarget.email} 将成为新 owner(获得工作区最高权与计费控制权)`,
557
+ '你将降为 member(普通成员权限;转让单事务原子,失败不产生中间态)',
558
+ '目标须是本工作区活跃成员且账号未禁用;钩子拦截/非法目标返回 409',
559
+ ]}
560
+ />
561
+ )}
562
+ </div>
563
+ )
564
+ }
565
+
566
+ function isConflict(e: unknown): boolean {
567
+ return typeof e === 'object' && e !== null && 'status' in e && (e as { status?: number }).status === 409
568
+ }