@ploykit/ui 0.6.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,114 @@
1
+
2
+ import { useEffect, useState, type ReactNode } from 'react'
3
+ import { X } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { Progress, ProgressLabel, ProgressValue } from './ui/progress'
6
+ import { Button } from './ui/button'
7
+ import { useAuth } from '@ploykit/hooks'
8
+
9
+ export interface OnboardingItem {
10
+ key: string
11
+ label: string
12
+ done: boolean
13
+ onClick?: () => void
14
+ }
15
+
16
+ export interface OnboardingChecklistProps {
17
+
18
+ items?: OnboardingItem[]
19
+
20
+ storageKey?: string
21
+ title?: string
22
+ }
23
+
24
+ const DISMISS_KEY = 'pk_onboarding_dismissed'
25
+
26
+ function defaultItems(hasWorkspaces: boolean): OnboardingItem[] {
27
+ return [
28
+ { key: 'create-workspace', label: '创建第一个工作区', done: hasWorkspaces },
29
+ ]
30
+ }
31
+
32
+ export function OnboardingChecklist({
33
+ items, storageKey = DISMISS_KEY, title = '开始使用',
34
+ }: OnboardingChecklistProps = {}) {
35
+ const { user, workspaces } = useAuth()
36
+
37
+
38
+ const [dismissed, setDismissed] = useState(false)
39
+ useEffect(() => {
40
+ try {
41
+ setDismissed(localStorage.getItem(storageKey) === '1')
42
+ } catch {
43
+ /* localStorage 不可用(隐私模式等)按未关闭处理——effect 期兜底,非渲染期吞异常 */
44
+ }
45
+ }, [storageKey])
46
+
47
+
48
+ const list = items ?? (user ? defaultItems(workspaces.length > 0) : [])
49
+ if (!user || dismissed || list.length === 0) return null
50
+
51
+ const doneCount = list.filter((i) => i.done).length
52
+ const pct = Math.round((doneCount / list.length) * 100)
53
+ const allDone = doneCount === list.length
54
+
55
+ const dismiss = () => {
56
+ try {
57
+ localStorage.setItem(storageKey, '1')
58
+ } catch {
59
+ /* 隐私模式等场景忽略持久化失败 */
60
+ }
61
+ setDismissed(true)
62
+ }
63
+
64
+ return (
65
+ <section className="w-full rounded-xl border bg-card p-4 shadow-xs">
66
+ <div className="flex items-start justify-between gap-2">
67
+ <div>
68
+ <h3 className="text-sm font-semibold">{title}</h3>
69
+ <p className="mt-0.5 text-xs text-muted-foreground">
70
+ {allDone ? '全部完成,开始你的旅程吧!' : `已完成 ${doneCount}/${list.length} 项`}
71
+ </p>
72
+ </div>
73
+ <Button variant="ghost" size="icon-sm" aria-label="关闭引导清单" onClick={dismiss}>
74
+ <X />
75
+ </Button>
76
+ </div>
77
+
78
+ <Progress value={pct} className="mt-3">
79
+ <ProgressLabel>{allDone ? '完成' : '进度'}</ProgressLabel>
80
+ <ProgressValue />
81
+ </Progress>
82
+
83
+ <ul className="mt-4 space-y-2">
84
+ {list.map((item) => (
85
+ <li key={item.key}>
86
+ <button
87
+ type="button"
88
+ onClick={item.done ? undefined : item.onClick}
89
+ disabled={item.done}
90
+ className={cn(
91
+ 'flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors',
92
+ item.done
93
+ ? 'border-transparent bg-muted/50 text-muted-foreground'
94
+ : 'border-border hover:bg-accent hover:text-accent-foreground',
95
+ !item.done && !item.onClick && 'cursor-default',
96
+ )}
97
+ >
98
+ <span
99
+ aria-hidden="true"
100
+ className={cn(
101
+ 'flex size-5 shrink-0 items-center justify-center rounded-full border text-xs',
102
+ item.done ? 'border-primary bg-primary text-primary-foreground' : 'border-input',
103
+ )}
104
+ >
105
+ {item.done ? '✓' : ''}
106
+ </span>
107
+ <span className={cn(item.done && 'line-through')}>{item.label}</span>
108
+ </button>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </section>
113
+ )
114
+ }
@@ -0,0 +1,67 @@
1
+
2
+ import { type ReactNode } from 'react'
3
+ import { Inbox, TriangleAlert } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import { Button } from './ui/button'
6
+ import {
7
+ Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
8
+ } from './ui/empty'
9
+
10
+
11
+
12
+ export function PageHeader({ title, description, actions }: {
13
+ title: string; description?: string; actions?: ReactNode
14
+ }) {
15
+ return (
16
+ <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
17
+ <div className="min-w-0">
18
+ <h1 className="text-2xl font-bold text-foreground">{title}</h1>
19
+ {description && <p className="mt-1 max-w-2xl text-sm text-muted-foreground">{description}</p>}
20
+ </div>
21
+ {actions && <div className="flex shrink-0 gap-2">{actions}</div>}
22
+ </div>
23
+ )
24
+ }
25
+
26
+ export function PageLoading({ label = '加载中…' }: { label?: string }) {
27
+
28
+
29
+ return (
30
+ <div className="flex items-center justify-center py-12" role="status" aria-live="polite">
31
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-primary" />
32
+ <span className="ml-3 text-sm text-muted-foreground">{label}</span>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ export function PageEmpty({ label = '暂无数据', hint }: { label?: string; hint?: string }) {
38
+ return (
39
+ <Empty className="py-8">
40
+ <EmptyHeader>
41
+ <EmptyMedia variant="icon">
42
+ <Inbox aria-hidden="true" />
43
+ </EmptyMedia>
44
+ <EmptyTitle>{label}</EmptyTitle>
45
+ {hint && <EmptyDescription>{hint}</EmptyDescription>}
46
+ </EmptyHeader>
47
+ </Empty>
48
+ )
49
+ }
50
+
51
+ export function PageError({ message, onRetry }: { message: string; onRetry?: () => void }) {
52
+ return (
53
+ <Empty className="py-8" role="alert">
54
+ <EmptyHeader>
55
+ <EmptyMedia variant="icon" className="bg-destructive/10 text-destructive">
56
+ <TriangleAlert aria-hidden="true" />
57
+ </EmptyMedia>
58
+ <EmptyTitle className="text-destructive">{message}</EmptyTitle>
59
+ {onRetry && (
60
+ <EmptyContent>
61
+ <Button variant="outline" onClick={onRetry}>重试</Button>
62
+ </EmptyContent>
63
+ )}
64
+ </EmptyHeader>
65
+ </Empty>
66
+ )
67
+ }
@@ -0,0 +1,94 @@
1
+ import { useState } from 'react'
2
+ import { ShieldCheck } from 'lucide-react'
3
+ import { useApi } from '@ploykit/hooks'
4
+ import { isApiError } from '@ploykit/client'
5
+ import {
6
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
7
+ } from './ui/dialog'
8
+ import { Button } from './ui/button'
9
+ import { Input } from './ui/input'
10
+ import { toast } from './toast'
11
+
12
+ /**
13
+ * Step-up(sudo mode)两段式协议的第二段(ADR 0011):敏感操作被
14
+ * 403 E_REAUTH_REQUIRED 拒绝后,弹本对话框验证密码(POST
15
+ * /auth/confirm-password),成功后自动重试原请求。
16
+ *
17
+ * 用法:页面捕获 isApiError(err, 'E_REAUTH_REQUIRED') 时,把"重试动作"
18
+ * (通常是 mutation.mutateAsync 绑定原参数)交给 retry,打开对话框即可。
19
+ */
20
+ export interface ReauthDialogProps {
21
+ open: boolean
22
+ /** 被挑战的原动作;确认成功后重新执行一次。为 null 时对话框只确认不重试。 */
23
+ retry: (() => Promise<unknown>) | null
24
+ onClose: () => void
25
+ }
26
+
27
+ export function ReauthDialog({ open, retry, onClose }: ReauthDialogProps) {
28
+ const api = useApi()
29
+ const [password, setPassword] = useState('')
30
+ const [error, setError] = useState('')
31
+ const [busy, setBusy] = useState(false)
32
+
33
+ const confirm = async () => {
34
+ if (!password || busy) return
35
+ setBusy(true)
36
+ setError('')
37
+ try {
38
+ await api.post('/auth/confirm-password', { password })
39
+ if (retry) await retry()
40
+ toast.success('已验证,操作已重试')
41
+ setPassword('')
42
+ onClose()
43
+ } catch (err) {
44
+ if (isApiError(err, 'E_UNAUTHENTICATED')) {
45
+ setError('密码不正确,请重试')
46
+ return
47
+ }
48
+ toast.error('操作失败,请稍后重试')
49
+ onClose()
50
+ } finally {
51
+ setBusy(false)
52
+ }
53
+ }
54
+
55
+ return (
56
+ <Dialog open={open} onOpenChange={(o) => { if (!o && !busy) onClose() }}>
57
+ <DialogContent className="max-w-sm">
58
+ <DialogHeader>
59
+ <DialogTitle className="flex items-center gap-2">
60
+ <ShieldCheck className="size-4" />
61
+ 需要验证密码
62
+ </DialogTitle>
63
+ <DialogDescription>
64
+ 这是敏感操作,需要确认密码后自动重试。密码确认在一段时间内有效。
65
+ </DialogDescription>
66
+ </DialogHeader>
67
+ <form
68
+ className="space-y-1"
69
+ onSubmit={(e) => { e.preventDefault(); void confirm() }}
70
+ >
71
+ <Input
72
+ type="password"
73
+ autoFocus
74
+ autoComplete="current-password"
75
+ aria-label="当前密码"
76
+ placeholder="当前密码"
77
+ value={password}
78
+ onChange={(e) => setPassword(e.target.value)}
79
+ disabled={busy}
80
+ />
81
+ {error && <p className="text-sm text-destructive">{error}</p>}
82
+ <DialogFooter className="mt-4">
83
+ <Button type="button" variant="outline" onClick={onClose} disabled={busy}>
84
+ 取消
85
+ </Button>
86
+ <Button type="submit" disabled={!password || busy}>
87
+ {busy ? '验证中…' : '验证并继续'}
88
+ </Button>
89
+ </DialogFooter>
90
+ </form>
91
+ </DialogContent>
92
+ </Dialog>
93
+ )
94
+ }
@@ -0,0 +1,147 @@
1
+
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
3
+ import {
4
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
5
+ } from './ui/dialog'
6
+ import { Button } from './ui/button'
7
+
8
+ export function SecretModal({ open, onOpenChange, secretName, secretValue, title }: {
9
+ open: boolean
10
+ /** 关闭回调。契约:用户勾选"我已保存"之前,本组件不会把 false 传进来——ESC、遮罩点击、关闭按钮均被拦截,调用方无需自行兜底。 */
11
+ onOpenChange: (open: boolean) => void
12
+ /** 密钥名称,用于默认标题「{secretName} 创建成功」。 */
13
+ secretName: string
14
+ /** 仅显示一次的密钥明文。 */
15
+ secretValue: string
16
+ /** 可选标题,覆盖默认的「{secretName} 创建成功」——用于"重置/查看"等非创建成功场景。 */
17
+ title?: string
18
+ }) {
19
+ const [copied, setCopied] = useState(false)
20
+ const [copyFailed, setCopyFailed] = useState(false)
21
+ const [confirmed, setConfirmed] = useState(false)
22
+ const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
23
+
24
+
25
+
26
+ useEffect(() => {
27
+ setCopied(false)
28
+ setCopyFailed(false)
29
+ setConfirmed(false)
30
+ if (copyTimer.current) {
31
+ clearTimeout(copyTimer.current)
32
+ copyTimer.current = null
33
+ }
34
+ }, [open, secretValue])
35
+
36
+
37
+ useEffect(() => () => {
38
+ if (copyTimer.current) clearTimeout(copyTimer.current)
39
+ }, [])
40
+
41
+ const copy = async () => {
42
+
43
+
44
+ try {
45
+ await navigator.clipboard.writeText(secretValue)
46
+ setCopied(true)
47
+ setCopyFailed(false)
48
+ } catch {
49
+ setCopied(false)
50
+ setCopyFailed(true)
51
+ }
52
+ if (copyTimer.current) clearTimeout(copyTimer.current)
53
+ copyTimer.current = setTimeout(() => {
54
+ setCopied(false)
55
+ setCopyFailed(false)
56
+ }, 2000)
57
+ }
58
+
59
+ return (
60
+ // 契约(见 props JSDoc):确认勾选前 onOpenChange 不会收到 false——
61
+ // ESC/遮罩/关闭按钮在此被统一拦截,防止密钥未保存即被关掉。
62
+ <Dialog open={open} onOpenChange={(o) => confirmed && onOpenChange(o)}>
63
+ <DialogContent>
64
+ <DialogHeader>
65
+ <DialogTitle>{title ?? `${secretName} 创建成功`}</DialogTitle>
66
+ <DialogDescription>
67
+ 此密钥只显示一次,关闭后无法再次查看。请立即保存到安全的地方。
68
+ </DialogDescription>
69
+ </DialogHeader>
70
+ <div className="flex items-center gap-2">
71
+ <code className="flex-1 px-3 py-2 bg-foreground/5 rounded font-mono text-sm break-all">
72
+ {secretValue}
73
+ </code>
74
+ <Button variant="outline" size="sm" onClick={copy}>
75
+ {copied ? '已复制' : copyFailed ? '复制失败,请手动复制' : '复制'}
76
+ </Button>
77
+ </div>
78
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
79
+ <input
80
+ type="checkbox"
81
+ checked={confirmed}
82
+ onChange={(e) => setConfirmed(e.target.checked)}
83
+ className="rounded border-input"
84
+ />
85
+ 我已将密钥保存到安全的地方
86
+ </label>
87
+ <DialogFooter>
88
+ <Button onClick={() => onOpenChange(false)} disabled={!confirmed}>
89
+ {confirmed ? '我已保存' : '请先确认已保存'}
90
+ </Button>
91
+ </DialogFooter>
92
+ </DialogContent>
93
+ </Dialog>
94
+ )
95
+ }
96
+
97
+ export function ImpactConfirmation({ open, onConfirm, onOpenChange, title, impacts, confirmText, danger }: {
98
+ open: boolean
99
+ /** 点击确认按钮后回调;回调返回后组件会立即请求关闭。 */
100
+ onConfirm: () => void
101
+ onOpenChange: (open: boolean) => void
102
+ title: string
103
+ /** 影响项列表。支持任意 ReactNode(可含链接/加粗等富文本);key 按下标生成,注意项内容需自行保证可读。 */
104
+ impacts: ReactNode[]
105
+ confirmText?: string
106
+ danger?: boolean
107
+ }) {
108
+ const [checked, setChecked] = useState(false)
109
+
110
+ return (
111
+ <Dialog open={open} onOpenChange={onOpenChange}>
112
+ <DialogContent>
113
+ <DialogHeader>
114
+ <DialogTitle>{title}</DialogTitle>
115
+ <DialogDescription>此操作将产生以下影响:</DialogDescription>
116
+ </DialogHeader>
117
+ {/* max-h + overflow:影响项过多时列表内部滚动,弹窗整体不被撑出视口 */}
118
+ <ul className="max-h-48 space-y-1.5 overflow-y-auto">
119
+ {impacts.map((impact, index) => (
120
+ <li key={index} className="flex items-start gap-2 text-sm text-foreground">
121
+ <span className="text-destructive mt-0.5">•</span> {impact}
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ <label className="flex items-center gap-2 text-sm text-muted-foreground pt-2 border-t">
126
+ <input
127
+ type="checkbox"
128
+ checked={checked}
129
+ onChange={(e) => setChecked(e.target.checked)}
130
+ className="rounded border-input"
131
+ />
132
+ 我理解并接受以上影响
133
+ </label>
134
+ <DialogFooter>
135
+ <Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
136
+ <Button
137
+ variant={danger ? 'destructive' : 'default'}
138
+ disabled={!checked}
139
+ onClick={() => { onConfirm(); onOpenChange(false) }}
140
+ >
141
+ {confirmText ?? '确认执行'}
142
+ </Button>
143
+ </DialogFooter>
144
+ </DialogContent>
145
+ </Dialog>
146
+ )
147
+ }
@@ -0,0 +1,39 @@
1
+
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '../lib/utils'
4
+ import {
5
+ Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent,
6
+ } from './ui/card'
7
+
8
+ export interface SettingsCardProps {
9
+ title: ReactNode
10
+ description?: ReactNode
11
+
12
+ cta?: ReactNode
13
+
14
+ danger?: boolean
15
+
16
+ bodyClassName?: string
17
+ children: ReactNode
18
+ }
19
+
20
+ export function SettingsCard({
21
+ title, description, cta, danger, bodyClassName, children,
22
+ }: SettingsCardProps) {
23
+ return (
24
+ <Card className="my-4 w-full max-w-4xl text-left" size="sm">
25
+ <CardHeader>
26
+ {/* G7.1:分块卡标题升 h2(PageHeader h1 之下的文档大纲层级),
27
+ 经 card.tsx CardTitle 的 as 多态,样式/props 不变。 */}
28
+ <h2 data-slot="card-title" className={cn('flex items-center gap-2 font-semibold leading-none tracking-tight', danger && 'text-destructive')}>{title}</h2>
29
+ {description && (
30
+ <CardDescription className={cn(danger && 'text-destructive/80')}>
31
+ {description}
32
+ </CardDescription>
33
+ )}
34
+ {cta && <CardAction>{cta}</CardAction>}
35
+ </CardHeader>
36
+ <CardContent className={cn('text-left', bodyClassName)}>{children}</CardContent>
37
+ </Card>
38
+ )
39
+ }
@@ -0,0 +1,32 @@
1
+
2
+ import { TriangleAlert } from 'lucide-react'
3
+ import { cn } from '../lib/utils'
4
+
5
+ export interface SiteBannerProps {
6
+
7
+ text?: string
8
+
9
+ kind?: 'info' | 'warn'
10
+ className?: string
11
+ }
12
+
13
+ export function SiteBanner({ text, kind = 'info', className }: SiteBannerProps) {
14
+ if (!text || !text.trim()) return null
15
+ const warn = kind === 'warn'
16
+ return (
17
+ <div
18
+ role="status"
19
+ data-slot="site-banner"
20
+ className={cn(
21
+ 'flex w-full items-center justify-center gap-2 border-b px-4 py-2 text-sm',
22
+ warn
23
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
24
+ : 'border-primary/25 bg-primary/10 text-primary',
25
+ className,
26
+ )}
27
+ >
28
+ {warn && <TriangleAlert className="size-4 shrink-0" aria-hidden="true" />}
29
+ <span className="min-w-0 whitespace-pre-wrap text-balance">{text}</span>
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,29 @@
1
+
2
+ import { SiteBanner } from './SiteBanner';
3
+ import { useSiteConfig } from '@ploykit/hooks';
4
+
5
+ export interface SiteBannerLayerProps {
6
+
7
+ maintenanceText?: string;
8
+ }
9
+
10
+ export function SiteBannerLayer({
11
+ maintenanceText,
12
+ }: SiteBannerLayerProps = {}) {
13
+ const { data } = useSiteConfig();
14
+ if (!data) return null;
15
+ return (
16
+ <>
17
+ {data.maintenance_mode === '1' && (
18
+ <SiteBanner
19
+ text={maintenanceText ?? '维护中:系统维护中,功能可能出现短暂不可用'}
20
+ kind="warn"
21
+ />
22
+ )}
23
+ <SiteBanner
24
+ text={data.banner_text}
25
+ kind={data.banner_kind === 'warn' ? 'warn' : 'info'}
26
+ />
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,12 @@
1
+ import { cn, statusLabel, statusTone, STATUS_TONE_CLASS } from '../lib/utils'
2
+
3
+ // 业务语义件(shadcn 无对应物,按"有则用 stock、无则留业务层"规则留守):
4
+ // 按订阅/订单/投递等业务状态映射色调与文案的徽章,从 stock 镜像 badge.tsx 迁出。
5
+ export function StatusBadge({ status, className }: { status: string; className?: string }) {
6
+ return (
7
+ <span className={cn('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap',
8
+ STATUS_TONE_CLASS[statusTone(status)], className)}>
9
+ {statusLabel(status)}
10
+ </span>
11
+ )
12
+ }
@@ -0,0 +1,103 @@
1
+
2
+ import { useNavigate } from 'react-router'
3
+ import type { ReactNode } from 'react'
4
+ import { LogOut, Settings, ShieldCheck } from 'lucide-react'
5
+ import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
6
+ import {
7
+ DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
8
+ DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
9
+ } from './ui/dropdown-menu'
10
+ import { useAuth } from '@ploykit/hooks'
11
+ import { toast } from './toast'
12
+
13
+ export interface UserMenuExtraItem {
14
+ label: string
15
+ onClick?: () => void
16
+ to?: string
17
+ icon?: ReactNode
18
+ destructive?: boolean
19
+ }
20
+
21
+ export interface UserMenuProps {
22
+
23
+ items?: UserMenuExtraItem[]
24
+
25
+ logoutRedirect?: string
26
+ }
27
+
28
+ function initialsOf(name: string, email: string): string {
29
+ const src = name.trim() || email.trim()
30
+ if (!src) return '?'
31
+ const parts = src.split(/[\s@._-]+/).filter(Boolean)
32
+ return parts.slice(0, 2).map((p) => p[0]?.toUpperCase() ?? '').join('') || src.slice(0, 2).toUpperCase()
33
+ }
34
+
35
+ export function UserMenu({ items = [], logoutRedirect = '/login' }: UserMenuProps) {
36
+ const { user, logout } = useAuth()
37
+ const navigate = useNavigate()
38
+
39
+ const name = user?.display_name || user?.email || ''
40
+ const email = user?.email ?? ''
41
+
42
+ const handleLogout = async () => {
43
+ try {
44
+ await logout()
45
+ } catch {
46
+
47
+
48
+ toast.error('退出登录失败,请稍后在登录页重试')
49
+ }
50
+ navigate(logoutRedirect)
51
+ }
52
+
53
+ return (
54
+ <DropdownMenu>
55
+ <DropdownMenuTrigger
56
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground
57
+ transition-colors hover:bg-accent hover:text-accent-foreground
58
+ aria-expanded:bg-accent"
59
+ aria-label="用户菜单"
60
+ >
61
+ <Avatar size="sm">
62
+ {user?.avatar_url ? <AvatarImage src={user.avatar_url} alt={name} /> : null}
63
+ <AvatarFallback>{initialsOf(user?.display_name ?? '', email)}</AvatarFallback>
64
+ </Avatar>
65
+ <span className="hidden max-w-40 truncate md:inline">{name || '未登录'}</span>
66
+ </DropdownMenuTrigger>
67
+ <DropdownMenuContent align="end" className="w-56">
68
+ <DropdownMenuGroup>
69
+ <DropdownMenuLabel className="p-0 font-normal">
70
+ <div className="flex flex-col px-2 py-1.5">
71
+ <span className="truncate text-sm font-medium text-foreground">{name || '未登录'}</span>
72
+ <span className="truncate text-xs text-muted-foreground">{email}</span>
73
+ </div>
74
+ </DropdownMenuLabel>
75
+ </DropdownMenuGroup>
76
+ <DropdownMenuSeparator />
77
+ <DropdownMenuItem onClick={() => navigate('/account/profile')}>
78
+ <Settings /> 账户设置
79
+ </DropdownMenuItem>
80
+ {user?.is_platform_admin && (
81
+ <DropdownMenuItem onClick={() => navigate('/admin')}>
82
+ <ShieldCheck /> 管理控制台
83
+ </DropdownMenuItem>
84
+ )}
85
+ {items.length > 0 && <DropdownMenuSeparator />}
86
+ {items.map((item) => (
87
+ <DropdownMenuItem
88
+ key={item.label}
89
+ variant={item.destructive ? 'destructive' : 'default'}
90
+ onClick={() => (item.to ? navigate(item.to) : item.onClick?.())}
91
+ >
92
+ {item.icon}
93
+ {item.label}
94
+ </DropdownMenuItem>
95
+ ))}
96
+ <DropdownMenuSeparator />
97
+ <DropdownMenuItem variant="destructive" onClick={handleLogout}>
98
+ <LogOut /> 退出登录
99
+ </DropdownMenuItem>
100
+ </DropdownMenuContent>
101
+ </DropdownMenu>
102
+ )
103
+ }