@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,202 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { RotateCcw, Save } from 'lucide-react'
3
+ import {
4
+ PageHeader, PageLoading, PageError,
5
+ } from '../../components/Page'
6
+ import { SettingsCard } from '../../components/SettingsCard'
7
+ import { ReauthDialog } from '../../components/ReauthDialog'
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 { Checkbox } from '../../components/ui/checkbox'
13
+ import { useRoleConfig, type PKRoleConfig } from '@ploykit/hooks'
14
+ import { isApiError } from '@ploykit/client'
15
+ import { apiErrorMessage } from '../../lib/api-error'
16
+
17
+ const ROLE_LABEL: Record<string, string> = { owner: '所有者', admin: '管理员', member: '成员' }
18
+
19
+ const DOMAIN_LABEL: Record<string, string> = {
20
+ workspace: '工作区',
21
+ members: '成员',
22
+ invites: '邀请',
23
+ roles: '角色权限',
24
+ billing: '计费',
25
+ webhooks: 'Webhooks',
26
+ notifications: '通知',
27
+ audit: '审计',
28
+ usage: '用量',
29
+ }
30
+
31
+ function domainOf(perm: string): string {
32
+ const i = perm.indexOf(':')
33
+ return i > 0 ? perm.slice(0, i) : perm
34
+ }
35
+
36
+ /** catalog 权限按域分组,保持目录的字典序。 */
37
+ function groupByDomain(catalog: string[]): Array<{ domain: string; perms: string[] }> {
38
+ const groups = new Map<string, string[]>()
39
+ for (const p of catalog) {
40
+ const d = domainOf(p)
41
+ const arr = groups.get(d) ?? []
42
+ arr.push(p)
43
+ groups.set(d, arr)
44
+ }
45
+ return [...groups.entries()].map(([domain, perms]) => ({ domain, perms }))
46
+ }
47
+
48
+ interface RoleDraft {
49
+ perms: string[]
50
+ dirty: boolean
51
+ }
52
+
53
+ function isReauth(err: unknown): boolean {
54
+ return isApiError(err, 'E_REAUTH_REQUIRED')
55
+ }
56
+
57
+ export interface RolePermissionsPageProps {
58
+ /** 无需 props:工作区上下文来自 PloykitProvider。保留 prop 通道便于产品扩展。 */
59
+ }
60
+
61
+ export function RolePermissionsPage(_props: RolePermissionsPageProps = {}) {
62
+ const { roles, catalog, loading, error, setPerms, resetPerms } = useRoleConfig()
63
+ const confirm = useConfirm()
64
+
65
+ const [drafts, setDrafts] = useState<Record<string, RoleDraft>>({})
66
+ const [reauthOpen, setReauthOpen] = useState(false)
67
+ const [pendingRetry, setPendingRetry] = useState<(() => Promise<unknown>) | null>(null)
68
+
69
+ // 数据到达(或写后失效重取)时以服务端有效集重建草稿
70
+ useEffect(() => {
71
+ const next: Record<string, RoleDraft> = {}
72
+ for (const r of roles) next[r.role] = { perms: [...r.perms], dirty: false }
73
+ setDrafts(next)
74
+ }, [roles])
75
+
76
+ const groups = useMemo(() => groupByDomain(catalog), [catalog])
77
+
78
+ const toggle = (role: string, perm: string, on: boolean) => {
79
+ setDrafts((d) => {
80
+ const cur = d[role]
81
+ if (!cur) return d
82
+ const perms = on ? [...cur.perms, perm].sort() : cur.perms.filter((p) => p !== perm)
83
+ const server = roles.find((r) => r.role === role)?.perms ?? []
84
+ const dirty = perms.join('\u0000') !== server.join('\u0000')
85
+ return { ...d, [role]: { perms, dirty } }
86
+ })
87
+ }
88
+
89
+ const save = async (role: string) => {
90
+ const draft = drafts[role]
91
+ if (!draft) return
92
+ try {
93
+ await setPerms.mutateAsync({ role, perms: draft.perms })
94
+ toast.success(`已更新${ROLE_LABEL[role] ?? role}权限`)
95
+ } catch (err) {
96
+ if (isReauth(err)) {
97
+ setPendingRetry(() => () => setPerms.mutateAsync({ role, perms: draft.perms }))
98
+ setReauthOpen(true)
99
+ return
100
+ }
101
+ toast.error(apiErrorMessage(err))
102
+ }
103
+ }
104
+
105
+ const reset = async (role: string) => {
106
+ const ok = await confirm({
107
+ title: `恢复${ROLE_LABEL[role] ?? role}默认权限?`,
108
+ description: '自定义权限集将被清除,该角色回到内置默认权限。',
109
+ confirmText: '恢复默认',
110
+ })
111
+ if (!ok) return
112
+ try {
113
+ await resetPerms.mutateAsync(role)
114
+ toast.success(`已恢复${ROLE_LABEL[role] ?? role}默认权限`)
115
+ } catch (err) {
116
+ if (isReauth(err)) {
117
+ setPendingRetry(() => () => resetPerms.mutateAsync(role))
118
+ setReauthOpen(true)
119
+ return
120
+ }
121
+ toast.error(apiErrorMessage(err))
122
+ }
123
+ }
124
+
125
+ if (loading) return <PageLoading label="加载角色权限…" />
126
+ if (error) return <PageError message={apiErrorMessage(error)} />
127
+
128
+ return (
129
+ <div className="mx-auto w-full max-w-4xl p-6">
130
+ <PageHeader
131
+ title="角色权限"
132
+ description="自定义各角色能做什么。所有者始终拥有全部权限;自定义集为整体替换(非累加),未知权限会被服务端拒绝。"
133
+ />
134
+
135
+ {roles.map((r: PKRoleConfig) => {
136
+ const draft = drafts[r.role]
137
+ return (
138
+ <SettingsCard
139
+ key={r.role}
140
+ title={
141
+ <>
142
+ {ROLE_LABEL[r.role] ?? r.role}
143
+ {r.overridden && <Badge variant="secondary">已自定义</Badge>}
144
+ </>
145
+ }
146
+ description={r.overridden
147
+ ? '当前为自定义权限集(覆盖默认)'
148
+ : '当前为内置默认权限集'}
149
+ cta={
150
+ <div className="flex gap-2">
151
+ {r.overridden && (
152
+ <Button variant="outline" size="sm" onClick={() => void reset(r.role)}>
153
+ <RotateCcw className="size-4" />
154
+ 恢复默认
155
+ </Button>
156
+ )}
157
+ <Button
158
+ size="sm"
159
+ disabled={!draft?.dirty}
160
+ onClick={() => void save(r.role)}
161
+ >
162
+ <Save className="size-4" />
163
+ 保存修改
164
+ </Button>
165
+ </div>
166
+ }
167
+ >
168
+ <div className="grid gap-4 sm:grid-cols-2">
169
+ {groups.map(({ domain, perms }) => (
170
+ <div key={domain} className="space-y-2">
171
+ <p className="text-sm font-medium text-muted-foreground">
172
+ {DOMAIN_LABEL[domain] ?? domain}
173
+ </p>
174
+ <div className="space-y-1.5">
175
+ {perms.map((perm) => (
176
+ <label
177
+ key={perm}
178
+ className="flex items-center gap-2 text-sm"
179
+ >
180
+ <Checkbox
181
+ checked={draft?.perms.includes(perm) ?? false}
182
+ onCheckedChange={(v) => toggle(r.role, perm, v === true)}
183
+ />
184
+ <span className="font-mono text-xs">{perm}</span>
185
+ </label>
186
+ ))}
187
+ </div>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </SettingsCard>
192
+ )
193
+ })}
194
+
195
+ <ReauthDialog
196
+ open={reauthOpen}
197
+ retry={pendingRetry}
198
+ onClose={() => { setReauthOpen(false); setPendingRetry(null) }}
199
+ />
200
+ </div>
201
+ )
202
+ }
@@ -0,0 +1,387 @@
1
+
2
+ import { useState } from 'react'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { Plus } from 'lucide-react'
5
+ import { z } from 'zod'
6
+ import {
7
+ PageHeader, PageLoading, PageError, PageEmpty,
8
+ } from '../../components/Page'
9
+ import { DataTable, type Column } from '../../components/DataTable'
10
+ import { FormField } from '../../components/FormField'
11
+ import { CronInput, describeCron } from '../../components/CronInput'
12
+ import { toast } from '../../components/toast'
13
+ import { Button } from '../../components/ui/button'
14
+ import { Input } from '../../components/ui/input'
15
+ import { Switch } from '../../components/ui/switch'
16
+ import {
17
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
18
+ } from '../../components/ui/dialog'
19
+ import { useApi } from '@ploykit/hooks'
20
+ import { useWorkspace } from '@ploykit/hooks'
21
+ import { useZodForm } from '@ploykit/hooks'
22
+ import { useConfirm } from '../../components/ConfirmDialog'
23
+ import { browserTzLabel, formatDateTimeTz } from '../../lib/utils'
24
+ import { apiErrorMessage } from '../../lib/api-error'
25
+
26
+
27
+
28
+ interface SchedulePlan {
29
+ id: string
30
+ workspace_id: string
31
+ kind: string
32
+ cron_expr: string
33
+ timezone: string
34
+ next_fire_at: string
35
+ misfire: 'skip' | 'once'
36
+ last_fired_at?: string | null
37
+ created_at: string
38
+ enabled: boolean
39
+ }
40
+
41
+ export const MISFIRE_LABEL: Record<string, string> = {
42
+ skip: '错过跳过',
43
+ once: '错过补发',
44
+ }
45
+
46
+
47
+ function localTimezone(): string {
48
+ try {
49
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
50
+ } catch {
51
+ return 'UTC'
52
+ }
53
+ }
54
+
55
+ const createSchema = z.object({
56
+ kind: z.string().trim().min(1, '请填写触发标识(kind),如 task.cleanup'),
57
+ cron_expr: z.string().trim().regex(/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/, '表达式须为五字段:分 时 日 月 周'),
58
+ timezone: z.string().min(1, '请选择时区'),
59
+ misfire: z.enum(['skip', 'once']),
60
+ })
61
+ type CreateForm = z.infer<typeof createSchema>
62
+
63
+ export interface SchedulesPageProps {}
64
+
65
+ export function SchedulesPage(_props: SchedulesPageProps = {}) {
66
+ const { current } = useWorkspace()
67
+ const api = useApi()
68
+ const wsId = current?.id ?? ''
69
+ const base = '/api/schedules'
70
+
71
+ const [createOpen, setCreateOpen] = useState(false)
72
+
73
+
74
+
75
+ const tzLabel = browserTzLabel()
76
+
77
+
78
+
79
+ const plansQ = useQuery({
80
+ queryKey: ['schedules', wsId],
81
+ queryFn: () => api.get<{ items: SchedulePlan[] }>(base),
82
+ enabled: !!wsId,
83
+ })
84
+
85
+ const qc = useQueryClient()
86
+ const invalidate = () => qc.invalidateQueries({ queryKey: ['schedules', wsId] })
87
+ const onError = (e: unknown, fallback: string) => toast.error(apiErrorMessage(e, fallback))
88
+
89
+
90
+
91
+ const toggle = useMutation({
92
+ mutationFn: (p: SchedulePlan) =>
93
+ api.patch<SchedulePlan>(`${base}/${p.id}`, { enabled: !p.enabled }),
94
+ onSuccess: async () => { toast.success('状态已更新'); await invalidate() },
95
+ onError: (e) => onError(e, '操作失败'),
96
+ })
97
+
98
+ const create = useMutation({
99
+ mutationFn: (values: CreateForm) => api.post<SchedulePlan>(base, values),
100
+ onSuccess: async () => { toast.success('计划已创建'); await invalidate() },
101
+ onError: (e) => onError(e, '创建失败'),
102
+ })
103
+
104
+
105
+
106
+ const [editing, setEditing] = useState<SchedulePlan | null>(null)
107
+ const updateM = useMutation({
108
+ mutationFn: (v: { plan: SchedulePlan; body: Partial<Pick<SchedulePlan, 'cron_expr' | 'timezone' | 'misfire'>> }) =>
109
+ api.patch<SchedulePlan>(`${base}/${v.plan.id}`, v.body),
110
+ onSuccess: async (_d, v) => {
111
+ toast.success(`计划 ${v.plan.kind} 已更新`)
112
+ setEditing(null)
113
+ await invalidate()
114
+ },
115
+ onError: (e) => onError(e, '更新失败'),
116
+ })
117
+
118
+
119
+
120
+
121
+ const confirm = useConfirm()
122
+ const removeM = useMutation({
123
+ mutationFn: (p: SchedulePlan) => api.delete<void>(`${base}/${p.id}`),
124
+ onSuccess: async (_d, p) => { toast.success(`计划 ${p.kind} 已删除`); await invalidate() },
125
+ onError: (e) => onError(e, '删除失败'),
126
+ })
127
+ const onRemove = async (p: SchedulePlan) => {
128
+ const ok = await confirm({
129
+ title: '删除调度计划',
130
+ description:
131
+ `确定删除计划 ${p.kind}(${describeCron(p.cron_expr)},计划时区 ${p.timezone})吗?` +
132
+ `下次触发 ${formatDateTimeTz(p.next_fire_at)}。删除后该计划不再触发,操作不可恢复。`,
133
+ confirmText: '删除',
134
+ danger: true,
135
+ })
136
+ if (ok) removeM.mutate(p)
137
+ }
138
+
139
+
140
+
141
+ const form = useZodForm<CreateForm>({
142
+ schema: createSchema,
143
+ defaultValues: { kind: '', cron_expr: '0 9 * * *', timezone: localTimezone(), misfire: 'skip' },
144
+ })
145
+ const cronValue = { cron: form.watch('cron_expr') ?? '', timezone: form.watch('timezone') ?? 'UTC' }
146
+ const setCron = (v: { cron: string; timezone: string }) => {
147
+ form.setValue('cron_expr', v.cron, { shouldValidate: true })
148
+ form.setValue('timezone', v.timezone, { shouldValidate: true })
149
+ }
150
+ const submitCreate = form.handleSubmit((values) => {
151
+ create.mutate(values, {
152
+ onSuccess: () => {
153
+ form.reset()
154
+ setCreateOpen(false)
155
+ },
156
+ })
157
+ })
158
+
159
+
160
+ const editForm = useZodForm<CreateForm>({
161
+ schema: createSchema,
162
+ defaultValues: { kind: '', cron_expr: '', timezone: 'UTC', misfire: 'skip' },
163
+ values: editing
164
+ ? {
165
+ kind: editing.kind,
166
+ cron_expr: editing.cron_expr,
167
+ timezone: editing.timezone,
168
+ misfire: editing.misfire === 'once' ? 'once' : 'skip',
169
+ }
170
+ : undefined,
171
+ })
172
+ const editCronValue = { cron: editForm.watch('cron_expr') ?? '', timezone: editForm.watch('timezone') ?? 'UTC' }
173
+ const setEditCron = (v: { cron: string; timezone: string }) => {
174
+ editForm.setValue('cron_expr', v.cron, { shouldValidate: true })
175
+ editForm.setValue('timezone', v.timezone, { shouldValidate: true })
176
+ }
177
+ const submitEdit = editForm.handleSubmit((values) => {
178
+ if (!editing) return
179
+ const body: Partial<Pick<SchedulePlan, 'cron_expr' | 'timezone' | 'misfire'>> = {}
180
+ if (values.cron_expr !== editing.cron_expr) body.cron_expr = values.cron_expr
181
+ if (values.timezone !== editing.timezone) body.timezone = values.timezone
182
+ if (values.misfire !== editing.misfire) body.misfire = values.misfire
183
+ if (Object.keys(body).length === 0) {
184
+ setEditing(null)
185
+ return
186
+ }
187
+ updateM.mutate({ plan: editing, body })
188
+ })
189
+
190
+
191
+
192
+ const columns: Column<SchedulePlan>[] = [
193
+ {
194
+
195
+
196
+ key: 'kind', header: '触发',
197
+ render: (p) => (
198
+ <div className="min-w-0">
199
+ <code className="block max-w-[20rem] break-all rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{p.kind}</code>
200
+ <p className="mt-1 max-w-[20rem] break-words text-xs text-muted-foreground">{describeCron(p.cron_expr)}</p>
201
+ </div>
202
+ ),
203
+ },
204
+ {
205
+ key: 'cron', header: '表达式 / 时区',
206
+ render: (p) => (
207
+ <div className="min-w-0">
208
+ <code className="block max-w-[20rem] break-all font-mono text-xs">{p.cron_expr}</code>
209
+ <p className="max-w-[20rem] break-words text-xs text-muted-foreground">{p.timezone}</p>
210
+ </div>
211
+ ),
212
+ },
213
+ {
214
+ key: 'misfire', header: '错过策略',
215
+ render: (p) => (
216
+ <span className="rounded border bg-muted px-1.5 py-0.5 text-xs">
217
+ {MISFIRE_LABEL[p.misfire] ?? p.misfire}
218
+ </span>
219
+ ),
220
+ },
221
+ {
222
+ key: 'next_fire_at', header: '下次触发',
223
+ render: (p) => (
224
+
225
+
226
+ <span
227
+ className="whitespace-nowrap text-muted-foreground"
228
+ title={`计划时区 ${p.timezone};此处按浏览器本地时区(${tzLabel})显示`}
229
+ >
230
+ {formatDateTimeTz(p.next_fire_at)}
231
+ </span>
232
+ ),
233
+ },
234
+ {
235
+ key: 'last_fired_at', header: '最近触发',
236
+ render: (p) => p.last_fired_at
237
+ ? (
238
+ <span
239
+ className="whitespace-nowrap text-muted-foreground"
240
+ title={`计划时区 ${p.timezone};此处按浏览器本地时区(${tzLabel})显示`}
241
+ >
242
+ {formatDateTimeTz(p.last_fired_at)}
243
+ </span>
244
+ )
245
+ : <span className="text-muted-foreground">—</span>,
246
+ },
247
+ {
248
+ key: 'enabled', header: '启用',
249
+ render: (p) => (
250
+
251
+ <Switch
252
+ checked={p.enabled}
253
+ disabled={toggle.isPending}
254
+ onCheckedChange={() => toggle.mutate(p)}
255
+ aria-label={`${p.enabled ? '停用' : '启用'}计划 ${p.kind}`}
256
+ />
257
+ ),
258
+ },
259
+ {
260
+
261
+ key: 'actions', header: '', className: 'text-right',
262
+ render: (p) => (
263
+ <div className="flex justify-end gap-1">
264
+ <Button variant="ghost" size="sm" onClick={() => setEditing(p)}>
265
+ 编辑
266
+ </Button>
267
+ <Button
268
+ variant="ghost"
269
+ size="sm"
270
+ className="text-destructive hover:text-destructive"
271
+ disabled={removeM.isPending}
272
+ onClick={() => void onRemove(p)}
273
+ >
274
+ 删除
275
+ </Button>
276
+ </div>
277
+ ),
278
+ },
279
+ ]
280
+
281
+
282
+
283
+ if (!wsId) {
284
+ return (
285
+ <div>
286
+ <PageHeader title="调度" description="用户自定义定时触发(cron + 时区)" />
287
+ <PageEmpty label="请先创建并选择一个工作区" />
288
+ </div>
289
+ )
290
+ }
291
+
292
+ return (
293
+ <div>
294
+ <PageHeader
295
+ title="调度"
296
+ description={`用户自定义定时触发(工作区:${current?.name ?? wsId})`}
297
+ actions={<Button onClick={() => setCreateOpen(true)}><Plus /> 创建计划</Button>}
298
+ />
299
+
300
+ {plansQ.isError ? (
301
+ <PageError message={apiErrorMessage(plansQ.error, '计划列表加载失败')} onRetry={() => plansQ.refetch()} />
302
+ ) : plansQ.isPending ? (
303
+ <PageLoading label="加载计划…" />
304
+ ) : (
305
+ <DataTable
306
+ columns={columns}
307
+ rows={plansQ.data?.items ?? []}
308
+ rowKey={(p) => p.id}
309
+ empty={<PageEmpty label="暂无调度计划" hint="点击右上角“创建计划”设置第一个定时触发" />}
310
+ />
311
+ )}
312
+
313
+ {/* 创建计划 */}
314
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
315
+ <DialogContent>
316
+ <DialogHeader>
317
+ <DialogTitle>创建调度计划</DialogTitle>
318
+ <DialogDescription>
319
+ 五字段 cron 表达式 + IANA 时区;错过策略"跳过"等下一个周期,
320
+ "补发"会先补最近一次。预览由后端计算,与实际触发一致。
321
+ </DialogDescription>
322
+ </DialogHeader>
323
+ <form className="space-y-4" onSubmit={submitCreate}>
324
+ <FormField label="触发标识(kind)" error={form.formState.errors.kind?.message} htmlFor="sched-kind">
325
+ <Input id="sched-kind" placeholder="task.cleanup" {...form.register('kind')} />
326
+ </FormField>
327
+ <FormField label="调度规则" error={form.formState.errors.cron_expr?.message ?? form.formState.errors.timezone?.message}>
328
+ <CronInput idPrefix="sched" value={cronValue} onChange={setCron} />
329
+ </FormField>
330
+ <FormField label="错过策略" error={form.formState.errors.misfire?.message} htmlFor="sched-misfire">
331
+ <select
332
+ id="sched-misfire"
333
+ className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
334
+ {...form.register('misfire')}
335
+ >
336
+ <option value="skip">错过跳过(默认)</option>
337
+ <option value="once">错过补发一次</option>
338
+ </select>
339
+ </FormField>
340
+ <DialogFooter>
341
+ <Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
342
+ <Button type="submit" disabled={create.isPending}>
343
+ {create.isPending ? '创建中…' : '创建计划'}
344
+ </Button>
345
+ </DialogFooter>
346
+ </form>
347
+ </DialogContent>
348
+ </Dialog>
349
+
350
+ {/* P3-42:编辑计划(换表达式/时区/错过策略;kind 与启停不改——启停在行内 Switch) */}
351
+ <Dialog open={editing !== null} onOpenChange={(o) => !o && setEditing(null)}>
352
+ <DialogContent>
353
+ <DialogHeader>
354
+ <DialogTitle>编辑调度计划</DialogTitle>
355
+ <DialogDescription>
356
+ 修改 {editing?.kind} 的调度规则;只提交改动的字段,下次触发时间按新规则重新计算。
357
+ </DialogDescription>
358
+ </DialogHeader>
359
+ <form className="space-y-4" onSubmit={submitEdit}>
360
+ <FormField label="触发标识(kind,不可修改)" htmlFor="sched-edit-kind">
361
+ <Input id="sched-edit-kind" value={editing?.kind ?? ''} readOnly disabled />
362
+ </FormField>
363
+ <FormField label="调度规则" error={editForm.formState.errors.cron_expr?.message ?? editForm.formState.errors.timezone?.message}>
364
+ <CronInput idPrefix="sched-edit" value={editCronValue} onChange={setEditCron} />
365
+ </FormField>
366
+ <FormField label="错过策略" error={editForm.formState.errors.misfire?.message} htmlFor="sched-edit-misfire">
367
+ <select
368
+ id="sched-edit-misfire"
369
+ className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
370
+ {...editForm.register('misfire')}
371
+ >
372
+ <option value="skip">错过跳过(默认)</option>
373
+ <option value="once">错过补发一次</option>
374
+ </select>
375
+ </FormField>
376
+ <DialogFooter>
377
+ <Button type="button" variant="outline" onClick={() => setEditing(null)}>取消</Button>
378
+ <Button type="submit" disabled={updateM.isPending}>
379
+ {updateM.isPending ? '保存中…' : '保存修改'}
380
+ </Button>
381
+ </DialogFooter>
382
+ </form>
383
+ </DialogContent>
384
+ </Dialog>
385
+ </div>
386
+ )
387
+ }