@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,195 @@
1
+
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { api } from '@ploykit/client'
4
+ import { apiErrorMessage } from '../lib/api-error'
5
+ import { Button } from './ui/button'
6
+ import { Input } from './ui/input'
7
+
8
+ export interface CronValue {
9
+ cron: string
10
+ timezone: string
11
+ }
12
+
13
+ export interface CronInputProps {
14
+ value: CronValue
15
+ onChange: (v: CronValue) => void
16
+
17
+ idPrefix?: string
18
+
19
+ previewCount?: number
20
+ disabled?: boolean
21
+ }
22
+
23
+
24
+ export const FALLBACK_TIMEZONES = [
25
+ 'UTC',
26
+ 'Asia/Shanghai',
27
+ 'Asia/Hong_Kong',
28
+ 'Asia/Singapore',
29
+ 'Asia/Tokyo',
30
+ 'Europe/London',
31
+ 'Europe/Berlin',
32
+ 'Europe/Paris',
33
+ 'America/New_York',
34
+ 'America/Chicago',
35
+ 'America/Los_Angeles',
36
+ 'Australia/Sydney',
37
+ ]
38
+
39
+
40
+ export function timezoneOptions(): string[] {
41
+ let zones: string[] = []
42
+ try {
43
+ const i = Intl as { supportedValuesOf?: (key: string) => string[] }
44
+ if (typeof i.supportedValuesOf === 'function') {
45
+ zones = i.supportedValuesOf('timeZone')
46
+ }
47
+ } catch {
48
+ // fall through 到内置列表
49
+ }
50
+ if (zones.length === 0) return FALLBACK_TIMEZONES
51
+ return Array.from(new Set(['UTC', ...zones]))
52
+ }
53
+
54
+
55
+ const DOW_LABEL: Record<string, string> = {
56
+ '0': '周日', '1': '周一', '2': '周二', '3': '周三', '4': '周四', '5': '周五', '6': '周六', '7': '周日',
57
+ }
58
+
59
+
60
+ export function describeCron(expr: string): string {
61
+ const fields = expr.trim().split(/\s+/)
62
+ if (fields.length !== 5) return expr
63
+ const [min, hour, dom, mon, dow] = fields
64
+ if (mon !== '*') return expr
65
+ const intIn = (s: string, lo: number, hi: number) => /^\d+$/.test(s) && Number(s) >= lo && Number(s) <= hi
66
+ if (min !== '*' && !intIn(min, 0, 59)) return expr
67
+ if (hour !== '*' && !intIn(hour, 0, 23)) return expr
68
+ if (dow !== '*' && !intIn(dow, 0, 7)) return expr
69
+ const hhmm = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}`
70
+ if (min === '*' && hour === '*' && dom === '*' && dow === '*') return '每分钟'
71
+ if (hour === '*' && dom === '*' && dow === '*') return `每小时(第 ${min.padStart(2, '0')} 分)`
72
+ if (dom === '*' && dow === '*') return `每天 ${hhmm}`
73
+ if (dom === '*' && hour !== '*' && min !== '*') return `每${DOW_LABEL[dow] ?? dow} ${hhmm}`
74
+ return expr
75
+ }
76
+
77
+
78
+ function formatPreviewTime(iso: string, tz: string): string {
79
+ const d = new Date(iso)
80
+ if (Number.isNaN(d.getTime())) return iso
81
+ try {
82
+ return new Intl.DateTimeFormat('zh-CN', {
83
+ timeZone: tz, dateStyle: 'medium', timeStyle: 'short',
84
+ }).format(d)
85
+ } catch {
86
+ return d.toISOString()
87
+ }
88
+ }
89
+
90
+ export function CronInput({
91
+ value,
92
+ onChange,
93
+ idPrefix = 'cron',
94
+ previewCount = 3,
95
+ disabled,
96
+ }: CronInputProps) {
97
+ const [preview, setPreview] = useState<string[] | null>(null)
98
+ const [error, setError] = useState<string | null>(null)
99
+ const [loading, setLoading] = useState(false)
100
+
101
+ const valueRef = useRef(value)
102
+ valueRef.current = value
103
+
104
+
105
+
106
+ useEffect(() => {
107
+ setPreview(null)
108
+ }, [value.cron, value.timezone])
109
+
110
+ const runPreview = async () => {
111
+ const reqCron = value.cron.trim()
112
+ const reqTz = value.timezone
113
+ setLoading(true)
114
+ setError(null)
115
+ setPreview(null)
116
+ const stillCurrent = () =>
117
+ valueRef.current.cron.trim() === reqCron && valueRef.current.timezone === reqTz
118
+ try {
119
+ const d = await api.post<{ items: string[] }>('/api/schedules/preview', {
120
+ cron_expr: reqCron,
121
+ timezone: reqTz,
122
+ count: previewCount,
123
+ })
124
+
125
+ if (stillCurrent()) setPreview(d?.items ?? [])
126
+ } catch (e) {
127
+ if (stillCurrent()) setError(apiErrorMessage(e, '预览失败,请检查表达式后重试'))
128
+ } finally {
129
+
130
+ setLoading(false)
131
+ }
132
+ }
133
+
134
+ const zones = timezoneOptions()
135
+
136
+ return (
137
+ <div className="space-y-2">
138
+ <div className="flex gap-2">
139
+ <div className="min-w-0 flex-1 space-y-1">
140
+ <label htmlFor={`${idPrefix}-expr`} className="text-sm font-medium">
141
+ Cron 表达式
142
+ </label>
143
+ <Input
144
+ id={`${idPrefix}-expr`}
145
+ placeholder="0 9 * * *"
146
+ className="font-mono"
147
+ value={value.cron}
148
+ disabled={disabled}
149
+ onChange={(e) => onChange({ ...value, cron: e.target.value })}
150
+ />
151
+ <p className="text-xs text-muted-foreground" data-testid="cron-hint">
152
+ {value.cron.trim() ? describeCron(value.cron) : '五字段:分 时 日 月 周'}
153
+ </p>
154
+ </div>
155
+ <div className="w-48 shrink-0 space-y-1">
156
+ <label htmlFor={`${idPrefix}-tz`} className="text-sm font-medium">
157
+ 时区
158
+ </label>
159
+ <select
160
+ id={`${idPrefix}-tz`}
161
+ className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
162
+ value={value.timezone}
163
+ disabled={disabled}
164
+ onChange={(e) => onChange({ ...value, timezone: e.target.value })}
165
+ >
166
+ {zones.map((tz) => (
167
+ <option key={tz} value={tz}>{tz}</option>
168
+ ))}
169
+ </select>
170
+ </div>
171
+ </div>
172
+ <div className="space-y-1">
173
+ <Button
174
+ type="button"
175
+ size="sm"
176
+ variant="outline"
177
+ disabled={disabled || loading || !value.cron.trim()}
178
+ onClick={() => void runPreview()}
179
+ >
180
+ {loading ? '预览中…' : `预览下次 ${previewCount} 次`}
181
+ </Button>
182
+ {error && (
183
+ <p role="alert" className="text-xs text-destructive">{error}</p>
184
+ )}
185
+ {preview && preview.length > 0 && (
186
+ <ul data-testid="cron-preview" className="list-inside list-disc text-xs text-muted-foreground">
187
+ {preview.map((t, i) => (
188
+ <li key={i}>{formatPreviewTime(t, value.timezone)}</li>
189
+ ))}
190
+ </ul>
191
+ )}
192
+ </div>
193
+ </div>
194
+ )
195
+ }
@@ -0,0 +1,152 @@
1
+
2
+ import { Fragment, type ReactNode } from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import { PageEmpty } from './Page'
5
+ import { Skeleton } from './ui/skeleton'
6
+ import { Button } from './ui/button'
7
+
8
+ export interface Column<T> {
9
+ key: string
10
+ header: ReactNode
11
+ render?: (row: T) => ReactNode
12
+ className?: string
13
+ }
14
+
15
+
16
+ export interface DataTablePagination {
17
+ page: number
18
+ pageSize: number
19
+ total: number
20
+ onPageChange: (page: number) => void
21
+ }
22
+
23
+ export interface DataTableProps<T> {
24
+ columns: Column<T>[]
25
+ rows: T[]
26
+ /** 行 key 生成器。第二参数是该行在 rows 中的下标,用于同业务键多行(如同 code 多版本)时拼出唯一 key。少参回调仍然兼容。 */
27
+ rowKey: (row: T, index: number) => string
28
+ loading?: boolean
29
+ empty?: ReactNode
30
+ /** 整行点击回调。传入后行 hover 显示 pointer 光标。 */
31
+ onRowClick?: (row: T) => void
32
+ /**
33
+ * 展开行扩展点:对每行调用,返回非 null 时在该行下方插入一个展开行
34
+ * (单个 td,colSpan 覆盖全部列)。展开/收起状态由调用方用行数据自行表达
35
+ * (收起的行返回 null 即不渲染)——本组件不持有内部展开状态。
36
+ */
37
+ renderExpanded?: (row: T, index: number) => ReactNode
38
+
39
+ pagination?: DataTablePagination
40
+ }
41
+
42
+ export function DataTable<T>({ columns, rows, rowKey, loading = false, empty, onRowClick, renderExpanded, pagination }: DataTableProps<T>) {
43
+
44
+
45
+
46
+ const stalePage =
47
+ !!pagination && !loading && rows.length === 0 && (pagination.total > 0 || pagination.page > 1)
48
+ if (!loading && rows.length === 0 && !stalePage) {
49
+ return <>{empty ?? <PageEmpty />}</>
50
+ }
51
+ const lastPage = pagination ? Math.max(1, Math.ceil(pagination.total / Math.max(1, pagination.pageSize))) : 1
52
+ return (
53
+
54
+ <div className="bg-card rounded-lg border overflow-hidden">
55
+ {/* X1(admin-ui 卷):容器只保圆角裁切,表格本体放内层横向滚动层——
56
+ 窄视口或长内容(超长名/事件码)下,overflow-hidden 直接把右侧列
57
+ (多为操作列)裁成不可达且无滚动出口(users/workspaces/orders/
58
+ webhook-deliveries/sso/overview 六页 S1 共同根因)。 */}
59
+ <div className="overflow-x-auto">
60
+ <table className="w-full text-sm">
61
+ <thead>
62
+ <tr className="border-b bg-muted/50 text-left text-muted-foreground">
63
+ {columns.map((col) => (
64
+ <th key={col.key} className={cn('px-4 py-3 font-medium whitespace-nowrap', col.className)}>{col.header}</th>
65
+ ))}
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ {loading ? (
70
+ <tr className="border-b last:border-0">
71
+ {columns.map((col) => (
72
+ <td key={col.key} className={cn('px-4 py-3', col.className)}>
73
+ <Skeleton className="h-4 w-full" />
74
+ </td>
75
+ ))}
76
+ </tr>
77
+ ) : stalePage ? (
78
+ <tr>
79
+ <td colSpan={columns.length} className="px-4 py-8 text-center text-sm text-muted-foreground">
80
+ 本页没有数据,可返回上一页
81
+ </td>
82
+ </tr>
83
+ ) : (
84
+ rows.map((row, index) => {
85
+ const expanded = renderExpanded ? renderExpanded(row, index) : null
86
+ return (
87
+ <Fragment key={rowKey(row, index)}>
88
+ <tr
89
+ className={cn('border-b last:border-0 hover:bg-muted/30', onRowClick && 'cursor-pointer')}
90
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
91
+ >
92
+ {columns.map((col) => (
93
+ <td key={col.key} className={cn('px-4 py-3', col.className)}>
94
+ {col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '')}
95
+ </td>
96
+ ))}
97
+ </tr>
98
+ {expanded !== null && (
99
+ <tr className="border-b last:border-0" data-slot="data-table-expanded-row">
100
+ <td colSpan={columns.length} className="px-4 py-3">
101
+ {expanded}
102
+ </td>
103
+ </tr>
104
+ )}
105
+ </Fragment>
106
+ )
107
+ })
108
+ )}
109
+ </tbody>
110
+ </table>
111
+ </div>
112
+ {pagination && (
113
+ <div
114
+ data-slot="data-table-pagination"
115
+ aria-busy={loading || undefined}
116
+ className="flex items-center justify-between border-t px-4 py-3 text-sm text-muted-foreground"
117
+ >
118
+ {/* B-users-8:加载中不显示"共 0 条 · 第 1/1 页"假总数——置灰为加载文案 */}
119
+ {loading ? (
120
+ <span>加载中…</span>
121
+ ) : (
122
+ <span>
123
+ 共 <span className="font-medium text-foreground">{pagination.total}</span> 条 · 第{' '}
124
+ <span className="font-medium text-foreground">
125
+ {pagination.page}/{lastPage}
126
+ </span>{' '}
127
+ 页
128
+ </span>
129
+ )}
130
+ <div className="flex gap-2">
131
+ <Button
132
+ size="sm"
133
+ variant="outline"
134
+ disabled={loading || pagination.page <= 1}
135
+ onClick={() => pagination.onPageChange(pagination.page - 1)}
136
+ >
137
+ 上一页
138
+ </Button>
139
+ <Button
140
+ size="sm"
141
+ variant="outline"
142
+ disabled={loading || pagination.page >= lastPage}
143
+ onClick={() => pagination.onPageChange(pagination.page + 1)}
144
+ >
145
+ 下一页
146
+ </Button>
147
+ </div>
148
+ </div>
149
+ )}
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,46 @@
1
+
2
+ import { Component, type ReactNode } from 'react'
3
+
4
+ export interface ErrorBoundaryProps {
5
+ children: ReactNode
6
+ fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
7
+ }
8
+
9
+ interface ErrorBoundaryState {
10
+ error: Error | null
11
+ }
12
+
13
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
14
+ state: ErrorBoundaryState = { error: null }
15
+
16
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
17
+ return { error }
18
+ }
19
+
20
+ componentDidCatch(error: Error): void {
21
+ console.error('[ErrorBoundary] 子树渲染出错:', error)
22
+ }
23
+
24
+ reset = (): void => {
25
+ this.setState({ error: null })
26
+ }
27
+
28
+ render(): ReactNode {
29
+ const { error } = this.state
30
+ if (error === null) return this.props.children
31
+ const { fallback } = this.props
32
+ if (fallback !== undefined) {
33
+ return typeof fallback === 'function' ? fallback(error, this.reset) : fallback
34
+ }
35
+ return (
36
+ <div role="alert" className="flex flex-col items-center justify-center py-12 text-center">
37
+ <div className="text-4xl mb-3">⚠️</div>
38
+ <p className="text-sm text-destructive">出错了</p>
39
+ {error.message && <p className="mt-1 text-xs text-muted-foreground">{error.message}</p>}
40
+ <button onClick={this.reset} className="mt-4 px-4 py-2 text-sm border rounded-md hover:bg-muted">
41
+ 重试
42
+ </button>
43
+ </div>
44
+ )
45
+ }
46
+ }
@@ -0,0 +1,56 @@
1
+
2
+ import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
3
+ import { Field, FieldLabel, FieldError, FieldDescription } from './ui/field'
4
+
5
+ export interface FormFieldProps {
6
+ label: string
7
+ error?: string
8
+
9
+ description?: string
10
+
11
+ required?: boolean
12
+ children: ReactNode
13
+ htmlFor?: string
14
+ }
15
+
16
+
17
+ function mergeDescribedBy(props: Record<string, unknown>, extra: string) {
18
+ const existing = typeof props['aria-describedby'] === 'string' ? props['aria-describedby'] : ''
19
+ return { 'aria-describedby': [existing, extra].filter(Boolean).join(' ') }
20
+ }
21
+
22
+ export function FormField({ label, error, description, required, children, htmlFor }: FormFieldProps) {
23
+ const errorId = htmlFor && error ? `${htmlFor}-error` : undefined
24
+ const descId = htmlFor && description ? `${htmlFor}-desc` : undefined
25
+ const describedBy = [descId, errorId].filter(Boolean).join(' ') || undefined
26
+
27
+ const control =
28
+ describedBy || error || required
29
+ ? isValidElement(children)
30
+ ? cloneElement(
31
+ children as ReactElement<Record<string, unknown>>,
32
+ {
33
+ ...(describedBy ? mergeDescribedBy((children as ReactElement<Record<string, unknown>>).props, describedBy) : {}),
34
+ ...(error ? { 'aria-invalid': true } : {}),
35
+ ...(required ? { 'aria-required': true } : {}),
36
+ },
37
+ )
38
+ : children
39
+ : children
40
+
41
+ return (
42
+ <Field data-invalid={!!error}>
43
+ <FieldLabel htmlFor={htmlFor}>
44
+ {label}
45
+ {required && (
46
+ <span aria-hidden="true" className="ml-0.5 text-destructive">
47
+ *
48
+ </span>
49
+ )}
50
+ </FieldLabel>
51
+ {control}
52
+ {description && <FieldDescription id={descId}>{description}</FieldDescription>}
53
+ <FieldError id={errorId}>{error}</FieldError>
54
+ </Field>
55
+ )
56
+ }
@@ -0,0 +1,23 @@
1
+
2
+ import { TriangleAlert } from 'lucide-react'
3
+ import { useAuth } from '@ploykit/hooks'
4
+ import { cn } from '../lib/utils'
5
+
6
+ export function ImpersonationBanner({ className }: { className?: string }) {
7
+ const { user } = useAuth()
8
+ if (!user?.impersonated_by) return null
9
+ return (
10
+ <div
11
+ role="status"
12
+ data-slot="impersonation-banner"
13
+ className={cn(
14
+ 'flex w-full items-center justify-center gap-2 border-b px-4 py-2 text-sm font-medium',
15
+ 'border-destructive/30 bg-destructive/10 text-destructive',
16
+ className,
17
+ )}
18
+ >
19
+ <TriangleAlert className="size-4 shrink-0" aria-hidden="true" />
20
+ <span>您正处于管理员模拟会话</span>
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,135 @@
1
+
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { api, type APINotification, type components } from '@ploykit/client'
5
+ import { queryKeys } from '@ploykit/hooks'
6
+ import { cn, formatDateTime } from '../lib/utils'
7
+
8
+
9
+ type Notification = APINotification
10
+
11
+ export function NotificationBell({ onNavigate }: { onNavigate?: (link: string) => void }) {
12
+ const [open, setOpen] = useState(false)
13
+ const ref = useRef<HTMLDivElement>(null)
14
+ const qc = useQueryClient()
15
+
16
+
17
+ const badgeQ = useQuery({
18
+ queryKey: [queryKeys.notifications, 'badge'],
19
+ queryFn: () => api.get<components['schemas']['BadgeResponse']>('/api/notifications/badge'),
20
+ refetchInterval: 60_000,
21
+ })
22
+ const unread = badgeQ.data?.count ?? 0
23
+
24
+
25
+ const listQ = useQuery({
26
+ queryKey: [queryKeys.notifications, 'list'],
27
+ queryFn: () => api.get<Notification[]>('/api/notifications?limit=8'),
28
+ staleTime: 30_000,
29
+ enabled: open,
30
+ })
31
+ const notifications = Array.isArray(listQ.data) ? listQ.data : []
32
+
33
+ const invalidate = () => {
34
+ void qc.invalidateQueries({ queryKey: [queryKeys.notifications] })
35
+ }
36
+
37
+ const markAllReadM = useMutation({
38
+ mutationFn: () => api.post('/api/notifications/read-all'),
39
+ onSuccess: invalidate,
40
+ })
41
+
42
+ const markReadM = useMutation({
43
+ mutationFn: (id: string) => api.post(`/api/notifications/${id}/read`),
44
+ onSuccess: invalidate,
45
+ })
46
+
47
+ useEffect(() => {
48
+ const onClickOutside = (e: MouseEvent) => {
49
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
50
+ }
51
+ document.addEventListener('mousedown', onClickOutside)
52
+ return () => document.removeEventListener('mousedown', onClickOutside)
53
+ }, [])
54
+
55
+ return (
56
+ <div ref={ref} className="relative">
57
+ <button
58
+ onClick={() => setOpen(!open)}
59
+
60
+
61
+
62
+ className="relative p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md
63
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
64
+ aria-label="通知"
65
+ aria-haspopup="true"
66
+ aria-expanded={open}
67
+ >
68
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
69
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
70
+ d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
71
+ </svg>
72
+ {unread > 0 && (
73
+
74
+
75
+ <span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px]
76
+ px-1 text-xs font-bold text-white bg-destructive rounded-full">
77
+ {unread > 99 ? '99+' : unread}
78
+ </span>
79
+ )}
80
+ </button>
81
+
82
+ {open && (
83
+ <div
84
+ className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50"
85
+ role="dialog"
86
+ aria-label="通知列表"
87
+ onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
88
+ >
89
+ <div className="flex items-center justify-between px-4 py-3 border-b">
90
+ <span className="text-sm font-semibold">通知</span>
91
+ {unread > 0 && (
92
+ <button
93
+ onClick={() => markAllReadM.mutate()}
94
+ disabled={markAllReadM.isPending}
95
+ className="text-xs text-primary hover:underline disabled:opacity-50
96
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
97
+ >全部已读</button>
98
+ )}
99
+ </div>
100
+ <ul className="max-h-80 overflow-y-auto list-none p-0 m-0" data-slot="notification-list">
101
+ {listQ.isPending ? (
102
+
103
+ <li className="py-8 text-center text-sm text-muted-foreground" aria-live="polite">加载中…</li>
104
+ ) : notifications.length === 0 ? (
105
+ <li className="py-8 text-center text-sm text-muted-foreground">暂无通知</li>
106
+ ) : notifications.map((n) => (
107
+ <li key={n.id} className="border-b last:border-0">
108
+ {/* P2-19:条目是可操作元素——button 语义(Tab 可达、Enter 可触发), */}
109
+ {/* 不再是 clickable div(键盘用户此前完全不可操作) */}
110
+ <button
111
+ type="button"
112
+ onClick={() => { if (n.link) onNavigate?.(n.link); markReadM.mutate(n.id); setOpen(false) }}
113
+ className={cn(
114
+ 'w-full text-left px-4 py-3 cursor-pointer hover:bg-muted ' +
115
+ 'focus-visible:outline-none focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
116
+ !n.read_at && 'bg-accent/50',
117
+ )}
118
+ >
119
+ <span className="flex items-start justify-between gap-2">
120
+ <span className="text-sm font-medium text-foreground">{n.title}</span>
121
+ {n.count > 1 && (
122
+ <span className="text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">×{n.count}</span>
123
+ )}
124
+ </span>
125
+ {n.body && <span className="block mt-0.5 text-xs text-muted-foreground line-clamp-2">{n.body}</span>}
126
+ <span className="block mt-1 text-[10px] text-muted-foreground">{formatDateTime(n.created_at)}</span>
127
+ </button>
128
+ </li>
129
+ ))}
130
+ </ul>
131
+ </div>
132
+ )}
133
+ </div>
134
+ )
135
+ }