@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,551 @@
1
+
2
+ import { useState } from 'react';
3
+ import { Link, useNavigate, useParams } from 'react-router';
4
+ import { ArrowLeft } from 'lucide-react';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import type { components } from '@ploykit/client';
7
+ import { useConfirm } from '../../components/ConfirmDialog';
8
+ import { DataTable, type Column } from '../../components/DataTable';
9
+ import { FormField } from '../../components/FormField';
10
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
11
+ import { ImpactConfirmation } from '../../components/SecretModal';
12
+ import { SettingsCard } from '../../components/SettingsCard';
13
+ import { toast } from '../../components/toast';
14
+ import { Badge } from '../../components/ui/badge'
15
+ import { StatusBadge } from '../../components/StatusBadge';
16
+ import { Button } from '../../components/ui/button';
17
+ import { Input } from '../../components/ui/input';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '../../components/ui/select';
25
+ import { useApi } from '@ploykit/hooks';
26
+ import { useAuth } from '@ploykit/hooks';
27
+ import { apiErrorMessage } from '../../lib/api-error';
28
+ import { formatDate, formatDateTime, shortID } from '../../lib/utils';
29
+ import { adminError } from './shared';
30
+
31
+ type AdminWorkspace = components['schemas']['AdminWorkspace'];
32
+
33
+
34
+ interface WsMemberRow {
35
+ user_id: string;
36
+ email: string;
37
+ display_name: string;
38
+ role: string;
39
+ created_at: string;
40
+ }
41
+
42
+
43
+ interface WsDetail extends AdminWorkspace {
44
+ members: WsMemberRow[];
45
+ }
46
+
47
+ const ROLE_OPTIONS = ['owner', 'admin', 'member'];
48
+
49
+
50
+ const QUOTA_KEY_HINT = '如 tasks_monthly';
51
+
52
+ export function WorkspaceDetailPage() {
53
+ const { id = '' } = useParams();
54
+ const api = useApi();
55
+ const qc = useQueryClient();
56
+ const confirm = useConfirm();
57
+ const navigate = useNavigate();
58
+ const { user } = useAuth();
59
+ const [confirmSlug, setConfirmSlug] = useState('');
60
+ const [deleteOpen, setDeleteOpen] = useState(false);
61
+
62
+ const [cancelSubOpen, setCancelSubOpen] = useState(false);
63
+
64
+ const [transferTarget, setTransferTarget] = useState<WsMemberRow | null>(
65
+ null,
66
+ );
67
+
68
+ const [quotaKey, setQuotaKey] = useState('');
69
+ const [quotaReason, setQuotaReason] = useState('');
70
+ const [quotaRef, setQuotaRef] = useState('');
71
+ const [quotaAmount, setQuotaAmount] = useState('');
72
+
73
+ const detailQ = useQuery({
74
+ queryKey: ['admin', 'workspace', id],
75
+ queryFn: () => api.get<WsDetail>(`/api/admin/workspaces/${id}`),
76
+ enabled: !!id,
77
+ });
78
+
79
+ const invalidate = () => {
80
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspace', id] });
81
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspaces'] });
82
+ };
83
+
84
+ const roleM = useMutation({
85
+ mutationFn: (v: { uid: string; email: string; role: string }) =>
86
+ api.patch(`/api/admin/workspaces/${id}/members/${v.uid}`, {
87
+ role: v.role,
88
+ }),
89
+ onSuccess: (_d, v) => {
90
+ toast.success(`${v.email} 角色已变更为 ${v.role}`);
91
+ invalidate();
92
+ },
93
+ onError: (e) => toast.error(apiErrorMessage(e, '角色变更失败')),
94
+ });
95
+
96
+ const removeM = useMutation({
97
+ mutationFn: (v: { uid: string; email: string }) =>
98
+ api.delete(`/api/admin/workspaces/${id}/members/${v.uid}`),
99
+ onSuccess: (_d, v) => {
100
+ toast.success(`${v.email} 已移出工作区`);
101
+ invalidate();
102
+ },
103
+ onError: (e) => toast.error(apiErrorMessage(e, '移除成员失败')),
104
+ });
105
+
106
+
107
+ const grantM = useMutation({
108
+ mutationFn: (v: {
109
+ key: string;
110
+ reason: string;
111
+ ref: string;
112
+ amount: number;
113
+ }) =>
114
+ api.post(`/api/admin/workspaces/${id}/quota/grant`, {
115
+ key: v.key,
116
+ reason: v.reason,
117
+ ref: v.ref,
118
+ amount: v.amount,
119
+ }),
120
+ onSuccess: () => {
121
+ toast.success('配额已授予(加成额度立即生效)');
122
+ setQuotaKey('');
123
+ setQuotaReason('');
124
+ setQuotaRef('');
125
+ setQuotaAmount('');
126
+ invalidate();
127
+ },
128
+ onError: (e) => toast.error(apiErrorMessage(e, '配额授予失败')),
129
+ });
130
+
131
+
132
+ const deleteM = useMutation({
133
+ mutationFn: (slug: string) =>
134
+ api.delete(
135
+ `/api/admin/workspaces/${id}?confirm=${encodeURIComponent(slug)}`,
136
+ ),
137
+ onSuccess: () => {
138
+ toast.success('工作区已删除');
139
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspaces'] });
140
+ navigate('/admin/workspaces');
141
+ },
142
+ onError: (e) => toast.error(apiErrorMessage(e, '删除失败')),
143
+ });
144
+
145
+
146
+
147
+ const cancelSubM = useMutation({
148
+ mutationFn: () =>
149
+ api.post(`/api/admin/workspaces/${id}/cancel-subscription`),
150
+ onSuccess: () => {
151
+ toast.success('订阅已取消,工作区已降级为 free');
152
+ setCancelSubOpen(false);
153
+ invalidate();
154
+ },
155
+ onError: (e) => toast.error(apiErrorMessage(e, '取消订阅失败')),
156
+ });
157
+
158
+
159
+
160
+ const transferM = useMutation({
161
+ mutationFn: (m: WsMemberRow) =>
162
+ api.post(`/api/admin/workspaces/${id}/transfer-ownership`, {
163
+ user_id: m.user_id,
164
+ }),
165
+ onSuccess: (_d, m) => {
166
+ toast.success(`所有权已转让给 ${m.email}(原 owner 已降为 member)`);
167
+ setTransferTarget(null);
168
+ invalidate();
169
+ },
170
+ onError: (e) => toast.error(apiErrorMessage(e, '所有权转让失败')),
171
+ });
172
+
173
+ const onGrant = () => {
174
+ const amount = Number.parseInt(quotaAmount, 10);
175
+ if (
176
+ !quotaKey.trim() ||
177
+ !quotaReason.trim() ||
178
+ !Number.isInteger(amount) ||
179
+ amount <= 0
180
+ ) {
181
+ toast.error('请填写配额键、原因与正整数额度');
182
+ return;
183
+ }
184
+ grantM.mutate({
185
+ key: quotaKey.trim(),
186
+ reason: quotaReason.trim(),
187
+ ref: quotaRef.trim(),
188
+ amount,
189
+ });
190
+ };
191
+
192
+ const onRemoveMember = async (m: WsMemberRow) => {
193
+ const ok = await confirm({
194
+ title: '移除成员',
195
+ description: `将把 ${m.email} 移出本工作区(${m.role})。最后一个 owner 受保护,无法移除。`,
196
+ confirmText: '移除',
197
+ danger: true,
198
+ });
199
+ if (ok) removeM.mutate({ uid: m.user_id, email: m.email });
200
+ };
201
+
202
+ if (detailQ.isError) {
203
+ return (
204
+ <div>
205
+ <PageHeader title="工作区详情" />
206
+ <PageError
207
+ message={adminError(detailQ.error, '工作区详情加载失败')}
208
+ onRetry={() => void detailQ.refetch()}
209
+ />
210
+ </div>
211
+ );
212
+ }
213
+
214
+ const ws = detailQ.data;
215
+ const slugReadyToDelete = !!ws && confirmSlug.trim() === ws.slug;
216
+
217
+ const memberColumns: Column<WsMemberRow>[] = [
218
+ {
219
+ key: 'email',
220
+ header: '成员',
221
+ render: (m) => (
222
+ <div className="min-w-0">
223
+ <p className="truncate font-medium">{m.email}</p>
224
+ <p className="truncate text-xs text-muted-foreground">
225
+ {m.display_name || '—'}
226
+ </p>
227
+ </div>
228
+ ),
229
+ },
230
+ {
231
+ key: 'role',
232
+ header: '角色',
233
+ className: 'w-40',
234
+ render: (m) => {
235
+ const mutating = roleM.isPending && roleM.variables?.uid === m.user_id;
236
+ return (
237
+ <Select
238
+ value={ROLE_OPTIONS.includes(m.role) ? m.role : 'member'}
239
+ onValueChange={(v) => {
240
+ if (typeof v === 'string' && v !== m.role)
241
+ roleM.mutate({ uid: m.user_id, email: m.email, role: v });
242
+ }}
243
+ >
244
+ <SelectTrigger
245
+ size="sm"
246
+ disabled={mutating}
247
+ aria-label={`变更 ${m.email} 角色`}
248
+ >
249
+ <SelectValue>{m.role}</SelectValue>
250
+ </SelectTrigger>
251
+ <SelectContent align="start">
252
+ {ROLE_OPTIONS.map((r) => (
253
+ <SelectItem key={r} value={r}>
254
+ {r}
255
+ </SelectItem>
256
+ ))}
257
+ </SelectContent>
258
+ </Select>
259
+ );
260
+ },
261
+ },
262
+ {
263
+ key: 'created_at',
264
+ header: '加入时间',
265
+ className: 'w-28',
266
+ render: (m) => (
267
+ <span className="whitespace-nowrap text-muted-foreground">
268
+ {formatDate(m.created_at)}
269
+ </span>
270
+ ),
271
+ },
272
+ {
273
+ key: 'actions',
274
+ header: '操作',
275
+ className: 'w-44',
276
+ render: (m) => {
277
+
278
+
279
+ const canTransfer =
280
+ (m.role === 'member' || m.role === 'admin') && m.user_id !== user?.id;
281
+ return (
282
+ <div className="flex items-center justify-end gap-1.5">
283
+ {canTransfer && (
284
+ <Button
285
+ variant="outline"
286
+ size="sm"
287
+ disabled={transferM.isPending}
288
+ title="目标升为 owner,当前 owner 降为 member(单事务原子)"
289
+ onClick={() => setTransferTarget(m)}
290
+ >
291
+ 转让所有权
292
+ </Button>
293
+ )}
294
+ <Button
295
+ variant="outline"
296
+ size="sm"
297
+ disabled={removeM.isPending}
298
+ onClick={() => void onRemoveMember(m)}
299
+ >
300
+ 移除
301
+ </Button>
302
+ </div>
303
+ );
304
+ },
305
+ },
306
+ ];
307
+
308
+ return (
309
+ <div className="space-y-6">
310
+ <PageHeader
311
+ title={ws ? ws.name : '工作区详情'}
312
+ description={
313
+ ws
314
+ ? `${ws.slug} · 工作区 ID ${shortID(ws.id)}`
315
+ : '基础信息 + 成员表 + 配额调整'
316
+ }
317
+ actions={
318
+ <Link
319
+ to="/admin/workspaces"
320
+ className="text-sm text-muted-foreground transition-colors hover:text-foreground"
321
+ >
322
+ <ArrowLeft size={15} className="mr-1 inline" aria-hidden="true" />
323
+ 返回工作区列表
324
+ </Link>
325
+ }
326
+ />
327
+
328
+ {detailQ.isPending && (
329
+ <p className="text-sm text-muted-foreground">加载工作区详情中…</p>
330
+ )}
331
+
332
+ {ws && (
333
+ <>
334
+ <SettingsCard
335
+ title="基础信息"
336
+ description="工作区档案(行内套餐变更在工作区列表页)"
337
+ >
338
+ <div className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
339
+ <p>
340
+ <span className="text-muted-foreground">标识(slug):</span>
341
+ <span className="font-mono">{ws.slug}</span>
342
+ </p>
343
+ <p className="flex items-center gap-2">
344
+ <span className="text-muted-foreground">套餐:</span>
345
+ <Badge variant="outline">{ws.plan_code}</Badge>
346
+ </p>
347
+ <p>
348
+ <span className="text-muted-foreground">成员数:</span>
349
+ <span className="tabular-nums">{ws.member_count}</span>
350
+ </p>
351
+ <p>
352
+ <span className="text-muted-foreground">创建时间:</span>
353
+ {formatDateTime(ws.created_at)}
354
+ </p>
355
+ </div>
356
+ </SettingsCard>
357
+
358
+ <SettingsCard
359
+ title="订阅"
360
+ description="计费区:当前套餐与订阅取消(渠道侧先取消,成功后立即降级 free;不发退款——退款属后续批次)"
361
+ cta={
362
+ <Button
363
+ variant="destructive"
364
+ disabled={ws.plan_code === 'free' || cancelSubM.isPending}
365
+ title={
366
+ ws.plan_code === 'free'
367
+ ? '当前已是 free,无可取消订阅'
368
+ : undefined
369
+ }
370
+ onClick={() => setCancelSubOpen(true)}
371
+ >
372
+ {cancelSubM.isPending ? '取消中…' : '取消订阅'}
373
+ </Button>
374
+ }
375
+ >
376
+ <div className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
377
+ <p className="flex items-center gap-2">
378
+ <span className="text-muted-foreground">当前套餐:</span>
379
+ <Badge variant="outline">{ws.plan_code}</Badge>
380
+ </p>
381
+ <p className="flex items-center gap-2">
382
+ <span className="text-muted-foreground">订阅状态:</span>
383
+ {ws.plan_code === 'free' ? (
384
+ <Badge variant="outline" className="text-muted-foreground">
385
+ 未订阅
386
+ </Badge>
387
+ ) : (
388
+ <Badge variant="outline">订阅中</Badge>
389
+ )}
390
+ </p>
391
+ </div>
392
+ </SettingsCard>
393
+
394
+ <SettingsCard
395
+ title={`成员(${ws.members.length})`}
396
+ description="行内改角色、转让所有权或移除;last-owner 保护由服务端兜底(409)"
397
+ bodyClassName="p-0"
398
+ >
399
+ <div className="px-6 pb-6">
400
+ <DataTable
401
+ columns={memberColumns}
402
+ rows={ws.members}
403
+ rowKey={(m) => m.user_id}
404
+ loading={detailQ.isPending}
405
+ empty={<PageEmpty label="暂无成员" />}
406
+ />
407
+ </div>
408
+ </SettingsCard>
409
+
410
+ <SettingsCard
411
+ title="配额调整"
412
+ description="手动授予加成额度(quota.Grant:同 键+原因+引用 幂等一次,正数加成)"
413
+ cta={
414
+ <Button onClick={onGrant} disabled={grantM.isPending}>
415
+ {grantM.isPending ? '授予中…' : '授予配额'}
416
+ </Button>
417
+ }
418
+ >
419
+ <div className="grid max-w-3xl gap-4 sm:grid-cols-2">
420
+ <FormField label="配额键(key)" htmlFor="quota-key">
421
+ <Input
422
+ id="quota-key"
423
+ className="font-mono"
424
+ placeholder={QUOTA_KEY_HINT}
425
+ value={quotaKey}
426
+ onChange={(e) => setQuotaKey(e.target.value)}
427
+ />
428
+ </FormField>
429
+ <FormField label="额度(amount,正整数)" htmlFor="quota-amount">
430
+ <Input
431
+ id="quota-amount"
432
+ type="number"
433
+ min={1}
434
+ step={1}
435
+ placeholder="如 100"
436
+ value={quotaAmount}
437
+ onChange={(e) => setQuotaAmount(e.target.value)}
438
+ />
439
+ </FormField>
440
+ <FormField label="原因(reason,必填)" htmlFor="quota-reason">
441
+ <Input
442
+ id="quota-reason"
443
+ placeholder="如 客户补偿 / 活动赠送"
444
+ value={quotaReason}
445
+ onChange={(e) => setQuotaReason(e.target.value)}
446
+ />
447
+ </FormField>
448
+ <FormField
449
+ label="引用(ref,可选;参与幂等键)"
450
+ htmlFor="quota-ref"
451
+ >
452
+ <Input
453
+ id="quota-ref"
454
+ className="font-mono"
455
+ placeholder="如 ticket-1234"
456
+ value={quotaRef}
457
+ onChange={(e) => setQuotaRef(e.target.value)}
458
+ />
459
+ </FormField>
460
+ </div>
461
+ </SettingsCard>
462
+
463
+ <SettingsCard
464
+ danger
465
+ title="删除工作区"
466
+ description="不可逆级联删除(成员/资源随工作区销毁,走 BeforeDelete 钩子)"
467
+ cta={
468
+ <Button
469
+ variant="destructive"
470
+ disabled={!slugReadyToDelete || deleteM.isPending}
471
+ onClick={() => setDeleteOpen(true)}
472
+ >
473
+ {deleteM.isPending ? '删除中…' : '删除工作区'}
474
+ </Button>
475
+ }
476
+ >
477
+ <div className="max-w-xl space-y-2">
478
+ <p className="text-sm text-muted-foreground">
479
+ 输入工作区 slug {ws.slug} 以确认删除。
480
+ </p>
481
+ <Input
482
+ placeholder={ws.slug}
483
+ value={confirmSlug}
484
+ onChange={(e) => setConfirmSlug(e.target.value)}
485
+ aria-label="输入工作区 slug 以确认删除"
486
+ />
487
+ </div>
488
+ </SettingsCard>
489
+
490
+ {/* 删除工作区:Input slug 确认(按钮门控)+ ImpactConfirmation 双层确认 */}
491
+ <ImpactConfirmation
492
+ open={deleteOpen}
493
+ onOpenChange={setDeleteOpen}
494
+ onConfirm={() => deleteM.mutate(ws.slug)}
495
+ title={`删除工作区 ${ws.name}`}
496
+ confirmText="永久删除"
497
+ danger
498
+ impacts={[
499
+ '工作区及其全部业务数据被级联删除,不可恢复',
500
+ '全部成员关系解除,成员不再能访问该工作区',
501
+ 'BeforeDelete 钩子可阻断删除(409),操作记入平台审计日志',
502
+ ]}
503
+ />
504
+
505
+ {/* 取消订阅(B1):ImpactConfirmation 二次确认;渠道取消失败时本地状态不动 */}
506
+ <ImpactConfirmation
507
+ open={cancelSubOpen}
508
+ onOpenChange={setCancelSubOpen}
509
+ onConfirm={() => cancelSubM.mutate()}
510
+ title={`取消工作区 ${ws.name} 的订阅`}
511
+ confirmText="立即取消订阅"
512
+ danger
513
+ impacts={[
514
+ '工作区立即降级为 free 套餐,配额随之收缩',
515
+ '渠道侧订阅同步取消(Stripe 停止后续扣款);渠道取消失败时本地状态保持不变',
516
+ '本操作不发退款(退款属后续批次),操作记入平台审计日志',
517
+ ]}
518
+ />
519
+
520
+ {/* 转让所有权(C 批次):ImpactConfirmation 列明降级影响——原 owner 降为
521
+ member;转让单事务原子,工作区始终保有 owner(不可造出零 owner 区) */}
522
+ {transferTarget && (
523
+ <ImpactConfirmation
524
+ open
525
+ onOpenChange={(o) => {
526
+ if (!o) setTransferTarget(null);
527
+ }}
528
+ onConfirm={() => transferM.mutate(transferTarget)}
529
+ title={`转让 ${ws.name} 的所有权给 ${transferTarget.email}`}
530
+ confirmText="确认转让"
531
+ impacts={[
532
+ ws.members.filter((m) => m.role === 'owner').length > 1
533
+ ? `当前 owner(${ws.members
534
+ .filter((m) => m.role === 'owner')
535
+ .map((m) => m.email)
536
+ .join(
537
+ '、',
538
+ )})中最早加入者将降为 member,不再保有工作区最高权`
539
+ : `当前 owner ${
540
+ ws.members.find((m) => m.role === 'owner')?.email ?? '—'
541
+ } 将降为 member,不再保有工作区最高权`,
542
+ `${transferTarget.email} 将成为新 owner(单事务原子完成,工作区始终保有 owner)`,
543
+ '目标须是本工作区活跃成员;BeforeOwnerChange 钩子可阻断(409),操作记入平台审计日志',
544
+ ]}
545
+ />
546
+ )}
547
+ </>
548
+ )}
549
+ </div>
550
+ );
551
+ }