@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,506 @@
1
+
2
+ import { useMemo, useState } from 'react'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { MoreHorizontal, Plus } from 'lucide-react'
5
+ import { z } from 'zod'
6
+ import type { components } from '@ploykit/client'
7
+ import {
8
+ PageHeader, PageLoading, PageError, PageEmpty,
9
+ } from '../../components/Page'
10
+ import { DataTable, type Column } from '../../components/DataTable'
11
+ import { FormField } from '../../components/FormField'
12
+ import { SecretModal } from '../../components/SecretModal'
13
+ import { useConfirm } from '../../components/ConfirmDialog'
14
+ import { toast } from '../../components/toast'
15
+ import { Button } from '../../components/ui/button'
16
+ import { Input } from '../../components/ui/input'
17
+ import { StatusBadge } from '../../components/StatusBadge'
18
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'
19
+ import { Switch } from '../../components/ui/switch'
20
+ import { Checkbox } from '../../components/ui/checkbox'
21
+ import {
22
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
23
+ } from '../../components/ui/dialog'
24
+ import {
25
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
26
+ } from '../../components/ui/dropdown-menu'
27
+ import { useApi } from '@ploykit/hooks'
28
+ import { useWorkspace } from '@ploykit/hooks'
29
+ import { useZodForm } from '@ploykit/hooks'
30
+ import { queryKeys } from '@ploykit/hooks'
31
+ import { formatDateTime } from '../../lib/utils'
32
+ import { apiErrorMessage } from '../../lib/api-error'
33
+ import { roleSatisfies } from '@ploykit/hooks'
34
+
35
+
36
+
37
+ type Subscription = components['schemas']['WebhookSubscription']
38
+ type Delivery = components['schemas']['WebhookDelivery']
39
+ type EventMeta = components['schemas']['WebhookEventItem']
40
+ type RotateSecretResponse = components['schemas']['RotateWebhookSecretResponse']
41
+
42
+ const createSchema = z.object({
43
+
44
+ url: z
45
+ .string()
46
+ .url('请输入合法的回调 URL(如 https://example.com/webhook)')
47
+ .refine((v) => v.startsWith('https://'), '回调地址必须使用 https(明文 http 会被服务端拒绝)'),
48
+ description: z.string(),
49
+ events: z.array(z.string()).min(1, '至少订阅一个事件类型'),
50
+ })
51
+ type CreateForm = z.infer<typeof createSchema>
52
+
53
+ export interface WebhooksPageProps {}
54
+
55
+ export function WebhooksPage(_props: WebhooksPageProps = {}) {
56
+ const { current } = useWorkspace()
57
+ const api = useApi()
58
+ const confirm = useConfirm()
59
+ const wsId = current?.id ?? ''
60
+ const base = `/api/workspaces/${wsId}/webhooks`
61
+
62
+ const canManage = roleSatisfies(current?.role, 'admin')
63
+
64
+ const [createOpen, setCreateOpen] = useState(false)
65
+ const [secret, setSecret] = useState<string | null>(null)
66
+
67
+ const [secretTitle, setSecretTitle] = useState<string | undefined>(undefined)
68
+
69
+
70
+
71
+ const subsQ = useQuery({
72
+ queryKey: [...queryKeys.webhooks(wsId), 'subscriptions'] as const,
73
+ queryFn: () => api.get<{ items: Subscription[] }>(`${base}/subscriptions`),
74
+ enabled: !!wsId,
75
+ })
76
+ const deliveriesQ = useQuery({
77
+ queryKey: [...queryKeys.webhooks(wsId), 'deliveries'] as const,
78
+ queryFn: () => api.get<{ items: Delivery[] }>(`${base}/deliveries?limit=50`),
79
+ enabled: !!wsId,
80
+
81
+
82
+
83
+
84
+ refetchInterval: 15_000,
85
+ })
86
+ const eventsQ = useQuery({
87
+ queryKey: [...queryKeys.webhooks(wsId), 'events'] as const,
88
+ queryFn: () => api.get<{ items: EventMeta[] }>(`${base}/events`),
89
+ enabled: !!wsId,
90
+ })
91
+
92
+ const qc = useQueryClient()
93
+ const invalidate = () => qc.invalidateQueries({ queryKey: queryKeys.webhooks(wsId) })
94
+ const onError = (e: unknown, fallback: string) => toast.error(apiErrorMessage(e, fallback))
95
+
96
+
97
+
98
+ const toggle = useMutation({
99
+ mutationFn: (s: Subscription) =>
100
+ api.patch<Subscription>(`${base}/subscriptions/${s.id}`, { is_active: !s.is_active }),
101
+ onSuccess: async () => { toast.success('状态已更新'); await invalidate() },
102
+ onError: (e) => onError(e, '操作失败'),
103
+ })
104
+
105
+ const ping = useMutation({
106
+ mutationFn: (id: string) => api.post(`${base}/subscriptions/${id}/ping`),
107
+ onSuccess: async () => { toast.success('Ping 已入队,结果见投递记录'); await invalidate() },
108
+ onError: (e) => onError(e, 'Ping 失败'),
109
+ })
110
+
111
+ const remove = useMutation({
112
+ mutationFn: (id: string) => api.delete(`${base}/subscriptions/${id}`),
113
+ onSuccess: async () => { toast.success('订阅已删除'); await invalidate() },
114
+ onError: (e) => onError(e, '删除失败'),
115
+ })
116
+
117
+ const redeliver = useMutation({
118
+ mutationFn: (id: string) => api.post<Delivery>(`${base}/deliveries/${id}/redeliver`),
119
+ onSuccess: async () => { toast.success('已重新入队投递'); await invalidate() },
120
+ onError: (e) => onError(e, '重新投递失败'),
121
+ })
122
+
123
+
124
+ const create = useMutation({
125
+ mutationFn: (values: CreateForm) =>
126
+ api.post<{ subscription: Subscription; secret: string }>(`${base}/subscriptions`, values),
127
+ onSuccess: async (data) => {
128
+ toast.success('订阅已创建')
129
+ setSecretTitle(undefined)
130
+ setSecret(data.secret)
131
+ await invalidate()
132
+ },
133
+ onError: (e) => onError(e, '创建失败'),
134
+ })
135
+
136
+
137
+
138
+
139
+
140
+ const rotate = useMutation({
141
+ mutationFn: (id: string) =>
142
+ api.post<RotateSecretResponse>(`${base}/subscriptions/${id}/rotate-secret`),
143
+ onSuccess: async (data) => {
144
+ toast.success('密钥已轮换')
145
+ setSecretTitle('Webhook 签名密钥轮换成功')
146
+ setSecret(data.secret)
147
+ await invalidate()
148
+ },
149
+ onError: (e) => onError(e, '轮换密钥失败'),
150
+ })
151
+
152
+
153
+ const latestBySub = useMemo(() => {
154
+ const map = new Map<string, Delivery>()
155
+ for (const d of deliveriesQ.data?.items ?? []) {
156
+ if (!map.has(d.subscription_id)) map.set(d.subscription_id, d)
157
+ }
158
+ return map
159
+ }, [deliveriesQ.data])
160
+
161
+
162
+
163
+
164
+
165
+ const subById = useMemo(() => {
166
+ const map = new Map<string, Subscription>()
167
+ for (const s of subsQ.data?.items ?? []) map.set(s.id, s)
168
+ return map
169
+ }, [subsQ.data])
170
+
171
+
172
+
173
+ const form = useZodForm<CreateForm>({
174
+ schema: createSchema,
175
+ defaultValues: { url: '', description: '', events: [] },
176
+ })
177
+ const selectedEvents = form.watch('events') ?? []
178
+ const toggleEvent = (type: string, checked: boolean) => {
179
+ form.setValue(
180
+ 'events',
181
+ checked ? [...selectedEvents, type] : selectedEvents.filter((t) => t !== type),
182
+ { shouldValidate: true },
183
+ )
184
+ }
185
+ const submitCreate = form.handleSubmit((values) => {
186
+ create.mutate(values, {
187
+ onSuccess: () => {
188
+ form.reset()
189
+ setCreateOpen(false)
190
+ },
191
+ })
192
+ })
193
+
194
+
195
+ const closeSecret = () => {
196
+ setSecret(null)
197
+ setSecretTitle(undefined)
198
+ }
199
+
200
+
201
+
202
+ const onRedeliverLatest = async (s: Subscription) => {
203
+ const latest = latestBySub.get(s.id)
204
+ if (!latest) {
205
+ toast.info('该订阅暂无投递记录,可先发送 Ping')
206
+ return
207
+ }
208
+ await redeliver.mutateAsync(latest.id).catch(() => {})
209
+ }
210
+
211
+ const onRemove = async (s: Subscription) => {
212
+ if (!(await confirm({
213
+ title: '删除订阅',
214
+ description: `确定删除 ${s.url} 吗?该操作不可恢复。`,
215
+ confirmText: '删除', danger: true,
216
+ }))) return
217
+ remove.mutate(s.id)
218
+ }
219
+
220
+
221
+ const onRotate = async (s: Subscription) => {
222
+ if (!(await confirm({
223
+ title: '轮换签名密钥',
224
+ description: `确定轮换 ${s.url} 的签名密钥吗?新密钥仅显示一次;旧密钥在 24h 宽限期内继续有效(期间投递同时携带新/旧双签名),之后仅新密钥可用。`,
225
+ confirmText: '轮换',
226
+ }))) return
227
+ rotate.mutate(s.id)
228
+ }
229
+
230
+
231
+
232
+ const subColumns: Column<Subscription>[] = [
233
+ {
234
+ key: 'url', header: '回调地址',
235
+ render: (s) => (
236
+ <div className="min-w-0 max-w-xs">
237
+ <p className="truncate font-medium">{s.url}</p>
238
+ {s.description && <p className="truncate text-xs text-muted-foreground">{s.description}</p>}
239
+ </div>
240
+ ),
241
+ },
242
+ {
243
+ key: 'events', header: '事件类型',
244
+ render: (s) => (
245
+ <div className="flex max-w-xs flex-wrap gap-1">
246
+ {s.event_types.map((t) => (
247
+ <code key={t} className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{t}</code>
248
+ ))}
249
+ </div>
250
+ ),
251
+ },
252
+ {
253
+ key: 'is_active', header: '启用',
254
+
255
+ render: (s) =>
256
+ canManage ? (
257
+ <Switch
258
+ checked={s.is_active}
259
+ disabled={toggle.isPending}
260
+ onCheckedChange={() => toggle.mutate(s)}
261
+ aria-label={`${s.is_active ? '暂停' : '恢复'}订阅 ${s.url}`}
262
+ />
263
+ ) : (
264
+ <span className="text-xs text-muted-foreground">{s.is_active ? '已启用' : '已暂停'}</span>
265
+ ),
266
+ },
267
+ {
268
+ key: 'latest', header: '最近投递',
269
+ render: (s) => {
270
+ const d = latestBySub.get(s.id)
271
+ return d ? (
272
+ <div className="min-w-0">
273
+ <StatusBadge status={d.status} />
274
+ <p className="mt-1 text-xs text-muted-foreground">{formatDateTime(d.created_at)}</p>
275
+ </div>
276
+ ) : <span className="text-muted-foreground">—</span>
277
+ },
278
+ },
279
+ {
280
+ key: 'attempts', header: '尝试',
281
+ render: (s) => {
282
+ const d = latestBySub.get(s.id)
283
+ return <span className="tabular-nums">{d ? d.attempts : 0}</span>
284
+ },
285
+ },
286
+ {
287
+ key: 'actions', header: '', className: 'w-10',
288
+
289
+
290
+ render: (s) =>
291
+ canManage ? (
292
+ <DropdownMenu>
293
+ <DropdownMenuTrigger
294
+ className="inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"
295
+ aria-label={`订阅 ${s.url} 的操作`}
296
+ >
297
+ <MoreHorizontal className="size-4" />
298
+ <span className="sr-only">打开菜单</span>
299
+ </DropdownMenuTrigger>
300
+ <DropdownMenuContent align="end" className="w-40">
301
+ <DropdownMenuItem onClick={() => ping.mutate(s.id)}>发送 Ping</DropdownMenuItem>
302
+ <DropdownMenuItem onClick={() => onRedeliverLatest(s)}>重投最近一条</DropdownMenuItem>
303
+ <DropdownMenuItem onClick={() => onRotate(s)}>轮换密钥</DropdownMenuItem>
304
+ <DropdownMenuItem variant="destructive" onClick={() => onRemove(s)}>删除</DropdownMenuItem>
305
+ </DropdownMenuContent>
306
+ </DropdownMenu>
307
+ ) : null,
308
+ },
309
+ ]
310
+
311
+ const deliveryColumns: Column<Delivery>[] = [
312
+ {
313
+ key: 'event', header: '事件',
314
+ render: (d) => (
315
+ <div className="min-w-0">
316
+ <p className="font-medium">{d.event_type}</p>
317
+ <p className="truncate font-mono text-xs text-muted-foreground">{d.event_id}</p>
318
+ </div>
319
+ ),
320
+ },
321
+ {
322
+
323
+ key: 'subscription', header: '归属订阅',
324
+ render: (d) => {
325
+ const sub = subById.get(d.subscription_id)
326
+ if (sub) {
327
+ return (
328
+ <span className="block max-w-xs truncate font-mono text-xs" title={sub.url}>
329
+ {sub.url}
330
+ </span>
331
+ )
332
+ }
333
+ return subsQ.isSuccess ? (
334
+ <span className="inline-flex items-center whitespace-nowrap rounded-md border px-1.5 py-0.5 text-xs text-muted-foreground">
335
+ 已删除订阅
336
+ </span>
337
+ ) : <span className="text-muted-foreground">—</span>
338
+ },
339
+ },
340
+ { key: 'status', header: '状态', render: (d) => <StatusBadge status={d.status} /> },
341
+ {
342
+ key: 'attempts', header: '尝试',
343
+ render: (d) => (
344
+ <span className="tabular-nums">
345
+ {d.attempts}
346
+ {d.last_status_code ? <span className="ml-1 text-xs text-muted-foreground">HTTP {d.last_status_code}</span> : null}
347
+ </span>
348
+ ),
349
+ },
350
+ {
351
+ key: 'last_error', header: '最后错误',
352
+ render: (d) =>
353
+ d.last_error ? (
354
+ <span className="block max-w-xs truncate text-xs text-destructive" title={d.last_error}>
355
+ {d.last_error}
356
+ </span>
357
+ ) : <span className="text-muted-foreground">—</span>,
358
+ },
359
+ {
360
+ key: 'created_at', header: '时间',
361
+ render: (d) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(d.created_at)}</span>,
362
+ },
363
+ {
364
+ key: 'actions', header: '操作', className: 'text-right',
365
+
366
+ render: (d) =>
367
+ canManage ? (
368
+ <Button size="sm" variant="outline" disabled={redeliver.isPending} onClick={() => redeliver.mutate(d.id)}>
369
+ 重新投递
370
+ </Button>
371
+ ) : null,
372
+ },
373
+ ]
374
+
375
+
376
+
377
+ if (!wsId) {
378
+ return (
379
+ <div>
380
+ <PageHeader title="Webhooks" description="出站事件订阅与投递管理" />
381
+ <PageEmpty label="请先创建并选择一个工作区" />
382
+ </div>
383
+ )
384
+ }
385
+
386
+ return (
387
+ <div>
388
+ <PageHeader
389
+ title="Webhooks"
390
+ description={`出站事件订阅与投递管理(工作区:${current?.name ?? wsId})`}
391
+
392
+ actions={canManage ? <Button onClick={() => setCreateOpen(true)}><Plus /> 创建订阅</Button> : undefined}
393
+ />
394
+
395
+ <Tabs defaultValue="subscriptions">
396
+ <TabsList>
397
+ <TabsTrigger value="subscriptions">订阅</TabsTrigger>
398
+ <TabsTrigger value="deliveries">投递记录</TabsTrigger>
399
+ </TabsList>
400
+
401
+ <TabsContent value="subscriptions" className="mt-4 space-y-4">
402
+ {subsQ.isError ? (
403
+ <PageError message={apiErrorMessage(subsQ.error, '订阅列表加载失败')} onRetry={() => subsQ.refetch()} />
404
+ ) : subsQ.isPending ? (
405
+ <PageLoading label="加载订阅…" />
406
+ ) : (
407
+ <DataTable
408
+ columns={subColumns}
409
+ rows={subsQ.data?.items ?? []}
410
+ rowKey={(s) => s.id}
411
+ empty={<PageEmpty label="暂无订阅" hint="点击右上角“创建订阅”接收第一个事件" />}
412
+ />
413
+ )}
414
+ </TabsContent>
415
+
416
+ <TabsContent value="deliveries" className="mt-4">
417
+ {deliveriesQ.isError ? (
418
+ <PageError message={apiErrorMessage(deliveriesQ.error, '投递记录加载失败')} onRetry={() => deliveriesQ.refetch()} />
419
+ ) : deliveriesQ.isPending ? (
420
+ <PageLoading label="加载投递记录…" />
421
+ ) : (
422
+ <DataTable
423
+ columns={deliveryColumns}
424
+ rows={deliveriesQ.data?.items ?? []}
425
+ rowKey={(d) => d.id}
426
+ empty={<PageEmpty label="暂无投递" hint="触发事件或发送 Ping 后,投递会出现在这里" />}
427
+ />
428
+ )}
429
+ </TabsContent>
430
+ </Tabs>
431
+
432
+ {/* 创建订阅 */}
433
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
434
+ <DialogContent>
435
+ <DialogHeader>
436
+ <DialogTitle>创建订阅</DialogTitle>
437
+ <DialogDescription>
438
+ 事件将以 HMAC 签名投递到回调地址;签名密钥在创建后仅展示一次。
439
+ </DialogDescription>
440
+ </DialogHeader>
441
+ <form className="space-y-4" onSubmit={submitCreate}>
442
+ <FormField label="回调 URL" error={form.formState.errors.url?.message} htmlFor="whk-url">
443
+ <Input id="whk-url" placeholder="https://example.com/webhook" {...form.register('url')} />
444
+ </FormField>
445
+ <FormField label="备注(可选)" error={form.formState.errors.description?.message} htmlFor="whk-desc">
446
+ <Input id="whk-desc" placeholder="用途说明,便于识别" {...form.register('description')} />
447
+ </FormField>
448
+ <FormField label="事件类型" error={form.formState.errors.events?.message}>
449
+ <div className="max-h-44 space-y-2 overflow-y-auto rounded-md border p-3">
450
+ {(eventsQ.data?.items ?? []).map((ev) => {
451
+
452
+
453
+
454
+
455
+
456
+
457
+
458
+ const cbId = `whk-evt-${ev.type.replace(/[^a-zA-Z0-9-]/g, '-')}`
459
+ const checked = selectedEvents.includes(ev.type)
460
+ return (
461
+ <label
462
+ key={ev.type}
463
+ className="flex items-start gap-2 text-sm"
464
+ htmlFor={cbId}
465
+ onClick={(e) => {
466
+ if ((e.target as HTMLElement).closest('[data-slot="checkbox"]')) return
467
+ e.preventDefault()
468
+ toggleEvent(ev.type, !checked)
469
+ }}
470
+ >
471
+ <Checkbox
472
+ id={cbId}
473
+ checked={checked}
474
+ onCheckedChange={(c) => toggleEvent(ev.type, c === true)}
475
+ />
476
+ <span className="min-w-0">
477
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{ev.type}</code>
478
+ {ev.description && <span className="ml-2 text-xs text-muted-foreground">{ev.description}</span>}
479
+ </span>
480
+ </label>
481
+ )
482
+ })}
483
+ {eventsQ.isPending && <p className="text-xs text-muted-foreground">加载事件目录…</p>}
484
+ </div>
485
+ </FormField>
486
+ <DialogFooter>
487
+ <Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
488
+ <Button type="submit" disabled={create.isPending}>
489
+ {create.isPending ? '创建中…' : '创建订阅'}
490
+ </Button>
491
+ </DialogFooter>
492
+ </form>
493
+ </DialogContent>
494
+ </Dialog>
495
+
496
+ {/* 一次性签名密钥(创建/轮换共用:明文只显示一次) */}
497
+ <SecretModal
498
+ open={secret !== null}
499
+ onOpenChange={(o) => !o && closeSecret()}
500
+ secretName="Webhook 签名密钥"
501
+ secretValue={secret ?? ''}
502
+ title={secretTitle}
503
+ />
504
+ </div>
505
+ )
506
+ }