@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.
- 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,98 @@
|
|
|
1
|
+
|
|
2
|
+
import { ApiError } from '@ploykit/client'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const KNOWN_MESSAGES: Record<string, string> = {
|
|
6
|
+
'invalid credentials': '邮箱或密码不正确,请检查后重试',
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
'current password incorrect': '当前密码不正确,请确认后重新输入',
|
|
10
|
+
'当前密码不正确,请确认后重新输入': '当前密码不正确,请确认后重新输入',
|
|
11
|
+
'新密码不能与当前密码相同': '新密码不能与当前密码相同,请换一个新密码',
|
|
12
|
+
'email already registered': '该邮箱已注册,可直接登录或换个邮箱重试',
|
|
13
|
+
'slug already taken': '该标识已被占用,请换个工作区名称重试',
|
|
14
|
+
'already a member': '该用户已是本工作区成员,无需重复邀请',
|
|
15
|
+
'invitation already pending': '该邮箱已有一封待处理的邀请,可先撤销旧邀请再重发',
|
|
16
|
+
'target is already an owner': '对方已是所有者,无需重复转让所有权',
|
|
17
|
+
|
|
18
|
+
'name required (<=64 chars)': '名称不能为空,且不超过 64 字节(约 21 个汉字)',
|
|
19
|
+
'name required': '名称不能为空',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const KNOWN_MESSAGE_PREFIXES: Array<[prefix: string, text: string]> = [
|
|
24
|
+
|
|
25
|
+
['slug must be', '标识格式有误:需 4-40 位小写字母、数字或连字符(-),且不能以连字符开头或结尾'],
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
['last owner cannot leave', '最后一个所有者不能离开工作区,请先转移所有权或删除工作区'],
|
|
29
|
+
|
|
30
|
+
['avatar_url must be', '头像链接格式有误:需以 http(s) 开头且不超过 2048 字符'],
|
|
31
|
+
|
|
32
|
+
['workspace cannot be deleted', '工作区暂时无法删除:存在未满足的前置条件(如未完结订单或活跃资源),请先处理后再试'],
|
|
33
|
+
|
|
34
|
+
['egress:', '回调地址不可达私网/被安全策略拦截,请改用公网 https 地址'],
|
|
35
|
+
['egressx:', '回调地址不可达私网/被安全策略拦截,请改用公网 https 地址'],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
const KNOWN_CODES: Record<string, string> = {
|
|
40
|
+
E_QUOTA_EXCEEDED: '配额已用尽,可在 设置→计费 升级套餐后重试',
|
|
41
|
+
E_TIMEOUT: '请求超时,网络可能较慢,请稍后重试',
|
|
42
|
+
E_ABORTED: '请求已取消',
|
|
43
|
+
|
|
44
|
+
E_CHANNEL_TEST_UNSUPPORTED: '该支付渠道不支持测试连接(manual 渠道无外部端点可探测)',
|
|
45
|
+
|
|
46
|
+
E_MALFORMED_RESPONSE: '服务返回了无法识别的响应,请稍后重试;若持续出现请联系平台管理员',
|
|
47
|
+
|
|
48
|
+
E_VALIDATION: '请求参数有误,请检查填写内容后重试',
|
|
49
|
+
|
|
50
|
+
E_INVALID_CRON: 'Cron 表达式格式有误(应为 5 段:分 时 日 月 周),请检查后重试',
|
|
51
|
+
E_INVALID_TZ: '时区无效,请填写 IANA 时区名(如 Asia/Shanghai)',
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
E_CONFLICT: '操作与当前状态冲突,请刷新页面查看最新状态后再试',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
function detailsText(details: unknown): string | undefined {
|
|
59
|
+
return typeof details === 'string' && details.trim() !== '' ? details.trim() : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function knownMessage(message: string): string | undefined {
|
|
63
|
+
const normalized = message.trim().toLowerCase()
|
|
64
|
+
const exact = KNOWN_MESSAGES[normalized]
|
|
65
|
+
if (exact) return exact
|
|
66
|
+
return KNOWN_MESSAGE_PREFIXES.find(([prefix]) => normalized.startsWith(prefix))?.[1]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function apiErrorMessage(err: unknown, fallback = '操作失败'): string {
|
|
70
|
+
if (err instanceof ApiError) {
|
|
71
|
+
|
|
72
|
+
const byMessage = knownMessage(err.message)
|
|
73
|
+
if (byMessage) return byMessage
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
const details = detailsText(err.details)
|
|
77
|
+
if (details) return details
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if (err.code && KNOWN_CODES[err.code]) return KNOWN_CODES[err.code]
|
|
81
|
+
if (err.status === 402) {
|
|
82
|
+
return '当前套餐不支持此操作。可在 设置→计费 升级套餐'
|
|
83
|
+
}
|
|
84
|
+
if (err.status === 401) return '登录已过期或未登录,请重新登录后再试'
|
|
85
|
+
if (err.status === 429) {
|
|
86
|
+
|
|
87
|
+
const wait = err.retryAfter !== undefined && err.retryAfter > 0 ? `(约 ${err.retryAfter} 秒后可重试)` : ''
|
|
88
|
+
return `请求过于频繁,请稍后再试${wait}`
|
|
89
|
+
}
|
|
90
|
+
if (err.status >= 500) return '服务暂时不可用,请稍后重试;若持续出现请联系平台管理员'
|
|
91
|
+
if (err.status === 404) return '不存在或无权访问'
|
|
92
|
+
if (err.status === 403) return '权限不足' + (err.message && err.message !== 'forbidden' ? `:${err.message}` : '')
|
|
93
|
+
return err.message || fallback
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (err instanceof TypeError) return '网络连接失败,请检查网络后重试'
|
|
97
|
+
return err instanceof Error && err.message ? err.message : fallback
|
|
98
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export function cn(...inputs: ClassValue[]) {
|
|
6
|
+
return twMerge(clsx(inputs))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export type StatusTone = 'positive' | 'critical' | 'warning' | 'neutral' | 'brand'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export const STATUS_TONE_CLASS: Record<StatusTone, string> = {
|
|
15
|
+
positive: 'bg-(--success)/10 text-(--success) border-(--success)/30',
|
|
16
|
+
critical: 'bg-(--destructive)/10 text-(--destructive) border-(--destructive)/30',
|
|
17
|
+
warning: 'bg-(--warning)/15 text-(--warning) border-(--warning)/40',
|
|
18
|
+
neutral: 'bg-muted text-muted-foreground border-border',
|
|
19
|
+
brand: 'bg-primary/10 text-primary border-primary/20',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function statusTone(status: string): StatusTone {
|
|
24
|
+
const s = status.toLowerCase()
|
|
25
|
+
if (['active','paid','delivered','completed','success','running','entitled','done'].includes(s)) return 'positive'
|
|
26
|
+
if (['failed','dead','error','disabled','canceled','expired','removed'].includes(s)) return 'critical'
|
|
27
|
+
if (['pending','warned','suspended','retrying'].includes(s)) return 'warning'
|
|
28
|
+
if (['owner','admin','pro','recommended'].includes(s)) return 'brand'
|
|
29
|
+
return 'neutral'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export const STATUS_LABEL: Record<string, string> = {
|
|
36
|
+
pending: '待处理',
|
|
37
|
+
accepted: '已接受',
|
|
38
|
+
declined: '已拒绝',
|
|
39
|
+
revoked: '已撤销',
|
|
40
|
+
expired: '已过期',
|
|
41
|
+
redeemed: '已兑换',
|
|
42
|
+
paid: '已支付',
|
|
43
|
+
canceled: '已取消',
|
|
44
|
+
active: '生效中',
|
|
45
|
+
inactive: '未启用',
|
|
46
|
+
enabled: '已启用',
|
|
47
|
+
disabled: '已禁用',
|
|
48
|
+
delivered: '已投递',
|
|
49
|
+
failed: '失败',
|
|
50
|
+
retrying: '重试中',
|
|
51
|
+
completed: '已完成',
|
|
52
|
+
done: '已完成',
|
|
53
|
+
running: '运行中',
|
|
54
|
+
success: '成功',
|
|
55
|
+
error: '错误',
|
|
56
|
+
|
|
57
|
+
dead: '死信/投递失败',
|
|
58
|
+
warned: '已警告',
|
|
59
|
+
suspended: '已暂停',
|
|
60
|
+
removed: '已移除',
|
|
61
|
+
entitled: '已生效',
|
|
62
|
+
refunded: '已退款',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export function statusLabel(status: string): string {
|
|
67
|
+
return STATUS_LABEL[status.toLowerCase()] ?? status
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
export function formatDate(iso: string): string {
|
|
74
|
+
return new Date(iso).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
export function formatDateTime(iso: string): string {
|
|
79
|
+
return new Date(iso).toLocaleString('zh-CN', {
|
|
80
|
+
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export function browserTzLabel(): string {
|
|
88
|
+
for (const nameType of ['shortOffset', 'short'] as const) {
|
|
89
|
+
try {
|
|
90
|
+
|
|
91
|
+
const opts = { timeZoneName: nameType } as Intl.DateTimeFormatOptions
|
|
92
|
+
const parts = new Intl.DateTimeFormat('en-US', opts).formatToParts(new Date())
|
|
93
|
+
const label = parts.find((p) => p.type === 'timeZoneName')?.value
|
|
94
|
+
if (label) return label
|
|
95
|
+
} catch {
|
|
96
|
+
// 试下一档兜底
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return '本地时区'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
export function formatDateTimeTz(iso: string): string {
|
|
104
|
+
return `${formatDateTime(iso)} (${browserTzLabel()})`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function shortID(id: string, len = 8): string {
|
|
108
|
+
return id.slice(0, len)
|
|
109
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
import { Link } from 'react-router'
|
|
3
|
+
|
|
4
|
+
export interface Feature { icon: string; title: string; desc: string }
|
|
5
|
+
export interface Plan {
|
|
6
|
+
name: string; price: string; period?: string; recommended?: boolean
|
|
7
|
+
features: string[]; cta: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function LandingPage({ brand, tagline, description, features, plans, registerPath = '/register', loginPath = '/login' }: {
|
|
11
|
+
brand: string
|
|
12
|
+
tagline: string
|
|
13
|
+
description: string
|
|
14
|
+
features: Feature[]
|
|
15
|
+
plans: Plan[]
|
|
16
|
+
|
|
17
|
+
registerPath?: string
|
|
18
|
+
|
|
19
|
+
loginPath?: string
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="min-h-screen bg-card">
|
|
23
|
+
<header className="border-b border-border">
|
|
24
|
+
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
|
25
|
+
<span className="text-xl font-bold text-foreground">{brand}</span>
|
|
26
|
+
<div className="flex items-center gap-3">
|
|
27
|
+
<Link to={loginPath} className="px-4 py-2 text-sm font-medium text-foreground rounded-lg hover:bg-muted">
|
|
28
|
+
登录
|
|
29
|
+
</Link>
|
|
30
|
+
<Link to={registerPath} className="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/80">
|
|
31
|
+
免费注册
|
|
32
|
+
</Link>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<section className="max-w-6xl mx-auto px-4 pt-20 pb-16 text-center">
|
|
38
|
+
<h1 className="text-5xl font-bold text-foreground tracking-tight">{tagline}</h1>
|
|
39
|
+
<p className="mt-4 text-xl text-muted-foreground max-w-2xl mx-auto">{description}</p>
|
|
40
|
+
<Link to={registerPath} className="mt-8 inline-block px-6 py-3 bg-primary text-primary-foreground font-medium rounded-lg hover:bg-primary/80 text-lg">
|
|
41
|
+
免费开始
|
|
42
|
+
</Link>
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<section className="bg-muted py-16">
|
|
46
|
+
<div className="max-w-6xl mx-auto px-4">
|
|
47
|
+
<h2 className="text-3xl font-bold text-center text-foreground mb-12">核心功能</h2>
|
|
48
|
+
<div className="grid md:grid-cols-3 gap-8">
|
|
49
|
+
{features.map((f, i) => (
|
|
50
|
+
<div key={`${i}-${f.title}`} className="p-6 bg-card rounded-xl border border-border">
|
|
51
|
+
<div className="text-3xl mb-3">{f.icon}</div>
|
|
52
|
+
<h3 className="text-lg font-semibold text-foreground">{f.title}</h3>
|
|
53
|
+
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">{f.desc}</p>
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section className="py-16">
|
|
61
|
+
<div className="max-w-6xl mx-auto px-4">
|
|
62
|
+
<h2 className="text-3xl font-bold text-center text-foreground mb-12">定价</h2>
|
|
63
|
+
<div className="grid md:grid-cols-2 gap-8 max-w-3xl mx-auto">
|
|
64
|
+
{plans.map((p, i) => (
|
|
65
|
+
<div key={`${i}-${p.name}`} className={`p-8 bg-card rounded-xl ${p.recommended ? 'border-2 border-primary relative' : 'border border-border'}`}>
|
|
66
|
+
{p.recommended && (
|
|
67
|
+
<span className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-primary text-primary-foreground text-xs font-medium rounded-full">推荐</span>
|
|
68
|
+
)}
|
|
69
|
+
<h3 className="text-lg font-semibold text-foreground">{p.name}</h3>
|
|
70
|
+
<p className="mt-1 text-3xl font-bold text-foreground">{p.price}<span className="text-sm text-muted-foreground">{p.period ?? '/月'}</span></p>
|
|
71
|
+
<ul className="mt-6 space-y-3 text-sm text-muted-foreground">
|
|
72
|
+
{p.features.map((f, j) => <li key={`${j}-${f}`}>✓ {f}</li>)}
|
|
73
|
+
</ul>
|
|
74
|
+
<Link to={registerPath} className={`mt-6 block text-center py-2.5 rounded-lg font-medium ${p.recommended ? 'bg-primary text-primary-foreground hover:bg-primary/80' : 'border border-input text-foreground hover:bg-muted'}`}>
|
|
75
|
+
{p.cta}
|
|
76
|
+
</Link>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<footer className="border-t border-border py-8 text-center text-sm text-muted-foreground">
|
|
84
|
+
© 2026 {brand}. All rights reserved.
|
|
85
|
+
</footer>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState, type FormEvent } from 'react'
|
|
3
|
+
import { useLocation } from 'react-router'
|
|
4
|
+
import { useLogin } from '@ploykit/hooks'
|
|
5
|
+
import { useApi } from '@ploykit/hooks'
|
|
6
|
+
import { useAuth } from '@ploykit/hooks'
|
|
7
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs'
|
|
8
|
+
import { Button } from '../components/ui/button'
|
|
9
|
+
import { Input } from '../components/ui/input'
|
|
10
|
+
import { FormField } from '../components/FormField'
|
|
11
|
+
import { apiErrorMessage } from '../lib/api-error'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export interface OAuthProviderOption {
|
|
15
|
+
id: string
|
|
16
|
+
label: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const SEND_COOLDOWN_S = 60
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function loginRedirectTarget(from: unknown, fallback = '/app'): string {
|
|
24
|
+
if (typeof from !== 'string') return fallback
|
|
25
|
+
if (!from.startsWith('/') || from.startsWith('//')) return fallback
|
|
26
|
+
return from
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LoginPageProps {
|
|
30
|
+
|
|
31
|
+
resetPath?: string
|
|
32
|
+
|
|
33
|
+
registerPath?: string
|
|
34
|
+
|
|
35
|
+
oauthProviders?: OAuthProviderOption[]
|
|
36
|
+
|
|
37
|
+
enableFedLogin?: boolean
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 纯密码形态(企业内部工具):隐藏验证码 Tab,页面直接是密码登录——
|
|
41
|
+
* 不渲染 Tabs 结构,OAuth 槽位照常由 oauthProviders 控制(不传即无)。
|
|
42
|
+
*/
|
|
43
|
+
passwordOnly?: boolean
|
|
44
|
+
|
|
45
|
+
onSuccess?: () => void
|
|
46
|
+
|
|
47
|
+
title?: string
|
|
48
|
+
description?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function LoginPage({
|
|
52
|
+
resetPath = '/forgot-password',
|
|
53
|
+
registerPath = '/register',
|
|
54
|
+
oauthProviders = [],
|
|
55
|
+
enableFedLogin = false,
|
|
56
|
+
passwordOnly = false,
|
|
57
|
+
onSuccess,
|
|
58
|
+
title = '登录',
|
|
59
|
+
description = '登录你的账号',
|
|
60
|
+
}: LoginPageProps) {
|
|
61
|
+
const { submit, loading, error: codeError } = useLogin()
|
|
62
|
+
const api = useApi()
|
|
63
|
+
const { refresh } = useAuth()
|
|
64
|
+
|
|
65
|
+
const from = loginRedirectTarget(
|
|
66
|
+
(useLocation().state as { from?: unknown } | null)?.from,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const [email, setEmail] = useState('')
|
|
70
|
+
const [code, setCode] = useState('')
|
|
71
|
+
const [codeSent, setCodeSent] = useState(false)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
const [sending, setSending] = useState(false)
|
|
75
|
+
const [sendError, setSendError] = useState<string | null>(null)
|
|
76
|
+
const [cooldown, setCooldown] = useState(0)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (cooldown <= 0) return
|
|
80
|
+
const t = setInterval(() => setCooldown((c) => c - 1), 1_000)
|
|
81
|
+
return () => clearInterval(t)
|
|
82
|
+
}, [cooldown])
|
|
83
|
+
|
|
84
|
+
const [password, setPassword] = useState('')
|
|
85
|
+
const [pwLoading, setPwLoading] = useState(false)
|
|
86
|
+
const [pwError, setPwError] = useState<string | null>(null)
|
|
87
|
+
|
|
88
|
+
const succeed = onSuccess ?? (() => { window.location.href = from })
|
|
89
|
+
|
|
90
|
+
const handleSendCode = async () => {
|
|
91
|
+
if (!email || sending || cooldown > 0) return
|
|
92
|
+
setSending(true)
|
|
93
|
+
setSendError(null)
|
|
94
|
+
try {
|
|
95
|
+
await api.post('/auth/send-code', { email })
|
|
96
|
+
setCodeSent(true)
|
|
97
|
+
setCooldown(SEND_COOLDOWN_S)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
|
|
100
|
+
setSendError(apiErrorMessage(e, '验证码发送失败,请稍后重试'))
|
|
101
|
+
} finally {
|
|
102
|
+
setSending(false)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleCodeSubmit = async (e: FormEvent) => {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
const result = await submit(email, code)
|
|
109
|
+
if (result.ok) succeed()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handlePasswordSubmit = async (e: FormEvent) => {
|
|
113
|
+
e.preventDefault()
|
|
114
|
+
setPwError(null)
|
|
115
|
+
setPwLoading(true)
|
|
116
|
+
try {
|
|
117
|
+
await api.post('/auth/login', { email, password })
|
|
118
|
+
await refresh()
|
|
119
|
+
succeed()
|
|
120
|
+
} catch (err) {
|
|
121
|
+
|
|
122
|
+
setPwError(apiErrorMessage(err, '登录失败'))
|
|
123
|
+
} finally {
|
|
124
|
+
setPwLoading(false)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
130
|
+
<div className="w-full max-w-sm space-y-4 p-8 bg-white rounded-lg shadow">
|
|
131
|
+
<div className="text-center">
|
|
132
|
+
<h1 className="text-2xl font-bold">{title}</h1>
|
|
133
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{passwordOnly ? (
|
|
137
|
+
<form onSubmit={handlePasswordSubmit} className="space-y-4 pt-2">
|
|
138
|
+
<FormField label="邮箱" htmlFor="login-pw-email">
|
|
139
|
+
<Input
|
|
140
|
+
id="login-pw-email" type="email" value={email} required
|
|
141
|
+
onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
|
142
|
+
autoComplete="email"
|
|
143
|
+
/>
|
|
144
|
+
</FormField>
|
|
145
|
+
<FormField label="密码" htmlFor="login-pw-password">
|
|
146
|
+
<Input
|
|
147
|
+
id="login-pw-password" type="password" value={password} required
|
|
148
|
+
onChange={(e) => setPassword(e.target.value)} placeholder="输入密码"
|
|
149
|
+
autoComplete="current-password"
|
|
150
|
+
/>
|
|
151
|
+
</FormField>
|
|
152
|
+
{pwError && <p role="alert" className="text-destructive text-sm">{pwError}</p>}
|
|
153
|
+
<Button type="submit" className="w-full" disabled={pwLoading || !email || !password}>
|
|
154
|
+
{pwLoading ? '登录中…' : '登录'}
|
|
155
|
+
</Button>
|
|
156
|
+
</form>
|
|
157
|
+
) : (
|
|
158
|
+
<Tabs defaultValue="code">
|
|
159
|
+
<TabsList className="w-full">
|
|
160
|
+
<TabsTrigger value="code">验证码登录</TabsTrigger>
|
|
161
|
+
<TabsTrigger value="password">密码登录</TabsTrigger>
|
|
162
|
+
</TabsList>
|
|
163
|
+
|
|
164
|
+
{/* 验证码模式(默认) */}
|
|
165
|
+
<TabsContent value="code">
|
|
166
|
+
<form onSubmit={handleCodeSubmit} className="space-y-4 pt-2">
|
|
167
|
+
<div className="space-y-1">
|
|
168
|
+
<label htmlFor="login-email" className="text-sm text-foreground">邮箱</label>
|
|
169
|
+
<input
|
|
170
|
+
id="login-email"
|
|
171
|
+
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
|
172
|
+
placeholder="you@example.com" required autoComplete="email"
|
|
173
|
+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
<div className="space-y-1">
|
|
177
|
+
<label htmlFor="login-code" className="text-sm text-foreground">验证码</label>
|
|
178
|
+
<div className="flex gap-2">
|
|
179
|
+
<input
|
|
180
|
+
id="login-code"
|
|
181
|
+
type="text" value={code} onChange={(e) => setCode(e.target.value)}
|
|
182
|
+
placeholder="6位数字" maxLength={6} required autoComplete="one-time-code"
|
|
183
|
+
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
|
184
|
+
/>
|
|
185
|
+
<button
|
|
186
|
+
type="button" onClick={handleSendCode}
|
|
187
|
+
disabled={sending || cooldown > 0 || !email}
|
|
188
|
+
className="px-4 py-2 text-sm text-primary border border-primary/30 rounded-md hover:bg-primary/10 disabled:opacity-50"
|
|
189
|
+
>
|
|
190
|
+
{cooldown > 0
|
|
191
|
+
? `重发(${cooldown}s)`
|
|
192
|
+
: codeSent
|
|
193
|
+
? '重新发送'
|
|
194
|
+
: sending
|
|
195
|
+
? '发送中…'
|
|
196
|
+
: '发送'}
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
{/* P3-29:错误 p 补 role="alert"(读屏即时播报) */}
|
|
201
|
+
{sendError && <p role="alert" className="text-destructive text-sm">{sendError}</p>}
|
|
202
|
+
{codeError && <p role="alert" className="text-destructive text-sm">{codeError}</p>}
|
|
203
|
+
<button
|
|
204
|
+
type="submit" disabled={loading || !email || !code}
|
|
205
|
+
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/80 disabled:opacity-50"
|
|
206
|
+
>
|
|
207
|
+
{loading ? '登录中…' : '登录'}
|
|
208
|
+
</button>
|
|
209
|
+
</form>
|
|
210
|
+
</TabsContent>
|
|
211
|
+
|
|
212
|
+
{/* 密码模式 */}
|
|
213
|
+
<TabsContent value="password">
|
|
214
|
+
<form onSubmit={handlePasswordSubmit} className="space-y-4 pt-2">
|
|
215
|
+
<FormField label="邮箱" htmlFor="login-pw-email">
|
|
216
|
+
<Input
|
|
217
|
+
id="login-pw-email" type="email" value={email} required
|
|
218
|
+
onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
|
219
|
+
autoComplete="email"
|
|
220
|
+
/>
|
|
221
|
+
</FormField>
|
|
222
|
+
<FormField label="密码" htmlFor="login-pw-password">
|
|
223
|
+
<Input
|
|
224
|
+
id="login-pw-password" type="password" value={password} required
|
|
225
|
+
onChange={(e) => setPassword(e.target.value)} placeholder="输入密码"
|
|
226
|
+
autoComplete="current-password"
|
|
227
|
+
/>
|
|
228
|
+
</FormField>
|
|
229
|
+
{pwError && <p role="alert" className="text-destructive text-sm">{pwError}</p>}
|
|
230
|
+
<Button type="submit" className="w-full" disabled={pwLoading || !email || !password}>
|
|
231
|
+
{pwLoading ? '登录中…' : '登录'}
|
|
232
|
+
</Button>
|
|
233
|
+
</form>
|
|
234
|
+
</TabsContent>
|
|
235
|
+
</Tabs>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{oauthProviders.length > 0 && (
|
|
239
|
+
<div className="space-y-2 pt-1" data-testid="oauth-providers">
|
|
240
|
+
<div className="relative py-2 text-center">
|
|
241
|
+
<span className="absolute inset-x-0 top-1/2 border-t border-border" />
|
|
242
|
+
<span className="relative z-10 bg-card px-2 text-xs text-muted-foreground">或使用以下方式登录</span>
|
|
243
|
+
</div>
|
|
244
|
+
{oauthProviders.map((p) => (
|
|
245
|
+
<a
|
|
246
|
+
key={p.id}
|
|
247
|
+
href={'/auth/oauth/' + p.id + '/start'}
|
|
248
|
+
className="block w-full py-2 px-4 text-center text-sm border border-input rounded-md hover:bg-background"
|
|
249
|
+
>
|
|
250
|
+
使用 {p.label} 登录
|
|
251
|
+
</a>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{enableFedLogin && (
|
|
257
|
+
<details className="pt-1" data-testid="fed-login">
|
|
258
|
+
<summary className="text-sm text-muted-foreground cursor-pointer select-none">企业登录</summary>
|
|
259
|
+
<form method="get" action="/auth/fed/start" className="mt-2 space-y-2">
|
|
260
|
+
<input
|
|
261
|
+
type="text" name="workspace" placeholder="工作区标识(slug)" required
|
|
262
|
+
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
|
263
|
+
/>
|
|
264
|
+
<button
|
|
265
|
+
type="submit"
|
|
266
|
+
className="w-full py-2 px-4 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/80"
|
|
267
|
+
>
|
|
268
|
+
通过企业 IdP 登录
|
|
269
|
+
</button>
|
|
270
|
+
</form>
|
|
271
|
+
</details>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{(resetPath || registerPath) && (
|
|
275
|
+
<div className="flex items-center justify-between text-sm">
|
|
276
|
+
{resetPath && (
|
|
277
|
+
<a href={resetPath} className="text-primary hover:underline">
|
|
278
|
+
忘记密码?
|
|
279
|
+
</a>
|
|
280
|
+
)}
|
|
281
|
+
{registerPath && (
|
|
282
|
+
<a href={registerPath} data-testid="register-link" className="text-primary hover:underline">
|
|
283
|
+
创建账号
|
|
284
|
+
</a>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
import '@testing-library/jest-dom/vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { MemoryRouter } from 'react-router'
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { LandingPage } from '../LandingPage'
|
|
7
|
+
|
|
8
|
+
const FEATURES = [
|
|
9
|
+
{ icon: '⚡', title: '快速', desc: '秒级启动' },
|
|
10
|
+
{ icon: '🔒', title: '安全', desc: '企业级权限' },
|
|
11
|
+
{ icon: '🧩', title: '快速', desc: '重名标题回归(P3-31)' },
|
|
12
|
+
]
|
|
13
|
+
const PLANS = [
|
|
14
|
+
{ name: 'Free', price: '¥0', features: ['基础功能', '基础功能'], cta: '免费开始' },
|
|
15
|
+
{ name: 'Free', price: '¥99', recommended: true, features: ['全部功能'], cta: '升级' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function renderPage() {
|
|
19
|
+
return render(
|
|
20
|
+
<MemoryRouter>
|
|
21
|
+
<LandingPage
|
|
22
|
+
brand="Ploykit"
|
|
23
|
+
tagline="多租户 SaaS 脚手架"
|
|
24
|
+
description="开箱即用的框架"
|
|
25
|
+
features={FEATURES}
|
|
26
|
+
plans={PLANS}
|
|
27
|
+
registerPath="/register"
|
|
28
|
+
loginPath="/login"
|
|
29
|
+
/>
|
|
30
|
+
</MemoryRouter>,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('LandingPage', () => {
|
|
35
|
+
it('渲染品牌/标语/功能/定价', () => {
|
|
36
|
+
renderPage()
|
|
37
|
+
expect(screen.getByText('Ploykit')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('多租户 SaaS 脚手架')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getAllByText('快速').length).toBe(2)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('登录/注册 CTA 指向可配路径', () => {
|
|
43
|
+
renderPage()
|
|
44
|
+
expect(screen.getByRole('link', { name: '登录' })).toHaveAttribute(
|
|
45
|
+
'href',
|
|
46
|
+
'/login',
|
|
47
|
+
)
|
|
48
|
+
const register = screen.getAllByRole('link', { name: /免费注册|免费开始/ })
|
|
49
|
+
for (const link of register) {
|
|
50
|
+
expect(link).toHaveAttribute('href', '/register')
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('推荐档位带"推荐"标记', () => {
|
|
55
|
+
renderPage()
|
|
56
|
+
expect(screen.getByText('推荐')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
})
|