@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.
- package/dist/AppProviders.js +2 -2
- package/dist/components/ConfirmDialog.js +1 -1
- package/dist/components/CronInput.js +3 -3
- package/dist/components/DataTable.js +4 -4
- package/dist/components/FormField.js +1 -1
- package/dist/components/ImpersonationBanner.js +1 -1
- package/dist/components/NotificationBell.js +1 -1
- package/dist/components/OnboardingChecklist.js +3 -3
- package/dist/components/Page.js +2 -2
- package/dist/components/ReauthDialog.js +4 -4
- package/dist/components/SecretModal.js +2 -2
- package/dist/components/SettingsCard.js +2 -2
- package/dist/components/SiteBanner.js +1 -1
- package/dist/components/SiteBannerLayer.js +1 -1
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/UserMenu.js +3 -3
- package/dist/components/WorkspaceSwitcher.js +7 -7
- package/dist/components/ui/alert-dialog.js +2 -2
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/breadcrumb.js +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/card.js +1 -1
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/dialog.js +2 -2
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/field.js +3 -3
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/label.js +1 -1
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/progress.js +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/separator.js +1 -1
- package/dist/components/ui/sheet.js +2 -2
- package/dist/components/ui/sidebar.js +8 -8
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/switch.js +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/tooltip.js +1 -1
- package/dist/index.js +78 -78
- package/dist/layouts/AppShell.js +4 -4
- package/dist/layouts/SettingsShell.js +4 -4
- package/dist/pages/LoginPage.js +5 -5
- package/dist/pages/account/NotificationPreferencesPage.js +3 -3
- package/dist/pages/account/ProfileSettingsPage.js +9 -9
- package/dist/pages/account/SecuritySettingsPage.js +13 -13
- package/dist/pages/account/TokensPage.js +12 -12
- package/dist/pages/admin/AdminSettingsPage.js +11 -11
- package/dist/pages/admin/AdminShell.js +3 -3
- package/dist/pages/admin/AuditPage.js +8 -8
- package/dist/pages/admin/OrdersPage.js +12 -12
- package/dist/pages/admin/OverviewPage.js +13 -13
- package/dist/pages/admin/SSOPage.js +6 -6
- package/dist/pages/admin/UserDetailPage.js +13 -13
- package/dist/pages/admin/UsersPage.js +13 -13
- package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
- package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
- package/dist/pages/admin/WorkspacesPage.js +9 -9
- package/dist/pages/admin/shared.js +1 -1
- package/dist/pages/auth/ForgotPasswordPage.js +4 -4
- package/dist/pages/auth/InviteAcceptPage.js +6 -6
- package/dist/pages/auth/RegisterPage.js +7 -7
- package/dist/pages/auth/ResetPasswordPage.js +5 -5
- package/dist/pages/auth/VerifyEmailPage.js +3 -3
- package/dist/pages/workspace/BillingPage.js +11 -11
- package/dist/pages/workspace/BillingSuccessPage.js +2 -2
- package/dist/pages/workspace/MembersPage.js +15 -15
- package/dist/pages/workspace/RolePermissionsPage.js +9 -9
- package/dist/pages/workspace/SchedulesPage.js +12 -12
- package/dist/pages/workspace/UsagePage.js +6 -6
- package/dist/pages/workspace/WebhooksPage.js +16 -16
- package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
- package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
- package/package.json +6 -5
- package/src/AppProviders.tsx +29 -0
- package/src/components/ConfirmDialog.tsx +77 -0
- package/src/components/CronInput.tsx +195 -0
- package/src/components/DataTable.tsx +152 -0
- package/src/components/ErrorBoundary.tsx +46 -0
- package/src/components/FormField.tsx +56 -0
- package/src/components/ImpersonationBanner.tsx +23 -0
- package/src/components/NotificationBell.tsx +135 -0
- package/src/components/OnboardingChecklist.tsx +114 -0
- package/src/components/Page.tsx +67 -0
- package/src/components/ReauthDialog.tsx +94 -0
- package/src/components/SecretModal.tsx +147 -0
- package/src/components/SettingsCard.tsx +39 -0
- package/src/components/SiteBanner.tsx +32 -0
- package/src/components/SiteBannerLayer.tsx +29 -0
- package/src/components/StatusBadge.tsx +12 -0
- package/src/components/UserMenu.tsx +103 -0
- package/src/components/WorkspaceSwitcher.tsx +159 -0
- package/src/components/__tests__/AppProviders.test.tsx +111 -0
- package/src/components/__tests__/CronInput.test.tsx +180 -0
- package/src/components/__tests__/DataTable.test.tsx +208 -0
- package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
- package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
- package/src/components/__tests__/NotificationBell.test.tsx +207 -0
- package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
- package/src/components/__tests__/SecretModal.test.tsx +112 -0
- package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
- package/src/components/__tests__/UserMenu.test.tsx +98 -0
- package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
- package/src/components/__tests__/form.test.tsx +133 -0
- package/src/components/__tests__/semantic-tokens.test.ts +41 -0
- package/src/components/__tests__/smoke.test.tsx +11 -0
- package/src/components/__tests__/toast.test.tsx +27 -0
- package/src/components/toast.tsx +23 -0
- package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
- package/src/components/ui/__tests__/avatar.test.tsx +30 -0
- package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
- package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
- package/src/components/ui/__tests__/field.test.tsx +43 -0
- package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
- package/src/components/ui/__tests__/popover.test.tsx +32 -0
- package/src/components/ui/__tests__/progress.test.tsx +24 -0
- package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
- package/src/components/ui/__tests__/select.test.tsx +48 -0
- package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
- package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
- package/src/components/ui/__tests__/subpath.test.tsx +38 -0
- package/src/components/ui/__tests__/switch.test.tsx +24 -0
- package/src/components/ui/__tests__/tabs.test.tsx +24 -0
- package/src/components/ui/__tests__/textarea.test.tsx +41 -0
- package/src/components/ui/alert-dialog.tsx +187 -0
- package/src/components/ui/avatar.tsx +108 -0
- package/src/components/ui/badge.tsx +51 -0
- package/src/components/ui/breadcrumb.tsx +124 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/dialog.tsx +166 -0
- package/src/components/ui/dropdown-menu.tsx +265 -0
- package/src/components/ui/empty.tsx +103 -0
- package/src/components/ui/field.tsx +238 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/select.tsx +235 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +146 -0
- package/src/components/ui/sidebar.tsx +723 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +26 -0
- package/src/components/ui/tooltip.tsx +63 -0
- package/src/dev.css +1 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +150 -0
- package/src/layouts/AppShell.tsx +163 -0
- package/src/layouts/AuthShell.tsx +24 -0
- package/src/layouts/SettingsShell.tsx +129 -0
- package/src/layouts/__tests__/AppShell.test.tsx +163 -0
- package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
- package/src/lib/__tests__/api-error.test.ts +163 -0
- package/src/lib/__tests__/utils.test.ts +21 -0
- package/src/lib/api-error.ts +98 -0
- package/src/lib/utils.ts +109 -0
- package/src/pages/LandingPage.tsx +88 -0
- package/src/pages/LoginPage.tsx +291 -0
- package/src/pages/__tests__/LandingPage.test.tsx +58 -0
- package/src/pages/__tests__/LoginPage.test.tsx +229 -0
- package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
- package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
- package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
- package/src/pages/account/ProfileSettingsPage.tsx +189 -0
- package/src/pages/account/SecuritySettingsPage.tsx +214 -0
- package/src/pages/account/TokensPage.tsx +211 -0
- package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
- package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
- package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
- package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
- package/src/pages/admin/AdminSettingsPage.tsx +564 -0
- package/src/pages/admin/AdminShell.tsx +199 -0
- package/src/pages/admin/AuditPage.tsx +338 -0
- package/src/pages/admin/OrdersPage.tsx +642 -0
- package/src/pages/admin/OverviewPage.tsx +559 -0
- package/src/pages/admin/SSOPage.tsx +166 -0
- package/src/pages/admin/UserDetailPage.tsx +641 -0
- package/src/pages/admin/UsersPage.tsx +462 -0
- package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
- package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
- package/src/pages/admin/WorkspacesPage.tsx +299 -0
- package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
- package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
- package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
- package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
- package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
- package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
- package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
- package/src/pages/admin/shared.ts +42 -0
- package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
- package/src/pages/auth/InviteAcceptPage.tsx +105 -0
- package/src/pages/auth/RegisterPage.tsx +204 -0
- package/src/pages/auth/ResetPasswordPage.tsx +150 -0
- package/src/pages/auth/VerifyEmailPage.tsx +76 -0
- package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
- package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
- package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
- package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
- package/src/pages/workspace/BillingPage.tsx +361 -0
- package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
- package/src/pages/workspace/MembersPage.tsx +568 -0
- package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
- package/src/pages/workspace/SchedulesPage.tsx +387 -0
- package/src/pages/workspace/UsagePage.tsx +207 -0
- package/src/pages/workspace/WebhooksPage.tsx +506 -0
- package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
- package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
- package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
- package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
- package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
- package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
- package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
- package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
- package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
- package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
- 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
|
+
}
|