@ploykit/ui 0.0.0-stage → 0.1.1

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 (196) hide show
  1. package/README.md +40 -2
  2. package/dist/components/ConfirmDialog.d.ts +13 -0
  3. package/dist/components/ConfirmDialog.js +29 -0
  4. package/dist/components/CronInput.d.ts +15 -0
  5. package/dist/components/CronInput.js +112 -0
  6. package/dist/components/DataTable.d.ts +22 -0
  7. package/dist/components/DataTable.js +13 -0
  8. package/dist/components/ErrorBoundary.d.ts +16 -0
  9. package/dist/components/ErrorBoundary.js +24 -0
  10. package/dist/components/FormField.d.ts +10 -0
  11. package/dist/components/FormField.js +22 -0
  12. package/dist/components/ImpersonationBanner.d.ts +3 -0
  13. package/dist/components/ImpersonationBanner.js +10 -0
  14. package/dist/components/NotificationBell.d.ts +3 -0
  15. package/dist/components/NotificationBell.js +47 -0
  16. package/dist/components/OnboardingChecklist.d.ts +12 -0
  17. package/dist/components/OnboardingChecklist.js +43 -0
  18. package/dist/components/Page.d.ts +17 -0
  19. package/dist/components/Page.js +16 -0
  20. package/dist/components/SecretModal.d.ts +16 -0
  21. package/dist/components/SecretModal.js +45 -0
  22. package/dist/components/SettingsCard.d.ts +10 -0
  23. package/dist/components/SettingsCard.js +6 -0
  24. package/dist/components/SiteBanner.d.ts +6 -0
  25. package/dist/components/SiteBanner.js +11 -0
  26. package/dist/components/SiteBannerLayer.d.ts +4 -0
  27. package/dist/components/SiteBannerLayer.js +9 -0
  28. package/dist/components/UserMenu.d.ts +13 -0
  29. package/dist/components/UserMenu.js +30 -0
  30. package/dist/components/WorkspaceSwitcher.d.ts +1 -0
  31. package/dist/components/WorkspaceSwitcher.js +53 -0
  32. package/dist/components/toast.d.ts +7 -0
  33. package/dist/components/toast.js +11 -0
  34. package/dist/components/ui/Badge.d.ts +13 -0
  35. package/dist/components/ui/Badge.js +21 -0
  36. package/dist/components/ui/Button.d.ts +10 -0
  37. package/dist/components/ui/Button.js +33 -0
  38. package/dist/components/ui/Input.d.ts +3 -0
  39. package/dist/components/ui/Input.js +10 -0
  40. package/dist/components/ui/alert-dialog.d.ts +18 -0
  41. package/dist/components/ui/alert-dialog.js +41 -0
  42. package/dist/components/ui/avatar.d.ts +11 -0
  43. package/dist/components/ui/avatar.js +22 -0
  44. package/dist/components/ui/breadcrumb.d.ts +10 -0
  45. package/dist/components/ui/breadcrumb.js +36 -0
  46. package/dist/components/ui/card.d.ts +13 -0
  47. package/dist/components/ui/card.js +24 -0
  48. package/dist/components/ui/checkbox.d.ts +3 -0
  49. package/dist/components/ui/checkbox.js +8 -0
  50. package/dist/components/ui/dialog.d.ts +15 -0
  51. package/dist/components/ui/dialog.js +26 -0
  52. package/dist/components/ui/empty.d.ts +13 -0
  53. package/dist/components/ui/empty.js +36 -0
  54. package/dist/components/ui/field.d.ts +24 -0
  55. package/dist/components/ui/field.js +67 -0
  56. package/dist/components/ui/label.d.ts +3 -0
  57. package/dist/components/ui/label.js +6 -0
  58. package/dist/components/ui/menu.d.ts +29 -0
  59. package/dist/components/ui/menu.js +50 -0
  60. package/dist/components/ui/popover.d.ts +9 -0
  61. package/dist/components/ui/popover.js +22 -0
  62. package/dist/components/ui/progress.d.ts +7 -0
  63. package/dist/components/ui/progress.js +19 -0
  64. package/dist/components/ui/select.d.ts +15 -0
  65. package/dist/components/ui/select.js +33 -0
  66. package/dist/components/ui/separator.d.ts +3 -0
  67. package/dist/components/ui/separator.js +7 -0
  68. package/dist/components/ui/sheet.d.ts +14 -0
  69. package/dist/components/ui/sheet.js +36 -0
  70. package/dist/components/ui/sidebar.d.ts +63 -0
  71. package/dist/components/ui/sidebar.js +265 -0
  72. package/dist/components/ui/skeleton.d.ts +3 -0
  73. package/dist/components/ui/skeleton.js +6 -0
  74. package/dist/components/ui/switch.d.ts +5 -0
  75. package/dist/components/ui/switch.js +7 -0
  76. package/dist/components/ui/tabs.d.ts +10 -0
  77. package/dist/components/ui/tabs.js +28 -0
  78. package/dist/components/ui/tooltip.d.ts +6 -0
  79. package/dist/components/ui/tooltip.js +16 -0
  80. package/dist/hooks/use-mobile.d.ts +1 -0
  81. package/dist/hooks/use-mobile.js +15 -0
  82. package/dist/hooks/useApi.d.ts +10 -0
  83. package/dist/hooks/useApi.js +11 -0
  84. package/dist/hooks/useAudit.d.ts +39 -0
  85. package/dist/hooks/useAudit.js +55 -0
  86. package/dist/hooks/useAuth.d.ts +1 -0
  87. package/dist/hooks/useAuth.js +4 -0
  88. package/dist/hooks/useBilling.d.ts +44 -0
  89. package/dist/hooks/useBilling.js +64 -0
  90. package/dist/hooks/useLogin.d.ts +10 -0
  91. package/dist/hooks/useLogin.js +39 -0
  92. package/dist/hooks/useMembers.d.ts +125 -0
  93. package/dist/hooks/useMembers.js +127 -0
  94. package/dist/hooks/useNotificationPrefs.d.ts +20 -0
  95. package/dist/hooks/useNotificationPrefs.js +42 -0
  96. package/dist/hooks/usePasswordReset.d.ts +5 -0
  97. package/dist/hooks/usePasswordReset.js +12 -0
  98. package/dist/hooks/useSEO.d.ts +6 -0
  99. package/dist/hooks/useSEO.js +14 -0
  100. package/dist/hooks/useSessions.d.ts +20 -0
  101. package/dist/hooks/useSessions.js +38 -0
  102. package/dist/hooks/useSiteConfig.d.ts +12 -0
  103. package/dist/hooks/useSiteConfig.js +11 -0
  104. package/dist/hooks/useTokens.d.ts +36 -0
  105. package/dist/hooks/useTokens.js +35 -0
  106. package/dist/hooks/useUsage.d.ts +20 -0
  107. package/dist/hooks/useUsage.js +21 -0
  108. package/dist/hooks/useWorkspace.d.ts +26 -0
  109. package/dist/hooks/useWorkspace.js +32 -0
  110. package/dist/hooks/useZodForm.d.ts +6 -0
  111. package/dist/hooks/useZodForm.js +6 -0
  112. package/dist/hooks/ws.d.ts +4 -0
  113. package/dist/hooks/ws.js +21 -0
  114. package/dist/index.d.ts +124 -0
  115. package/dist/index.js +94 -0
  116. package/dist/layouts/AppShell.d.ts +20 -0
  117. package/dist/layouts/AppShell.js +21 -0
  118. package/dist/layouts/AuthShell.d.ts +7 -0
  119. package/dist/layouts/AuthShell.js +4 -0
  120. package/dist/layouts/SettingsShell.d.ts +19 -0
  121. package/dist/layouts/SettingsShell.js +21 -0
  122. package/dist/lib/api-error.d.ts +1 -0
  123. package/dist/lib/api-error.js +74 -0
  124. package/dist/lib/perm.d.ts +2 -0
  125. package/dist/lib/perm.js +7 -0
  126. package/dist/lib/utils.d.ts +12 -0
  127. package/dist/lib/utils.js +84 -0
  128. package/dist/lib/ws-query-bridge.d.ts +7 -0
  129. package/dist/lib/ws-query-bridge.js +35 -0
  130. package/dist/pages/LandingPage.d.ts +22 -0
  131. package/dist/pages/LandingPage.js +5 -0
  132. package/dist/pages/LoginPage.d.ts +15 -0
  133. package/dist/pages/LoginPage.js +87 -0
  134. package/dist/pages/account/NotificationPreferencesPage.d.ts +13 -0
  135. package/dist/pages/account/NotificationPreferencesPage.js +41 -0
  136. package/dist/pages/account/ProfileSettingsPage.d.ts +4 -0
  137. package/dist/pages/account/ProfileSettingsPage.js +96 -0
  138. package/dist/pages/account/SecuritySettingsPage.d.ts +1 -0
  139. package/dist/pages/account/SecuritySettingsPage.js +134 -0
  140. package/dist/pages/account/TokensPage.d.ts +1 -0
  141. package/dist/pages/account/TokensPage.js +117 -0
  142. package/dist/pages/admin/AdminSettingsPage.d.ts +1 -0
  143. package/dist/pages/admin/AdminSettingsPage.js +210 -0
  144. package/dist/pages/admin/AdminShell.d.ts +7 -0
  145. package/dist/pages/admin/AdminShell.js +79 -0
  146. package/dist/pages/admin/AuditPage.d.ts +1 -0
  147. package/dist/pages/admin/AuditPage.js +164 -0
  148. package/dist/pages/admin/OrdersPage.d.ts +1 -0
  149. package/dist/pages/admin/OrdersPage.js +236 -0
  150. package/dist/pages/admin/OverviewPage.d.ts +1 -0
  151. package/dist/pages/admin/OverviewPage.js +200 -0
  152. package/dist/pages/admin/SSOPage.d.ts +1 -0
  153. package/dist/pages/admin/SSOPage.js +53 -0
  154. package/dist/pages/admin/UserDetailPage.d.ts +1 -0
  155. package/dist/pages/admin/UserDetailPage.js +266 -0
  156. package/dist/pages/admin/UsersPage.d.ts +4 -0
  157. package/dist/pages/admin/UsersPage.js +201 -0
  158. package/dist/pages/admin/WebhookDeliveriesPage.d.ts +1 -0
  159. package/dist/pages/admin/WebhookDeliveriesPage.js +128 -0
  160. package/dist/pages/admin/WorkspaceDetailPage.d.ts +1 -0
  161. package/dist/pages/admin/WorkspaceDetailPage.js +202 -0
  162. package/dist/pages/admin/WorkspacesPage.d.ts +1 -0
  163. package/dist/pages/admin/WorkspacesPage.js +133 -0
  164. package/dist/pages/admin/shared.d.ts +8 -0
  165. package/dist/pages/admin/shared.js +23 -0
  166. package/dist/pages/auth/ForgotPasswordPage.d.ts +6 -0
  167. package/dist/pages/auth/ForgotPasswordPage.js +32 -0
  168. package/dist/pages/auth/InviteAcceptPage.d.ts +5 -0
  169. package/dist/pages/auth/InviteAcceptPage.js +40 -0
  170. package/dist/pages/auth/RegisterPage.d.ts +15 -0
  171. package/dist/pages/auth/RegisterPage.js +80 -0
  172. package/dist/pages/auth/ResetPasswordPage.d.ts +4 -0
  173. package/dist/pages/auth/ResetPasswordPage.js +71 -0
  174. package/dist/pages/auth/VerifyEmailPage.d.ts +1 -0
  175. package/dist/pages/auth/VerifyEmailPage.js +45 -0
  176. package/dist/pages/workspace/BillingPage.d.ts +4 -0
  177. package/dist/pages/workspace/BillingPage.js +183 -0
  178. package/dist/pages/workspace/BillingSuccessPage.d.ts +4 -0
  179. package/dist/pages/workspace/BillingSuccessPage.js +72 -0
  180. package/dist/pages/workspace/MembersPage.d.ts +3 -0
  181. package/dist/pages/workspace/MembersPage.js +262 -0
  182. package/dist/pages/workspace/SchedulesPage.d.ts +4 -0
  183. package/dist/pages/workspace/SchedulesPage.js +177 -0
  184. package/dist/pages/workspace/UsagePage.d.ts +15 -0
  185. package/dist/pages/workspace/UsagePage.js +74 -0
  186. package/dist/pages/workspace/WebhooksPage.d.ts +3 -0
  187. package/dist/pages/workspace/WebhooksPage.js +242 -0
  188. package/dist/pages/workspace/WorkspaceAuditPage.d.ts +3 -0
  189. package/dist/pages/workspace/WorkspaceAuditPage.js +109 -0
  190. package/dist/pages/workspace/WorkspaceGeneralPage.d.ts +4 -0
  191. package/dist/pages/workspace/WorkspaceGeneralPage.js +101 -0
  192. package/dist/provider/PloykitProvider.d.ts +50 -0
  193. package/dist/provider/PloykitProvider.js +240 -0
  194. package/dist/realtime.d.ts +3 -0
  195. package/dist/realtime.js +11 -0
  196. package/package.json +61 -4
@@ -0,0 +1,266 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Link, useNavigate, useParams } from 'react-router';
4
+ import { ArrowLeft, Download } from 'lucide-react';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { apiFetch } from '@ploykit/client';
7
+ import { useConfirm } from '../../components/ConfirmDialog';
8
+ import { DataTable } from '../../components/DataTable';
9
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
10
+ import { ImpactConfirmation } from '../../components/SecretModal';
11
+ import { SettingsCard } from '../../components/SettingsCard';
12
+ import { toast } from '../../components/toast';
13
+ import { Badge, StatusBadge } from '../../components/ui/Badge';
14
+ import { Button } from '../../components/ui/Button';
15
+ import { Input } from '../../components/ui/Input';
16
+ import { useApi, ApiError } from '../../hooks/useApi';
17
+ import { useAuth } from '../../hooks/useAuth';
18
+ import { apiErrorMessage } from '../../lib/api-error';
19
+ import { formatDate, formatDateTime, shortID } from '../../lib/utils';
20
+ import { adminError } from './shared';
21
+ function resendErrorMessage(err) {
22
+ if (err instanceof ApiError) {
23
+ if (err.status === 409)
24
+ return '该用户邮箱已验证,无需重发';
25
+ if (err.status === 429)
26
+ return '发送冷却中,请约 1 分钟后再试';
27
+ }
28
+ return apiErrorMessage(err, '验证邮件发送失败');
29
+ }
30
+ export function UserDetailPage() {
31
+ const { id = '' } = useParams();
32
+ const api = useApi();
33
+ const qc = useQueryClient();
34
+ const confirm = useConfirm();
35
+ const navigate = useNavigate();
36
+ const me = useAuth().user;
37
+ const [confirmEmail, setConfirmEmail] = useState('');
38
+ const [deleteOpen, setDeleteOpen] = useState(false);
39
+ const [kickOpen, setKickOpen] = useState(false);
40
+ const [exporting, setExporting] = useState(false);
41
+ const detailQ = useQuery({
42
+ queryKey: ['admin', 'user', id],
43
+ queryFn: () => api.get(`/api/admin/users/${id}`),
44
+ enabled: !!id,
45
+ });
46
+ const invalidate = () => {
47
+ void qc.invalidateQueries({ queryKey: ['admin', 'user', id] });
48
+ void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
49
+ };
50
+ const kickM = useMutation({
51
+ mutationFn: () => api.post(`/api/admin/users/${id}/kick`),
52
+ onSuccess: () => {
53
+ toast.success('已吊销该用户全部在途会话');
54
+ invalidate();
55
+ },
56
+ onError: (e) => toast.error(apiErrorMessage(e, '踢下线失败')),
57
+ });
58
+ const revokeSessionM = useMutation({
59
+ mutationFn: (sid) => api.post(`/api/admin/users/${id}/sessions/${sid}/revoke`),
60
+ onSuccess: () => {
61
+ toast.success('会话已吊销');
62
+ invalidate();
63
+ },
64
+ onError: (e) => toast.error(apiErrorMessage(e, '会话吊销失败')),
65
+ });
66
+ const resetPwdM = useMutation({
67
+ mutationFn: () => api.post(`/api/admin/users/${id}/password-reset`),
68
+ onSuccess: () => toast.success('重置邮件已发送'),
69
+ onError: (e) => toast.error(apiErrorMessage(e, '重置邮件发送失败')),
70
+ });
71
+ const resendM = useMutation({
72
+ mutationFn: () => api.post(`/api/admin/users/${id}/resend-verification`),
73
+ onSuccess: () => toast.success('验证邮件已发送'),
74
+ onError: (e) => toast.error(resendErrorMessage(e)),
75
+ });
76
+ const markVerifiedM = useMutation({
77
+ mutationFn: () => api.post(`/api/admin/users/${id}/mark-verified`),
78
+ onSuccess: () => {
79
+ toast.success('已标记邮箱已验证');
80
+ invalidate();
81
+ },
82
+ onError: (e) => toast.error(apiErrorMessage(e, '标记失败')),
83
+ });
84
+ const revokePatM = useMutation({
85
+ mutationFn: (patID) => api.post(`/api/admin/users/${id}/pats/${patID}/revoke`),
86
+ onSuccess: () => {
87
+ toast.success('令牌已吊销');
88
+ invalidate();
89
+ },
90
+ onError: (e) => toast.error(apiErrorMessage(e, '令牌吊销失败')),
91
+ });
92
+ const deleteM = useMutation({
93
+ mutationFn: (email) => api.delete(`/api/admin/users/${id}?confirm=${encodeURIComponent(email)}`),
94
+ onSuccess: () => {
95
+ toast.success('账号已删除(软删)');
96
+ void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
97
+ navigate('/admin/users');
98
+ },
99
+ onError: (e) => toast.error(apiErrorMessage(e, '删除失败')),
100
+ });
101
+ const onExportData = async () => {
102
+ setExporting(true);
103
+ try {
104
+ const blob = await apiFetch(`/api/admin/users/${id}/export`, {
105
+ responseType: 'blob',
106
+ });
107
+ const url = URL.createObjectURL(blob);
108
+ const a = document.createElement('a');
109
+ a.href = url;
110
+ a.download = `userdata-${id}.json`;
111
+ a.click();
112
+ URL.revokeObjectURL(url);
113
+ toast.success('用户数据导出已开始下载');
114
+ }
115
+ catch (e) {
116
+ toast.error(apiErrorMessage(e, '导出失败'));
117
+ }
118
+ finally {
119
+ setExporting(false);
120
+ }
121
+ };
122
+ const onRevokeSession = async (s) => {
123
+ const ok = await confirm({
124
+ title: '吊销会话',
125
+ description: `将吊销该设备会话(${s.device || shortID(s.id)}),该设备需要重新登录。`,
126
+ confirmText: '吊销',
127
+ danger: true,
128
+ });
129
+ if (ok)
130
+ revokeSessionM.mutate(s.id);
131
+ };
132
+ const onRevokePat = async (p) => {
133
+ const ok = await confirm({
134
+ title: '吊销访问令牌',
135
+ description: `将吊销「${p.name}」,使用该令牌的集成立即失效。`,
136
+ confirmText: '吊销',
137
+ danger: true,
138
+ });
139
+ if (ok)
140
+ revokePatM.mutate(p.id);
141
+ };
142
+ if (detailQ.isError) {
143
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u7528\u6237\u8BE6\u60C5" }), _jsx(PageError, { message: adminError(detailQ.error, '用户详情加载失败'), onRetry: () => void detailQ.refetch() })] }));
144
+ }
145
+ const d = detailQ.data;
146
+ const u = d?.user;
147
+ const isSelf = !!u && me?.id === u.id;
148
+ const deleteBlockedReason = isSelf
149
+ ? '不能删除当前登录的管理员账号自己'
150
+ : u?.is_platform_admin
151
+ ? '不能删除另一位平台管理员(admin-on-admin 禁止)'
152
+ : '';
153
+ const emailReadyToDelete = !!u && confirmEmail.trim().toLowerCase() === u.email.toLowerCase();
154
+ const sessionColumns = [
155
+ {
156
+ key: 'device',
157
+ header: '设备',
158
+ render: (s) => (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate", children: s.device || '未知设备' }), _jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", title: s.ip_hash, children: s.ip_hash ? shortID(s.ip_hash) : '—' })] })),
159
+ },
160
+ {
161
+ key: 'last_seen_at',
162
+ header: '最近活跃',
163
+ className: 'w-44',
164
+ render: (s) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(s.last_seen_at) })),
165
+ },
166
+ {
167
+ key: 'expires_at',
168
+ header: '过期时间',
169
+ className: 'w-44',
170
+ render: (s) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(s.expires_at) })),
171
+ },
172
+ {
173
+ key: 'actions',
174
+ header: '操作',
175
+ className: 'w-20',
176
+ render: (s) => (_jsx(Button, { variant: "outline", size: "sm", disabled: revokeSessionM.isPending, onClick: () => void onRevokeSession(s), children: "\u540A\u9500" })),
177
+ },
178
+ ];
179
+ const patColumns = [
180
+ {
181
+ key: 'name',
182
+ header: '令牌',
183
+ render: (p) => (_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "truncate font-medium", children: [p.name, p.revoked_at && (_jsx(Badge, { variant: "outline", className: "ml-2 text-muted-foreground", children: "\u5DF2\u540A\u9500" }))] }), _jsxs("p", { className: "truncate font-mono text-xs text-muted-foreground", children: [p.prefix, "\u2026"] })] })),
184
+ },
185
+ {
186
+ key: 'created_at',
187
+ header: '创建时间',
188
+ className: 'w-28',
189
+ render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(p.created_at) })),
190
+ },
191
+ {
192
+ key: 'last_used_at',
193
+ header: '最近使用',
194
+ className: 'w-44',
195
+ render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: p.last_used_at ? formatDateTime(p.last_used_at) : '从未使用' })),
196
+ },
197
+ {
198
+ key: 'expires_at',
199
+ header: '过期时间',
200
+ className: 'w-28',
201
+ render: (p) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: p.expires_at ? formatDate(p.expires_at) : '永不过期' })),
202
+ },
203
+ {
204
+ key: 'actions',
205
+ header: '操作',
206
+ className: 'w-20',
207
+ render: (p) => p.revoked_at ? (_jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" })) : (_jsx(Button, { variant: "outline", size: "sm", disabled: revokePatM.isPending, onClick: () => void onRevokePat(p), children: "\u540A\u9500" })),
208
+ },
209
+ ];
210
+ const wsColumns = [
211
+ {
212
+ key: 'name',
213
+ header: '工作区',
214
+ render: (w) => (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium", children: w.name }), _jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", children: w.slug })] })),
215
+ },
216
+ {
217
+ key: 'role',
218
+ header: '角色',
219
+ className: 'w-24',
220
+ render: (w) => _jsx(StatusBadge, { status: w.role }),
221
+ },
222
+ {
223
+ key: 'created_at',
224
+ header: '加入时间',
225
+ className: 'w-28',
226
+ render: (w) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(w.created_at) })),
227
+ },
228
+ ];
229
+ return (_jsxs("div", { className: "space-y-6", children: [_jsx(PageHeader, { title: u ? u.email : '用户详情', description: u
230
+ ? `${u.display_name || '未设置昵称'} · 用户 ID ${shortID(u.id)}`
231
+ : '用户资料与会话 / 令牌 / 工作区聚合视图', actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", size: "sm", disabled: exporting || !u, onClick: () => void onExportData(), title: "GDPR \u7528\u6237\u6570\u636E\u5BFC\u51FA\uFF08\u8D44\u6599 / \u6210\u5458\u5173\u7CFB / \u5728\u9014\u4F1A\u8BDD\u5143\u6570\u636E / PAT \u5143\u6570\u636E / \u6700\u8FD1\u5BA1\u8BA1\u4E8B\u4EF6\uFF09", children: [_jsx(Download, {}), " ", exporting ? '导出中…' : '导出数据'] }), _jsxs(Link, { to: "/admin/users", className: "text-sm text-muted-foreground transition-colors hover:text-foreground", children: [_jsx(ArrowLeft, { size: 15, className: "mr-1 inline", "aria-hidden": "true" }), "\u8FD4\u56DE\u7528\u6237\u5217\u8868"] })] }) }), detailQ.isPending && (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u52A0\u8F7D\u7528\u6237\u8BE6\u60C5\u4E2D\u2026" })), u && (_jsxs(_Fragment, { children: [_jsxs(SettingsCard, { title: "\u8D26\u53F7\u8D44\u6599\u4E0E\u5B89\u5168\u64CD\u4F5C", description: "\u72B6\u6001\u3001\u9A8C\u8BC1\u4E0E\u767B\u5F55\u4F1A\u8BDD\u7684\u5168\u5C40\u64CD\u4F5C\uFF08\u5168\u90E8\u8BB0\u5165\u5E73\u53F0\u5BA1\u8BA1\uFF09", cta: _jsx(Button, { variant: "destructive", onClick: () => setKickOpen(true), disabled: kickM.isPending, children: kickM.isPending ? '踢下线中…' : '踢下线' }), children: [_jsxs("div", { className: "grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3", children: [_jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u72B6\u6001\uFF1A" }), _jsx(StatusBadge, { status: u.status })] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u90AE\u7BB1\u9A8C\u8BC1\uFF1A" }), u.email_verified ? (_jsx(Badge, { variant: "outline", children: "\u5DF2\u9A8C\u8BC1" })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u9A8C\u8BC1" }))] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5E73\u53F0\u7BA1\u7406\u5458\uFF1A" }), u.is_platform_admin ? (_jsx(Badge, { variant: "secondary", children: "\u662F" })) : (_jsx("span", { children: "\u5426" }))] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6CE8\u518C\u65F6\u95F4\uFF1A" }), formatDateTime(u.created_at)] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6700\u8FD1\u767B\u5F55\uFF1A" }), u.last_login_at ? formatDateTime(u.last_login_at) : '从未登录'] })] }), _jsxs("div", { className: "mt-4 flex flex-wrap gap-2 border-t pt-4", children: [_jsx(Button, { variant: "outline", size: "sm", disabled: resetPwdM.isPending, onClick: async () => {
232
+ const ok = await confirm({
233
+ title: '发送密码重置邮件',
234
+ description: `将向 ${u.email} 发送密码重置邮件(走用户自助重置链路,不直接改密码)。`,
235
+ confirmText: '发送邮件',
236
+ });
237
+ if (ok)
238
+ resetPwdM.mutate();
239
+ }, children: resetPwdM.isPending ? '发送中…' : '重置密码' }), _jsx(Button, { variant: "outline", size: "sm", disabled: resendM.isPending || u.email_verified, onClick: async () => {
240
+ const ok = await confirm({
241
+ title: '重发验证邮件',
242
+ description: `将向 ${u.email} 补发邮箱验证邮件(1 分钟冷却)。`,
243
+ confirmText: '发送邮件',
244
+ });
245
+ if (ok)
246
+ resendM.mutate();
247
+ }, children: resendM.isPending ? '发送中…' : '重发验证' }), _jsx(Button, { variant: "outline", size: "sm", disabled: markVerifiedM.isPending || u.email_verified, onClick: async () => {
248
+ const ok = await confirm({
249
+ title: '标记邮箱已验证',
250
+ description: '邮件链路不可达时的兜底:直接把该邮箱标记为已验证(幂等)。',
251
+ confirmText: '标记已验证',
252
+ });
253
+ if (ok)
254
+ markVerifiedM.mutate();
255
+ }, children: markVerifiedM.isPending ? '标记中…' : '标记已验证' })] })] }), _jsx(SettingsCard, { title: `在途会话(${d?.sessions.length ?? 0})`, description: "\u8BE5\u7528\u6237\u5F53\u524D\u6709\u6548\u7684\u767B\u5F55\u4F1A\u8BDD\uFF1B\u53EF\u5355\u8BBE\u5907\u540A\u9500", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: sessionColumns, rows: d?.sessions ?? [], rowKey: (s) => s.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u6CA1\u6709\u5728\u9014\u4F1A\u8BDD", hint: "\u8BE5\u7528\u6237\u5F53\u524D\u6CA1\u6709\u4EFB\u4F55\u6D3B\u8DC3\u767B\u5F55\uFF08\u6216\u5DF2\u88AB\u8E22\u4E0B\u7EBF\uFF09" }) }) }) }), _jsx(SettingsCard, { title: `访问令牌 PAT(${d?.pats.length ?? 0})`, description: "\u76EE\u6807\u7528\u6237\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C\u7684\u53EA\u8BFB\u5143\u6570\u636E\uFF08\u4E0D\u542B\u51ED\u636E\u6750\u6599\uFF09", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: patColumns, rows: d?.pats ?? [], rowKey: (p) => p.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u6682\u65E0\u8BBF\u95EE\u4EE4\u724C" }) }) }) }), _jsx(SettingsCard, { title: `所属工作区(${d?.workspaces.length ?? 0})`, description: "\u8BE5\u7528\u6237\u4F5C\u4E3A\u6210\u5458\u52A0\u5165\u7684\u5DE5\u4F5C\u533A\u4E0E\u89D2\u8272", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: wsColumns, rows: d?.workspaces ?? [], rowKey: (w) => w.id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u4E0D\u5C5E\u4E8E\u4EFB\u4F55\u5DE5\u4F5C\u533A", hint: "\u5DE5\u4F5C\u533A\u5217\u8868\u5668\u672A\u63A5\u7EBF\u65F6\u4E5F\u663E\u793A\u4E3A\u7A7A" }) }) }) }), _jsx(SettingsCard, { danger: true, title: "\u5220\u9664\u8D26\u53F7", description: "\u8F6F\u5220\uFF1Astatus=deleted\u3001\u90AE\u7BB1\u6539\u5199\u3001PAT \u5168\u540A\u9500\uFF0C\u4E0D\u53EF\u6062\u590D", cta: _jsx(Button, { variant: "destructive", disabled: !!deleteBlockedReason ||
256
+ !emailReadyToDelete ||
257
+ deleteM.isPending, onClick: () => setDeleteOpen(true), children: deleteM.isPending ? '删除中…' : '删除账号' }), children: _jsxs("div", { className: "max-w-xl space-y-2", children: [deleteBlockedReason ? (_jsxs("p", { className: "text-sm text-muted-foreground", children: [deleteBlockedReason, "\uFF0C\u65E0\u6CD5\u5728\u6B64\u5220\u9664\u3002"] })) : (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u8F93\u5165\u76EE\u6807\u90AE\u7BB1 ", u.email, " \u4EE5\u786E\u8BA4\u5220\u9664\u3002"] })), _jsx(Input, { placeholder: u.email, value: confirmEmail, onChange: (e) => setConfirmEmail(e.target.value), "aria-label": "\u8F93\u5165\u76EE\u6807\u90AE\u7BB1\u4EE5\u786E\u8BA4\u5220\u9664", disabled: !!deleteBlockedReason })] }) }), _jsx(ImpactConfirmation, { open: deleteOpen, onOpenChange: setDeleteOpen, onConfirm: () => deleteM.mutate(u.email), title: `删除账号 ${u.email}`, confirmText: "\u6C38\u4E45\u5220\u9664", danger: true, impacts: [
258
+ '账号进入 deleted 状态,邮箱被改写释放,无法恢复',
259
+ '该用户全部在途会话与个人访问令牌(PAT)立即吊销',
260
+ '名下工作区与业务数据保留,操作记入平台审计日志',
261
+ ] }), _jsx(ImpactConfirmation, { open: kickOpen, onOpenChange: setKickOpen, onConfirm: () => kickM.mutate(), title: `踢下线 ${u.email}`, confirmText: "\u8E22\u4E0B\u7EBF", danger: true, impacts: [
262
+ '该用户全部在途会话立即吊销,所有设备需重新登录',
263
+ '账号状态不变(不是封禁),重新登录即可恢复',
264
+ '操作将记入平台审计日志(admin.user_kick)',
265
+ ] })] }))] }));
266
+ }
@@ -0,0 +1,4 @@
1
+ export interface UsersPageProps {
2
+ impersonatePath?: string;
3
+ }
4
+ export declare function UsersPage({ impersonatePath }?: UsersPageProps): import("react").JSX.Element;
@@ -0,0 +1,201 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useNavigate, useSearchParams } from 'react-router';
4
+ import { MoreHorizontal, LogIn, ShieldCheck, ShieldOff, UserCog, UserRoundSearch, } from 'lucide-react';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { useConfirm } from '../../components/ConfirmDialog';
7
+ import { DataTable } from '../../components/DataTable';
8
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
9
+ import { ImpactConfirmation } from '../../components/SecretModal';
10
+ import { toast } from '../../components/toast';
11
+ import { Badge, StatusBadge } from '../../components/ui/Badge';
12
+ import { Button } from '../../components/ui/Button';
13
+ import { Input } from '../../components/ui/Input';
14
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '../../components/ui/menu';
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
16
+ import { useApi } from '../../hooks/useApi';
17
+ import { useAuth } from '../../hooks/useAuth';
18
+ import { formatDate, formatDateTime } from '../../lib/utils';
19
+ import { ADMIN_PAGE_SIZE, adminError, adminListQuery, } from './shared';
20
+ const STATUS_OPTIONS = [
21
+ { value: 'active', label: '生效中' },
22
+ { value: 'disabled', label: '已封禁' },
23
+ ];
24
+ export function UsersPage({ impersonatePath = '/app' } = {}) {
25
+ const api = useApi();
26
+ const qc = useQueryClient();
27
+ const confirm = useConfirm();
28
+ const navigate = useNavigate();
29
+ const me = useAuth().user;
30
+ const [searchParams, setSearchParams] = useSearchParams();
31
+ const q = searchParams.get('q') ?? '';
32
+ const statusParam = searchParams.get('status') ?? '';
33
+ const status = STATUS_OPTIONS.some((s) => s.value === statusParam)
34
+ ? statusParam
35
+ : '';
36
+ const page = Math.max(1, Number.parseInt(searchParams.get('page') ?? '1', 10) || 1);
37
+ const applyFilter = (nextQ, nextStatus, nextPage) => {
38
+ const p = new URLSearchParams(searchParams);
39
+ if (nextQ)
40
+ p.set('q', nextQ);
41
+ else
42
+ p.delete('q');
43
+ if (nextStatus)
44
+ p.set('status', nextStatus);
45
+ else
46
+ p.delete('status');
47
+ if (nextPage > 1)
48
+ p.set('page', String(nextPage));
49
+ else
50
+ p.delete('page');
51
+ setSearchParams(p, { replace: true });
52
+ };
53
+ const [searchInput, setSearchInput] = useState(q);
54
+ const [action, setAction] = useState(null);
55
+ useEffect(() => {
56
+ const t = setTimeout(() => {
57
+ const next = searchInput.trim();
58
+ if (next !== q)
59
+ applyFilter(next, status, 1);
60
+ }, 300);
61
+ return () => clearTimeout(t);
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [searchInput]);
64
+ const usersQ = useQuery({
65
+ queryKey: ['admin', 'users', page, q, status],
66
+ queryFn: () => api.get(`/api/admin/users${adminListQuery(page, ADMIN_PAGE_SIZE, { q, status })}`),
67
+ });
68
+ const invalidate = () => {
69
+ void qc.invalidateQueries({ queryKey: ['admin', 'users'] });
70
+ void qc.invalidateQueries({ queryKey: ['admin', 'user'] });
71
+ void qc.invalidateQueries({ queryKey: ['admin', 'stats'] });
72
+ };
73
+ const statusM = useMutation({
74
+ mutationFn: (v) => api.patch(`/api/admin/users/${v.user.id}/status`, { status: v.status }),
75
+ onSuccess: (_d, v) => {
76
+ toast.success(`${v.user.email} 已${v.status === 'disabled' ? '封禁' : '解封'}`);
77
+ setAction(null);
78
+ void invalidate();
79
+ },
80
+ onError: (e) => toast.error(adminError(e, '操作失败')),
81
+ });
82
+ const adminFlagM = useMutation({
83
+ mutationFn: (v) => api.patch(`/api/admin/users/${v.user.id}/admin`, { is_admin: v.isAdmin }),
84
+ onSuccess: (_d, v) => {
85
+ toast.success(`${v.user.email} ${v.isAdmin ? '已授予' : '已撤销'}平台管理员`);
86
+ void invalidate();
87
+ },
88
+ onError: (e) => toast.error(adminError(e, '操作失败')),
89
+ });
90
+ const impersonateM = useMutation({
91
+ mutationFn: (user) => api.post(`/api/admin/users/${user.id}/impersonate`),
92
+ onSuccess: () => {
93
+ window.location.href = impersonatePath;
94
+ },
95
+ onError: (e) => {
96
+ toast.error(adminError(e, '模拟登录失败'));
97
+ setAction(null);
98
+ },
99
+ });
100
+ const onToggleAdmin = async (user) => {
101
+ const revoke = user.is_platform_admin;
102
+ const ok = await confirm({
103
+ title: revoke ? '撤销平台管理员' : '授予平台管理员',
104
+ description: revoke
105
+ ? `${user.email} 将失去平台管理权限(管理控制台入口随之消失)。`
106
+ : `${user.email} 将获得平台管理权限,可进入管理控制台操作平台全部数据。`,
107
+ confirmText: revoke ? '撤销管理员' : '授予管理员',
108
+ danger: revoke,
109
+ });
110
+ if (ok)
111
+ adminFlagM.mutate({ user, isAdmin: !revoke });
112
+ };
113
+ const raw = usersQ.data?.items ?? [];
114
+ const total = usersQ.data?.total ?? 0;
115
+ const pastEnd = !usersQ.isPending && raw.length === 0 && page > 1;
116
+ const filtered = !!(q || status);
117
+ const columns = [
118
+ {
119
+ key: 'email',
120
+ header: '用户',
121
+ render: (u) => (_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "truncate font-medium", children: [u.email, u.is_platform_admin && (_jsx(Badge, { variant: "secondary", className: "ml-2", children: "\u5E73\u53F0\u7BA1\u7406\u5458" }))] }), _jsx("p", { className: "truncate text-xs text-muted-foreground", children: u.display_name || '—' })] })),
122
+ },
123
+ {
124
+ key: 'status',
125
+ header: '状态',
126
+ className: 'w-24',
127
+ render: (u) => _jsx(StatusBadge, { status: u.status }),
128
+ },
129
+ {
130
+ key: 'email_verified',
131
+ header: '邮箱验证',
132
+ className: 'w-24',
133
+ render: (u) => u.email_verified ? (_jsx(Badge, { variant: "outline", children: "\u5DF2\u9A8C\u8BC1" })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u9A8C\u8BC1" })),
134
+ },
135
+ {
136
+ key: 'created_at',
137
+ header: '注册时间',
138
+ className: 'w-28',
139
+ render: (u) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(u.created_at) })),
140
+ },
141
+ {
142
+ key: 'last_login_at',
143
+ header: '最近登录',
144
+ className: 'w-44',
145
+ render: (u) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: u.last_login_at ? formatDateTime(u.last_login_at) : '从未登录' })),
146
+ },
147
+ {
148
+ key: 'actions',
149
+ header: '操作',
150
+ className: 'w-16',
151
+ render: (u) => {
152
+ if (me?.id === u.id) {
153
+ return (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`/admin/users/${u.id}`), children: [_jsx(UserRoundSearch, {}), " \u8BE6\u60C5"] }));
154
+ }
155
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { className: "inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground max-md:size-10", "aria-label": `操作 ${u.email}`, children: _jsx(MoreHorizontal, { size: 16, "aria-hidden": "true" }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-48", children: [_jsx(DropdownMenuGroup, { children: _jsx(DropdownMenuLabel, { className: "truncate", children: u.email }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => navigate(`/admin/users/${u.id}`), children: [_jsx(UserRoundSearch, {}), " \u7528\u6237\u8BE6\u60C5"] }), _jsx(DropdownMenuSeparator, {}), u.status === 'active' ? (_jsxs(DropdownMenuItem, { variant: "destructive", onClick: () => setAction({ kind: 'disable', user: u }), children: [_jsx(ShieldOff, {}), " \u5C01\u7981\u8D26\u53F7"] })) : (_jsxs(DropdownMenuItem, { onClick: () => setAction({ kind: 'enable', user: u }), children: [_jsx(ShieldCheck, {}), " \u89E3\u5C01\u8D26\u53F7"] })), _jsxs(DropdownMenuItem, { onClick: () => void onToggleAdmin(u), children: [_jsx(UserCog, {}), ' ', u.is_platform_admin ? '撤销平台管理员' : '授予平台管理员'] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { disabled: u.is_platform_admin, onClick: () => setAction({ kind: 'impersonate', user: u }), children: [_jsx(LogIn, {}), " \u6A21\u62DF\u767B\u5F55"] })] })] }));
156
+ },
157
+ },
158
+ ];
159
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u7528\u6237", description: "\u5E73\u53F0\u5168\u90E8\u6CE8\u518C\u7528\u6237\uFF1B\u652F\u6301\u90AE\u7BB1/\u6635\u79F0\u641C\u7D22\u4E0E\u72B6\u6001\u8FC7\u6EE4\uFF08\u670D\u52A1\u7AEF\u68C0\u7D22\uFF09" }), _jsxs("div", { className: "mb-4 flex flex-wrap items-center gap-2", children: [_jsx(Input, { className: "w-72", placeholder: "\u6309\u90AE\u7BB1\u524D\u7F00 / \u6635\u79F0\u641C\u7D22\u2026", "aria-label": "\u641C\u7D22\u7528\u6237", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: (e) => {
160
+ if (e.key === 'Enter')
161
+ applyFilter(searchInput.trim(), status, 1);
162
+ } }), _jsxs(Select, { value: status || 'all', onValueChange: (v) => {
163
+ const next = typeof v === 'string' && v !== 'all' ? v : '';
164
+ applyFilter(q, next, 1);
165
+ }, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u6309\u72B6\u6001\u8FC7\u6EE4", children: _jsx(SelectValue, { children: status
166
+ ? (STATUS_OPTIONS.find((s) => s.value === status)?.label ??
167
+ status)
168
+ : '全部状态' }) }), _jsxs(SelectContent, { align: "start", children: [_jsx(SelectItem, { value: "all", children: "\u5168\u90E8\u72B6\u6001" }), STATUS_OPTIONS.map((s) => (_jsx(SelectItem, { value: s.value, children: s.label }, s.value)))] })] }), filtered && (_jsx(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => {
169
+ setSearchInput('');
170
+ applyFilter('', '', 1);
171
+ }, children: "\u6E05\u9664\u8FC7\u6EE4" }))] }), usersQ.isError ? (_jsx(PageError, { message: adminError(usersQ.error, '用户列表加载失败'), onRetry: () => void usersQ.refetch() })) : (_jsx(DataTable, { columns: columns, rows: raw, rowKey: (u) => u.id, loading: usersQ.isPending, pagination: {
172
+ page,
173
+ pageSize: ADMIN_PAGE_SIZE,
174
+ total,
175
+ onPageChange: (p) => applyFilter(q, status, p),
176
+ }, empty: pastEnd ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(PageEmpty, { label: "\u6CA1\u6709\u66F4\u591A\u7528\u6237\u4E86", hint: "\u5F53\u524D\u9875\u6570\u636E\u53EF\u80FD\u5DF2\u88AB\u5220\u9664" }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => applyFilter(q, status, 1), children: "\u8FD4\u56DE\u7B2C\u4E00\u9875" })] })) : filtered ? (_jsx(PageEmpty, { label: "\u6CA1\u6709\u5339\u914D\u7528\u6237", hint: "\u6362\u4E2A\u5173\u952E\u8BCD\u3001\u653E\u5BBD\u72B6\u6001\u6761\u4EF6\u6216\u6E05\u9664\u8FC7\u6EE4" })) : (_jsx(PageEmpty, { label: "\u6682\u65E0\u7528\u6237" })) })), _jsx(ImpactConfirmation, { open: action?.kind === 'disable' || action?.kind === 'enable', onOpenChange: (o) => !o && setAction(null), title: action?.kind === 'disable'
177
+ ? `封禁 ${action.user.email}`
178
+ : action?.kind === 'enable'
179
+ ? `解封 ${action.user.email}`
180
+ : '', danger: action?.kind === 'disable', confirmText: action?.kind === 'disable' ? '封禁' : '解封', impacts: action?.kind === 'disable'
181
+ ? [
182
+ '该用户将无法通过邮箱密码与 OAuth 登录',
183
+ '其名下工作区与数据保留,不受影响',
184
+ '操作将记入平台审计日志',
185
+ ]
186
+ : ['该用户恢复登录能力', '操作将记入平台审计日志'], onConfirm: () => {
187
+ if (action) {
188
+ statusM.mutate({
189
+ user: action.user,
190
+ status: action.kind === 'disable' ? 'disabled' : 'active',
191
+ });
192
+ }
193
+ } }), _jsx(ImpactConfirmation, { open: action?.kind === 'impersonate', onOpenChange: (o) => !o && setAction(null), title: action?.kind === 'impersonate' ? `模拟登录 ${action.user.email}` : '', confirmText: impersonateM.isPending ? '切换中…' : '以该用户进入产品', impacts: [
194
+ '浏览器将以该用户身份进入产品(/app)',
195
+ '当前管理员会话被替换,退出登录后需重新登录管理员账号',
196
+ '审计将记录 admin.impersonate,操作可追溯',
197
+ ], onConfirm: () => {
198
+ if (action?.kind === 'impersonate')
199
+ impersonateM.mutate(action.user);
200
+ } })] }));
201
+ }
@@ -0,0 +1 @@
1
+ export declare function WebhookDeliveriesPage(): import("react").JSX.Element;
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSearchParams } from 'react-router';
3
+ import { RefreshCw } from 'lucide-react';
4
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { DataTable } from '../../components/DataTable';
6
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
7
+ import { toast } from '../../components/toast';
8
+ import { Badge, StatusBadge } from '../../components/ui/Badge';
9
+ import { Button } from '../../components/ui/Button';
10
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
11
+ import { useApi } from '../../hooks/useApi';
12
+ import { formatDateTime, shortID, statusLabel } from '../../lib/utils';
13
+ import { ADMIN_PAGE_SIZE, adminError, adminListQuery, } from './shared';
14
+ const DELIVERY_STATUSES = ['pending', 'delivered', 'dead'];
15
+ const LIST_REFETCH_MS = 30_000;
16
+ const COUNT_STALE_MS = 60_000;
17
+ export function WebhookDeliveriesPage() {
18
+ const api = useApi();
19
+ const qc = useQueryClient();
20
+ const [searchParams, setSearchParams] = useSearchParams();
21
+ const statusParam = searchParams.get('status') ?? '';
22
+ const status = DELIVERY_STATUSES.includes(statusParam) ? statusParam : '';
23
+ const page = Math.max(1, Number.parseInt(searchParams.get('page') ?? '1', 10) || 1);
24
+ const setFilter = (nextStatus, nextPage = 1) => {
25
+ const p = new URLSearchParams(searchParams);
26
+ if (nextStatus)
27
+ p.set('status', nextStatus);
28
+ else
29
+ p.delete('status');
30
+ if (nextPage > 1)
31
+ p.set('page', String(nextPage));
32
+ else
33
+ p.delete('page');
34
+ setSearchParams(p, { replace: true });
35
+ };
36
+ const listQ = useQuery({
37
+ queryKey: ['admin', 'webhook-deliveries', page, status],
38
+ queryFn: () => api.get(`/api/admin/webhook-deliveries${adminListQuery(page, ADMIN_PAGE_SIZE, { status })}`),
39
+ refetchInterval: LIST_REFETCH_MS,
40
+ });
41
+ const countQuery = (s) => ({
42
+ queryKey: ['admin', 'webhook-deliveries-count', s],
43
+ queryFn: () => api.get(`/api/admin/webhook-deliveries${adminListQuery(1, 1, { status: s })}`),
44
+ staleTime: COUNT_STALE_MS,
45
+ });
46
+ const pendingCountQ = useQuery(countQuery('pending'));
47
+ const deliveredCountQ = useQuery(countQuery('delivered'));
48
+ const deadCountQ = useQuery(countQuery('dead'));
49
+ const statusCounts = {
50
+ pending: pendingCountQ.data?.total,
51
+ delivered: deliveredCountQ.data?.total,
52
+ dead: deadCountQ.data?.total,
53
+ };
54
+ const redeliverM = useMutation({
55
+ mutationFn: (d) => api.post(`/api/admin/webhook-deliveries/${d.id}/redeliver`),
56
+ onSuccess: (d2, d) => {
57
+ toast.success(`已重新入队(新投递 ${shortID(d2?.id ?? '')},溯源 ${d.event_id}.r)`);
58
+ void qc.invalidateQueries({ queryKey: ['admin', 'webhook-deliveries'] });
59
+ },
60
+ onError: (e) => toast.error(adminError(e, '重投失败')),
61
+ });
62
+ const items = listQ.data?.items ?? [];
63
+ const total = listQ.data?.total ?? 0;
64
+ const pastEnd = !listQ.isPending && items.length === 0 && page > 1;
65
+ const columns = [
66
+ {
67
+ key: 'workspace_id',
68
+ header: '工作区',
69
+ className: 'w-24',
70
+ render: (d) => d.workspace_id ? (_jsx("span", { className: "font-mono text-xs", title: d.workspace_id, children: shortID(d.workspace_id) })) : (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u5B64\u513F" })),
71
+ },
72
+ {
73
+ key: 'url',
74
+ header: '目标 URL',
75
+ render: (d) => d.url ? (_jsx("p", { className: "max-w-[22rem] truncate font-mono text-xs", title: d.url, children: d.url })) : (_jsx("span", { className: "text-xs text-muted-foreground", children: "\uFF08\u8BA2\u9605\u5DF2\u5220\u9664\uFF09" })),
76
+ },
77
+ {
78
+ key: 'event_id',
79
+ header: '事件 ID',
80
+ className: 'w-32',
81
+ render: (d) => (_jsx("code", { className: "block max-w-[8rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs", title: d.event_id, children: d.event_id })),
82
+ },
83
+ {
84
+ key: 'event_type',
85
+ header: '事件',
86
+ className: 'w-40',
87
+ render: (d) => (_jsx("code", { className: "block max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs", title: d.event_type, children: d.event_type })),
88
+ },
89
+ {
90
+ key: 'status',
91
+ header: '状态',
92
+ className: 'w-24',
93
+ render: (d) => _jsx(StatusBadge, { status: d.status }),
94
+ },
95
+ {
96
+ key: 'attempts',
97
+ header: '尝试',
98
+ className: 'w-16 text-right',
99
+ render: (d) => _jsx("span", { className: "tabular-nums", children: d.attempts }),
100
+ },
101
+ {
102
+ key: 'last_status',
103
+ header: '最近响应',
104
+ className: 'w-32',
105
+ render: (d) => d.last_status_code ? (_jsx("span", { className: "font-mono text-xs", title: d.last_error ?? undefined, children: d.last_status_code })) : d.last_error ? (_jsx("span", { className: "block max-w-[12rem] truncate text-xs text-destructive", title: d.last_error, children: d.last_error })) : (_jsx("span", { className: "text-xs text-muted-foreground", children: "\u2014" })),
106
+ },
107
+ {
108
+ key: 'created_at',
109
+ header: '时间',
110
+ className: 'w-44',
111
+ render: (d) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDateTime(d.created_at) })),
112
+ },
113
+ {
114
+ key: 'actions',
115
+ header: '操作',
116
+ className: 'w-20',
117
+ render: (d) => (_jsx(Button, { variant: "outline", size: "sm", disabled: redeliverM.isPending, onClick: () => redeliverM.mutate(d), children: "\u91CD\u6295" })),
118
+ },
119
+ ];
120
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "Webhooks", description: "\u6240\u6709\u5DE5\u4F5C\u533A\u7684 Webhook \u6295\u9012\u4E0E\u91CD\u8BD5\u8BB0\u5F55\uFF0C\u6309\u65F6\u95F4\u5012\u5E8F" }), _jsxs("div", { className: "mb-4 flex flex-wrap items-center gap-2", children: [_jsxs(Select, { value: status || 'all', onValueChange: (v) => {
121
+ setFilter(typeof v === 'string' && v !== 'all' ? v : '', 1);
122
+ }, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u6309\u6295\u9012\u72B6\u6001\u8FC7\u6EE4", children: _jsx(SelectValue, { children: status ? statusLabel(status) : '全部状态' }) }), _jsxs(SelectContent, { align: "start", children: [_jsx(SelectItem, { value: "all", children: "\u5168\u90E8\u72B6\u6001" }), DELIVERY_STATUSES.map((s) => (_jsxs(SelectItem, { value: s, children: [statusLabel(s), typeof statusCounts[s] === 'number' ? ` (${statusCounts[s]})` : ''] }, s)))] })] }), _jsxs(Button, { variant: "outline", size: "sm", disabled: listQ.isFetching, onClick: () => void listQ.refetch(), children: [_jsx(RefreshCw, { size: 14, "aria-hidden": "true" }), listQ.isFetching ? '刷新中…' : '刷新'] })] }), listQ.isError ? (_jsx(PageError, { message: adminError(listQ.error, '投递列表加载失败'), onRetry: () => void listQ.refetch() })) : (_jsx(DataTable, { columns: columns, rows: items, rowKey: (d) => d.id, loading: listQ.isPending, pagination: {
123
+ page,
124
+ pageSize: ADMIN_PAGE_SIZE,
125
+ total,
126
+ onPageChange: (p) => setFilter(status, p),
127
+ }, empty: pastEnd ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(PageEmpty, { label: "\u6CA1\u6709\u66F4\u591A\u6295\u9012\u4E86", hint: "\u5F53\u524D\u9875\u6570\u636E\u53EF\u80FD\u5DF2\u88AB\u5220\u9664" }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => setFilter(status, 1), children: "\u8FD4\u56DE\u7B2C\u4E00\u9875" })] })) : status ? (_jsx(PageEmpty, { label: "\u6CA1\u6709\u8BE5\u72B6\u6001\u7684\u6295\u9012", hint: "\u6362\u4E2A\u72B6\u6001\u6216\u6E05\u9664\u8FC7\u6EE4" })) : (_jsx(PageEmpty, { label: "\u6682\u65E0\u6295\u9012\u8BB0\u5F55", hint: "\u5DE5\u4F5C\u533A\u89E6\u53D1\u4E8B\u4EF6\u540E\uFF0C\u8BA2\u9605\u6295\u9012\u4F1A\u5728\u6B64\u51FA\u73B0" })) }))] }));
128
+ }
@@ -0,0 +1 @@
1
+ export declare function WorkspaceDetailPage(): import("react").JSX.Element;