@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,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
|
+
}
|