@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,199 @@
1
+
2
+ import { Link, Navigate, useLocation } from 'react-router';
3
+ import type { ReactNode } from 'react';
4
+ import {
5
+ Activity,
6
+ ArrowLeft,
7
+ Building2,
8
+ KeyRound,
9
+ LayoutDashboard,
10
+ Receipt,
11
+ ScrollText,
12
+ Settings,
13
+ ShieldCheck,
14
+ Users,
15
+ Webhook,
16
+ } from 'lucide-react';
17
+ import { buttonVariants } from '../../components/ui/button';
18
+ import { UserMenu } from '../../components/UserMenu';
19
+ import { useAuth } from '@ploykit/hooks';
20
+ import { cn } from '../../lib/utils';
21
+
22
+ interface AdminNavItem {
23
+ to: string;
24
+ label: string;
25
+ icon: ReactNode;
26
+
27
+ hash?: boolean;
28
+ }
29
+
30
+ const NAV: AdminNavItem[] = [
31
+ {
32
+ to: '/admin/overview',
33
+ label: '概览',
34
+ icon: <LayoutDashboard size={16} aria-hidden="true" />,
35
+ },
36
+ {
37
+ to: '/admin/users',
38
+ label: '用户',
39
+ icon: <Users size={16} aria-hidden="true" />,
40
+ },
41
+ {
42
+ to: '/admin/workspaces',
43
+ label: '工作区',
44
+ icon: <Building2 size={16} aria-hidden="true" />,
45
+ },
46
+ {
47
+ to: '/admin/orders',
48
+ label: '订单',
49
+ icon: <Receipt size={16} aria-hidden="true" />,
50
+ },
51
+ {
52
+ to: '/admin/webhook-deliveries',
53
+ label: 'Webhooks',
54
+ icon: <Webhook size={16} aria-hidden="true" />,
55
+ },
56
+
57
+ {
58
+ to: '/admin/sso',
59
+ label: 'SSO',
60
+ icon: <KeyRound size={16} aria-hidden="true" />,
61
+ },
62
+ {
63
+ to: '/admin/audit',
64
+ label: '审计',
65
+ icon: <ScrollText size={16} aria-hidden="true" />,
66
+ },
67
+ {
68
+ to: '/admin/overview#analytics',
69
+ label: '分析',
70
+ icon: <Activity size={16} aria-hidden="true" />,
71
+ hash: true,
72
+ },
73
+
74
+ {
75
+ to: '/admin/settings',
76
+ label: '系统',
77
+ icon: <Settings size={16} aria-hidden="true" />,
78
+ },
79
+ ];
80
+
81
+ function isActive(pathname: string, hash: string, item: AdminNavItem): boolean {
82
+ if (item.hash) return pathname === '/admin/overview' && hash === '#analytics';
83
+ if (item.to === '/admin/overview')
84
+ return pathname === '/admin/overview' && hash !== '#analytics';
85
+ return pathname === item.to || pathname.startsWith(item.to + '/');
86
+ }
87
+
88
+ function renderNav(
89
+ pathname: string,
90
+ hash: string,
91
+ className: string,
92
+ ) {
93
+ return NAV.map((item) => {
94
+ const active = isActive(pathname, hash, item);
95
+ return (
96
+ <Link
97
+ key={item.label}
98
+ to={item.to}
99
+ aria-current={active ? 'page' : undefined}
100
+ className={cn(
101
+ buttonVariants({ variant: 'ghost' }),
102
+ className,
103
+ active && 'bg-muted',
104
+ )}
105
+ >
106
+ {item.icon}
107
+ {item.label}
108
+ </Link>
109
+ );
110
+ });
111
+ }
112
+
113
+ export interface AdminShellProps {
114
+ children: ReactNode;
115
+
116
+ productPath?: string;
117
+
118
+ loginPath?: string;
119
+ }
120
+
121
+ export function AdminShell({
122
+ children,
123
+ productPath = '/app',
124
+ loginPath = '/login',
125
+ }: AdminShellProps) {
126
+ const { user, loading } = useAuth();
127
+ const { pathname, hash, search } = useLocation();
128
+
129
+ if (loading) {
130
+ return (
131
+ <div className="flex min-h-svh items-center justify-center text-sm text-muted-foreground">
132
+ 加载中…
133
+ </div>
134
+ );
135
+ }
136
+
137
+ if (!user) {
138
+ return (
139
+ <Navigate to={loginPath} state={{ from: `${pathname}${search}` }} replace />
140
+ );
141
+ }
142
+
143
+ return (
144
+ <div className="flex h-svh flex-col bg-background text-foreground">
145
+ <header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4 lg:px-6">
146
+ <Link
147
+ to="/admin/overview"
148
+ className="flex items-center gap-2 text-sm font-semibold text-foreground"
149
+ >
150
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
151
+ <ShieldCheck size={16} aria-hidden="true" />
152
+ </span>
153
+ 管理控制台
154
+ </Link>
155
+ <div className="flex items-center gap-2">
156
+ <Link
157
+ to={productPath}
158
+ className={cn(
159
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
160
+ 'text-muted-foreground',
161
+ )}
162
+ >
163
+ <ArrowLeft size={15} aria-hidden="true" /> 返回产品
164
+ </Link>
165
+ <UserMenu />
166
+ </div>
167
+ </header>
168
+
169
+ <div className="flex min-h-0 flex-1 flex-col">
170
+ {/* 窄屏:紧凑 chips 横向导航(B9/X2——不再 w-full 撑满一屏,多项可见) */}
171
+ <nav
172
+ aria-label="管理控制台导航"
173
+ className="flex gap-1 overflow-x-auto border-b px-3 py-2 md:hidden"
174
+ >
175
+ {renderNav(
176
+ pathname,
177
+ hash,
178
+ 'shrink-0 justify-start whitespace-nowrap px-3',
179
+ )}
180
+ </nav>
181
+
182
+ <div className="flex min-h-0 flex-1">
183
+ {/* 宽屏:左侧固定节导航 */}
184
+ <aside className="hidden w-56 shrink-0 border-r md:block">
185
+ <nav aria-label="管理控制台导航" className="space-y-1 p-3">
186
+ {renderNav(pathname, hash, 'w-full justify-start')}
187
+ </nav>
188
+ </aside>
189
+
190
+ <main className="min-w-0 flex-1 overflow-y-auto">
191
+ <div className="mx-auto w-full max-w-6xl p-4 lg:p-6">
192
+ {children}
193
+ </div>
194
+ </main>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,338 @@
1
+
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { useSearchParams } from 'react-router';
4
+ import { Download, Search } from 'lucide-react';
5
+ import { useQuery } from '@tanstack/react-query';
6
+ import { apiFetch } from '@ploykit/client';
7
+ import type { components } from '@ploykit/client';
8
+ import { DataTable, type Column } from '../../components/DataTable';
9
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
10
+ import { toast } from '../../components/toast';
11
+ import { Button } from '../../components/ui/button';
12
+ import { Input } from '../../components/ui/input';
13
+ import { useApi } from '@ploykit/hooks';
14
+ import { apiErrorMessage } from '../../lib/api-error';
15
+ import { shortID, formatDateTime } from '../../lib/utils';
16
+ import { ADMIN_PAGE_SIZE, adminError } from './shared';
17
+
18
+ type AuditEvent = components['schemas']['AuditEvent'];
19
+ type AuditListResponse = components['schemas']['AuditListResponse'];
20
+
21
+ interface FilterDraft {
22
+ workspace: string;
23
+ action: string;
24
+ actor: string;
25
+ actorId: string;
26
+ from: string;
27
+ to: string;
28
+ }
29
+
30
+ const emptyDraft: FilterDraft = {
31
+ workspace: '',
32
+ action: '',
33
+ actor: '',
34
+ actorId: '',
35
+ from: '',
36
+ to: '',
37
+ };
38
+
39
+
40
+ function draftFromParams(p: URLSearchParams): FilterDraft {
41
+ return {
42
+ workspace: p.get('workspace') ?? '',
43
+ action: p.get('action') ?? '',
44
+ actor: p.get('actor') ?? '',
45
+ actorId: p.get('actor_id') ?? '',
46
+ from: p.get('from') ?? '',
47
+ to: p.get('to') ?? '',
48
+ };
49
+ }
50
+
51
+
52
+ function paramsFromDraft(d: FilterDraft, page: number): URLSearchParams {
53
+ const p = new URLSearchParams();
54
+ if (d.workspace.trim()) p.set('workspace', d.workspace.trim());
55
+ if (d.action.trim()) p.set('action', d.action.trim());
56
+ if (d.actor.trim()) p.set('actor', d.actor.trim());
57
+ if (d.actorId.trim()) p.set('actor_id', d.actorId.trim());
58
+ if (d.from) p.set('from', d.from);
59
+ if (d.to) p.set('to', d.to);
60
+ if (page > 1) p.set('page', String(page));
61
+ return p;
62
+ }
63
+
64
+
65
+ function auditQueryString(
66
+ d: FilterDraft,
67
+ limit: number,
68
+ offset: number,
69
+ withPaging: boolean,
70
+ ): string {
71
+ const p = new URLSearchParams();
72
+ if (d.workspace.trim()) p.set('workspace', d.workspace.trim());
73
+ if (d.action.trim()) p.set('action', d.action.trim());
74
+ if (d.actor.trim()) p.set('actor', d.actor.trim());
75
+ if (d.actorId.trim()) p.set('actor_id', d.actorId.trim());
76
+ if (d.from) p.set('from', `${d.from}T00:00:00Z`);
77
+ if (d.to) {
78
+
79
+ const t = new Date(`${d.to}T00:00:00Z`);
80
+ t.setUTCDate(t.getUTCDate() + 1);
81
+ p.set('to', t.toISOString());
82
+ }
83
+ if (withPaging) {
84
+ p.set('limit', String(limit));
85
+ p.set('offset', String(offset));
86
+ }
87
+ const qs = p.toString();
88
+ return qs ? `?${qs}` : '';
89
+ }
90
+
91
+ export function AuditPage() {
92
+ const api = useApi();
93
+ const [searchParams, setSearchParams] = useSearchParams();
94
+
95
+ const [draft, setDraft] = useState<FilterDraft>(() =>
96
+ draftFromParams(searchParams),
97
+ );
98
+
99
+
100
+ useEffect(() => {
101
+ setDraft(draftFromParams(searchParams));
102
+ }, [searchParams]);
103
+ const applied = useMemo(() => draftFromParams(searchParams), [searchParams]);
104
+ const page = Math.max(
105
+ 1,
106
+ Number.parseInt(searchParams.get('page') ?? '1', 10) || 1,
107
+ );
108
+ const [exporting, setExporting] = useState(false);
109
+
110
+ const offset = (page - 1) * ADMIN_PAGE_SIZE;
111
+ const auditQ = useQuery({
112
+ queryKey: [
113
+ 'admin',
114
+ 'audit',
115
+ auditQueryString(applied, ADMIN_PAGE_SIZE, offset, true),
116
+ ],
117
+ queryFn: () =>
118
+ api.get<AuditListResponse>(
119
+ `/api/admin/audit${auditQueryString(applied, ADMIN_PAGE_SIZE, offset, true)}`,
120
+ ),
121
+ });
122
+
123
+
124
+ const onExportCsv = async () => {
125
+ setExporting(true);
126
+ try {
127
+ const blob = await apiFetch<Blob>(
128
+ `/api/admin/audit/export.csv${auditQueryString(applied, 0, 0, false)}`,
129
+ { responseType: 'blob' },
130
+ );
131
+ const url = URL.createObjectURL(blob);
132
+ const a = document.createElement('a');
133
+ a.href = url;
134
+ a.download = `audit-admin-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.csv`;
135
+ a.click();
136
+ URL.revokeObjectURL(url);
137
+ toast.success('CSV 导出已开始下载');
138
+ } catch (e) {
139
+ toast.error(apiErrorMessage(e, '导出失败'));
140
+ } finally {
141
+ setExporting(false);
142
+ }
143
+ };
144
+
145
+ const apply = () => {
146
+ setSearchParams(paramsFromDraft(draft, 1));
147
+ };
148
+
149
+ const items = auditQ.data?.items ?? [];
150
+ const total = auditQ.data?.total ?? 0;
151
+
152
+ const pastEnd = !auditQ.isPending && items.length === 0 && page > 1;
153
+
154
+ const columns: Column<AuditEvent>[] = [
155
+ {
156
+ key: 'created_at',
157
+ header: '时间',
158
+ className: 'w-44',
159
+ render: (e) => (
160
+ <span className="whitespace-nowrap text-muted-foreground">
161
+ {formatDateTime(e.created_at)}
162
+ </span>
163
+ ),
164
+ },
165
+ {
166
+ key: 'workspace',
167
+ header: '工作区',
168
+ className: 'w-28',
169
+ render: (e) =>
170
+ e.workspace_id ? (
171
+ <span className="font-mono text-xs" title={e.workspace_id}>
172
+ {shortID(e.workspace_id)}
173
+ </span>
174
+ ) : (
175
+ <span className="text-xs text-muted-foreground">平台级</span>
176
+ ),
177
+ },
178
+ {
179
+ key: 'actor',
180
+ header: '操作者',
181
+ className: 'w-[22%]',
182
+ render: (e) => {
183
+ const snap = (e.actor_snapshot ?? {}) as { email?: unknown };
184
+ const email = typeof snap.email === 'string' ? snap.email : '';
185
+ return (
186
+ <div className="min-w-0">
187
+ <p className="truncate">{email || e.actor_id || '—'}</p>
188
+ <p className="text-xs text-muted-foreground">{e.actor_type}</p>
189
+ </div>
190
+ );
191
+ },
192
+ },
193
+ {
194
+ key: 'action',
195
+ header: '动作',
196
+ className: 'w-[20%]',
197
+ render: (e) => (
198
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
199
+ {e.action}
200
+ </code>
201
+ ),
202
+ },
203
+ {
204
+ key: 'resource',
205
+ header: '资源',
206
+ render: (e) => (
207
+ <div className="min-w-0">
208
+ <p className="truncate">{e.resource_type}</p>
209
+ {e.resource_id && (
210
+ <p className="truncate font-mono text-xs text-muted-foreground">
211
+ {e.resource_id}
212
+ </p>
213
+ )}
214
+ </div>
215
+ ),
216
+ },
217
+ ];
218
+
219
+ return (
220
+ <div>
221
+ <PageHeader
222
+ title="审计"
223
+ description="平台级审计日志(跨工作区;按时间倒序,保留最近事件)"
224
+ />
225
+
226
+ <div className="mb-4 flex flex-wrap items-end gap-2">
227
+ <label className="flex flex-col gap-1 text-sm">
228
+ <span className="text-muted-foreground">工作区 ID</span>
229
+ <Input
230
+ className="w-56 font-mono"
231
+ placeholder="workspace uuid"
232
+ value={draft.workspace}
233
+ onChange={(e) => setDraft({ ...draft, workspace: e.target.value })}
234
+ />
235
+ </label>
236
+ <label className="flex flex-col gap-1 text-sm">
237
+ <span className="text-muted-foreground">动作</span>
238
+ <Input
239
+ className="w-44"
240
+ placeholder="如 workspace.rename"
241
+ value={draft.action}
242
+ onChange={(e) => setDraft({ ...draft, action: e.target.value })}
243
+ />
244
+ </label>
245
+ <label className="flex flex-col gap-1 text-sm">
246
+ <span className="text-muted-foreground">操作者邮箱</span>
247
+ <Input
248
+ className="w-48"
249
+ placeholder="邮箱前缀,如 alice@"
250
+ value={draft.actor}
251
+ onChange={(e) => setDraft({ ...draft, actor: e.target.value })}
252
+ />
253
+ </label>
254
+ <label className="flex flex-col gap-1 text-sm">
255
+ <span className="text-muted-foreground">操作者 ID</span>
256
+ <Input
257
+ className="w-52 font-mono"
258
+ placeholder="actor uuid"
259
+ value={draft.actorId}
260
+ onChange={(e) => setDraft({ ...draft, actorId: e.target.value })}
261
+ />
262
+ </label>
263
+ <label className="flex flex-col gap-1 text-sm">
264
+ <span className="text-muted-foreground">起始日期</span>
265
+ <Input
266
+ className="w-40"
267
+ type="date"
268
+ value={draft.from}
269
+ onChange={(e) => setDraft({ ...draft, from: e.target.value })}
270
+ />
271
+ </label>
272
+ <label className="flex flex-col gap-1 text-sm">
273
+ <span className="text-muted-foreground">结束日期</span>
274
+ <Input
275
+ className="w-40"
276
+ type="date"
277
+ value={draft.to}
278
+ onChange={(e) => setDraft({ ...draft, to: e.target.value })}
279
+ />
280
+ </label>
281
+ <Button onClick={apply}>
282
+ <Search /> 查询
283
+ </Button>
284
+ <Button
285
+ variant="outline"
286
+ onClick={() => {
287
+ setDraft(emptyDraft);
288
+ setSearchParams(paramsFromDraft(emptyDraft, 1));
289
+ }}
290
+ >
291
+ 重置
292
+ </Button>
293
+ <Button variant="outline" disabled={exporting} onClick={onExportCsv}>
294
+ <Download /> {exporting ? '导出中…' : '导出 CSV'}
295
+ </Button>
296
+ </div>
297
+
298
+ {auditQ.isError ? (
299
+ <PageError
300
+ message={adminError(auditQ.error, '审计日志加载失败')}
301
+ onRetry={() => void auditQ.refetch()}
302
+ />
303
+ ) : (
304
+ <DataTable
305
+ columns={columns}
306
+ rows={items}
307
+ rowKey={(e) => e.id}
308
+ loading={auditQ.isPending}
309
+ pagination={{
310
+ page,
311
+ pageSize: ADMIN_PAGE_SIZE,
312
+ total,
313
+ onPageChange: (p) => setSearchParams(paramsFromDraft(applied, p)),
314
+ }}
315
+ empty={
316
+ pastEnd ? (
317
+ <div className="flex flex-col items-center gap-2">
318
+ <PageEmpty label="没有更多审计记录了" hint="当前页数据可能已被轮转删除" />
319
+ <Button
320
+ variant="outline"
321
+ size="sm"
322
+ onClick={() => setSearchParams(paramsFromDraft(applied, 1))}
323
+ >
324
+ 返回第一页
325
+ </Button>
326
+ </div>
327
+ ) : (
328
+ <PageEmpty
329
+ label="暂无审计记录"
330
+ hint="调整过滤条件或触发一些操作后再来"
331
+ />
332
+ )
333
+ }
334
+ />
335
+ )}
336
+ </div>
337
+ );
338
+ }