@ploykit/ui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,129 @@
1
+
2
+ import { Link, useLocation, useNavigate } from 'react-router'
3
+ import type { ReactNode } from 'react'
4
+ import { cn } from '../lib/utils'
5
+ import { roleSatisfies, type Perm } from '@ploykit/hooks'
6
+ import { useWorkspace } from '@ploykit/hooks'
7
+ import { buttonVariants } from '../components/ui/button'
8
+ import { Separator } from '../components/ui/separator'
9
+ import {
10
+ Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue,
11
+ } from '../components/ui/select'
12
+
13
+ export interface SettingsNavItem {
14
+ path: string
15
+ label: string
16
+
17
+ icon?: ReactNode
18
+
19
+ perm?: Perm
20
+ }
21
+
22
+ export interface SettingsNavGroup {
23
+
24
+ group: string
25
+ items: SettingsNavItem[]
26
+ }
27
+
28
+ export interface SettingsShellProps {
29
+
30
+ nav?: SettingsNavGroup[]
31
+ children: ReactNode
32
+ title?: string
33
+ description?: string
34
+ }
35
+
36
+ export function SettingsShell({ nav, children, title = '设置', description }: SettingsShellProps) {
37
+ const location = useLocation()
38
+ const navigate = useNavigate()
39
+ const role = useWorkspace().current?.role
40
+
41
+ const isActive = (path: string) =>
42
+ location.pathname === path || location.pathname.startsWith(path + '/')
43
+
44
+
45
+ const visibleNav = (nav ?? [])
46
+ .map((g) => ({ ...g, items: g.items.filter((i) => roleSatisfies(role, i.perm)) }))
47
+ .filter((g) => g.items.length > 0)
48
+
49
+ const flatItems = visibleNav.flatMap((g) => g.items)
50
+ const activeItem = flatItems.find((i) => isActive(i.path))
51
+ const hasNav = visibleNav.length > 0
52
+
53
+ return (
54
+ <div className="mx-auto w-full max-w-6xl">
55
+ <div>
56
+ {/* G7.1(双 H1,9 页复现):壳标题降为非 heading 的品牌区——页内 PageHeader
57
+ 的 h1 是全页唯一一级标题(审计建议二选一降级,此处让位)。
58
+ 视觉保持 text-2xl md:text-3xl 字重不变,仅去掉 heading 语义。 */}
59
+ <div className="text-2xl md:text-3xl font-bold tracking-tight">{title}</div>
60
+ {description && <p className="mt-1 text-base text-muted-foreground">{description}</p>}
61
+ </div>
62
+ <Separator className="my-4 lg:my-6" />
63
+
64
+ <div className={cn('flex flex-col gap-6', hasNav && 'lg:flex-row lg:gap-0 lg:space-x-12')}>
65
+ {/* 窄屏:分组导航降级为 Select(仅页内导航模式) */}
66
+ {hasNav && (
67
+ <div className="lg:hidden">
68
+ <Select
69
+ value={activeItem?.path ?? null}
70
+ onValueChange={(v) => typeof v === 'string' && v && navigate(v)}
71
+ >
72
+ <SelectTrigger className="w-full" aria-label="设置导航">
73
+ <SelectValue>{activeItem?.label ?? '跳转设置页'}</SelectValue>
74
+ </SelectTrigger>
75
+ <SelectContent align="start">
76
+ {visibleNav.map((group) => (
77
+ <SelectGroup key={group.group}>
78
+ <SelectLabel>{group.group}</SelectLabel>
79
+ {group.items.map((item) => (
80
+ <SelectItem key={item.path} value={item.path}>{item.label}</SelectItem>
81
+ ))}
82
+ </SelectGroup>
83
+ ))}
84
+ </SelectContent>
85
+ </Select>
86
+ </div>
87
+ )}
88
+
89
+ {/* 宽屏:左侧分组导航(sticky——长设置页滚动时导航常驻;仅页内导航模式)。
90
+ self-start 是 sticky 生效前提(flex 行内 item 默认 stretch 拉满高度后
91
+ 无滚动余量);top-0 相对 AppShell 内容区滚动容器(P3-23 起该容器为
92
+ div(overflow-auto),地标 main 在 SidebarInset——滚动责任未变)。 */}
93
+ {hasNav && (
94
+ <aside className="hidden lg:block lg:w-1/5 lg:self-start lg:sticky lg:top-0">
95
+ <nav aria-label="设置导航">
96
+ {visibleNav.map((group) => (
97
+ <div key={group.group} className="mb-6">
98
+ <p className="mb-2 px-2 text-xs uppercase tracking-wider text-muted-foreground">
99
+ {group.group}
100
+ </p>
101
+ <ul className="space-y-1">
102
+ {group.items.map((item) => (
103
+ <li key={item.path}>
104
+ <Link
105
+ to={item.path}
106
+ aria-current={isActive(item.path) ? 'page' : undefined}
107
+ className={cn(
108
+ buttonVariants({ variant: 'ghost' }),
109
+ 'w-full justify-start',
110
+ isActive(item.path) && 'bg-muted',
111
+ )}
112
+ >
113
+ {item.icon}
114
+ {item.label}
115
+ </Link>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </div>
120
+ ))}
121
+ </nav>
122
+ </aside>
123
+ )}
124
+
125
+ <div className="min-w-0 flex-1">{children}</div>
126
+ </div>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,163 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { MemoryRouter, Routes, Route } from 'react-router'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { AppShell } from '../AppShell'
6
+
7
+
8
+
9
+ const mocks = vi.hoisted(() => ({ impersonatedBy: undefined as string | undefined }))
10
+ vi.mock('../../../../hooks/src/hooks/useAuth', () => ({
11
+ useAuth: () => ({
12
+ user: {
13
+ email: 'alice@example.com',
14
+ display_name: 'Alice',
15
+ impersonated_by: mocks.impersonatedBy,
16
+ },
17
+ workspaces: [],
18
+ loading: false,
19
+ refresh: vi.fn(),
20
+ logout: vi.fn(async () => {}),
21
+ }),
22
+ }))
23
+
24
+ const nav = [
25
+ { path: '/app', label: '概览' },
26
+ { path: '/realtime', label: '实时' },
27
+ ]
28
+
29
+
30
+ function settingsContent() {
31
+ return (
32
+ <nav aria-label="设置导航侧栏">
33
+ <a href="/app">返回产品</a>
34
+ <ul>
35
+ <li><a href="/settings/workspace/general">概况</a></li>
36
+ </ul>
37
+ </nav>
38
+ )
39
+ }
40
+
41
+ function renderAt(path: string, withSettingsContent = true) {
42
+ return render(
43
+ <MemoryRouter initialEntries={[path]}>
44
+ <Routes>
45
+ <Route
46
+ path="*"
47
+ element={(
48
+ <AppShell
49
+ brand="MyProduct"
50
+ nav={nav}
51
+ settingsContent={withSettingsContent ? settingsContent() : undefined}
52
+ >
53
+ <div>page-body</div>
54
+ </AppShell>
55
+ )}
56
+ />
57
+ </Routes>
58
+ </MemoryRouter>,
59
+ )
60
+ }
61
+
62
+ describe('AppShell 设置模式(IA v2)', () => {
63
+ beforeEach(() => {
64
+ mocks.impersonatedBy = undefined
65
+ })
66
+
67
+ it('产品路径:渲染产品 nav 项,不渲染 settingsContent', () => {
68
+ renderAt('/app')
69
+ expect(screen.queryByRole('link', { name: '返回产品' })).not.toBeInTheDocument()
70
+
71
+ expect(screen.getAllByText('概览').length).toBeGreaterThan(0)
72
+ expect(screen.getAllByText('实时').length).toBeGreaterThan(0)
73
+ })
74
+
75
+ it('/settings/*:侧栏切换为 settingsContent,产品 nav 隐藏,品牌保留', () => {
76
+ renderAt('/settings/workspace/general')
77
+ expect(screen.getByRole('link', { name: '返回产品' })).toBeInTheDocument()
78
+ expect(screen.getByRole('link', { name: '概况' })).toBeInTheDocument()
79
+ expect(screen.queryByText('实时')).not.toBeInTheDocument()
80
+ expect(screen.getByText('MyProduct')).toBeInTheDocument()
81
+ })
82
+
83
+ it('/account/*:同样进入设置模式', () => {
84
+ renderAt('/account/profile')
85
+ expect(screen.getByRole('link', { name: '返回产品' })).toBeInTheDocument()
86
+ expect(screen.queryByText('实时')).not.toBeInTheDocument()
87
+ })
88
+
89
+ it('未传 settingsContent 时设置路径回退产品 nav', () => {
90
+ renderAt('/settings/workspace/general', false)
91
+ expect(screen.queryByRole('link', { name: '返回产品' })).not.toBeInTheDocument()
92
+
93
+ expect(screen.getAllByText('实时').length).toBeGreaterThan(0)
94
+ })
95
+ })
96
+
97
+ describe('AppShell 模拟会话横幅(ADR 0008)', () => {
98
+ beforeEach(() => {
99
+ mocks.impersonatedBy = undefined
100
+ })
101
+
102
+ it('impersonated_by 非空:内容区顶部持久显示警示条(顶栏之上)', () => {
103
+ mocks.impersonatedBy = '11111111-2222-3333-4444-555555555555'
104
+ const { container } = renderAt('/app')
105
+ const banner = screen.getByText('您正处于管理员模拟会话')
106
+ expect(banner).toBeInTheDocument()
107
+
108
+ const bannerSlot = banner.closest('[data-slot="impersonation-banner"]')
109
+ const header = container.querySelector('header')
110
+ expect(bannerSlot).not.toBeNull()
111
+ expect(header).not.toBeNull()
112
+ expect(bannerSlot!.compareDocumentPosition(header!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
113
+ })
114
+
115
+ it('普通会话(impersonated_by 缺省):不渲染警示条', () => {
116
+ renderAt('/app')
117
+ expect(screen.queryByText('您正处于管理员模拟会话')).not.toBeInTheDocument()
118
+ })
119
+ })
120
+
121
+ describe('AppShell 地标结构(P3-23)', () => {
122
+ it('全树唯一 <main> 地标(SidebarInset 承担),内容区滚动容器不再嵌套 main', () => {
123
+ const { container } = renderAt('/app')
124
+
125
+ expect(screen.getAllByRole('main')).toHaveLength(1)
126
+
127
+ const scrollContainer = container.querySelector('main > div.flex-1.overflow-auto, [data-slot="sidebar-inset"] > div.overflow-auto')
128
+ expect(scrollContainer).not.toBeNull()
129
+ expect(scrollContainer!.textContent).toContain('page-body')
130
+ })
131
+ })
132
+
133
+
134
+ describe('AppShell 侧栏增强(自 stock 镜像化后上提至组合层)', () => {
135
+ const sidebarState = () => document.querySelector('[data-slot="sidebar"]')?.getAttribute('data-state')
136
+
137
+ it('P2-17:sidebar_state=false cookie → 初值折叠(stock provider 只写不读,回读在组合层)', () => {
138
+ document.cookie = 'sidebar_state=false; path=/'
139
+ renderAt('/app', false)
140
+ expect(sidebarState()).toBe('collapsed')
141
+ document.cookie = 'sidebar_state=; path=/; max-age=0'
142
+ })
143
+
144
+ it('P2-17:无 cookie → 默认展开', () => {
145
+ document.cookie = 'sidebar_state=; path=/; max-age=0'
146
+ renderAt('/app', false)
147
+ expect(sidebarState()).toBe('expanded')
148
+ })
149
+
150
+ it('P3-1:输入焦点时 Ctrl+B 不折叠侧栏(捕获阶段拦截 stock 全局监听)', () => {
151
+ render(
152
+ <MemoryRouter initialEntries={['/app']}>
153
+ <AppShell brand="MyProduct" nav={nav}>
154
+ <input aria-label="输入框" />
155
+ </AppShell>
156
+ </MemoryRouter>,
157
+ )
158
+ expect(sidebarState()).toBe('expanded')
159
+ const input = screen.getByRole('textbox', { name: '输入框' })
160
+ fireEvent.keyDown(input, { key: 'b', ctrlKey: true })
161
+ expect(sidebarState()).toBe('expanded')
162
+ })
163
+ })
@@ -0,0 +1,146 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { MemoryRouter, Routes, Route } from 'react-router'
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+ import { SettingsShell, type SettingsNavGroup } from '../SettingsShell'
6
+
7
+
8
+ const mocks = vi.hoisted(() => ({ role: undefined as string | undefined }))
9
+ vi.mock('../../../../hooks/src/hooks/useWorkspace', () => ({
10
+ useWorkspace: () => ({
11
+ current: mocks.role ? { id: 'ws-1', name: 'Acme', slug: 'acme', role: mocks.role } : null,
12
+ switchTo: vi.fn(),
13
+ clear: vi.fn(),
14
+ }),
15
+ }))
16
+
17
+ const nav: SettingsNavGroup[] = [
18
+ {
19
+ group: '账户',
20
+ items: [
21
+ { path: '/settings/profile', label: '个人资料' },
22
+ { path: '/settings/notifications', label: '通知偏好' },
23
+ ],
24
+ },
25
+ {
26
+ group: '工作区',
27
+ items: [
28
+ { path: '/settings/workspace/general', label: '概况' },
29
+ { path: '/settings/workspace/members', label: '成员' },
30
+ ],
31
+ },
32
+ ]
33
+
34
+ function Page({ title }: { title: string }) {
35
+ return (
36
+ <SettingsShell nav={nav} title="设置" description="desc">
37
+ <div>{title}</div>
38
+ </SettingsShell>
39
+ )
40
+ }
41
+
42
+ function renderAt(path: string) {
43
+ return render(
44
+ <MemoryRouter initialEntries={[path]}>
45
+ <Routes>
46
+ <Route path="/settings/profile" element={<Page title="profile-content" />} />
47
+ <Route path="/settings/notifications" element={<Page title="notifications-content" />} />
48
+ <Route path="/settings/workspace/members" element={<Page title="members-content" />} />
49
+ </Routes>
50
+ </MemoryRouter>,
51
+ )
52
+ }
53
+
54
+ describe('SettingsShell', () => {
55
+ beforeEach(() => { mocks.role = undefined })
56
+
57
+ it('渲染标题/描述/全部分组与条目(URL 驱动激活态)', () => {
58
+ renderAt('/settings/profile')
59
+
60
+ expect(screen.getByText('设置')).toBeInTheDocument()
61
+ expect(screen.queryByRole('heading', { name: '设置' })).not.toBeInTheDocument()
62
+ expect(screen.getByText('desc')).toBeInTheDocument()
63
+ expect(screen.getByText('账户')).toBeInTheDocument()
64
+ expect(screen.getByText('工作区')).toBeInTheDocument()
65
+
66
+ const active = screen.getByRole('link', { name: '个人资料' })
67
+ expect(active).toHaveAttribute('aria-current', 'page')
68
+ expect(screen.getByRole('link', { name: '通知偏好' })).not.toHaveAttribute('aria-current')
69
+ expect(screen.getByText('profile-content')).toBeInTheDocument()
70
+ })
71
+
72
+ it('点击导航项切换激活与内容(无内部 tab state)', () => {
73
+ renderAt('/settings/profile')
74
+ fireEvent.click(screen.getByRole('link', { name: '成员' }))
75
+ expect(screen.getByText('members-content')).toBeInTheDocument()
76
+ expect(screen.getByRole('link', { name: '成员' })).toHaveAttribute('aria-current', 'page')
77
+ expect(screen.getByRole('link', { name: '个人资料' })).not.toHaveAttribute('aria-current')
78
+ })
79
+ })
80
+
81
+ describe('SettingsShell 角色门控(IA v2 hidden 语义)', () => {
82
+ const gatedNav: SettingsNavGroup[] = [
83
+ {
84
+ group: '工作区',
85
+ items: [
86
+ { path: '/settings/workspace/general', label: '概况' },
87
+ { path: '/settings/workspace/members', label: '成员' },
88
+ { path: '/settings/workspace/audit', label: '审计', perm: 'admin' },
89
+ { path: '/settings/workspace/billing', label: '计费', perm: 'owner' },
90
+ ],
91
+ },
92
+ ]
93
+
94
+ function renderGated() {
95
+ return render(
96
+ <MemoryRouter initialEntries={['/settings/workspace/general']}>
97
+ <SettingsShell nav={gatedNav}>
98
+ <div>page-body</div>
99
+ </SettingsShell>
100
+ </MemoryRouter>,
101
+ )
102
+ }
103
+
104
+ beforeEach(() => { mocks.role = 'member' })
105
+
106
+ it('member:无 perm 项可见,perm=admin(审计)隐藏', () => {
107
+ renderGated()
108
+ expect(screen.getByRole('link', { name: '概况' })).toBeInTheDocument()
109
+ expect(screen.getByRole('link', { name: '成员' })).toBeInTheDocument()
110
+ expect(screen.queryByRole('link', { name: '审计' })).not.toBeInTheDocument()
111
+ })
112
+
113
+ it('admin:可见 admin 项(审计),仍隐藏 owner 项(计费)', () => {
114
+ mocks.role = 'admin'
115
+ renderGated()
116
+ expect(screen.getByRole('link', { name: '审计' })).toBeInTheDocument()
117
+ expect(screen.queryByRole('link', { name: '计费' })).not.toBeInTheDocument()
118
+ })
119
+
120
+ it('owner:全部可见', () => {
121
+ mocks.role = 'owner'
122
+ renderGated()
123
+ expect(screen.getByRole('link', { name: '审计' })).toBeInTheDocument()
124
+ expect(screen.getByRole('link', { name: '计费' })).toBeInTheDocument()
125
+ })
126
+
127
+ it('角色未加载(无工作区):管理项按最小权限隐藏', () => {
128
+ mocks.role = undefined
129
+ renderGated()
130
+ expect(screen.getByRole('link', { name: '概况' })).toBeInTheDocument()
131
+ expect(screen.queryByRole('link', { name: '审计' })).not.toBeInTheDocument()
132
+ })
133
+
134
+ it('整组过滤后为空则不渲染分组标题', () => {
135
+ render(
136
+ <MemoryRouter initialEntries={['/account/profile']}>
137
+ <SettingsShell
138
+ nav={[{ group: '仅管理', items: [{ path: '/settings/workspace/audit', label: '审计', perm: 'admin' }] }]}
139
+ >
140
+ <div>page-body</div>
141
+ </SettingsShell>
142
+ </MemoryRouter>,
143
+ )
144
+ expect(screen.queryByText('仅管理')).not.toBeInTheDocument()
145
+ })
146
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { ApiError } from '@ploykit/client'
3
+ import { apiErrorMessage } from '../api-error'
4
+
5
+
6
+
7
+ describe('apiErrorMessage', () => {
8
+ it('已知英文文案 → 中文可行动文案(email already registered / slug already taken)', () => {
9
+ expect(apiErrorMessage(new ApiError(409, 'E_CONFLICT', 'email already registered')))
10
+ .toBe('该邮箱已注册,可直接登录或换个邮箱重试')
11
+ expect(apiErrorMessage(new ApiError(409, 'E_CONFLICT', 'slug already taken')))
12
+ .toBe('该标识已被占用,请换个工作区名称重试')
13
+
14
+ expect(apiErrorMessage(new ApiError(409, 'E_CONFLICT', ' Email Already Registered ')))
15
+ .toBe('该邮箱已注册,可直接登录或换个邮箱重试')
16
+ })
17
+
18
+ it('E_QUOTA_EXCEEDED 错误码 → 配额用尽 + 升级指引(优先于 message)', () => {
19
+ expect(apiErrorMessage(new ApiError(402, 'E_QUOTA_EXCEEDED', 'quota exceeded for workspaces')))
20
+ .toBe('配额已用尽,可在 设置→计费 升级套餐后重试')
21
+ })
22
+
23
+ it('X4:超时/中止/渠道不支持/畸形响应码 → 中文可行动文案', () => {
24
+ expect(apiErrorMessage(new ApiError(0, 'E_TIMEOUT', 'request timed out after 15000ms')))
25
+ .toBe('请求超时,网络可能较慢,请稍后重试')
26
+ expect(apiErrorMessage(new ApiError(0, 'E_ABORTED', 'request aborted by caller')))
27
+ .toBe('请求已取消')
28
+ expect(apiErrorMessage(new ApiError(400, 'E_CHANNEL_TEST_UNSUPPORTED', 'manual channel cannot be tested')))
29
+ .toContain('不支持测试连接')
30
+ expect(apiErrorMessage(new ApiError(502, 'E_MALFORMED_RESPONSE', 'non-JSON response (502)')))
31
+ .toContain('无法识别的响应')
32
+ })
33
+
34
+ it('X4/B-settings-2:服务端中文 details 优先于英文 message', () => {
35
+ expect(apiErrorMessage(new ApiError(400, 'E_VALIDATION', 'invalid settings key/value', '必须是 0..100000 的整数(0 = 关闭限流)')))
36
+ .toBe('必须是 0..100000 的整数(0 = 关闭限流)')
37
+
38
+ expect(apiErrorMessage(new ApiError(409, 'E_CONFLICT', 'email already registered', { field: 'email' })))
39
+ .toBe('该邮箱已注册,可直接登录或换个邮箱重试')
40
+ })
41
+
42
+ it('402(无已知码)→ 需要升级(details 并入);404 → 不存在;403 → 权限不足', () => {
43
+ expect(apiErrorMessage(new ApiError(402, 'E_PLAN_LIMIT', 'seats limit')))
44
+ .toContain('当前套餐不支持此操作')
45
+ expect(apiErrorMessage(new ApiError(402, 'E_PLAN_LIMIT', 'seats limit', '成员数超出当前套餐上限')))
46
+ .toContain('成员数超出当前套餐上限')
47
+ expect(apiErrorMessage(new ApiError(404, 'E_NOT_FOUND', 'workspace not found')))
48
+ .toBe('不存在或无权访问')
49
+ expect(apiErrorMessage(new ApiError(403, 'E_FORBIDDEN', 'forbidden'))).toBe('权限不足')
50
+ })
51
+
52
+ it('X4:401/429 给中文兜底(429 带 Retry-After 秒数提示)', () => {
53
+ expect(apiErrorMessage(new ApiError(401, 'E_UNAUTHENTICATED', 'unauthenticated')))
54
+ .toBe('登录已过期或未登录,请重新登录后再试')
55
+ expect(apiErrorMessage(new ApiError(429, 'E_RATE_LIMITED', 'too many requests')))
56
+ .toBe('请求过于频繁,请稍后再试')
57
+ expect(apiErrorMessage(new ApiError(429, 'E_RATE_LIMITED', 'too many requests', undefined, 30)))
58
+ .toBe('请求过于频繁,请稍后再试(约 30 秒后可重试)')
59
+ })
60
+
61
+ it('X4/B-users-5:5xx 不再透传英文原文,统一中文兜底', () => {
62
+ expect(apiErrorMessage(new ApiError(500, 'E_INTERNAL', 'internal error')))
63
+ .toBe('服务暂时不可用,请稍后重试;若持续出现请联系平台管理员')
64
+ })
65
+
66
+ it('4xx 未知 ApiError 保留原始 message;网络异常给可重试文案;其余走 fallback', () => {
67
+
68
+ expect(apiErrorMessage(new ApiError(400, 'E_BAD_JSON', 'boom'))).toBe('boom')
69
+ expect(apiErrorMessage(new TypeError('fetch failed'))).toBe('网络连接失败,请检查网络后重试')
70
+ expect(apiErrorMessage({}, '兜底文案')).toBe('兜底文案')
71
+ })
72
+
73
+
74
+
75
+ describe('G7.4 域映射补齐', () => {
76
+ type Case = { name: string; err: ApiError; expect: string | RegExp }
77
+ const cases: Case[] = [
78
+ {
79
+ name: "E_UNAUTHENTICATED 'current password incorrect' → 改密域文案(压过 401 兜底)",
80
+ err: new ApiError(401, 'E_UNAUTHENTICATED', 'current password incorrect'),
81
+ expect: /当前密码不正确,请确认后重新输入/,
82
+ },
83
+ {
84
+ name: "E_VALIDATION 'name required (<=64 chars)' → 名称约束(字节口径)",
85
+ err: new ApiError(400, 'E_VALIDATION', 'name required (<=64 chars)'),
86
+ expect: /64 字节/,
87
+ },
88
+ {
89
+ name: "E_VALIDATION 'name required' → 精确子类优先于通用兜底",
90
+ err: new ApiError(400, 'E_VALIDATION', 'name required'),
91
+ expect: /名称不能为空$/,
92
+ },
93
+ {
94
+ name: 'E_VALIDATION 未知文案(无 details)→ 通用校验兜底,不再直出英文',
95
+ err: new ApiError(400, 'E_VALIDATION', 'meter display_name required'),
96
+ expect: /请求参数有误/,
97
+ },
98
+ {
99
+ name: "前缀匹配:'slug must be 4-40 chars of [a-z0-9-]…' → 标识约束中文",
100
+ err: new ApiError(400, 'E_VALIDATION', "slug must be 4-40 chars of [a-z0-9-], not starting/ending with '-'"),
101
+ expect: /标识格式有误/,
102
+ },
103
+ {
104
+ name: '前缀匹配:egress 预检拒绝(含 ADR 编号原文)→ 公网 https 指引',
105
+ err: new ApiError(400, 'E_VALIDATION', 'egress: "hook.internal" resolves to a private/reserved address 10.0.0.8 — outbound blocked by the SSRF guard'),
106
+ expect: '回调地址不可达私网/被安全策略拦截,请改用公网 https 地址',
107
+ },
108
+ {
109
+ name: '前缀匹配:egressx: 前缀(引擎配置侧)同文案',
110
+ err: new ApiError(400, 'E_VALIDATION', 'egressx: URL parse failed'),
111
+ expect: '回调地址不可达私网/被安全策略拦截,请改用公网 https 地址',
112
+ },
113
+ {
114
+ name: 'E_INVALID_CRON → cron 中文格式指引',
115
+ err: new ApiError(400, 'E_INVALID_CRON', 'invalid cron expression (expect 5 fields: minute hour day month weekday)'),
116
+ expect: /Cron 表达式格式有误/,
117
+ },
118
+ {
119
+ name: 'E_INVALID_TZ → 时区中文指引',
120
+ err: new ApiError(400, 'E_INVALID_TZ', 'invalid timezone (expect IANA name, e.g. Asia/Shanghai)'),
121
+ expect: /时区无效/,
122
+ },
123
+ {
124
+ name: 'E_CONFLICT 未知冲突(order is not pending)→ 通用冲突兜底',
125
+ err: new ApiError(409, 'E_CONFLICT', 'order is not pending'),
126
+ expect: /操作与当前状态冲突/,
127
+ },
128
+ {
129
+ name: 'E_CONFLICT 已有精确映射(last owner)不被通用兜底吞掉',
130
+ err: new ApiError(409, 'E_CONFLICT', 'last owner cannot leave'),
131
+ expect: '最后一个所有者不能离开工作区,请先转移所有权或删除工作区',
132
+ },
133
+ {
134
+ name: "E_CONFLICT 'target is already an owner'(成员转让)→ 域文案",
135
+ err: new ApiError(409, 'E_CONFLICT', 'target is already an owner'),
136
+ expect: '对方已是所有者,无需重复转让所有权',
137
+ },
138
+ {
139
+ name: "前缀匹配:'workspace cannot be deleted: open orders exist'(删除前置拦截)→ 域文案",
140
+ err: new ApiError(409, 'E_CONFLICT', 'workspace cannot be deleted: open orders exist'),
141
+ expect: '工作区暂时无法删除:存在未满足的前置条件(如未完结订单或活跃资源),请先处理后再试',
142
+ },
143
+ ]
144
+
145
+ it.each(cases)('$name', ({ err, expect: expected }) => {
146
+ const got = apiErrorMessage(err)
147
+ if (expected instanceof RegExp) expect(got).toMatch(expected)
148
+ else expect(got).toBe(expected)
149
+ })
150
+
151
+ it('优先级回归:消息级 > details > 码级(同一错误三种来源层层兜底)', () => {
152
+
153
+ expect(apiErrorMessage(new ApiError(400, 'E_VALIDATION', 'bad value', '限流值必须是 0..100000 的整数')))
154
+ .toBe('限流值必须是 0..100000 的整数')
155
+
156
+ expect(apiErrorMessage(new ApiError(402, 'E_QUOTA_EXCEEDED', 'quota exceeded')))
157
+ .toBe('配额已用尽,可在 设置→计费 升级套餐后重试')
158
+
159
+ expect(apiErrorMessage(new ApiError(401, 'E_UNAUTHENTICATED', 'session expired')))
160
+ .toBe('登录已过期或未登录,请重新登录后再试')
161
+ })
162
+ })
163
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { browserTzLabel, formatDateTime, formatDateTimeTz } from '../utils'
3
+
4
+
5
+ describe('时区口径标注(B1)', () => {
6
+ it('browserTzLabel 恒有非空标签(shortOffset→short→"本地时区"逐档兜底)', () => {
7
+ const label = browserTzLabel()
8
+ expect(label.length).toBeGreaterThan(0)
9
+
10
+ expect(label).toMatch(/^(GMT|UTC|本地时区)/)
11
+ })
12
+
13
+ it('formatDateTimeTz = 本地时间 + " (标签)" 口径后缀', () => {
14
+ const iso = '2026-10-12T01:00:00Z'
15
+ expect(formatDateTimeTz(iso)).toBe(`${formatDateTime(iso)} (${browserTzLabel()})`)
16
+ })
17
+
18
+ it('非法时间不抛异常(与 formatDateTime 同一容错口径)', () => {
19
+ expect(typeof formatDateTimeTz('not-a-date')).toBe('string')
20
+ })
21
+ })