@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,115 @@
|
|
|
1
|
+
|
|
2
|
+
import '@testing-library/jest-dom/vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { ResetPasswordPage } from '../ResetPasswordPage'
|
|
6
|
+
|
|
7
|
+
const mockedPost = vi.hoisted(() => vi.fn())
|
|
8
|
+
vi.mock('@ploykit/client', () => ({
|
|
9
|
+
api: {
|
|
10
|
+
post: mockedPost,
|
|
11
|
+
get: vi.fn(),
|
|
12
|
+
put: vi.fn(),
|
|
13
|
+
patch: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const STRONG_PW = 'Abcdef1234!'
|
|
19
|
+
|
|
20
|
+
function renderWithQuery(query: string) {
|
|
21
|
+
const original = window.location.href
|
|
22
|
+
window.history.replaceState(null, '', `/reset-password${query}`)
|
|
23
|
+
const view = render(<ResetPasswordPage />)
|
|
24
|
+
return {
|
|
25
|
+
...view,
|
|
26
|
+
restore: () => window.history.replaceState(null, '', original),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockedPost.mockReset().mockResolvedValue(undefined)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
window.history.replaceState(null, '', '/')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('ResetPasswordPage', () => {
|
|
39
|
+
it('缺 email/token → 链接无效提示,不发请求', async () => {
|
|
40
|
+
const v = renderWithQuery('')
|
|
41
|
+
expect(await screen.findByTestId('reset-invalid')).toBeInTheDocument()
|
|
42
|
+
expect(mockedPost).not.toHaveBeenCalled()
|
|
43
|
+
v.restore()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('有效提交 → POST /auth/reset-password {email, token, new_password}', async () => {
|
|
47
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-1')
|
|
48
|
+
await screen.findByText('正在为 user@example.com 设置新密码')
|
|
49
|
+
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: STRONG_PW } })
|
|
50
|
+
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: STRONG_PW } })
|
|
51
|
+
fireEvent.click(screen.getByRole('button', { name: '重置密码' }))
|
|
52
|
+
|
|
53
|
+
await waitFor(() =>
|
|
54
|
+
expect(mockedPost).toHaveBeenCalledWith('/auth/reset-password', {
|
|
55
|
+
email: 'user@example.com',
|
|
56
|
+
token: 'tok-1',
|
|
57
|
+
new_password: STRONG_PW,
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
v.restore()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('P3-28:成功后 URL 中的 token 被清除 + 倒计时文案出现', async () => {
|
|
64
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-2')
|
|
65
|
+
await screen.findByText('正在为 user@example.com 设置新密码')
|
|
66
|
+
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: STRONG_PW } })
|
|
67
|
+
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: STRONG_PW } })
|
|
68
|
+
fireEvent.click(screen.getByRole('button', { name: '重置密码' }))
|
|
69
|
+
|
|
70
|
+
expect(await screen.findByTestId('reset-done')).toBeInTheDocument()
|
|
71
|
+
expect(screen.getByText(/秒后跳转登录页/)).toBeInTheDocument()
|
|
72
|
+
await waitFor(() =>
|
|
73
|
+
expect(window.location.search).not.toContain('token'),
|
|
74
|
+
)
|
|
75
|
+
v.restore()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('P3-30:两次不一致 → 错误归因到确认字段,不发请求', async () => {
|
|
79
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-3')
|
|
80
|
+
await screen.findByText('正在为 user@example.com 设置新密码')
|
|
81
|
+
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: STRONG_PW } })
|
|
82
|
+
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'Different123!' } })
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: '重置密码' }))
|
|
84
|
+
|
|
85
|
+
expect(await screen.findByText('两次输入的密码不一致')).toBeInTheDocument()
|
|
86
|
+
expect(mockedPost).not.toHaveBeenCalled()
|
|
87
|
+
v.restore()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('P3-30:弱密码(少于三类)被前端拦截', async () => {
|
|
91
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-4')
|
|
92
|
+
await screen.findByText('正在为 user@example.com 设置新密码')
|
|
93
|
+
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'abcdefghijk' } })
|
|
94
|
+
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'abcdefghijk' } })
|
|
95
|
+
fireEvent.click(screen.getByRole('button', { name: '重置密码' }))
|
|
96
|
+
|
|
97
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
98
|
+
'需包含大写/小写/数字/符号中的至少三类',
|
|
99
|
+
)
|
|
100
|
+
expect(mockedPost).not.toHaveBeenCalled()
|
|
101
|
+
v.restore()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('服务端失败(令牌失效)→ 表单级 alert 展示', async () => {
|
|
105
|
+
mockedPost.mockRejectedValueOnce(new Error('reset token expired'))
|
|
106
|
+
const v = renderWithQuery('?email=user@example.com&token=stale')
|
|
107
|
+
await screen.findByText('正在为 user@example.com 设置新密码')
|
|
108
|
+
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: STRONG_PW } })
|
|
109
|
+
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: STRONG_PW } })
|
|
110
|
+
fireEvent.click(screen.getByRole('button', { name: '重置密码' }))
|
|
111
|
+
|
|
112
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('reset token expired')
|
|
113
|
+
v.restore()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
|
|
2
|
+
import '@testing-library/jest-dom/vitest'
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { VerifyEmailPage } from '../VerifyEmailPage'
|
|
6
|
+
|
|
7
|
+
const mockedPost = vi.hoisted(() => vi.fn())
|
|
8
|
+
vi.mock('@ploykit/client', () => ({
|
|
9
|
+
api: {
|
|
10
|
+
post: mockedPost,
|
|
11
|
+
get: vi.fn(),
|
|
12
|
+
put: vi.fn(),
|
|
13
|
+
patch: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
function renderWithQuery(query: string) {
|
|
19
|
+
const original = window.location.href
|
|
20
|
+
window.history.replaceState(null, '', `/verify-email${query}`)
|
|
21
|
+
const view = render(<VerifyEmailPage />)
|
|
22
|
+
return { ...view, restore: () => window.history.replaceState(null, '', original) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockedPost.mockReset().mockResolvedValue(undefined)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
window.history.replaceState(null, '', '/')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('VerifyEmailPage', () => {
|
|
34
|
+
it('有效参数 → POST /auth/verify-email → 成功态', async () => {
|
|
35
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-1')
|
|
36
|
+
await waitFor(() =>
|
|
37
|
+
expect(mockedPost).toHaveBeenCalledWith('/auth/verify-email', {
|
|
38
|
+
email: 'user@example.com',
|
|
39
|
+
token: 'tok-1',
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
expect(await screen.findByText('邮箱验证成功')).toBeInTheDocument()
|
|
43
|
+
v.restore()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('P3-28:成功后 URL 中的 token 被清除', async () => {
|
|
47
|
+
const v = renderWithQuery('?email=user@example.com&token=tok-2')
|
|
48
|
+
await screen.findByText('邮箱验证成功')
|
|
49
|
+
await waitFor(() => expect(window.location.search).not.toContain('token'))
|
|
50
|
+
v.restore()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('缺参(无 token)→ 直接失败态,不发请求', async () => {
|
|
54
|
+
const v = renderWithQuery('?email=user@example.com')
|
|
55
|
+
expect(await screen.findByText('验证失败')).toBeInTheDocument()
|
|
56
|
+
expect(mockedPost).not.toHaveBeenCalled()
|
|
57
|
+
v.restore()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('服务端拒绝(令牌失效)→ 失败态展示服务端语义', async () => {
|
|
61
|
+
mockedPost.mockRejectedValueOnce(new Error('verify token expired'))
|
|
62
|
+
const v = renderWithQuery('?email=user@example.com&token=stale')
|
|
63
|
+
expect(await screen.findByText('验证失败')).toBeInTheDocument()
|
|
64
|
+
expect(screen.getByText('verify token expired')).toBeInTheDocument()
|
|
65
|
+
v.restore()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('重渲染(StrictMode 双跑)不重复消费令牌:POST 恰一次', async () => {
|
|
69
|
+
const v = renderWithQuery('?email=user@example.com&token=once')
|
|
70
|
+
await screen.findByText('邮箱验证成功')
|
|
71
|
+
expect(mockedPost).toHaveBeenCalledTimes(1)
|
|
72
|
+
v.restore()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useSearchParams } from 'react-router'
|
|
4
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
5
|
+
import { PageHeader, PageLoading, PageError, PageEmpty } from '../../components/Page'
|
|
6
|
+
import { DataTable, type Column } from '../../components/DataTable'
|
|
7
|
+
import { SettingsCard } from '../../components/SettingsCard'
|
|
8
|
+
import { useConfirm } from '../../components/ConfirmDialog'
|
|
9
|
+
import { toast } from '../../components/toast'
|
|
10
|
+
import { Button } from '../../components/ui/button'
|
|
11
|
+
import { Badge } from '../../components/ui/badge'
|
|
12
|
+
import { StatusBadge } from '../../components/StatusBadge'
|
|
13
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'
|
|
14
|
+
import { useApi } from '@ploykit/hooks'
|
|
15
|
+
import { useBilling } from '@ploykit/hooks'
|
|
16
|
+
import { useSiteConfig } from '@ploykit/hooks'
|
|
17
|
+
import { useWorkspace } from '@ploykit/hooks'
|
|
18
|
+
import {
|
|
19
|
+
queryKeys, type PKOrder, type PKPlan, type PKSubscription,
|
|
20
|
+
} from '@ploykit/hooks'
|
|
21
|
+
import { formatDateTime, cn } from '../../lib/utils'
|
|
22
|
+
import { apiErrorMessage } from '../../lib/api-error'
|
|
23
|
+
|
|
24
|
+
type Interval = 'monthly' | 'yearly'
|
|
25
|
+
|
|
26
|
+
const INTERVAL_LABEL: Record<Interval, string> = { monthly: '按月', yearly: '按年' }
|
|
27
|
+
const INTERVAL_UNIT: Record<Interval, string> = { monthly: '月', yearly: '年' }
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const CHANNEL_LABEL: Record<string, string> = {
|
|
32
|
+
stripe: 'Stripe(银行卡)',
|
|
33
|
+
alipay: '支付宝',
|
|
34
|
+
wechat: '微信支付',
|
|
35
|
+
manual: '线下转账(管理员核销)',
|
|
36
|
+
}
|
|
37
|
+
const channelLabel = (code: string): string => CHANNEL_LABEL[code] ?? code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
const LIMIT_LABEL: Record<string, string> = {
|
|
41
|
+
tasks_monthly: '任务数 / 周期',
|
|
42
|
+
workspaces: '工作区数',
|
|
43
|
+
seats: '成员席位',
|
|
44
|
+
webhooks: 'Webhook 订阅',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function priceCentsOf(plan: PKPlan, interval: Interval): number {
|
|
48
|
+
const key = interval === 'yearly' ? 'price_yearly_cents' : 'price_monthly_cents'
|
|
49
|
+
return plan.limits[key] ?? 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function fmtCents(cents: number, currency?: string): string {
|
|
54
|
+
if (cents === 0) return '免费'
|
|
55
|
+
const cur = (currency ?? 'CNY').toUpperCase()
|
|
56
|
+
const symbol = cur === 'CNY' ? '¥' : `${cur} `
|
|
57
|
+
return `${symbol}${(cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BillingPageProps {
|
|
61
|
+
|
|
62
|
+
channel?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function BillingPage({ channel = 'stripe' }: BillingPageProps = {}) {
|
|
66
|
+
const { current } = useWorkspace()
|
|
67
|
+
const { subscription, plans, loading } = useBilling()
|
|
68
|
+
const api = useApi()
|
|
69
|
+
const confirm = useConfirm()
|
|
70
|
+
const qc = useQueryClient()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
74
|
+
const [interval, setInterval] = useState<Interval>(
|
|
75
|
+
searchParams.get('interval') === 'yearly' ? 'yearly' : 'monthly',
|
|
76
|
+
)
|
|
77
|
+
const [payChannelSel, setPayChannelSel] = useState(searchParams.get('channel') ?? channel)
|
|
78
|
+
const [checkoutPlan, setCheckoutPlan] = useState<string | null>(null)
|
|
79
|
+
|
|
80
|
+
const [manualNote, setManualNote] = useState<string | null>(null)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
const syncParams = (next: { interval?: Interval; channel?: string }) => {
|
|
84
|
+
const p = new URLSearchParams(searchParams)
|
|
85
|
+
if (next.interval) p.set('interval', next.interval)
|
|
86
|
+
if (next.channel) p.set('channel', next.channel)
|
|
87
|
+
setSearchParams(p, { replace: true })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
const configQ = useSiteConfig()
|
|
95
|
+
const billingChannels = configQ.data?.billing_channels
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
const channelOptions = billingChannels !== undefined ? billingChannels : Object.keys(CHANNEL_LABEL)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
const payChannel =
|
|
102
|
+
billingChannels !== undefined && billingChannels.length > 0 && !billingChannels.includes(payChannelSel)
|
|
103
|
+
? billingChannels[0]
|
|
104
|
+
: payChannelSel
|
|
105
|
+
const noChannels = billingChannels !== undefined && billingChannels.length === 0
|
|
106
|
+
|
|
107
|
+
const wsId = current?.id ?? ''
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
const subErrQ = useQuery({
|
|
111
|
+
queryKey: queryKeys.billingSubscription(wsId),
|
|
112
|
+
queryFn: () => api.get<PKSubscription>('/api/billing/subscription'),
|
|
113
|
+
enabled: !!wsId,
|
|
114
|
+
})
|
|
115
|
+
const plansErrQ = useQuery({
|
|
116
|
+
queryKey: queryKeys.billingPlans,
|
|
117
|
+
queryFn: () => api.get<PKPlan[]>('/api/billing/plans'),
|
|
118
|
+
})
|
|
119
|
+
const loadError = subErrQ.isError
|
|
120
|
+
? subErrQ.error
|
|
121
|
+
: plansErrQ.isError
|
|
122
|
+
? plansErrQ.error
|
|
123
|
+
: null
|
|
124
|
+
const orders: PKOrder[] = subscription?.orders ?? []
|
|
125
|
+
const currentPlanCode = subscription?.plan_code ?? null
|
|
126
|
+
const sortedPlans = [...plans].sort((a, b) => a.sort_no - b.sort_no)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
const checkoutDescription =
|
|
131
|
+
payChannel === 'manual'
|
|
132
|
+
? '升级立即生效;下单后请联系管理员完成线下转账,订单在管理员核销后生效。'
|
|
133
|
+
: payChannel === 'stripe'
|
|
134
|
+
? '升级立即生效;Stripe 结账完成后自动跳回。'
|
|
135
|
+
: '升级立即生效;在所选渠道的收银台完成支付后自动生效。'
|
|
136
|
+
|
|
137
|
+
const refreshBilling = () => qc.invalidateQueries({ queryKey: queryKeys.billing })
|
|
138
|
+
|
|
139
|
+
const handleCheckout = async (plan: PKPlan) => {
|
|
140
|
+
if (noChannels || (billingChannels !== undefined && billingChannels.length > 0 && !billingChannels.includes(payChannel))) {
|
|
141
|
+
toast.error('该支付渠道未配置')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
setCheckoutPlan(plan.code)
|
|
145
|
+
setManualNote(null)
|
|
146
|
+
try {
|
|
147
|
+
const res = await api.post<{ action_url?: string }>('/api/billing/checkout', {
|
|
148
|
+
plan_code: plan.code, interval, channel: payChannel,
|
|
149
|
+
})
|
|
150
|
+
if (res.action_url) {
|
|
151
|
+
|
|
152
|
+
window.location.href = res.action_url
|
|
153
|
+
} else if (payChannel === 'manual') {
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
setManualNote(`已创建 ${plan.name} 订单(待处理):下单后请联系管理员完成支付,订单在管理员核销后生效。`)
|
|
158
|
+
toast.success('订单已创建,请联系管理员完成支付')
|
|
159
|
+
await refreshBilling()
|
|
160
|
+
} else {
|
|
161
|
+
toast.error('支付渠道未返回跳转地址')
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
toast.error(apiErrorMessage(e, '发起结账失败'))
|
|
165
|
+
} finally {
|
|
166
|
+
setCheckoutPlan(null)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const handleCancel = async (order: PKOrder) => {
|
|
171
|
+
|
|
172
|
+
if (!(await confirm({
|
|
173
|
+
title: '取消订单',
|
|
174
|
+
description: `取消 ${order.plan_code}(${INTERVAL_LABEL[order.interval as Interval] ?? order.interval})的待处理订单?`,
|
|
175
|
+
confirmText: '取消订单', danger: true,
|
|
176
|
+
}))) return
|
|
177
|
+
try {
|
|
178
|
+
await api.post(`/api/billing/orders/${order.id}/cancel`)
|
|
179
|
+
await refreshBilling()
|
|
180
|
+
toast.success('订单已取消')
|
|
181
|
+
} catch (e) {
|
|
182
|
+
toast.error(apiErrorMessage(e, '取消失败'))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const orderColumns: Column<PKOrder>[] = [
|
|
187
|
+
{ key: 'plan_code', header: '套餐', render: (o) => <span className="font-medium">{o.plan_code}</span> },
|
|
188
|
+
{ key: 'interval', header: '周期', render: (o) => INTERVAL_LABEL[o.interval as Interval] ?? o.interval },
|
|
189
|
+
{ key: 'amount_cents', header: '金额', render: (o) => fmtCents(o.amount_cents, o.currency) },
|
|
190
|
+
{ key: 'status', header: '状态', render: (o) => <StatusBadge status={o.status} /> },
|
|
191
|
+
{ key: 'created_at', header: '创建时间', render: (o) => <span className="text-muted-foreground">{formatDateTime(o.created_at)}</span> },
|
|
192
|
+
{
|
|
193
|
+
key: 'actions', header: '操作', className: 'text-right',
|
|
194
|
+
render: (o) =>
|
|
195
|
+
o.status === 'pending' ? (
|
|
196
|
+
<Button size="sm" variant="outline" onClick={() => handleCancel(o)}>取消</Button>
|
|
197
|
+
) : null,
|
|
198
|
+
},
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
if (!wsId) {
|
|
202
|
+
return (
|
|
203
|
+
<div>
|
|
204
|
+
<PageHeader title="计费" />
|
|
205
|
+
<PageEmpty label="请先创建并选择一个工作区" />
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (loading) return <PageLoading label="加载计费信息…" />
|
|
210
|
+
|
|
211
|
+
if (loadError) {
|
|
212
|
+
return (
|
|
213
|
+
<div>
|
|
214
|
+
<PageHeader title="计费" />
|
|
215
|
+
<PageError
|
|
216
|
+
message={apiErrorMessage(loadError, '计费信息加载失败')}
|
|
217
|
+
onRetry={() => {
|
|
218
|
+
void subErrQ.refetch()
|
|
219
|
+
void plansErrQ.refetch()
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div>
|
|
228
|
+
<PageHeader title="计费" description="当前套餐、升级与订单管理(按周期购买,到期后需再次购买或续期)" />
|
|
229
|
+
|
|
230
|
+
{/* 当前套餐 */}
|
|
231
|
+
<SettingsCard
|
|
232
|
+
title="当前套餐"
|
|
233
|
+
description="工作区当前生效的套餐与历史订单。"
|
|
234
|
+
cta={<Badge variant="outline">{currentPlanCode ?? '未知'}</Badge>}
|
|
235
|
+
>
|
|
236
|
+
<p className="text-sm text-muted-foreground">
|
|
237
|
+
套餐 <span className="font-medium text-foreground">{currentPlanCode ?? '—'}</span>
|
|
238
|
+
,共 {orders.length} 笔订单(待处理 {orders.filter((o) => o.status === 'pending').length} 笔)。
|
|
239
|
+
</p>
|
|
240
|
+
</SettingsCard>
|
|
241
|
+
|
|
242
|
+
{/* 套餐选择 */}
|
|
243
|
+
<SettingsCard title="选择套餐" description={checkoutDescription}>
|
|
244
|
+
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
245
|
+
{/* interval 切换 pill(formbricks 版式) */}
|
|
246
|
+
<div className="w-fit rounded-xl border bg-muted p-1" role="group" aria-label="计费周期">
|
|
247
|
+
{(['monthly', 'yearly'] as Interval[]).map((it) => (
|
|
248
|
+
<button
|
|
249
|
+
key={it}
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={() => { setInterval(it); syncParams({ interval: it }) }}
|
|
252
|
+
className={cn(
|
|
253
|
+
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
|
254
|
+
interval === it
|
|
255
|
+
? 'bg-primary text-primary-foreground shadow-sm'
|
|
256
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
{INTERVAL_LABEL[it]}
|
|
260
|
+
</button>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
<div className="flex items-center gap-2">
|
|
264
|
+
<Select value={payChannel} onValueChange={(v) => {
|
|
265
|
+
if (typeof v === 'string') { setPayChannelSel(v); syncParams({ channel: v }) }
|
|
266
|
+
}}>
|
|
267
|
+
<SelectTrigger size="sm" className="w-44" aria-label="支付渠道">
|
|
268
|
+
<SelectValue>{channelLabel(payChannel)}</SelectValue>
|
|
269
|
+
</SelectTrigger>
|
|
270
|
+
<SelectContent>
|
|
271
|
+
{channelOptions.map((code) => (
|
|
272
|
+
<SelectItem key={code} value={code}>{channelLabel(code)}</SelectItem>
|
|
273
|
+
))}
|
|
274
|
+
</SelectContent>
|
|
275
|
+
</Select>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* B1/G4:manual 空 InfoURL 的线下支付指引(下单成功但无收银台时持续可见) */}
|
|
280
|
+
{manualNote && (
|
|
281
|
+
<p
|
|
282
|
+
role="note"
|
|
283
|
+
className="mb-4 rounded-lg border-(--warning)/40 bg-(--warning)/15 px-3 py-2 text-sm text-(--warning)"
|
|
284
|
+
>
|
|
285
|
+
{manualNote}
|
|
286
|
+
</p>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
290
|
+
{sortedPlans.map((plan) => {
|
|
291
|
+
const isCurrent = plan.code === currentPlanCode
|
|
292
|
+
const price = priceCentsOf(plan, interval)
|
|
293
|
+
const features = Object.entries(plan.limits)
|
|
294
|
+
.filter(([k]) => !k.startsWith('price_') && k !== 'trial_days')
|
|
295
|
+
.map(([k, v]) => `${LIMIT_LABEL[k] ?? k}:${v < 0 ? '不限' : v}`)
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
key={plan.code}
|
|
299
|
+
className={cn(
|
|
300
|
+
'flex flex-col rounded-xl border bg-card p-5 shadow-xs',
|
|
301
|
+
isCurrent && 'border-primary/40',
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<div className="flex items-center justify-between">
|
|
305
|
+
<p className="font-semibold">{plan.name}</p>
|
|
306
|
+
{isCurrent && <Badge>当前计划</Badge>}
|
|
307
|
+
</div>
|
|
308
|
+
<p className="mt-3 text-3xl font-bold tabular-nums">
|
|
309
|
+
{fmtCents(price)}
|
|
310
|
+
{price > 0 && (
|
|
311
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
312
|
+
{` / ${INTERVAL_UNIT[interval]}`}
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
315
|
+
</p>
|
|
316
|
+
<Button
|
|
317
|
+
className="mt-4 w-full"
|
|
318
|
+
variant={isCurrent ? 'secondary' : 'default'}
|
|
319
|
+
disabled={isCurrent || checkoutPlan !== null || noChannels}
|
|
320
|
+
onClick={() => handleCheckout(plan)}
|
|
321
|
+
>
|
|
322
|
+
{isCurrent
|
|
323
|
+
? '当前计划'
|
|
324
|
+
: checkoutPlan === plan.code
|
|
325
|
+
? '跳转收银台…'
|
|
326
|
+
: '升级'}
|
|
327
|
+
</Button>
|
|
328
|
+
{/* F8:升级不可用原因可见化(不只 title 悬停)。B1/G4:动态渠道表下
|
|
329
|
+
仅剩"站点零渠道"一种不可用场景,文案如实、不再自相矛盾。 */}
|
|
330
|
+
{!isCurrent && noChannels && (
|
|
331
|
+
<p className="mt-2 text-xs text-muted-foreground" role="note">
|
|
332
|
+
暂不可升级:站点未配置任何支付渠道,请联系管理员。
|
|
333
|
+
</p>
|
|
334
|
+
)}
|
|
335
|
+
{features.length > 0 && (
|
|
336
|
+
<ul className="mt-6 space-y-3 border-t pt-6 text-sm text-muted-foreground">
|
|
337
|
+
{features.map((f) => (
|
|
338
|
+
<li key={f} className="flex items-center gap-2">
|
|
339
|
+
<span className="text-primary">✓</span> {f}
|
|
340
|
+
</li>
|
|
341
|
+
))}
|
|
342
|
+
</ul>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
})}
|
|
347
|
+
</div>
|
|
348
|
+
</SettingsCard>
|
|
349
|
+
|
|
350
|
+
{/* 订单 */}
|
|
351
|
+
<SettingsCard title="订单" description="全部购买记录;待处理订单可取消。" bodyClassName="px-4 pt-4 pb-4">
|
|
352
|
+
<DataTable
|
|
353
|
+
columns={orderColumns}
|
|
354
|
+
rows={orders}
|
|
355
|
+
rowKey={(o) => o.id}
|
|
356
|
+
empty={<PageEmpty label="暂无订单" hint="选择套餐完成首次购买后,订单会出现在这里" />}
|
|
357
|
+
/>
|
|
358
|
+
</SettingsCard>
|
|
359
|
+
</div>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useNavigate } from 'react-router'
|
|
4
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
5
|
+
import { Skeleton } from '../../components/ui/skeleton'
|
|
6
|
+
import { toast } from '../../components/toast'
|
|
7
|
+
import { useApi } from '@ploykit/hooks'
|
|
8
|
+
import { useWorkspace } from '@ploykit/hooks'
|
|
9
|
+
import { queryKeys, type PKSubscription } from '@ploykit/hooks'
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 5_000
|
|
12
|
+
const MAX_TRIES = 12
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function toBaseline(sub: PKSubscription): { plan: string; paidIds: string } {
|
|
16
|
+
return {
|
|
17
|
+
plan: sub.plan_code,
|
|
18
|
+
paidIds: sub.orders.filter((o) => o.status === 'paid').map((o) => o.id).sort().join(','),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BillingSuccessPageProps {
|
|
23
|
+
|
|
24
|
+
redirectTo?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BillingSuccessPage({ redirectTo = '/settings/workspace/billing' }: BillingSuccessPageProps = {}) {
|
|
28
|
+
const { current } = useWorkspace()
|
|
29
|
+
const api = useApi()
|
|
30
|
+
const qc = useQueryClient()
|
|
31
|
+
const navigate = useNavigate()
|
|
32
|
+
const wsId = current?.id ?? ''
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
const [baseline, setBaseline] = useState<{ plan: string; paidIds: string } | null>(() => {
|
|
38
|
+
if (!wsId) return null
|
|
39
|
+
const cached = qc.getQueryData<PKSubscription>(queryKeys.billingSubscription(wsId))
|
|
40
|
+
return cached ? toBaseline(cached) : null
|
|
41
|
+
})
|
|
42
|
+
const tries = useRef(0)
|
|
43
|
+
const done = useRef(false)
|
|
44
|
+
|
|
45
|
+
const subQ = useQuery({
|
|
46
|
+
queryKey: queryKeys.billingSubscription(wsId),
|
|
47
|
+
queryFn: () => api.get<PKSubscription>('/api/billing/subscription'),
|
|
48
|
+
enabled: !!wsId,
|
|
49
|
+
refetchInterval: POLL_MS,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const finish = (ok: boolean) => {
|
|
53
|
+
if (done.current) return
|
|
54
|
+
done.current = true
|
|
55
|
+
if (ok) toast.success('支付成功,套餐已生效')
|
|
56
|
+
else toast.info('支付结果确认超时,可稍后在计费页查看')
|
|
57
|
+
navigate(redirectTo)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const sub = subQ.data
|
|
65
|
+
if (!sub || !subQ.dataUpdatedAt || done.current) return
|
|
66
|
+
|
|
67
|
+
if (baseline === null) {
|
|
68
|
+
setBaseline(toBaseline(sub))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
tries.current += 1
|
|
73
|
+
const planChanged = sub.plan_code !== baseline.plan
|
|
74
|
+
const newPaid = toBaseline(sub).paidIds !== baseline.paidIds
|
|
75
|
+
|
|
76
|
+
if (planChanged || newPaid) {
|
|
77
|
+
finish(true)
|
|
78
|
+
} else if (tries.current >= MAX_TRIES) {
|
|
79
|
+
finish(false)
|
|
80
|
+
}
|
|
81
|
+
// baseline 有意不入依赖:基线固定后轮询推进只由 dataUpdatedAt 驱动
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [subQ.dataUpdatedAt]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if (!wsId) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-6 text-center">
|
|
89
|
+
<p className="text-sm font-medium">暂无法确认支付结果</p>
|
|
90
|
+
<p className="text-xs text-muted-foreground">
|
|
91
|
+
当前没有选中的工作区,无法查询订阅状态。
|
|
92
|
+
</p>
|
|
93
|
+
<a
|
|
94
|
+
href={redirectTo}
|
|
95
|
+
className="text-sm text-primary underline-offset-4 hover:underline"
|
|
96
|
+
>
|
|
97
|
+
前往计费页查看当前套餐
|
|
98
|
+
</a>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-6">
|
|
105
|
+
<div className="size-8 animate-spin rounded-full border-2 border-muted border-t-primary" aria-hidden="true" />
|
|
106
|
+
<p className="text-sm font-medium">正在确认支付结果…</p>
|
|
107
|
+
<p className="text-xs text-muted-foreground">确认完成后将自动返回计费页,请勿关闭页面。</p>
|
|
108
|
+
<div className="mt-4 w-full max-w-md space-y-2" aria-hidden="true">
|
|
109
|
+
<Skeleton className="h-4 w-full" />
|
|
110
|
+
<Skeleton className="h-4 w-3/4" />
|
|
111
|
+
<Skeleton className="h-4 w-1/2" />
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|