@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,642 @@
1
+
2
+ import { useState } from 'react';
3
+ import { useSearchParams } from 'react-router';
4
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { useConfirm } from '../../components/ConfirmDialog';
6
+ import { DataTable, type Column } from '../../components/DataTable';
7
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
8
+ import { SettingsCard } from '../../components/SettingsCard';
9
+ import { toast } from '../../components/toast';
10
+ import { Badge } from '../../components/ui/badge'
11
+ import { StatusBadge } from '../../components/StatusBadge';
12
+ import { Button } from '../../components/ui/button';
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from '../../components/ui/select';
20
+ import {
21
+ Tabs,
22
+ TabsContent,
23
+ TabsList,
24
+ TabsTrigger,
25
+ } from '../../components/ui/tabs';
26
+ import { useApi } from '@ploykit/hooks';
27
+ import { formatDateTime, shortID, statusLabel } from '../../lib/utils';
28
+ import {
29
+ ADMIN_PAGE_SIZE,
30
+ adminError,
31
+ adminListQuery,
32
+ formatCents,
33
+ type AdminListEnvelope,
34
+ } from './shared';
35
+
36
+
37
+ interface AdminOrderRow {
38
+ id: string;
39
+ workspace_id: string;
40
+ workspace_name: string;
41
+ user_id: string;
42
+ plan_code: string;
43
+ interval: string;
44
+ amount_cents: number;
45
+ currency: string;
46
+ channel: string;
47
+ status: string;
48
+ channel_ref?: string;
49
+ paid_at?: string;
50
+ canceled_at?: string;
51
+ refunded_at?: string;
52
+ created_at: string;
53
+ }
54
+
55
+
56
+ interface AdminPaymentEventRow {
57
+ id: string;
58
+ channel: string;
59
+ channel_event_id: string;
60
+ event_type: string;
61
+ order_id?: string;
62
+ process_status: string;
63
+ process_error?: string;
64
+ processed_at?: string;
65
+ processed: boolean;
66
+ created_at: string;
67
+ }
68
+
69
+
70
+ interface AdminBillingChannelRow {
71
+ name: string;
72
+ configured: boolean;
73
+ key_masked: string;
74
+ webhook_configured: boolean;
75
+ }
76
+
77
+
78
+ interface AdminBillingConfigEnvelope {
79
+ channels: AdminBillingChannelRow[];
80
+ }
81
+
82
+
83
+ const ORDER_STATUSES = ['pending', 'paid', 'failed', 'canceled', 'refunded'];
84
+
85
+
86
+ function orderStatusLabel(s: string): string {
87
+ if (s === 'refunded') return '已退款';
88
+ return statusLabel(s);
89
+ }
90
+
91
+
92
+ const PROCESS_STATUS_LABEL: Record<string, string> = {
93
+ processed: '已处理',
94
+ pending: '待处理',
95
+ error: '处理出错',
96
+ };
97
+
98
+ export function OrdersPage() {
99
+ const api = useApi();
100
+ const qc = useQueryClient();
101
+ const confirm = useConfirm();
102
+
103
+
104
+ const [searchParams, setSearchParams] = useSearchParams();
105
+ const statusParam = searchParams.get('status') ?? '';
106
+ const status = ORDER_STATUSES.includes(statusParam) ? statusParam : '';
107
+ const orderPage = Math.max(
108
+ 1,
109
+ Number.parseInt(searchParams.get('page') ?? '1', 10) || 1,
110
+ );
111
+ const setOrderFilter = (nextStatus: string, nextPage = 1) => {
112
+ const p = new URLSearchParams(searchParams);
113
+ if (nextStatus) p.set('status', nextStatus);
114
+ else p.delete('status');
115
+ if (nextPage > 1) p.set('page', String(nextPage));
116
+ else p.delete('page');
117
+ setSearchParams(p, { replace: true });
118
+ };
119
+ const [eventPage, setEventPage] = useState(1);
120
+
121
+ const ordersQ = useQuery({
122
+ queryKey: ['admin', 'orders', orderPage, status],
123
+ queryFn: () =>
124
+ api.get<AdminListEnvelope<AdminOrderRow>>(
125
+ `/api/admin/orders${adminListQuery(orderPage, ADMIN_PAGE_SIZE, { status })}`,
126
+ ),
127
+ });
128
+ const eventsQ = useQuery({
129
+ queryKey: ['admin', 'payment-events', eventPage],
130
+ queryFn: () =>
131
+ api.get<AdminListEnvelope<AdminPaymentEventRow>>(
132
+ `/api/admin/payment-events${adminListQuery(eventPage, ADMIN_PAGE_SIZE)}`,
133
+ ),
134
+ });
135
+
136
+
137
+ const channelsQ = useQuery({
138
+ queryKey: ['admin', 'billing-config'],
139
+ queryFn: () =>
140
+ api.get<AdminBillingConfigEnvelope>('/api/admin/billing/config'),
141
+ });
142
+
143
+
144
+ const [testingChannel, setTestingChannel] = useState('');
145
+ const testConnM = useMutation({
146
+ mutationFn: (channel: string) =>
147
+ api.post('/api/admin/billing/test-connection', { channel }),
148
+ onMutate: (channel) => setTestingChannel(channel),
149
+ onSettled: () => setTestingChannel(''),
150
+ onSuccess: (_d, channel) => toast.success(`${channel} 连接正常`),
151
+ onError: (e, channel) =>
152
+ toast.error(adminError(e, `${channel} 连接测试失败`)),
153
+ });
154
+
155
+ const cancelM = useMutation({
156
+ mutationFn: (o: AdminOrderRow) => api.delete(`/api/admin/orders/${o.id}`),
157
+ onSuccess: (_d, o) => {
158
+ toast.success(`订单 ${shortID(o.id)} 已取消`);
159
+ void qc.invalidateQueries({ queryKey: ['admin', 'orders'] });
160
+ },
161
+ onError: (e) => toast.error(adminError(e, '订单取消失败')),
162
+ });
163
+
164
+
165
+
166
+
167
+ const markPaidM = useMutation({
168
+ mutationFn: (o: AdminOrderRow) =>
169
+ api.post(`/api/admin/billing/orders/${o.id}/mark-paid`),
170
+ onSuccess: (_d, o) => {
171
+ toast.success(`订单 ${shortID(o.id)} 已核销(manual 标记已付)`);
172
+ void qc.invalidateQueries({ queryKey: ['admin', 'orders'] });
173
+ },
174
+ onError: (e) => toast.error(adminError(e, '标记已付失败')),
175
+ });
176
+
177
+
178
+ const expiryM = useMutation({
179
+ mutationFn: () =>
180
+ api.post<{ expired: number }>('/api/admin/billing/run-expiry'),
181
+ onSuccess: (d) =>
182
+ toast.success(`本轮到期降级完成:降级 ${d?.expired ?? 0} 个工作区`),
183
+ onError: (e) => toast.error(adminError(e, '到期降级触发失败')),
184
+ });
185
+
186
+
187
+ const overageM = useMutation({
188
+ mutationFn: () =>
189
+ api.post<{ orders_created: number }>('/api/admin/billing/run-overage'),
190
+ onSuccess: (d) =>
191
+ toast.success(`本轮超额出账完成:新建 ${d?.orders_created ?? 0} 笔订单`),
192
+ onError: (e) => toast.error(adminError(e, '超额出账触发失败')),
193
+ });
194
+
195
+ const onCancelOrder = async (o: AdminOrderRow) => {
196
+ const ok = await confirm({
197
+ title: '取消订单',
198
+ description: `将取消 ${o.workspace_name} 的 ${formatCents(o.amount_cents, o.currency)} 订单(${shortID(o.id)})。仅 pending 订单可取消。`,
199
+ confirmText: '取消订单',
200
+ danger: true,
201
+ });
202
+ if (ok) cancelM.mutate(o);
203
+ };
204
+
205
+
206
+ const onMarkPaid = async (o: AdminOrderRow) => {
207
+ const ok = await confirm({
208
+ title: '标记已付(manual 核销)',
209
+ description: `确认已线下收到 ${o.workspace_name} 的 ${formatCents(o.amount_cents, o.currency)}(订单 ${shortID(o.id)},渠道 manual)?标记后 pending→paid 并发 billing.order_marked_paid 事件。`,
210
+ confirmText: '标记已付',
211
+ danger: true,
212
+ });
213
+ if (ok) markPaidM.mutate(o);
214
+ };
215
+
216
+
217
+
218
+ const onRunExpiry = async () => {
219
+ const ok = await confirm({
220
+ title: '触发一轮到期降级',
221
+ description:
222
+ '将立即扫描全部工作区,把订阅已到期的工作区降级为免费套餐——命中的工作区即刻失去付费能力(用户可重新购买恢复)。扫描幂等,可重复执行;操作计入平台审计日志。',
223
+ confirmText: '执行降级扫描',
224
+ danger: true,
225
+ });
226
+ if (ok) expiryM.mutate();
227
+ };
228
+
229
+ const onRunOverage = async () => {
230
+ const ok = await confirm({
231
+ title: '触发一轮超额出账',
232
+ description:
233
+ '将立即为全部超额工作区按当前用量创建计量账单订单(真实出账,用户会看到新的待支付订单)。操作幂等,可重复执行;结果计入平台审计日志。',
234
+ confirmText: '执行出账',
235
+ danger: true,
236
+ });
237
+ if (ok) overageM.mutate();
238
+ };
239
+
240
+ const orders = ordersQ.data?.items ?? [];
241
+ const ordersTotal = ordersQ.data?.total ?? 0;
242
+ const pastEndOrders =
243
+ !ordersQ.isPending && orders.length === 0 && orderPage > 1;
244
+
245
+ const orderColumns: Column<AdminOrderRow>[] = [
246
+ {
247
+ key: 'workspace',
248
+ header: '工作区',
249
+ render: (o) => (
250
+
251
+ <div className="min-w-0 max-w-[16rem]">
252
+ <p className="truncate font-medium">{o.workspace_name || '—'}</p>
253
+ <p
254
+ className="truncate font-mono text-xs text-muted-foreground"
255
+ title={o.workspace_id}
256
+ >
257
+ {shortID(o.workspace_id)}
258
+ </p>
259
+ </div>
260
+ ),
261
+ },
262
+ {
263
+ key: 'plan',
264
+ header: '套餐 / 周期',
265
+ className: 'w-32',
266
+ render: (o) => (
267
+ <div className="min-w-0 max-w-[12rem]">
268
+ <p className="truncate">{o.plan_code}</p>
269
+ <p className="truncate text-xs text-muted-foreground">{o.interval}</p>
270
+ </div>
271
+ ),
272
+ },
273
+ {
274
+ key: 'amount_cents',
275
+ header: '金额',
276
+ className: 'w-28',
277
+ render: (o) => (
278
+ <span className="whitespace-nowrap font-medium tabular-nums">
279
+ {formatCents(o.amount_cents, o.currency)}
280
+ </span>
281
+ ),
282
+ },
283
+ {
284
+ key: 'channel',
285
+ header: '渠道',
286
+ className: 'w-24',
287
+ render: (o) => <span className="text-muted-foreground">{o.channel}</span>,
288
+ },
289
+ {
290
+ key: 'status',
291
+ header: '状态',
292
+ className: 'w-24',
293
+ render: (o) => <StatusBadge status={o.status} />,
294
+ },
295
+ {
296
+ key: 'created_at',
297
+ header: '创建时间',
298
+ className: 'w-44',
299
+ render: (o) => (
300
+ <span className="whitespace-nowrap text-muted-foreground">
301
+ {formatDateTime(o.created_at)}
302
+ </span>
303
+ ),
304
+ },
305
+ {
306
+ key: 'actions',
307
+ header: '操作',
308
+ className: 'w-36',
309
+ render: (o) => (
310
+ <div className="flex items-center justify-end gap-1.5">
311
+ {/* 批次 2.8:pending 且 channel=manual 的行可核销(标记已付);
312
+ stripe 等渠道订单不出现(核销 = 绕过渠道记账,服务端 400 兜底)。 */}
313
+ {o.status === 'pending' && o.channel === 'manual' && (
314
+ <Button
315
+ variant="outline"
316
+ size="sm"
317
+ disabled={markPaidM.isPending}
318
+ title="线下收款确认后核销:pending→paid,并发 billing.order_marked_paid 事件"
319
+ onClick={() => void onMarkPaid(o)}
320
+ >
321
+ 标记已付
322
+ </Button>
323
+ )}
324
+ <Button
325
+ variant="outline"
326
+ size="sm"
327
+ disabled={o.status !== 'pending' || cancelM.isPending}
328
+ title={o.status !== 'pending' ? '仅 pending 订单可取消' : undefined}
329
+ onClick={() => void onCancelOrder(o)}
330
+ >
331
+ 取消
332
+ </Button>
333
+ </div>
334
+ ),
335
+ },
336
+ ];
337
+
338
+ const events = eventsQ.data?.items ?? [];
339
+ const eventsTotal = eventsQ.data?.total ?? 0;
340
+ const pastEndEvents =
341
+ !eventsQ.isPending && events.length === 0 && eventPage > 1;
342
+
343
+ const eventColumns: Column<AdminPaymentEventRow>[] = [
344
+ {
345
+ key: 'channel',
346
+ header: '渠道 / 事件',
347
+ render: (e) => (
348
+ <div className="min-w-0 max-w-[16rem]">
349
+ <p className="truncate">{e.channel}</p>
350
+ <p className="truncate text-xs text-muted-foreground">
351
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
352
+ {e.event_type}
353
+ </code>
354
+ </p>
355
+ </div>
356
+ ),
357
+ },
358
+ {
359
+ key: 'channel_event_id',
360
+ header: '渠道事件 ID',
361
+ className: 'w-40',
362
+ render: (e) => (
363
+ <span
364
+ className="truncate font-mono text-xs text-muted-foreground"
365
+ title={e.channel_event_id}
366
+ >
367
+ {shortID(e.channel_event_id)}
368
+ </span>
369
+ ),
370
+ },
371
+ {
372
+ key: 'order_id',
373
+ header: '关联订单',
374
+ className: 'w-28',
375
+ render: (e) =>
376
+ e.order_id ? (
377
+ <span className="font-mono text-xs" title={e.order_id}>
378
+ {shortID(e.order_id)}
379
+ </span>
380
+ ) : (
381
+ <span className="text-xs text-muted-foreground">—</span>
382
+ ),
383
+ },
384
+ {
385
+ key: 'processed',
386
+ header: '处理状态',
387
+ className: 'w-32',
388
+ render: (e) => (
389
+ <div className="flex min-w-0 items-center gap-1.5">
390
+ {e.processed ? (
391
+ <Badge variant="outline">已处理</Badge>
392
+ ) : (
393
+ <Badge variant="outline" className="text-muted-foreground">
394
+ 未处理
395
+ </Badge>
396
+ )}
397
+ {/* B-orders-12:处理状态走中文映射,不再与英文枚举原文双显 */}
398
+ {e.process_status !== 'processed' && (
399
+ <span
400
+ className="truncate text-xs text-muted-foreground"
401
+ title={e.process_error ?? ''}
402
+ >
403
+ {PROCESS_STATUS_LABEL[e.process_status] ?? e.process_status}
404
+ </span>
405
+ )}
406
+ </div>
407
+ ),
408
+ },
409
+ {
410
+ key: 'created_at',
411
+ header: '时间',
412
+ className: 'w-44',
413
+ render: (e) => (
414
+ <span className="whitespace-nowrap text-muted-foreground">
415
+ {formatDateTime(e.created_at)}
416
+ </span>
417
+ ),
418
+ },
419
+ ];
420
+
421
+ return (
422
+ <div>
423
+ <PageHeader
424
+ title="订单"
425
+ description="跨工作区订单、支付事件幂等层与计费运维手动触发"
426
+ actions={
427
+ <div className="flex gap-2">
428
+ <Button
429
+ variant="outline"
430
+ size="sm"
431
+ disabled={expiryM.isPending}
432
+ onClick={() => void onRunExpiry()}
433
+ >
434
+ {expiryM.isPending ? '执行中…' : '触发到期降级'}
435
+ </Button>
436
+ <Button
437
+ variant="outline"
438
+ size="sm"
439
+ disabled={overageM.isPending}
440
+ onClick={() => void onRunOverage()}
441
+ >
442
+ {overageM.isPending ? '执行中…' : '触发超额出账'}
443
+ </Button>
444
+ </div>
445
+ }
446
+ />
447
+
448
+ <SettingsCard
449
+ title="支付渠道"
450
+ description="渠道凭据配置状态与连通性(env 是密钥真源,此处只读可见性;密钥 DB 化待第二渠道出现再做)"
451
+ >
452
+ {channelsQ.isError ? (
453
+ <PageError
454
+ message={adminError(channelsQ.error, '渠道配置加载失败')}
455
+ onRetry={() => void channelsQ.refetch()}
456
+ />
457
+ ) : (
458
+ <div className="space-y-2">
459
+ {channelsQ.isPending && (
460
+ <p className="text-sm text-muted-foreground">加载渠道配置中…</p>
461
+ )}
462
+ {(channelsQ.data?.channels ?? []).map((ch) => (
463
+ <div
464
+ key={ch.name}
465
+ className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border px-4 py-3 text-sm"
466
+ >
467
+ <span className="min-w-20 font-medium">{ch.name}</span>
468
+ {ch.configured ? (
469
+ <Badge>已配置</Badge>
470
+ ) : (
471
+ <Badge variant="outline" className="text-muted-foreground">
472
+ 未配置
473
+ </Badge>
474
+ )}
475
+ <code
476
+ className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
477
+ title="密钥掩码(原始密钥不可见)"
478
+ >
479
+ {ch.key_masked}
480
+ </code>
481
+ {ch.webhook_configured ? (
482
+ <Badge variant="outline">webhook 已配置</Badge>
483
+ ) : (
484
+ <Badge variant="outline" className="text-muted-foreground">
485
+ webhook 未配置
486
+ </Badge>
487
+ )}
488
+ <span className="flex-1" />
489
+ <Button
490
+ variant="outline"
491
+ size="sm"
492
+ disabled={
493
+ !ch.configured ||
494
+ ch.name === 'manual' ||
495
+ testConnM.isPending
496
+ }
497
+ title={
498
+ !ch.configured
499
+ ? '渠道未配置密钥,无法测试'
500
+ : ch.name === 'manual'
501
+ ? 'manual 为线下收款渠道,无外部服务可测试连接'
502
+ : '向渠道发起一次只读连通性请求'
503
+ }
504
+ onClick={() => testConnM.mutate(ch.name)}
505
+ >
506
+ {testingChannel === ch.name ? '测试中…' : '测试连接'}
507
+ </Button>
508
+ </div>
509
+ ))}
510
+ {!channelsQ.isPending &&
511
+ (channelsQ.data?.channels ?? []).length === 0 && (
512
+ <p className="text-sm text-muted-foreground">暂无渠道</p>
513
+ )}
514
+ </div>
515
+ )}
516
+ </SettingsCard>
517
+
518
+ <Tabs defaultValue="orders">
519
+ <TabsList className="mb-4">
520
+ <TabsTrigger value="orders">订单</TabsTrigger>
521
+ <TabsTrigger value="payment-events">支付事件</TabsTrigger>
522
+ </TabsList>
523
+
524
+ <TabsContent value="orders">
525
+ <div className="mb-4 flex flex-wrap items-center gap-2">
526
+ <Select
527
+ value={status || 'all'}
528
+ onValueChange={(v) => {
529
+ const next =
530
+ typeof v === 'string' && v !== 'all' ? v : '';
531
+ setOrderFilter(next, 1);
532
+ }}
533
+ >
534
+ <SelectTrigger
535
+ size="sm"
536
+ className="w-44"
537
+ aria-label="按订单状态过滤"
538
+ >
539
+ <SelectValue>
540
+ {status ? orderStatusLabel(status) : '全部状态'}
541
+ </SelectValue>
542
+ </SelectTrigger>
543
+ <SelectContent align="start">
544
+ <SelectItem value="all">全部状态</SelectItem>
545
+ {ORDER_STATUSES.map((s) => (
546
+ <SelectItem key={s} value={s}>
547
+ {orderStatusLabel(s)}
548
+ </SelectItem>
549
+ ))}
550
+ </SelectContent>
551
+ </Select>
552
+ </div>
553
+ {ordersQ.isError ? (
554
+ <PageError
555
+
556
+ message={`${adminError(ordersQ.error, '订单列表加载失败')};重试仍失败时,请稍后再来或联系平台管理员`}
557
+ onRetry={() => void ordersQ.refetch()}
558
+ />
559
+ ) : (
560
+ <DataTable
561
+ columns={orderColumns}
562
+ rows={orders}
563
+ rowKey={(o) => o.id}
564
+ loading={ordersQ.isPending}
565
+ pagination={{
566
+ page: orderPage,
567
+ pageSize: ADMIN_PAGE_SIZE,
568
+ total: ordersTotal,
569
+ onPageChange: (p) => setOrderFilter(status, p),
570
+ }}
571
+ empty={
572
+ pastEndOrders ? (
573
+ <div className="flex flex-col items-center gap-2">
574
+ <PageEmpty label="没有更多订单了" hint="当前页数据可能已被删除" />
575
+ <Button
576
+ variant="outline"
577
+ size="sm"
578
+ onClick={() => setOrderFilter(status, 1)}
579
+ >
580
+ 返回第一页
581
+ </Button>
582
+ </div>
583
+ ) : status ? (
584
+ <PageEmpty
585
+ label="没有该状态的订单"
586
+ hint="换个状态或清除过滤"
587
+ />
588
+ ) : (
589
+ <PageEmpty
590
+ label="暂无订单"
591
+ hint="工作区完成购买后订单会出现在这里(含线下 manual 单)"
592
+ />
593
+ )
594
+ }
595
+ />
596
+ )}
597
+ </TabsContent>
598
+
599
+ <TabsContent value="payment-events">
600
+ {eventsQ.isError ? (
601
+ <PageError
602
+ message={adminError(eventsQ.error, '支付事件加载失败')}
603
+ onRetry={() => void eventsQ.refetch()}
604
+ />
605
+ ) : (
606
+ <DataTable
607
+ columns={eventColumns}
608
+ rows={events}
609
+ rowKey={(e) => e.id}
610
+ loading={eventsQ.isPending}
611
+ pagination={{
612
+ page: eventPage,
613
+ pageSize: ADMIN_PAGE_SIZE,
614
+ total: eventsTotal,
615
+ onPageChange: setEventPage,
616
+ }}
617
+ empty={
618
+ pastEndEvents ? (
619
+ <div className="flex flex-col items-center gap-3 py-10 text-sm text-muted-foreground">
620
+ <p>没有更多支付事件了</p>
621
+ <Button
622
+ variant="outline"
623
+ size="sm"
624
+ onClick={() => setEventPage(1)}
625
+ >
626
+ 返回第一页
627
+ </Button>
628
+ </div>
629
+ ) : (
630
+ <PageEmpty
631
+ label="暂无支付事件"
632
+ hint="渠道回调到达后在此可见(含未消费事件)"
633
+ />
634
+ )
635
+ }
636
+ />
637
+ )}
638
+ </TabsContent>
639
+ </Tabs>
640
+ </div>
641
+ );
642
+ }