@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,229 @@
1
+
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { useSearchParams } from 'react-router'
4
+ import { Download, Search } from 'lucide-react'
5
+ import { PageHeader, PageLoading, PageError, PageEmpty } from '../../components/Page'
6
+ import { DataTable, type Column } from '../../components/DataTable'
7
+ import { Button } from '../../components/ui/button'
8
+ import { Input } from '../../components/ui/input'
9
+ import { toast } from '../../components/toast'
10
+ import {
11
+ useAudit, exportAuditCsv,
12
+ type AuditFilters, type PKAuditEvent,
13
+ } from '@ploykit/hooks'
14
+ import { formatDateTime } from '../../lib/utils'
15
+ import { apiErrorMessage } from '../../lib/api-error'
16
+
17
+ const PAGE_SIZE = 20
18
+
19
+
20
+ interface FilterDraft {
21
+ action: string
22
+ actorId: string
23
+ resourceType: string
24
+ from: string
25
+ to: string
26
+ }
27
+
28
+
29
+ function draftFromParams(p: URLSearchParams): FilterDraft {
30
+ return {
31
+ action: p.get('action') ?? '',
32
+ actorId: p.get('actor_id') ?? '',
33
+ resourceType: p.get('resource_type') ?? '',
34
+ from: p.get('from') ?? '',
35
+ to: p.get('to') ?? '',
36
+ }
37
+ }
38
+
39
+
40
+ function paramsFromDraft(d: FilterDraft, offset: number): URLSearchParams {
41
+ const p = new URLSearchParams()
42
+ if (d.action.trim()) p.set('action', d.action.trim())
43
+ if (d.actorId.trim()) p.set('actor_id', d.actorId.trim())
44
+ if (d.resourceType.trim()) p.set('resource_type', d.resourceType.trim())
45
+ if (d.from) p.set('from', d.from)
46
+ if (d.to) p.set('to', d.to)
47
+ if (offset > 0) p.set('offset', String(offset))
48
+ return p
49
+ }
50
+
51
+ function toFilters(d: FilterDraft): AuditFilters {
52
+ return {
53
+ action: d.action.trim(),
54
+ actorId: d.actorId.trim(),
55
+ resourceType: d.resourceType.trim(),
56
+ from: d.from ? `${d.from}T00:00:00Z` : '',
57
+ to: d.to ? `${d.to}T23:59:59Z` : '',
58
+ }
59
+ }
60
+
61
+ export interface WorkspaceAuditPageProps {}
62
+
63
+ export function WorkspaceAuditPage(_props: WorkspaceAuditPageProps = {}) {
64
+ const [searchParams, setSearchParams] = useSearchParams()
65
+
66
+ const [draft, setDraft] = useState<FilterDraft>(() => draftFromParams(searchParams))
67
+
68
+ useEffect(() => {
69
+ setDraft(draftFromParams(searchParams))
70
+ }, [searchParams])
71
+ const [rangeError, setRangeError] = useState<string | null>(null)
72
+ const appliedDraft = useMemo(() => draftFromParams(searchParams), [searchParams])
73
+ const applied = useMemo(() => toFilters(appliedDraft), [appliedDraft])
74
+ const offset = Math.max(0, Number.parseInt(searchParams.get('offset') ?? '0', 10) || 0)
75
+
76
+ const { wsId, items, total, loading, error, refetch } = useAudit(applied, PAGE_SIZE, offset)
77
+
78
+ const apply = () => {
79
+
80
+ if (draft.from && draft.to && draft.from > draft.to) {
81
+ setRangeError('起始日期不能晚于结束日期')
82
+ return
83
+ }
84
+ setRangeError(null)
85
+ setSearchParams(paramsFromDraft(draft, 0))
86
+ }
87
+
88
+ const onPageChange = (p: number) => {
89
+ setSearchParams(paramsFromDraft(appliedDraft, Math.max(0, (p - 1) * PAGE_SIZE)))
90
+ }
91
+
92
+ const exportCsv = async () => {
93
+ try {
94
+ await exportAuditCsv(applied)
95
+ toast.success('CSV 导出已开始下载')
96
+ } catch (e) {
97
+ toast.error(apiErrorMessage(e, '导出失败'))
98
+ }
99
+ }
100
+
101
+ const columns: Column<PKAuditEvent>[] = [
102
+ {
103
+ key: 'created_at', header: '时间', className: 'w-[16%]',
104
+ render: (e) => <span className="whitespace-nowrap text-muted-foreground">{formatDateTime(e.created_at)}</span>,
105
+ },
106
+ {
107
+ key: 'actor', header: '操作者', className: 'w-[20%]',
108
+ render: (e) => {
109
+ const email = typeof e.actor_snapshot?.email === 'string' ? e.actor_snapshot.email : ''
110
+ return (
111
+ <div className="min-w-0">
112
+ <p className="truncate">{email || e.actor_id || '—'}</p>
113
+ <p className="text-xs text-muted-foreground">{e.actor_type}</p>
114
+ </div>
115
+ )
116
+ },
117
+ },
118
+ {
119
+ key: 'action', header: '动作', className: 'w-[22%]',
120
+ render: (e) => <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{e.action}</code>,
121
+ },
122
+ {
123
+ key: 'resource', header: '资源', className: 'w-[42%]',
124
+ render: (e) => (
125
+ <div className="min-w-0">
126
+ <p className="truncate">{e.resource_type}</p>
127
+ {e.resource_id && <p className="truncate font-mono text-xs text-muted-foreground">{e.resource_id}</p>}
128
+ </div>
129
+ ),
130
+ },
131
+ ]
132
+
133
+ if (!wsId) {
134
+ return (
135
+ <div>
136
+ <PageHeader title="审计日志" description="工作区内的关键操作记录" />
137
+ <PageEmpty label="请先创建并选择一个工作区" />
138
+ </div>
139
+ )
140
+ }
141
+
142
+ return (
143
+ <div>
144
+ <PageHeader title="审计日志" description="工作区内的关键操作记录(保留最近事件,按时间倒序)" />
145
+
146
+ <div className="mb-4 flex flex-wrap items-end gap-2">
147
+ <label className="flex flex-col gap-1 text-sm">
148
+ <span className="text-muted-foreground">动作</span>
149
+ <Input
150
+ className="w-44"
151
+ placeholder="如 workspace.rename"
152
+ value={draft.action}
153
+ onChange={(e) => setDraft((d) => ({ ...d, action: e.target.value }))}
154
+ />
155
+ </label>
156
+ <label className="flex flex-col gap-1 text-sm">
157
+ <span className="text-muted-foreground">操作者 ID</span>
158
+ <Input
159
+ className="w-52"
160
+ placeholder="actor uuid"
161
+ value={draft.actorId}
162
+ onChange={(e) => setDraft((d) => ({ ...d, actorId: e.target.value }))}
163
+ />
164
+ </label>
165
+ <label className="flex flex-col gap-1 text-sm">
166
+ <span className="text-muted-foreground">资源类型</span>
167
+ <Input
168
+ className="w-40"
169
+ placeholder="如 workspace"
170
+ value={draft.resourceType}
171
+ onChange={(e) => setDraft((d) => ({ ...d, resourceType: e.target.value }))}
172
+ />
173
+ </label>
174
+ <label className="flex flex-col gap-1 text-sm">
175
+ <span className="text-muted-foreground">开始日期</span>
176
+ <Input
177
+ className="w-40"
178
+ type="date"
179
+ value={draft.from}
180
+ onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value }))}
181
+ />
182
+ </label>
183
+ <label className="flex flex-col gap-1 text-sm">
184
+ <span className="text-muted-foreground">结束日期</span>
185
+ <Input
186
+ className="w-40"
187
+ type="date"
188
+ value={draft.to}
189
+ onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value }))}
190
+ />
191
+ </label>
192
+ <Button onClick={apply}>
193
+ <Search /> 查询
194
+ </Button>
195
+ <Button variant="outline" onClick={exportCsv}>
196
+ <Download /> 导出 CSV
197
+ </Button>
198
+ </div>
199
+
200
+ {/* P3-40:日期区间错误就地可见(不静默发起恒空查询) */}
201
+ {rangeError && (
202
+ <p role="alert" className="mb-4 text-sm text-destructive">
203
+ {rangeError}
204
+ </p>
205
+ )}
206
+
207
+ {loading ? (
208
+ <PageLoading label="加载审计日志…" />
209
+ ) : error ? (
210
+ <PageError message={apiErrorMessage(error, '审计日志加载失败')} onRetry={() => refetch()} />
211
+ ) : (
212
+ <>
213
+ <DataTable
214
+ columns={columns}
215
+ rows={items}
216
+ rowKey={(e) => e.id}
217
+ empty={<PageEmpty label="暂无审计记录" hint="调整过滤条件或触发一些操作后再来" />}
218
+ pagination={{
219
+ page: Math.floor(offset / PAGE_SIZE) + 1,
220
+ pageSize: PAGE_SIZE,
221
+ total,
222
+ onPageChange,
223
+ }}
224
+ />
225
+ </>
226
+ )}
227
+ </div>
228
+ )
229
+ }
@@ -0,0 +1,184 @@
1
+
2
+ import { useState } from 'react'
3
+ import { useNavigate } from 'react-router'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
+ import { z } from 'zod'
6
+ import { PageHeader, PageError } from '../../components/Page'
7
+ import { SettingsCard } from '../../components/SettingsCard'
8
+ import { FormField } from '../../components/FormField'
9
+ import { ImpactConfirmation } from '../../components/SecretModal'
10
+ import { useConfirm } from '../../components/ConfirmDialog'
11
+ import { toast } from '../../components/toast'
12
+ import { Button } from '../../components/ui/button'
13
+ import { Input } from '../../components/ui/input'
14
+ import { useApi } from '@ploykit/hooks'
15
+ import { useWorkspace } from '@ploykit/hooks'
16
+ import { useZodForm } from '@ploykit/hooks'
17
+ import { queryKeys, type PKWorkspace } from '@ploykit/hooks'
18
+ import { apiErrorMessage } from '../../lib/api-error'
19
+ import { roleSatisfies } from '@ploykit/hooks'
20
+
21
+
22
+
23
+ const renameSchema = z.object({
24
+ name: z.string().trim().min(1, '请输入工作区名称').max(64, '名称最多 64 字符'),
25
+ })
26
+ type RenameForm = z.infer<typeof renameSchema>
27
+
28
+ export interface WorkspaceGeneralPageProps {
29
+
30
+ exitTo?: string
31
+ }
32
+
33
+ export function WorkspaceGeneralPage({ exitTo = '/app' }: WorkspaceGeneralPageProps = {}) {
34
+ const { current, clear } = useWorkspace()
35
+ const api = useApi()
36
+ const confirm = useConfirm()
37
+ const navigate = useNavigate()
38
+ const qc = useQueryClient()
39
+ const [saving, setSaving] = useState(false)
40
+ const [deleteOpen, setDeleteOpen] = useState(false)
41
+
42
+ const ws = current
43
+ const form = useZodForm<RenameForm>({
44
+ schema: renameSchema,
45
+ defaultValues: { name: ws?.name ?? '' },
46
+ values: ws ? { name: ws.name } : undefined, // 工作区数据到达后同步进表单
47
+ })
48
+
49
+ if (!ws) {
50
+ return (
51
+ <div>
52
+ <PageHeader title="工作区概况" />
53
+ <PageError message="工作区不存在或尚未选择" onRetry={() => qc.invalidateQueries({ queryKey: queryKeys.workspaceList })} />
54
+ </div>
55
+ )
56
+ }
57
+
58
+
59
+
60
+ const canRename = roleSatisfies(ws.role, 'admin')
61
+ const canDelete = roleSatisfies(ws.role, 'owner')
62
+
63
+ const refreshList = async () => {
64
+ await qc.invalidateQueries({ queryKey: queryKeys.workspace })
65
+ }
66
+
67
+ const handleRename = form.handleSubmit(async (values) => {
68
+ setSaving(true)
69
+ try {
70
+ await api.patch<PKWorkspace>(`/api/workspaces/${ws.id}`, { name: values.name })
71
+ await refreshList()
72
+ toast.success('工作区名称已更新')
73
+ } catch (e) {
74
+ toast.error(apiErrorMessage(e, '重命名失败'))
75
+ } finally {
76
+ setSaving(false)
77
+ }
78
+ })
79
+
80
+ const afterExit = async () => {
81
+ clear()
82
+ await refreshList()
83
+ navigate(exitTo)
84
+ }
85
+
86
+ const handleLeave = async () => {
87
+ if (!(await confirm({
88
+ title: '退出工作区',
89
+ description: `退出后将失去「${ws.name}」的访问权限;你创建的数据会保留在工作区内。`,
90
+ confirmText: '退出', danger: true,
91
+ }))) return
92
+ try {
93
+ await api.post(`/api/workspaces/${ws.id}/leave`)
94
+ toast.success('已退出工作区')
95
+ await afterExit()
96
+ } catch (e) {
97
+
98
+
99
+
100
+ toast.error(is409(e)
101
+ ? '最后一个所有者不能离开工作区,请先转移所有权或删除工作区'
102
+ : apiErrorMessage(e, '退出失败'))
103
+ }
104
+ }
105
+
106
+ const handleDelete = async () => {
107
+ try {
108
+ await api.delete(`/api/workspaces/${ws.id}`)
109
+ toast.success('工作区已删除')
110
+ await afterExit()
111
+ } catch (e) {
112
+
113
+ toast.error(apiErrorMessage(e, '删除失败'))
114
+ }
115
+ }
116
+
117
+ return (
118
+ <div>
119
+ <PageHeader title="工作区概况" description={`工作区 ${ws.name}(${ws.slug})的基础设置`} />
120
+
121
+ <SettingsCard
122
+ title="基础信息"
123
+ description="工作区名称对成员可见;slug 是 URL 标识,创建后不可修改。"
124
+ >
125
+ <form className="max-w-md space-y-4" onSubmit={handleRename}>
126
+ <FormField label="工作区名称" error={form.formState.errors.name?.message} htmlFor="ws-name">
127
+ <Input id="ws-name" disabled={!canRename} {...form.register('name')} />
128
+ </FormField>
129
+ <FormField label="Slug(只读)" htmlFor="ws-slug">
130
+ <Input id="ws-slug" value={ws.slug} readOnly disabled />
131
+ </FormField>
132
+ {canRename && (
133
+ <Button type="submit" disabled={saving}>{saving ? '保存中…' : '保存修改'}</Button>
134
+ )}
135
+ </form>
136
+ </SettingsCard>
137
+
138
+ <SettingsCard danger title="危险区" description="以下操作影响全部成员,请谨慎执行。">
139
+ <div className="space-y-4">
140
+ <div className="flex flex-row items-center justify-between gap-4 rounded-lg border p-4">
141
+ <div>
142
+ <p className="text-sm font-medium">退出工作区</p>
143
+ <p className="mt-0.5 text-sm text-muted-foreground">
144
+ 退出后你将不再是「{ws.name}」的成员。最后一位 owner 无法退出。
145
+ </p>
146
+ </div>
147
+ <Button variant="outline" onClick={handleLeave}>退出工作区</Button>
148
+ </div>
149
+
150
+ {canDelete && (
151
+ <div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-destructive/40 p-4">
152
+ <div>
153
+ <p className="text-sm font-medium text-destructive">删除工作区</p>
154
+ <p className="mt-0.5 text-sm text-muted-foreground">
155
+ 硬删除工作区及其全部数据,操作不可恢复。
156
+ </p>
157
+ </div>
158
+ <Button variant="destructive" onClick={() => setDeleteOpen(true)}>删除工作区</Button>
159
+ </div>
160
+ )}
161
+ </div>
162
+ </SettingsCard>
163
+
164
+ <ImpactConfirmation
165
+ open={deleteOpen}
166
+ onOpenChange={setDeleteOpen}
167
+ onConfirm={handleDelete}
168
+ title={`删除工作区「${ws.name}」`}
169
+ impacts={[
170
+ '工作区全部数据将被永久删除,且不可恢复',
171
+ '所有成员立即失去访问权限',
172
+ '未撤销的邀请与分享链接同时失效',
173
+ '历史审计记录将保留(合规要求),但不再关联可访问的工作区',
174
+ ]}
175
+ confirmText="永久删除"
176
+ danger
177
+ />
178
+ </div>
179
+ )
180
+ }
181
+
182
+ function is409(e: unknown): boolean {
183
+ return typeof e === 'object' && e !== null && 'status' in e && (e as { status?: number }).status === 409
184
+ }