@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,559 @@
1
+
2
+ import { useEffect, useState } from 'react';
3
+ import { useLocation, useSearchParams } from 'react-router';
4
+ import { Copy, Megaphone } from 'lucide-react';
5
+ import { useMutation, useQuery } from '@tanstack/react-query';
6
+ import type { components } from '@ploykit/client';
7
+ import { DataTable, type Column } from '../../components/DataTable';
8
+ import { FormField } from '../../components/FormField';
9
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
10
+ import { toast } from '../../components/toast';
11
+ import { Badge } from '../../components/ui/badge';
12
+ import { Button } from '../../components/ui/button';
13
+ import {
14
+ Card,
15
+ CardAction,
16
+ CardDescription,
17
+ CardHeader,
18
+ CardTitle,
19
+ } from '../../components/ui/card';
20
+ import {
21
+ Dialog,
22
+ DialogContent,
23
+ DialogDescription,
24
+ DialogFooter,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ } from '../../components/ui/dialog';
28
+ import { Input } from '../../components/ui/input';
29
+ import {
30
+ Select,
31
+ SelectContent,
32
+ SelectItem,
33
+ SelectTrigger,
34
+ SelectValue,
35
+ } from '../../components/ui/select';
36
+ import { useApi } from '@ploykit/hooks';
37
+ import { apiErrorMessage } from '../../lib/api-error';
38
+ import { formatDateTime } from '../../lib/utils';
39
+ import { adminError } from './shared';
40
+
41
+ type AdminStats = components['schemas']['AdminStats'];
42
+ type AnalyticsRow = components['schemas']['AnalyticsSummaryRow'];
43
+
44
+
45
+ interface RecentEventRow {
46
+ id: number;
47
+ workspace_id?: string;
48
+ user_id?: string;
49
+ event_type: string;
50
+ entity_type?: string;
51
+ entity_id?: string;
52
+ payload: Record<string, unknown>;
53
+ created_at: string;
54
+ }
55
+
56
+ const DAY_OPTIONS = [7, 14, 30, 90];
57
+ const RECENT_LIMIT = 20;
58
+
59
+
60
+ const STATS_CALIBER: Record<string, string> = {
61
+ 总用户: '全部注册用户(含已封禁)',
62
+ 活跃用户: '状态为「生效中」的用户数(非登录活跃度)',
63
+ 工作区: '全部工作区总数',
64
+ 付费工作区: '当前套餐不是免费套餐的工作区数',
65
+ '近 7 天新增用户': '最近 7 天内注册的用户数',
66
+ '近 30 天新增用户': '最近 30 天内注册的用户数',
67
+ };
68
+
69
+
70
+ function EventTypeCode({ type }: { type: string }) {
71
+ return (
72
+ <code
73
+ className="block max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
74
+ title={type}
75
+ >
76
+ {type}
77
+ </code>
78
+ );
79
+ }
80
+
81
+ const analyticsColumns: Column<AnalyticsRow>[] = [
82
+ {
83
+ key: 'event_type',
84
+ header: '事件类型',
85
+ render: (r) => <EventTypeCode type={r.event_type} />,
86
+ },
87
+ {
88
+ key: 'count',
89
+ header: '次数',
90
+ className: 'w-24 text-right',
91
+ render: (r) => (
92
+ <span className="font-medium tabular-nums">
93
+ {r.count.toLocaleString()}
94
+ </span>
95
+ ),
96
+ },
97
+ {
98
+ key: 'last_at',
99
+ header: '最近发生',
100
+ className: 'w-44',
101
+ render: (r) => (
102
+ <span className="whitespace-nowrap text-muted-foreground">
103
+ {formatDateTime(r.last_at)}
104
+ </span>
105
+ ),
106
+ },
107
+ ];
108
+
109
+ export function OverviewPage() {
110
+ const api = useApi();
111
+ const { hash } = useLocation();
112
+
113
+ const [searchParams, setSearchParams] = useSearchParams();
114
+ const daysParam = Number(searchParams.get('days'));
115
+ const [days, setDays] = useState(
116
+ DAY_OPTIONS.includes(daysParam) ? daysParam : 7,
117
+ );
118
+ const [recentType, setRecentType] = useState(searchParams.get('type') ?? '');
119
+
120
+
121
+ const updateParams = (next: { days?: number; type?: string }) => {
122
+ const p = new URLSearchParams(searchParams);
123
+ if (next.days !== undefined) {
124
+ if (next.days === 7) p.delete('days');
125
+ else p.set('days', String(next.days));
126
+ }
127
+ if (next.type !== undefined) {
128
+ if (next.type === '') p.delete('type');
129
+ else p.set('type', next.type);
130
+ }
131
+ setSearchParams(p, { replace: true });
132
+ };
133
+
134
+
135
+ const [announceOpen, setAnnounceOpen] = useState(false);
136
+ const [target, setTarget] = useState<'all' | 'user'>('all');
137
+ const [targetUser, setTargetUser] = useState('');
138
+ const [title, setTitle] = useState('');
139
+ const [body, setBody] = useState('');
140
+ const [link, setLink] = useState('');
141
+
142
+ const notifyM = useMutation({
143
+ mutationFn: () =>
144
+ api.post<{ status?: string; sent?: number; failed?: number }>(
145
+ '/api/admin/notify',
146
+ {
147
+ target,
148
+ user_id: target === 'user' ? targetUser.trim() : undefined,
149
+ title: title.trim(),
150
+ body: body.trim(),
151
+ link: link.trim() || undefined,
152
+ },
153
+ ),
154
+ onSuccess: (r) => {
155
+
156
+
157
+ if (typeof r?.sent === 'number') {
158
+ const failedSuffix =
159
+ typeof r.failed === 'number' && r.failed > 0
160
+ ? `,失败 ${r.failed}`
161
+ : '';
162
+ toast.success(`公告已发送:成功 ${r.sent}${failedSuffix}`);
163
+ } else if (target === 'user') {
164
+ toast.success('公告已发送给指定用户');
165
+ } else {
166
+
167
+ toast.success('公告已全员发送,逐人明细可在「审计」页查看');
168
+ }
169
+ setAnnounceOpen(false);
170
+ setTitle('');
171
+ setBody('');
172
+ setLink('');
173
+ setTargetUser('');
174
+ },
175
+ onError: (e) => toast.error(adminError(e, '公告发送失败')),
176
+ });
177
+
178
+ const statsQ = useQuery({
179
+ queryKey: ['admin', 'stats'],
180
+ queryFn: () => api.get<AdminStats>('/api/admin/stats'),
181
+ });
182
+ const analyticsQ = useQuery({
183
+ queryKey: ['admin', 'analytics', days],
184
+ queryFn: () => api.get<AnalyticsRow[]>(`/api/admin/analytics?days=${days}`),
185
+ });
186
+
187
+
188
+
189
+
190
+ const [knownTypes, setKnownTypes] = useState<string[]>([]);
191
+ useEffect(() => {
192
+ const types = (analyticsQ.data ?? []).map((r) => r.event_type);
193
+ if (types.length === 0) return;
194
+ setKnownTypes((prev) => {
195
+ const merged = Array.from(new Set([...prev, ...types])).sort();
196
+
197
+ return merged.length === prev.length ? prev : merged;
198
+ });
199
+ }, [analyticsQ.data]);
200
+
201
+ const recentQ = useQuery({
202
+ queryKey: ['admin', 'analytics-recent', recentType],
203
+ queryFn: () =>
204
+ api.get<RecentEventRow[]>(
205
+ `/api/admin/analytics/recent?limit=${RECENT_LIMIT}${recentType ? `&type=${encodeURIComponent(recentType)}` : ''}`,
206
+ ),
207
+ });
208
+ const recentColumns: Column<RecentEventRow>[] = [
209
+ {
210
+ key: 'event_type',
211
+ header: '事件类型',
212
+ render: (r) => <EventTypeCode type={r.event_type} />,
213
+ },
214
+ {
215
+ key: 'entity',
216
+ header: '对象',
217
+ render: (r) =>
218
+ r.entity_type || r.entity_id ? (
219
+ <span
220
+ className="block max-w-[16rem] truncate font-mono text-xs text-muted-foreground"
221
+ title={`${r.entity_type ?? ''} ${r.entity_id ?? ''}`}
222
+ >
223
+ {[r.entity_type, r.entity_id].filter(Boolean).join(' ')}
224
+ </span>
225
+ ) : (
226
+ <span className="text-xs text-muted-foreground">—</span>
227
+ ),
228
+ },
229
+ {
230
+ key: 'workspace_id',
231
+ header: '工作区',
232
+ className: 'w-32',
233
+ render: (r) =>
234
+ r.workspace_id ? (
235
+ <span className="flex items-center gap-1">
236
+ <span
237
+ className="font-mono text-xs text-muted-foreground"
238
+ title={r.workspace_id}
239
+ >
240
+ {r.workspace_id.slice(0, 8)}
241
+ </span>
242
+ {/* B7:短期补偿——ID 无名称解析,给一键复制完整 ID */}
243
+ <Button
244
+ variant="ghost"
245
+ size="sm"
246
+ className="size-6 p-0 text-muted-foreground"
247
+ aria-label={`复制工作区 ID ${r.workspace_id}`}
248
+ title={`复制完整 ID:${r.workspace_id}`}
249
+ onClick={() => {
250
+ const full = r.workspace_id ?? '';
251
+ if (typeof navigator.clipboard?.writeText === 'function') {
252
+ void navigator.clipboard
253
+ .writeText(full)
254
+ .then(() => toast.success('工作区 ID 已复制'))
255
+ .catch(() =>
256
+ toast.error(`复制失败,完整 ID:${full}`),
257
+ );
258
+ } else {
259
+ toast.info(`当前环境不支持自动复制,完整 ID:${full}`);
260
+ }
261
+ }}
262
+ >
263
+ <Copy size={12} aria-hidden="true" />
264
+ </Button>
265
+ </span>
266
+ ) : (
267
+ <span className="text-xs text-muted-foreground">平台</span>
268
+ ),
269
+ },
270
+ {
271
+ key: 'created_at',
272
+ header: '时间',
273
+ className: 'w-44',
274
+ render: (r) => (
275
+ <span className="whitespace-nowrap text-muted-foreground">
276
+ {formatDateTime(r.created_at)}
277
+ </span>
278
+ ),
279
+ },
280
+ ];
281
+
282
+
283
+ useEffect(() => {
284
+ if (hash === '#analytics') {
285
+ document
286
+ .getElementById('admin-analytics')
287
+ ?.scrollIntoView({ block: 'start' });
288
+ }
289
+ }, [hash]);
290
+
291
+ const stats: [string, number][] = statsQ.data
292
+ ? [
293
+ ['总用户', statsQ.data.total_users],
294
+ ['活跃用户', statsQ.data.active_users],
295
+ ['工作区', statsQ.data.total_workspaces],
296
+ ['付费工作区', statsQ.data.paid_workspaces],
297
+ ['近 7 天新增用户', statsQ.data.new_users_7d],
298
+ ['近 30 天新增用户', statsQ.data.new_users_30d],
299
+ ]
300
+ : [];
301
+
302
+ return (
303
+ <div className="space-y-6">
304
+ <PageHeader
305
+ title="概览"
306
+ description="平台全局指标与事件埋点汇总"
307
+ actions={
308
+ <Button onClick={() => setAnnounceOpen(true)}>
309
+ <Megaphone /> 发布公告
310
+ </Button>
311
+ }
312
+ />
313
+
314
+ {statsQ.isPending && (
315
+ <p className="text-sm text-muted-foreground">加载指标中…</p>
316
+ )}
317
+ {statsQ.isError && (
318
+ <PageError
319
+ message={adminError(statsQ.error, '指标加载失败')}
320
+ onRetry={() => void statsQ.refetch()}
321
+ />
322
+ )}
323
+ {statsQ.isSuccess && (
324
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
325
+ {stats.map(([label, value]) => (
326
+ <Card key={label} size="sm" title={STATS_CALIBER[label]}>
327
+ <CardHeader>
328
+ <CardDescription>{label}</CardDescription>
329
+ <CardTitle className="text-2xl font-semibold tabular-nums">
330
+ {value.toLocaleString()}
331
+ </CardTitle>
332
+ </CardHeader>
333
+ </Card>
334
+ ))}
335
+ </div>
336
+ )}
337
+
338
+ {/* 批次 3.3(AN3 活动流):最近埋点事件倒序 20 条;类型过滤选项跨范围累积
339
+ (B4)。空态区分:全类型无事件 = 管线未接/无流量;过滤后无事件 = 换类型。
340
+ B11:区块名升格 h2,标题在上、说明在下(修视觉倒挂)。 */}
341
+ <Card>
342
+ <CardHeader>
343
+ <CardTitle>
344
+ <h2 className="font-medium">最近事件</h2>
345
+ </CardTitle>
346
+ <CardDescription>活动流:近端埋点事件,按时间倒序</CardDescription>
347
+ <CardAction>
348
+ <Select
349
+ value={recentType || 'all'}
350
+ onValueChange={(v) => {
351
+ const next = typeof v === 'string' && v !== 'all' ? v : '';
352
+ setRecentType(next);
353
+ updateParams({ type: next });
354
+ }}
355
+ >
356
+ <SelectTrigger size="sm" aria-label="按事件类型过滤活动流">
357
+ <SelectValue>{recentType || '全部类型'}</SelectValue>
358
+ </SelectTrigger>
359
+ <SelectContent align="end">
360
+ <SelectItem value="all">全部类型</SelectItem>
361
+ {knownTypes.map((t) => (
362
+ <SelectItem key={t} value={t}>
363
+ {t}
364
+ </SelectItem>
365
+ ))}
366
+ </SelectContent>
367
+ </Select>
368
+ </CardAction>
369
+ </CardHeader>
370
+ <div className="px-6">
371
+ {recentQ.isError ? (
372
+ <p className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
373
+ 活动流加载失败({apiErrorMessage(recentQ.error, '未知错误')})
374
+ <button
375
+ className="underline"
376
+ onClick={() => void recentQ.refetch()}
377
+ >
378
+ 重试
379
+ </button>
380
+ </p>
381
+ ) : (
382
+ <DataTable
383
+ columns={recentColumns}
384
+ rows={recentQ.data ?? []}
385
+ rowKey={(r) => String(r.id)}
386
+ loading={recentQ.isPending}
387
+ empty={
388
+ recentType ? (
389
+ <PageEmpty
390
+ label={`近端无 ${recentType} 事件`}
391
+ hint="换个类型或清除过滤"
392
+ />
393
+ ) : (
394
+ <PageEmpty
395
+ label="暂无埋点事件"
396
+ hint="业务流量到达后在此实时可见(analytics 管线产物)"
397
+ />
398
+ )
399
+ }
400
+ />
401
+ )}
402
+ </div>
403
+ </Card>
404
+
405
+ <section id="admin-analytics" className="scroll-mt-4">
406
+ <Card>
407
+ <CardHeader>
408
+ <CardTitle>
409
+ <h2 className="font-medium">事件埋点汇总</h2>
410
+ </CardTitle>
411
+ <CardDescription>按事件类型聚合的出现次数与最近发生时间</CardDescription>
412
+ <CardAction>
413
+ <Select
414
+ value={days}
415
+ onValueChange={(v) => {
416
+ if (typeof v === 'number') {
417
+ setDays(v);
418
+ updateParams({ days: v });
419
+ }
420
+ }}
421
+ >
422
+ <SelectTrigger size="sm" aria-label="统计时间范围">
423
+ <SelectValue>近 {days} 天</SelectValue>
424
+ </SelectTrigger>
425
+ <SelectContent align="end">
426
+ {DAY_OPTIONS.map((d) => (
427
+ <SelectItem key={d} value={d}>
428
+ 近 {d} 天
429
+ </SelectItem>
430
+ ))}
431
+ </SelectContent>
432
+ </Select>
433
+ </CardAction>
434
+ </CardHeader>
435
+ <div className="px-6">
436
+ {analyticsQ.isError ? (
437
+ <p className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
438
+ 埋点汇总加载失败(
439
+ {apiErrorMessage(analyticsQ.error, '未知错误')})
440
+ <button
441
+ className="underline"
442
+ onClick={() => void analyticsQ.refetch()}
443
+ >
444
+ 重试
445
+ </button>
446
+ </p>
447
+ ) : (
448
+ <DataTable
449
+ columns={analyticsColumns}
450
+ rows={analyticsQ.data ?? []}
451
+ rowKey={(r) => r.event_type}
452
+ loading={analyticsQ.isPending}
453
+ empty={
454
+ <PageEmpty
455
+ label="暂无埋点事件"
456
+ hint="近期没有事件写入,或埋点管线未接线"
457
+ />
458
+ }
459
+ />
460
+ )}
461
+ </div>
462
+ </Card>
463
+ </section>
464
+
465
+ <p className="text-xs text-muted-foreground">
466
+ <Badge variant="outline" className="mr-1">
467
+ 提示
468
+ </Badge>
469
+ 管理台数据为平台级视图;用户/工作区的具体操作见左侧导航。
470
+ </p>
471
+
472
+ {/* 发布公告:target user/all;title/body 必填,link 可选;同题同日去重。
473
+ B6:文案去 API 词汇(type=/user_id/uuid/审计事件名)。 */}
474
+ <Dialog open={announceOpen} onOpenChange={setAnnounceOpen}>
475
+ <DialogContent className="sm:max-w-lg">
476
+ <DialogHeader>
477
+ <DialogTitle>发布公告</DialogTitle>
478
+ <DialogDescription>
479
+ 以站内通知发送。全员模式单次上限 1000 人;同标题同日只发一次(重复
480
+ 发送自动跳过)。
481
+ </DialogDescription>
482
+ </DialogHeader>
483
+ <div className="space-y-4">
484
+ <FormField label="收件范围" htmlFor="announce-target">
485
+ <Select
486
+ value={target}
487
+ onValueChange={(v) => {
488
+ if (v === 'user' || v === 'all') setTarget(v);
489
+ }}
490
+ >
491
+ <SelectTrigger id="announce-target" aria-label="收件范围">
492
+ <SelectValue>
493
+ {target === 'all' ? '全部活跃用户' : '指定用户'}
494
+ </SelectValue>
495
+ </SelectTrigger>
496
+ <SelectContent align="start">
497
+ <SelectItem value="all">全部活跃用户</SelectItem>
498
+ <SelectItem value="user">指定用户</SelectItem>
499
+ </SelectContent>
500
+ </Select>
501
+ </FormField>
502
+ {target === 'user' && (
503
+ <FormField label="用户 ID(必填)" htmlFor="announce-user">
504
+ <Input
505
+ id="announce-user"
506
+ className="font-mono"
507
+ placeholder="输入目标用户的用户 ID"
508
+ value={targetUser}
509
+ onChange={(e) => setTargetUser(e.target.value)}
510
+ />
511
+ </FormField>
512
+ )}
513
+ <FormField label="标题(必填)" htmlFor="announce-title">
514
+ <Input
515
+ id="announce-title"
516
+ placeholder="如:今晚 22:00 例行维护"
517
+ value={title}
518
+ onChange={(e) => setTitle(e.target.value)}
519
+ />
520
+ </FormField>
521
+ <FormField label="正文(必填)" htmlFor="announce-body">
522
+ <textarea
523
+ id="announce-body"
524
+ className="flex min-h-24 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
525
+ placeholder="公告内容(支持多行)"
526
+ value={body}
527
+ onChange={(e) => setBody(e.target.value)}
528
+ />
529
+ </FormField>
530
+ <FormField label="跳转链接(可选)" htmlFor="announce-link">
531
+ <Input
532
+ id="announce-link"
533
+ placeholder="https://… 或站内页面路径;填写后通知可点击跳转"
534
+ value={link}
535
+ onChange={(e) => setLink(e.target.value)}
536
+ />
537
+ </FormField>
538
+ </div>
539
+ <DialogFooter>
540
+ <Button variant="outline" onClick={() => setAnnounceOpen(false)}>
541
+ 取消
542
+ </Button>
543
+ <Button
544
+ disabled={
545
+ notifyM.isPending ||
546
+ !title.trim() ||
547
+ !body.trim() ||
548
+ (target === 'user' && !targetUser.trim())
549
+ }
550
+ onClick={() => notifyM.mutate()}
551
+ >
552
+ {notifyM.isPending ? '发送中…' : '发送'}
553
+ </Button>
554
+ </DialogFooter>
555
+ </DialogContent>
556
+ </Dialog>
557
+ </div>
558
+ );
559
+ }