@ploykit/ui 0.0.0-stage → 0.1.0

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 (196) hide show
  1. package/README.md +41 -3
  2. package/dist/components/ConfirmDialog.d.ts +13 -0
  3. package/dist/components/ConfirmDialog.js +29 -0
  4. package/dist/components/CronInput.d.ts +15 -0
  5. package/dist/components/CronInput.js +112 -0
  6. package/dist/components/DataTable.d.ts +22 -0
  7. package/dist/components/DataTable.js +13 -0
  8. package/dist/components/ErrorBoundary.d.ts +16 -0
  9. package/dist/components/ErrorBoundary.js +24 -0
  10. package/dist/components/FormField.d.ts +10 -0
  11. package/dist/components/FormField.js +22 -0
  12. package/dist/components/ImpersonationBanner.d.ts +3 -0
  13. package/dist/components/ImpersonationBanner.js +10 -0
  14. package/dist/components/NotificationBell.d.ts +3 -0
  15. package/dist/components/NotificationBell.js +47 -0
  16. package/dist/components/OnboardingChecklist.d.ts +12 -0
  17. package/dist/components/OnboardingChecklist.js +43 -0
  18. package/dist/components/Page.d.ts +17 -0
  19. package/dist/components/Page.js +16 -0
  20. package/dist/components/SecretModal.d.ts +16 -0
  21. package/dist/components/SecretModal.js +45 -0
  22. package/dist/components/SettingsCard.d.ts +10 -0
  23. package/dist/components/SettingsCard.js +6 -0
  24. package/dist/components/SiteBanner.d.ts +6 -0
  25. package/dist/components/SiteBanner.js +11 -0
  26. package/dist/components/SiteBannerLayer.d.ts +4 -0
  27. package/dist/components/SiteBannerLayer.js +9 -0
  28. package/dist/components/UserMenu.d.ts +13 -0
  29. package/dist/components/UserMenu.js +30 -0
  30. package/dist/components/WorkspaceSwitcher.d.ts +1 -0
  31. package/dist/components/WorkspaceSwitcher.js +53 -0
  32. package/dist/components/toast.d.ts +7 -0
  33. package/dist/components/toast.js +11 -0
  34. package/dist/components/ui/Badge.d.ts +13 -0
  35. package/dist/components/ui/Badge.js +21 -0
  36. package/dist/components/ui/Button.d.ts +10 -0
  37. package/dist/components/ui/Button.js +33 -0
  38. package/dist/components/ui/Input.d.ts +3 -0
  39. package/dist/components/ui/Input.js +10 -0
  40. package/dist/components/ui/alert-dialog.d.ts +18 -0
  41. package/dist/components/ui/alert-dialog.js +41 -0
  42. package/dist/components/ui/avatar.d.ts +11 -0
  43. package/dist/components/ui/avatar.js +22 -0
  44. package/dist/components/ui/breadcrumb.d.ts +10 -0
  45. package/dist/components/ui/breadcrumb.js +36 -0
  46. package/dist/components/ui/card.d.ts +13 -0
  47. package/dist/components/ui/card.js +24 -0
  48. package/dist/components/ui/checkbox.d.ts +3 -0
  49. package/dist/components/ui/checkbox.js +8 -0
  50. package/dist/components/ui/dialog.d.ts +15 -0
  51. package/dist/components/ui/dialog.js +26 -0
  52. package/dist/components/ui/empty.d.ts +13 -0
  53. package/dist/components/ui/empty.js +36 -0
  54. package/dist/components/ui/field.d.ts +24 -0
  55. package/dist/components/ui/field.js +67 -0
  56. package/dist/components/ui/label.d.ts +3 -0
  57. package/dist/components/ui/label.js +6 -0
  58. package/dist/components/ui/menu.d.ts +29 -0
  59. package/dist/components/ui/menu.js +50 -0
  60. package/dist/components/ui/popover.d.ts +9 -0
  61. package/dist/components/ui/popover.js +22 -0
  62. package/dist/components/ui/progress.d.ts +7 -0
  63. package/dist/components/ui/progress.js +19 -0
  64. package/dist/components/ui/select.d.ts +15 -0
  65. package/dist/components/ui/select.js +33 -0
  66. package/dist/components/ui/separator.d.ts +3 -0
  67. package/dist/components/ui/separator.js +7 -0
  68. package/dist/components/ui/sheet.d.ts +14 -0
  69. package/dist/components/ui/sheet.js +36 -0
  70. package/dist/components/ui/sidebar.d.ts +63 -0
  71. package/dist/components/ui/sidebar.js +265 -0
  72. package/dist/components/ui/skeleton.d.ts +3 -0
  73. package/dist/components/ui/skeleton.js +6 -0
  74. package/dist/components/ui/switch.d.ts +5 -0
  75. package/dist/components/ui/switch.js +7 -0
  76. package/dist/components/ui/tabs.d.ts +10 -0
  77. package/dist/components/ui/tabs.js +28 -0
  78. package/dist/components/ui/tooltip.d.ts +6 -0
  79. package/dist/components/ui/tooltip.js +16 -0
  80. package/dist/hooks/use-mobile.d.ts +1 -0
  81. package/dist/hooks/use-mobile.js +15 -0
  82. package/dist/hooks/useApi.d.ts +10 -0
  83. package/dist/hooks/useApi.js +11 -0
  84. package/dist/hooks/useAudit.d.ts +39 -0
  85. package/dist/hooks/useAudit.js +55 -0
  86. package/dist/hooks/useAuth.d.ts +1 -0
  87. package/dist/hooks/useAuth.js +4 -0
  88. package/dist/hooks/useBilling.d.ts +44 -0
  89. package/dist/hooks/useBilling.js +64 -0
  90. package/dist/hooks/useLogin.d.ts +10 -0
  91. package/dist/hooks/useLogin.js +39 -0
  92. package/dist/hooks/useMembers.d.ts +125 -0
  93. package/dist/hooks/useMembers.js +127 -0
  94. package/dist/hooks/useNotificationPrefs.d.ts +20 -0
  95. package/dist/hooks/useNotificationPrefs.js +42 -0
  96. package/dist/hooks/usePasswordReset.d.ts +5 -0
  97. package/dist/hooks/usePasswordReset.js +12 -0
  98. package/dist/hooks/useSEO.d.ts +6 -0
  99. package/dist/hooks/useSEO.js +14 -0
  100. package/dist/hooks/useSessions.d.ts +20 -0
  101. package/dist/hooks/useSessions.js +38 -0
  102. package/dist/hooks/useSiteConfig.d.ts +12 -0
  103. package/dist/hooks/useSiteConfig.js +11 -0
  104. package/dist/hooks/useTokens.d.ts +36 -0
  105. package/dist/hooks/useTokens.js +35 -0
  106. package/dist/hooks/useUsage.d.ts +20 -0
  107. package/dist/hooks/useUsage.js +21 -0
  108. package/dist/hooks/useWorkspace.d.ts +26 -0
  109. package/dist/hooks/useWorkspace.js +32 -0
  110. package/dist/hooks/useZodForm.d.ts +6 -0
  111. package/dist/hooks/useZodForm.js +6 -0
  112. package/dist/hooks/ws.d.ts +4 -0
  113. package/dist/hooks/ws.js +21 -0
  114. package/dist/index.d.ts +124 -0
  115. package/dist/index.js +94 -0
  116. package/dist/layouts/AppShell.d.ts +20 -0
  117. package/dist/layouts/AppShell.js +21 -0
  118. package/dist/layouts/AuthShell.d.ts +7 -0
  119. package/dist/layouts/AuthShell.js +4 -0
  120. package/dist/layouts/SettingsShell.d.ts +19 -0
  121. package/dist/layouts/SettingsShell.js +21 -0
  122. package/dist/lib/api-error.d.ts +1 -0
  123. package/dist/lib/api-error.js +74 -0
  124. package/dist/lib/perm.d.ts +2 -0
  125. package/dist/lib/perm.js +7 -0
  126. package/dist/lib/utils.d.ts +12 -0
  127. package/dist/lib/utils.js +84 -0
  128. package/dist/lib/ws-query-bridge.d.ts +7 -0
  129. package/dist/lib/ws-query-bridge.js +35 -0
  130. package/dist/pages/LandingPage.d.ts +22 -0
  131. package/dist/pages/LandingPage.js +5 -0
  132. package/dist/pages/LoginPage.d.ts +15 -0
  133. package/dist/pages/LoginPage.js +87 -0
  134. package/dist/pages/account/NotificationPreferencesPage.d.ts +13 -0
  135. package/dist/pages/account/NotificationPreferencesPage.js +41 -0
  136. package/dist/pages/account/ProfileSettingsPage.d.ts +4 -0
  137. package/dist/pages/account/ProfileSettingsPage.js +96 -0
  138. package/dist/pages/account/SecuritySettingsPage.d.ts +1 -0
  139. package/dist/pages/account/SecuritySettingsPage.js +134 -0
  140. package/dist/pages/account/TokensPage.d.ts +1 -0
  141. package/dist/pages/account/TokensPage.js +117 -0
  142. package/dist/pages/admin/AdminSettingsPage.d.ts +1 -0
  143. package/dist/pages/admin/AdminSettingsPage.js +210 -0
  144. package/dist/pages/admin/AdminShell.d.ts +7 -0
  145. package/dist/pages/admin/AdminShell.js +79 -0
  146. package/dist/pages/admin/AuditPage.d.ts +1 -0
  147. package/dist/pages/admin/AuditPage.js +164 -0
  148. package/dist/pages/admin/OrdersPage.d.ts +1 -0
  149. package/dist/pages/admin/OrdersPage.js +236 -0
  150. package/dist/pages/admin/OverviewPage.d.ts +1 -0
  151. package/dist/pages/admin/OverviewPage.js +200 -0
  152. package/dist/pages/admin/SSOPage.d.ts +1 -0
  153. package/dist/pages/admin/SSOPage.js +53 -0
  154. package/dist/pages/admin/UserDetailPage.d.ts +1 -0
  155. package/dist/pages/admin/UserDetailPage.js +266 -0
  156. package/dist/pages/admin/UsersPage.d.ts +4 -0
  157. package/dist/pages/admin/UsersPage.js +201 -0
  158. package/dist/pages/admin/WebhookDeliveriesPage.d.ts +1 -0
  159. package/dist/pages/admin/WebhookDeliveriesPage.js +128 -0
  160. package/dist/pages/admin/WorkspaceDetailPage.d.ts +1 -0
  161. package/dist/pages/admin/WorkspaceDetailPage.js +202 -0
  162. package/dist/pages/admin/WorkspacesPage.d.ts +1 -0
  163. package/dist/pages/admin/WorkspacesPage.js +133 -0
  164. package/dist/pages/admin/shared.d.ts +8 -0
  165. package/dist/pages/admin/shared.js +23 -0
  166. package/dist/pages/auth/ForgotPasswordPage.d.ts +6 -0
  167. package/dist/pages/auth/ForgotPasswordPage.js +32 -0
  168. package/dist/pages/auth/InviteAcceptPage.d.ts +5 -0
  169. package/dist/pages/auth/InviteAcceptPage.js +40 -0
  170. package/dist/pages/auth/RegisterPage.d.ts +15 -0
  171. package/dist/pages/auth/RegisterPage.js +80 -0
  172. package/dist/pages/auth/ResetPasswordPage.d.ts +4 -0
  173. package/dist/pages/auth/ResetPasswordPage.js +71 -0
  174. package/dist/pages/auth/VerifyEmailPage.d.ts +1 -0
  175. package/dist/pages/auth/VerifyEmailPage.js +45 -0
  176. package/dist/pages/workspace/BillingPage.d.ts +4 -0
  177. package/dist/pages/workspace/BillingPage.js +183 -0
  178. package/dist/pages/workspace/BillingSuccessPage.d.ts +4 -0
  179. package/dist/pages/workspace/BillingSuccessPage.js +72 -0
  180. package/dist/pages/workspace/MembersPage.d.ts +3 -0
  181. package/dist/pages/workspace/MembersPage.js +262 -0
  182. package/dist/pages/workspace/SchedulesPage.d.ts +4 -0
  183. package/dist/pages/workspace/SchedulesPage.js +177 -0
  184. package/dist/pages/workspace/UsagePage.d.ts +15 -0
  185. package/dist/pages/workspace/UsagePage.js +74 -0
  186. package/dist/pages/workspace/WebhooksPage.d.ts +3 -0
  187. package/dist/pages/workspace/WebhooksPage.js +242 -0
  188. package/dist/pages/workspace/WorkspaceAuditPage.d.ts +3 -0
  189. package/dist/pages/workspace/WorkspaceAuditPage.js +109 -0
  190. package/dist/pages/workspace/WorkspaceGeneralPage.d.ts +4 -0
  191. package/dist/pages/workspace/WorkspaceGeneralPage.js +101 -0
  192. package/dist/provider/PloykitProvider.d.ts +50 -0
  193. package/dist/provider/PloykitProvider.js +240 -0
  194. package/dist/realtime.d.ts +3 -0
  195. package/dist/realtime.js +11 -0
  196. package/package.json +63 -6
@@ -0,0 +1,202 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Link, useNavigate, useParams } from 'react-router';
4
+ import { ArrowLeft } from 'lucide-react';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { useConfirm } from '../../components/ConfirmDialog';
7
+ import { DataTable } from '../../components/DataTable';
8
+ import { FormField } from '../../components/FormField';
9
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
10
+ import { ImpactConfirmation } from '../../components/SecretModal';
11
+ import { SettingsCard } from '../../components/SettingsCard';
12
+ import { toast } from '../../components/toast';
13
+ import { Badge } from '../../components/ui/Badge';
14
+ import { Button } from '../../components/ui/Button';
15
+ import { Input } from '../../components/ui/Input';
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
17
+ import { useApi } from '../../hooks/useApi';
18
+ import { useAuth } from '../../hooks/useAuth';
19
+ import { apiErrorMessage } from '../../lib/api-error';
20
+ import { formatDate, formatDateTime, shortID } from '../../lib/utils';
21
+ import { adminError } from './shared';
22
+ const ROLE_OPTIONS = ['owner', 'admin', 'member'];
23
+ const QUOTA_KEY_HINT = '如 tasks_monthly';
24
+ export function WorkspaceDetailPage() {
25
+ const { id = '' } = useParams();
26
+ const api = useApi();
27
+ const qc = useQueryClient();
28
+ const confirm = useConfirm();
29
+ const navigate = useNavigate();
30
+ const { user } = useAuth();
31
+ const [confirmSlug, setConfirmSlug] = useState('');
32
+ const [deleteOpen, setDeleteOpen] = useState(false);
33
+ const [cancelSubOpen, setCancelSubOpen] = useState(false);
34
+ const [transferTarget, setTransferTarget] = useState(null);
35
+ const [quotaKey, setQuotaKey] = useState('');
36
+ const [quotaReason, setQuotaReason] = useState('');
37
+ const [quotaRef, setQuotaRef] = useState('');
38
+ const [quotaAmount, setQuotaAmount] = useState('');
39
+ const detailQ = useQuery({
40
+ queryKey: ['admin', 'workspace', id],
41
+ queryFn: () => api.get(`/api/admin/workspaces/${id}`),
42
+ enabled: !!id,
43
+ });
44
+ const invalidate = () => {
45
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspace', id] });
46
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspaces'] });
47
+ };
48
+ const roleM = useMutation({
49
+ mutationFn: (v) => api.patch(`/api/admin/workspaces/${id}/members/${v.uid}`, {
50
+ role: v.role,
51
+ }),
52
+ onSuccess: (_d, v) => {
53
+ toast.success(`${v.email} 角色已变更为 ${v.role}`);
54
+ invalidate();
55
+ },
56
+ onError: (e) => toast.error(apiErrorMessage(e, '角色变更失败')),
57
+ });
58
+ const removeM = useMutation({
59
+ mutationFn: (v) => api.delete(`/api/admin/workspaces/${id}/members/${v.uid}`),
60
+ onSuccess: (_d, v) => {
61
+ toast.success(`${v.email} 已移出工作区`);
62
+ invalidate();
63
+ },
64
+ onError: (e) => toast.error(apiErrorMessage(e, '移除成员失败')),
65
+ });
66
+ const grantM = useMutation({
67
+ mutationFn: (v) => api.post(`/api/admin/workspaces/${id}/quota/grant`, {
68
+ key: v.key,
69
+ reason: v.reason,
70
+ ref: v.ref,
71
+ amount: v.amount,
72
+ }),
73
+ onSuccess: () => {
74
+ toast.success('配额已授予(加成额度立即生效)');
75
+ setQuotaKey('');
76
+ setQuotaReason('');
77
+ setQuotaRef('');
78
+ setQuotaAmount('');
79
+ invalidate();
80
+ },
81
+ onError: (e) => toast.error(apiErrorMessage(e, '配额授予失败')),
82
+ });
83
+ const deleteM = useMutation({
84
+ mutationFn: (slug) => api.delete(`/api/admin/workspaces/${id}?confirm=${encodeURIComponent(slug)}`),
85
+ onSuccess: () => {
86
+ toast.success('工作区已删除');
87
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspaces'] });
88
+ navigate('/admin/workspaces');
89
+ },
90
+ onError: (e) => toast.error(apiErrorMessage(e, '删除失败')),
91
+ });
92
+ const cancelSubM = useMutation({
93
+ mutationFn: () => api.post(`/api/admin/workspaces/${id}/cancel-subscription`),
94
+ onSuccess: () => {
95
+ toast.success('订阅已取消,工作区已降级为 free');
96
+ setCancelSubOpen(false);
97
+ invalidate();
98
+ },
99
+ onError: (e) => toast.error(apiErrorMessage(e, '取消订阅失败')),
100
+ });
101
+ const transferM = useMutation({
102
+ mutationFn: (m) => api.post(`/api/admin/workspaces/${id}/transfer-ownership`, {
103
+ user_id: m.user_id,
104
+ }),
105
+ onSuccess: (_d, m) => {
106
+ toast.success(`所有权已转让给 ${m.email}(原 owner 已降为 member)`);
107
+ setTransferTarget(null);
108
+ invalidate();
109
+ },
110
+ onError: (e) => toast.error(apiErrorMessage(e, '所有权转让失败')),
111
+ });
112
+ const onGrant = () => {
113
+ const amount = Number.parseInt(quotaAmount, 10);
114
+ if (!quotaKey.trim() ||
115
+ !quotaReason.trim() ||
116
+ !Number.isInteger(amount) ||
117
+ amount <= 0) {
118
+ toast.error('请填写配额键、原因与正整数额度');
119
+ return;
120
+ }
121
+ grantM.mutate({
122
+ key: quotaKey.trim(),
123
+ reason: quotaReason.trim(),
124
+ ref: quotaRef.trim(),
125
+ amount,
126
+ });
127
+ };
128
+ const onRemoveMember = async (m) => {
129
+ const ok = await confirm({
130
+ title: '移除成员',
131
+ description: `将把 ${m.email} 移出本工作区(${m.role})。最后一个 owner 受保护,无法移除。`,
132
+ confirmText: '移除',
133
+ danger: true,
134
+ });
135
+ if (ok)
136
+ removeM.mutate({ uid: m.user_id, email: m.email });
137
+ };
138
+ if (detailQ.isError) {
139
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u5DE5\u4F5C\u533A\u8BE6\u60C5" }), _jsx(PageError, { message: adminError(detailQ.error, '工作区详情加载失败'), onRetry: () => void detailQ.refetch() })] }));
140
+ }
141
+ const ws = detailQ.data;
142
+ const slugReadyToDelete = !!ws && confirmSlug.trim() === ws.slug;
143
+ const memberColumns = [
144
+ {
145
+ key: 'email',
146
+ header: '成员',
147
+ render: (m) => (_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium", children: m.email }), _jsx("p", { className: "truncate text-xs text-muted-foreground", children: m.display_name || '—' })] })),
148
+ },
149
+ {
150
+ key: 'role',
151
+ header: '角色',
152
+ className: 'w-40',
153
+ render: (m) => {
154
+ const mutating = roleM.isPending && roleM.variables?.uid === m.user_id;
155
+ return (_jsxs(Select, { value: ROLE_OPTIONS.includes(m.role) ? m.role : 'member', onValueChange: (v) => {
156
+ if (typeof v === 'string' && v !== m.role)
157
+ roleM.mutate({ uid: m.user_id, email: m.email, role: v });
158
+ }, children: [_jsx(SelectTrigger, { size: "sm", disabled: mutating, "aria-label": `变更 ${m.email} 角色`, children: _jsx(SelectValue, { children: m.role }) }), _jsx(SelectContent, { align: "start", children: ROLE_OPTIONS.map((r) => (_jsx(SelectItem, { value: r, children: r }, r))) })] }));
159
+ },
160
+ },
161
+ {
162
+ key: 'created_at',
163
+ header: '加入时间',
164
+ className: 'w-28',
165
+ render: (m) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(m.created_at) })),
166
+ },
167
+ {
168
+ key: 'actions',
169
+ header: '操作',
170
+ className: 'w-44',
171
+ render: (m) => {
172
+ const canTransfer = (m.role === 'member' || m.role === 'admin') && m.user_id !== user?.id;
173
+ return (_jsxs("div", { className: "flex items-center justify-end gap-1.5", children: [canTransfer && (_jsx(Button, { variant: "outline", size: "sm", disabled: transferM.isPending, title: "\u76EE\u6807\u5347\u4E3A owner\uFF0C\u5F53\u524D owner \u964D\u4E3A member\uFF08\u5355\u4E8B\u52A1\u539F\u5B50\uFF09", onClick: () => setTransferTarget(m), children: "\u8F6C\u8BA9\u6240\u6709\u6743" })), _jsx(Button, { variant: "outline", size: "sm", disabled: removeM.isPending, onClick: () => void onRemoveMember(m), children: "\u79FB\u9664" })] }));
174
+ },
175
+ },
176
+ ];
177
+ return (_jsxs("div", { className: "space-y-6", children: [_jsx(PageHeader, { title: ws ? ws.name : '工作区详情', description: ws
178
+ ? `${ws.slug} · 工作区 ID ${shortID(ws.id)}`
179
+ : '基础信息 + 成员表 + 配额调整', actions: _jsxs(Link, { to: "/admin/workspaces", className: "text-sm text-muted-foreground transition-colors hover:text-foreground", children: [_jsx(ArrowLeft, { size: 15, className: "mr-1 inline", "aria-hidden": "true" }), "\u8FD4\u56DE\u5DE5\u4F5C\u533A\u5217\u8868"] }) }), detailQ.isPending && (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u52A0\u8F7D\u5DE5\u4F5C\u533A\u8BE6\u60C5\u4E2D\u2026" })), ws && (_jsxs(_Fragment, { children: [_jsx(SettingsCard, { title: "\u57FA\u7840\u4FE1\u606F", description: "\u5DE5\u4F5C\u533A\u6863\u6848\uFF08\u884C\u5185\u5957\u9910\u53D8\u66F4\u5728\u5DE5\u4F5C\u533A\u5217\u8868\u9875\uFF09", children: _jsxs("div", { className: "grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-4", children: [_jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6807\u8BC6\uFF08slug\uFF09\uFF1A" }), _jsx("span", { className: "font-mono", children: ws.slug })] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5957\u9910\uFF1A" }), _jsx(Badge, { variant: "outline", children: ws.plan_code })] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u6210\u5458\u6570\uFF1A" }), _jsx("span", { className: "tabular-nums", children: ws.member_count })] }), _jsxs("p", { children: [_jsx("span", { className: "text-muted-foreground", children: "\u521B\u5EFA\u65F6\u95F4\uFF1A" }), formatDateTime(ws.created_at)] })] }) }), _jsx(SettingsCard, { title: "\u8BA2\u9605", description: "\u8BA1\u8D39\u533A\uFF1A\u5F53\u524D\u5957\u9910\u4E0E\u8BA2\u9605\u53D6\u6D88\uFF08\u6E20\u9053\u4FA7\u5148\u53D6\u6D88\uFF0C\u6210\u529F\u540E\u7ACB\u5373\u964D\u7EA7 free\uFF1B\u4E0D\u53D1\u9000\u6B3E\u2014\u2014\u9000\u6B3E\u5C5E\u540E\u7EED\u6279\u6B21\uFF09", cta: _jsx(Button, { variant: "destructive", disabled: ws.plan_code === 'free' || cancelSubM.isPending, title: ws.plan_code === 'free'
180
+ ? '当前已是 free,无可取消订阅'
181
+ : undefined, onClick: () => setCancelSubOpen(true), children: cancelSubM.isPending ? '取消中…' : '取消订阅' }), children: _jsxs("div", { className: "grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2", children: [_jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u5F53\u524D\u5957\u9910\uFF1A" }), _jsx(Badge, { variant: "outline", children: ws.plan_code })] }), _jsxs("p", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "\u8BA2\u9605\u72B6\u6001\uFF1A" }), ws.plan_code === 'free' ? (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u8BA2\u9605" })) : (_jsx(Badge, { variant: "outline", children: "\u8BA2\u9605\u4E2D" }))] })] }) }), _jsx(SettingsCard, { title: `成员(${ws.members.length})`, description: "\u884C\u5185\u6539\u89D2\u8272\u3001\u8F6C\u8BA9\u6240\u6709\u6743\u6216\u79FB\u9664\uFF1Blast-owner \u4FDD\u62A4\u7531\u670D\u52A1\u7AEF\u515C\u5E95\uFF08409\uFF09", bodyClassName: "p-0", children: _jsx("div", { className: "px-6 pb-6", children: _jsx(DataTable, { columns: memberColumns, rows: ws.members, rowKey: (m) => m.user_id, loading: detailQ.isPending, empty: _jsx(PageEmpty, { label: "\u6682\u65E0\u6210\u5458" }) }) }) }), _jsx(SettingsCard, { title: "\u914D\u989D\u8C03\u6574", description: "\u624B\u52A8\u6388\u4E88\u52A0\u6210\u989D\u5EA6\uFF08quota.Grant\uFF1A\u540C \u952E+\u539F\u56E0+\u5F15\u7528 \u5E42\u7B49\u4E00\u6B21\uFF0C\u6B63\u6570\u52A0\u6210\uFF09", cta: _jsx(Button, { onClick: onGrant, disabled: grantM.isPending, children: grantM.isPending ? '授予中…' : '授予配额' }), children: _jsxs("div", { className: "grid max-w-3xl gap-4 sm:grid-cols-2", children: [_jsx(FormField, { label: "\u914D\u989D\u952E\uFF08key\uFF09", htmlFor: "quota-key", children: _jsx(Input, { id: "quota-key", className: "font-mono", placeholder: QUOTA_KEY_HINT, value: quotaKey, onChange: (e) => setQuotaKey(e.target.value) }) }), _jsx(FormField, { label: "\u989D\u5EA6\uFF08amount\uFF0C\u6B63\u6574\u6570\uFF09", htmlFor: "quota-amount", children: _jsx(Input, { id: "quota-amount", type: "number", min: 1, step: 1, placeholder: "\u5982 100", value: quotaAmount, onChange: (e) => setQuotaAmount(e.target.value) }) }), _jsx(FormField, { label: "\u539F\u56E0\uFF08reason\uFF0C\u5FC5\u586B\uFF09", htmlFor: "quota-reason", children: _jsx(Input, { id: "quota-reason", placeholder: "\u5982 \u5BA2\u6237\u8865\u507F / \u6D3B\u52A8\u8D60\u9001", value: quotaReason, onChange: (e) => setQuotaReason(e.target.value) }) }), _jsx(FormField, { label: "\u5F15\u7528\uFF08ref\uFF0C\u53EF\u9009\uFF1B\u53C2\u4E0E\u5E42\u7B49\u952E\uFF09", htmlFor: "quota-ref", children: _jsx(Input, { id: "quota-ref", className: "font-mono", placeholder: "\u5982 ticket-1234", value: quotaRef, onChange: (e) => setQuotaRef(e.target.value) }) })] }) }), _jsx(SettingsCard, { danger: true, title: "\u5220\u9664\u5DE5\u4F5C\u533A", description: "\u4E0D\u53EF\u9006\u7EA7\u8054\u5220\u9664\uFF08\u6210\u5458/\u8D44\u6E90\u968F\u5DE5\u4F5C\u533A\u9500\u6BC1\uFF0C\u8D70 BeforeDelete \u94A9\u5B50\uFF09", cta: _jsx(Button, { variant: "destructive", disabled: !slugReadyToDelete || deleteM.isPending, onClick: () => setDeleteOpen(true), children: deleteM.isPending ? '删除中…' : '删除工作区' }), children: _jsxs("div", { className: "max-w-xl space-y-2", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u8F93\u5165\u5DE5\u4F5C\u533A slug ", ws.slug, " \u4EE5\u786E\u8BA4\u5220\u9664\u3002"] }), _jsx(Input, { placeholder: ws.slug, value: confirmSlug, onChange: (e) => setConfirmSlug(e.target.value), "aria-label": "\u8F93\u5165\u5DE5\u4F5C\u533A slug \u4EE5\u786E\u8BA4\u5220\u9664" })] }) }), _jsx(ImpactConfirmation, { open: deleteOpen, onOpenChange: setDeleteOpen, onConfirm: () => deleteM.mutate(ws.slug), title: `删除工作区 ${ws.name}`, confirmText: "\u6C38\u4E45\u5220\u9664", danger: true, impacts: [
182
+ '工作区及其全部业务数据被级联删除,不可恢复',
183
+ '全部成员关系解除,成员不再能访问该工作区',
184
+ 'BeforeDelete 钩子可阻断删除(409),操作记入平台审计日志',
185
+ ] }), _jsx(ImpactConfirmation, { open: cancelSubOpen, onOpenChange: setCancelSubOpen, onConfirm: () => cancelSubM.mutate(), title: `取消工作区 ${ws.name} 的订阅`, confirmText: "\u7ACB\u5373\u53D6\u6D88\u8BA2\u9605", danger: true, impacts: [
186
+ '工作区立即降级为 free 套餐,配额随之收缩',
187
+ '渠道侧订阅同步取消(Stripe 停止后续扣款);渠道取消失败时本地状态保持不变',
188
+ '本操作不发退款(退款属后续批次),操作记入平台审计日志',
189
+ ] }), transferTarget && (_jsx(ImpactConfirmation, { open: true, onOpenChange: (o) => {
190
+ if (!o)
191
+ setTransferTarget(null);
192
+ }, onConfirm: () => transferM.mutate(transferTarget), title: `转让 ${ws.name} 的所有权给 ${transferTarget.email}`, confirmText: "\u786E\u8BA4\u8F6C\u8BA9", impacts: [
193
+ ws.members.filter((m) => m.role === 'owner').length > 1
194
+ ? `当前 owner(${ws.members
195
+ .filter((m) => m.role === 'owner')
196
+ .map((m) => m.email)
197
+ .join('、')})中最早加入者将降为 member,不再保有工作区最高权`
198
+ : `当前 owner ${ws.members.find((m) => m.role === 'owner')?.email ?? '—'} 将降为 member,不再保有工作区最高权`,
199
+ `${transferTarget.email} 将成为新 owner(单事务原子完成,工作区始终保有 owner)`,
200
+ '目标须是本工作区活跃成员;BeforeOwnerChange 钩子可阻断(409),操作记入平台审计日志',
201
+ ] }))] }))] }));
202
+ }
@@ -0,0 +1 @@
1
+ export declare function WorkspacesPage(): import("react").JSX.Element;
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Link, useSearchParams } from 'react-router';
4
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { DataTable } from '../../components/DataTable';
6
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page';
7
+ import { toast } from '../../components/toast';
8
+ import { Badge } from '../../components/ui/Badge';
9
+ import { Button } from '../../components/ui/Button';
10
+ import { Input } from '../../components/ui/Input';
11
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select';
12
+ import { useApi } from '../../hooks/useApi';
13
+ import { formatDate } from '../../lib/utils';
14
+ import { ADMIN_PAGE_SIZE, adminError, adminListQuery, } from './shared';
15
+ const FALLBACK_PLAN_CODES = ['free'];
16
+ export function WorkspacesPage() {
17
+ const api = useApi();
18
+ const qc = useQueryClient();
19
+ const [searchParams, setSearchParams] = useSearchParams();
20
+ const q = searchParams.get('q') ?? '';
21
+ const page = Math.max(1, Number.parseInt(searchParams.get('page') ?? '1', 10) || 1);
22
+ const applyFilter = (nextQ, nextPage) => {
23
+ const sp = new URLSearchParams(searchParams);
24
+ if (nextQ)
25
+ sp.set('q', nextQ);
26
+ else
27
+ sp.delete('q');
28
+ if (nextPage > 1)
29
+ sp.set('page', String(nextPage));
30
+ else
31
+ sp.delete('page');
32
+ setSearchParams(sp, { replace: true });
33
+ };
34
+ const setPage = (p) => applyFilter(q, p);
35
+ const [searchInput, setSearchInput] = useState(q);
36
+ useEffect(() => {
37
+ const t = setTimeout(() => {
38
+ const next = searchInput.trim();
39
+ if (next !== q)
40
+ applyFilter(next, 1);
41
+ }, 300);
42
+ return () => clearTimeout(t);
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [searchInput]);
45
+ const wsQ = useQuery({
46
+ queryKey: ['admin', 'workspaces', page, q],
47
+ queryFn: () => api.get(`/api/admin/workspaces${adminListQuery(page, ADMIN_PAGE_SIZE, { q })}`),
48
+ });
49
+ const plansQ = useQuery({
50
+ queryKey: ['admin', 'plans'],
51
+ queryFn: () => api.get('/api/admin/plans'),
52
+ });
53
+ const planCodes = plansQ.data?.items?.length
54
+ ? plansQ.data.items
55
+ : FALLBACK_PLAN_CODES;
56
+ const planName = new Map((plansQ.data?.plans ?? [])
57
+ .filter((p) => p.name)
58
+ .map((p) => [p.code, p.name]));
59
+ const planTrial = new Map((plansQ.data?.plans ?? [])
60
+ .filter((p) => p.trial_days > 0)
61
+ .map((p) => [p.code, p.trial_days]));
62
+ const planLabel = (code) => {
63
+ const name = planName.get(code) ?? code;
64
+ return planTrial.has(code)
65
+ ? `${name}(新订阅享 ${planTrial.get(code)} 天试用)`
66
+ : name;
67
+ };
68
+ const planM = useMutation({
69
+ mutationFn: (v) => api.patch(`/api/admin/workspaces/${v.ws.id}/plan`, {
70
+ plan_code: v.planCode,
71
+ }),
72
+ onSuccess: (_d, v) => {
73
+ toast.success(`${v.ws.name} 套餐已变更为 ${planLabel(v.planCode)}`);
74
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspaces'] });
75
+ void qc.invalidateQueries({ queryKey: ['admin', 'workspace'] });
76
+ void qc.invalidateQueries({ queryKey: ['admin', 'stats'] });
77
+ },
78
+ onError: (e) => toast.error(adminError(e, '套餐变更失败')),
79
+ });
80
+ const raw = wsQ.data?.items ?? [];
81
+ const total = wsQ.data?.total ?? 0;
82
+ const pastEnd = !wsQ.isPending && raw.length === 0 && page > 1;
83
+ const columns = [
84
+ {
85
+ key: 'name',
86
+ header: '工作区',
87
+ render: (ws) => (_jsxs("div", { className: "min-w-0 max-w-[24rem]", children: [_jsx("p", { className: "truncate font-medium", children: _jsx(Link, { to: `/admin/workspaces/${ws.id}`, className: "transition-colors hover:text-primary hover:underline", children: ws.name }) }), _jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", children: ws.slug })] })),
88
+ },
89
+ {
90
+ key: 'owner_email',
91
+ header: '属主',
92
+ className: 'w-48',
93
+ render: (ws) => (_jsx("span", { className: "truncate text-muted-foreground", title: ws.owner_email ?? undefined, children: ws.owner_email ?? '—' })),
94
+ },
95
+ {
96
+ key: 'plan_code',
97
+ header: '套餐',
98
+ className: 'w-44',
99
+ render: (ws) => {
100
+ const mutating = planM.isPending && planM.variables?.ws.id === ws.id;
101
+ const known = planCodes.includes(ws.plan_code);
102
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Select, { value: ws.plan_code, onValueChange: (v) => {
103
+ if (typeof v === 'string' && v !== ws.plan_code)
104
+ planM.mutate({ ws, planCode: v });
105
+ }, children: [_jsx(SelectTrigger, { size: "sm", className: "max-md:h-10", disabled: mutating, "aria-label": `变更 ${ws.name} 套餐`, children: _jsx(SelectValue, { children: planLabel(ws.plan_code) }) }), _jsx(SelectContent, { align: "start", children: planCodes.map((code) => (_jsx(SelectItem, { value: code, children: planLabel(code) }, code))) })] }), !known && (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", children: "\u672A\u77E5\u5957\u9910" }))] }));
106
+ },
107
+ },
108
+ {
109
+ key: 'member_count',
110
+ header: '成员数',
111
+ className: 'w-20',
112
+ render: (ws) => _jsx("span", { className: "tabular-nums", children: ws.member_count }),
113
+ },
114
+ {
115
+ key: 'created_at',
116
+ header: '创建时间',
117
+ className: 'w-28',
118
+ render: (ws) => (_jsx("span", { className: "whitespace-nowrap text-muted-foreground", children: formatDate(ws.created_at) })),
119
+ },
120
+ ];
121
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u5DE5\u4F5C\u533A", description: "\u5E73\u53F0\u5168\u90E8\u5DE5\u4F5C\u533A\uFF1B\u652F\u6301\u540D\u79F0/slug \u641C\u7D22\uFF0C\u884C\u5185\u53EF\u76F4\u63A5\u53D8\u66F4\u5957\u9910\uFF08\u5BA1\u8BA1\u7559\u75D5\uFF09" }), _jsxs("div", { className: "mb-4 flex flex-wrap items-center gap-2", children: [_jsx(Input, { className: "w-72", placeholder: "\u6309\u540D\u79F0 / slug \u524D\u7F00\u641C\u7D22\u2026", "aria-label": "\u641C\u7D22\u5DE5\u4F5C\u533A", value: searchInput, onChange: (e) => setSearchInput(e.target.value), onKeyDown: (e) => {
122
+ if (e.key === 'Enter')
123
+ applyFilter(searchInput.trim(), 1);
124
+ } }), q && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
125
+ setSearchInput('');
126
+ applyFilter('', 1);
127
+ }, children: "\u6E05\u9664\u641C\u7D22" }))] }), wsQ.isError ? (_jsx(PageError, { message: adminError(wsQ.error, '工作区列表加载失败'), onRetry: () => void wsQ.refetch() })) : (_jsx(DataTable, { columns: columns, rows: raw, rowKey: (ws) => ws.id, loading: wsQ.isPending, pagination: {
128
+ page,
129
+ pageSize: ADMIN_PAGE_SIZE,
130
+ total,
131
+ onPageChange: setPage,
132
+ }, empty: pastEnd ? (_jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx(PageEmpty, { label: "\u6CA1\u6709\u66F4\u591A\u5DE5\u4F5C\u533A\u4E86", hint: "\u5F53\u524D\u9875\u6570\u636E\u53EF\u80FD\u5DF2\u88AB\u5220\u9664" }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => setPage(1), children: "\u8FD4\u56DE\u7B2C\u4E00\u9875" })] })) : q ? (_jsx(PageEmpty, { label: "\u6CA1\u6709\u5339\u914D\u7684\u5DE5\u4F5C\u533A", hint: "\u6362\u4E2A\u5173\u952E\u8BCD\u6216\u6E05\u9664\u641C\u7D22" })) : (_jsx(PageEmpty, { label: "\u6682\u65E0\u5DE5\u4F5C\u533A" })) }))] }));
133
+ }
@@ -0,0 +1,8 @@
1
+ export declare const ADMIN_PAGE_SIZE = 20;
2
+ export declare function adminError(err: unknown, fallback: string): string;
3
+ export interface AdminListEnvelope<T> {
4
+ items: T[];
5
+ total: number;
6
+ }
7
+ export declare function adminListQuery(page: number, pageSize?: number, filters?: Record<string, string | undefined>): string;
8
+ export declare function formatCents(cents: number, currency?: string): string;
@@ -0,0 +1,23 @@
1
+ import { ApiError } from '../../hooks/useApi';
2
+ import { apiErrorMessage } from '../../lib/api-error';
3
+ export const ADMIN_PAGE_SIZE = 20;
4
+ export function adminError(err, fallback) {
5
+ if (err instanceof ApiError && err.status === 403) {
6
+ return '需要平台管理员权限(当前账号无权访问管理控制台)';
7
+ }
8
+ return apiErrorMessage(err, fallback);
9
+ }
10
+ export function adminListQuery(page, pageSize = ADMIN_PAGE_SIZE, filters = {}) {
11
+ const p = new URLSearchParams();
12
+ p.set('page', String(page));
13
+ p.set('page_size', String(pageSize));
14
+ for (const [k, v] of Object.entries(filters)) {
15
+ if (v && v.trim())
16
+ p.set(k, v.trim());
17
+ }
18
+ return `?${p.toString()}`;
19
+ }
20
+ export function formatCents(cents, currency = 'CNY') {
21
+ const symbol = currency.toUpperCase() === 'CNY' ? '¥' : `${currency.toUpperCase()} `;
22
+ return `${symbol}${(cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
23
+ }
@@ -0,0 +1,6 @@
1
+ export interface ForgotPasswordPageProps {
2
+ title?: string;
3
+ sentHint?: string;
4
+ backPath?: string;
5
+ }
6
+ export declare function ForgotPasswordPage({ title, sentHint, backPath, }: ForgotPasswordPageProps): import("react").JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { AuthShell } from '../../layouts/AuthShell';
4
+ import { FormField } from '../../components/FormField';
5
+ import { Button } from '../../components/ui/Button';
6
+ import { Input } from '../../components/ui/Input';
7
+ import { usePasswordReset } from '../../hooks/usePasswordReset';
8
+ export function ForgotPasswordPage({ title = '找回密码', sentHint = '如果该邮箱存在,重置链接已发送,请查收邮箱。', backPath = '/login', }) {
9
+ const { requestReset } = usePasswordReset();
10
+ const [email, setEmail] = useState('');
11
+ const [loading, setLoading] = useState(false);
12
+ const [sent, setSent] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ const handleSubmit = async (e) => {
15
+ e.preventDefault();
16
+ if (!email)
17
+ return;
18
+ setError(null);
19
+ setLoading(true);
20
+ try {
21
+ await requestReset(email);
22
+ setSent(true);
23
+ }
24
+ catch (err) {
25
+ setError(err instanceof Error ? err.message : '发送失败,请稍后重试');
26
+ }
27
+ finally {
28
+ setLoading(false);
29
+ }
30
+ };
31
+ return (_jsx(AuthShell, { children: _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsx("div", { className: "space-y-1 text-center", children: _jsx("h1", { className: "text-2xl font-bold tracking-tight", children: title }) }), sent ? (_jsx("p", { className: "text-sm text-muted-foreground", "data-testid": "sent-hint", children: sentHint })) : (_jsxs(_Fragment, { children: [_jsx(FormField, { label: "\u90AE\u7BB1", error: error ?? undefined, htmlFor: "forgot-email", children: _jsx(Input, { id: "forgot-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", required: true }) }), _jsx(Button, { type: "submit", className: "w-full", disabled: loading || !email, children: loading ? '发送中…' : '发送重置链接' })] })), _jsx("a", { href: backPath, className: "block text-center text-sm text-primary underline-offset-4 hover:underline", children: "\u8FD4\u56DE\u767B\u5F55" })] }) }));
32
+ }
@@ -0,0 +1,5 @@
1
+ export interface InviteAcceptPageProps {
2
+ onAccepted?: (workspaceId: string) => void;
3
+ onDeclined?: () => void;
4
+ }
5
+ export declare function InviteAcceptPage({ onAccepted, onDeclined }?: InviteAcceptPageProps): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useNavigate } from 'react-router';
3
+ import { Check, X } from 'lucide-react';
4
+ import { Button } from '../../components/ui/Button';
5
+ import { Badge } from '../../components/ui/Badge';
6
+ import { PageLoading, PageEmpty } from '../../components/Page';
7
+ import { toast } from '../../components/toast';
8
+ import { useMyInvitations } from '../../hooks/useMembers';
9
+ import { formatDateTime } from '../../lib/utils';
10
+ import { apiErrorMessage } from '../../lib/api-error';
11
+ const ROLE_LABEL = { owner: '所有者', admin: '管理员', member: '成员' };
12
+ export function InviteAcceptPage({ onAccepted, onDeclined } = {}) {
13
+ const navigate = useNavigate();
14
+ const { invitations, loading, error, refetch, accept, decline } = useMyInvitations();
15
+ const pending = invitations.filter((i) => i.status === 'pending');
16
+ const onAccept = async (id, name) => {
17
+ try {
18
+ const ws = await accept.mutateAsync(id);
19
+ toast.success(`已加入「${name}」`);
20
+ if (onAccepted)
21
+ onAccepted(ws.id);
22
+ else
23
+ navigate('/app');
24
+ }
25
+ catch (e) {
26
+ toast.error(apiErrorMessage(e, '接受邀请失败'));
27
+ }
28
+ };
29
+ const onDecline = async (id, name) => {
30
+ try {
31
+ await decline.mutateAsync(id);
32
+ toast.success(`已拒绝「${name}」的邀请`);
33
+ onDeclined?.();
34
+ }
35
+ catch (e) {
36
+ toast.error(apiErrorMessage(e, '操作失败'));
37
+ }
38
+ };
39
+ return (_jsx("div", { className: "flex min-h-svh w-full items-center justify-center bg-muted/40 p-6 md:p-10", children: _jsxs("div", { className: "w-full max-w-md rounded-xl border bg-card p-6 shadow-sm", children: [_jsx("h1", { className: "text-xl font-bold", children: "\u6211\u7684\u9080\u8BF7" }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: "\u63A5\u53D7\u540E\u4F60\u5C06\u52A0\u5165\u5BF9\u5E94\u5DE5\u4F5C\u533A\u3002" }), _jsx("div", { className: "mt-4", children: loading ? (_jsx(PageLoading, { label: "\u52A0\u8F7D\u9080\u8BF7\u2026" })) : error ? (_jsxs("div", { className: "py-6 text-center text-sm text-red-600", children: [apiErrorMessage(error, '邀请加载失败'), _jsx("div", { className: "mt-3", children: _jsx(Button, { size: "sm", variant: "outline", onClick: () => refetch(), children: "\u91CD\u8BD5" }) })] })) : pending.length === 0 ? (_jsx(PageEmpty, { label: "\u6682\u65E0\u5F85\u5904\u7406\u7684\u9080\u8BF7", hint: "\u6536\u5230\u65B0\u9080\u8BF7\u65F6\u4F1A\u51FA\u73B0\u5728\u8FD9\u91CC" })) : (_jsx("ul", { className: "divide-y", children: pending.map((inv) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm font-medium", children: inv.workspace_name || inv.workspace_slug || '工作区' }), _jsxs("p", { className: "mt-0.5 flex items-center gap-2 text-xs text-muted-foreground", children: [_jsx(Badge, { variant: "secondary", children: ROLE_LABEL[inv.role] ?? inv.role }), _jsxs("span", { children: [formatDateTime(inv.expires_at), " \u8FC7\u671F"] })] })] }), _jsxs("div", { className: "flex shrink-0 gap-1.5", children: [_jsxs(Button, { size: "sm", variant: "outline", disabled: decline.isPending, onClick: () => onDecline(inv.id, inv.workspace_name), children: [_jsx(X, {}), " \u62D2\u7EDD"] }), _jsxs(Button, { size: "sm", disabled: accept.isPending, onClick: () => onAccept(inv.id, inv.workspace_name), children: [_jsx(Check, {}), " \u63A5\u53D7"] })] })] }, inv.id))) })) })] }) }));
40
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ export declare const passwordSchema: z.ZodString;
3
+ export declare const registerSchema: z.ZodObject<{
4
+ email: z.ZodString;
5
+ password: z.ZodString;
6
+ display_name: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strip>;
8
+ export type RegisterForm = z.infer<typeof registerSchema>;
9
+ export interface RegisterPageProps {
10
+ onSuccess?: () => void;
11
+ loginPath?: string;
12
+ title?: string;
13
+ description?: string;
14
+ }
15
+ export declare function RegisterPage({ onSuccess, loginPath, title, description, }: RegisterPageProps): import("react").JSX.Element;
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { z } from 'zod';
4
+ import { AuthShell } from '../../layouts/AuthShell';
5
+ import { FormField } from '../../components/FormField';
6
+ import { Button } from '../../components/ui/Button';
7
+ import { Input } from '../../components/ui/Input';
8
+ import { FieldDescription, FieldSeparator } from '../../components/ui/field';
9
+ import { useApi } from '../../hooks/useApi';
10
+ import { useAuth } from '../../hooks/useAuth';
11
+ import { useSiteConfig } from '../../hooks/useSiteConfig';
12
+ import { useZodForm } from '../../hooks/useZodForm';
13
+ import { apiErrorMessage } from '../../lib/api-error';
14
+ import { toast } from '../../components/toast';
15
+ function passwordKinds(pw) {
16
+ let kinds = 0;
17
+ if (/[0-9]/.test(pw))
18
+ kinds++;
19
+ if (/[a-z]/.test(pw))
20
+ kinds++;
21
+ if (/[A-Z]/.test(pw))
22
+ kinds++;
23
+ if (/[^0-9a-zA-Z]/.test(pw))
24
+ kinds++;
25
+ return kinds;
26
+ }
27
+ export const passwordSchema = z
28
+ .string()
29
+ .min(10, '密码至少 10 位')
30
+ .refine((pw) => new TextEncoder().encode(pw).length <= 128, '密码过长(按字节计最多 128,一个汉字占 3 字节)')
31
+ .refine((pw) => passwordKinds(pw) >= 3, '需包含大写/小写/数字/符号中的至少三类');
32
+ export const registerSchema = z.object({
33
+ email: z.string().min(1, '请输入邮箱').max(254, '邮箱过长').email('邮箱格式不正确'),
34
+ password: passwordSchema,
35
+ display_name: z.string().max(100, '昵称最多 100 字符').optional(),
36
+ });
37
+ export function RegisterPage({ onSuccess, loginPath = '/login', title = '创建账号', description = '填写以下信息开始使用', }) {
38
+ const api = useApi();
39
+ const { refresh } = useAuth();
40
+ const { data: siteCfg } = useSiteConfig();
41
+ const inviteOnly = siteCfg?.allow_signup === '0';
42
+ const [submitting, setSubmitting] = useState(false);
43
+ const [serverError, setServerError] = useState(null);
44
+ const form = useZodForm({
45
+ schema: registerSchema,
46
+ defaultValues: { email: '', password: '', display_name: '' },
47
+ });
48
+ const handleSubmit = form.handleSubmit(async (values) => {
49
+ setServerError(null);
50
+ setSubmitting(true);
51
+ try {
52
+ const body = {
53
+ email: values.email,
54
+ password: values.password,
55
+ };
56
+ if (values.display_name?.trim())
57
+ body.display_name = values.display_name.trim();
58
+ await api.post('/auth/register', body);
59
+ await refresh();
60
+ toast.success('注册成功');
61
+ if (onSuccess)
62
+ onSuccess();
63
+ else
64
+ window.location.href = '/app';
65
+ }
66
+ catch (e) {
67
+ const msg = apiErrorMessage(e, '注册失败,请稍后重试');
68
+ setServerError(msg);
69
+ toast.error(msg);
70
+ }
71
+ finally {
72
+ setSubmitting(false);
73
+ }
74
+ });
75
+ const pw = form.watch('password') ?? '';
76
+ const kinds = passwordKinds(pw);
77
+ return (_jsxs(_Fragment, { children: [inviteOnly && (_jsx("div", { className: "fixed inset-x-0 top-0 z-50 border-b border-primary/25 bg-primary/10 px-4 py-2 text-center text-sm text-primary", children: "\u5F53\u524D\u4E3A\u9080\u8BF7\u5236\u6CE8\u518C\uFF1A\u4EC5\u767D\u540D\u5355\u90AE\u7BB1\u53EF\u5B8C\u6210\u6CE8\u518C\uFF08\u6700\u7EC8\u4EE5\u670D\u52A1\u7AEF\u6821\u9A8C\u4E3A\u51C6\uFF09" })), _jsx(AuthShell, { footer: _jsx("span", { children: "\u6CE8\u518C\u5373\u4EE3\u8868\u540C\u610F\u6211\u4EEC\u7684\u670D\u52A1\u6761\u6B3E\u4E0E\u9690\u79C1\u653F\u7B56" }), children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-6", children: [_jsxs("div", { className: "flex flex-col items-center gap-1 text-center", children: [_jsx("h1", { className: "text-2xl font-bold", children: title }), _jsx("p", { className: "text-sm text-balance text-muted-foreground", children: description })] }), _jsx(FormField, { label: "\u90AE\u7BB1", error: form.formState.errors.email?.message, htmlFor: "reg-email", children: _jsx(Input, { id: "reg-email", type: "email", placeholder: "you@example.com", autoComplete: "email", ...form.register('email') }) }), _jsxs(FormField, { label: "\u5BC6\u7801", error: form.formState.errors.password?.message, htmlFor: "reg-password", children: [_jsx(Input, { id: "reg-password", type: "password", placeholder: "\u81F3\u5C11 10 \u4F4D\uFF0C\u542B\u4E09\u7C7B\u5B57\u7B26", autoComplete: "new-password", ...form.register('password') }), pw.length > 0 && (_jsxs(FieldDescription, { children: ["\u5B57\u7B26\u7C7B\u522B\uFF1A\u5927\u5199 ", /[A-Z]/.test(pw) ? '✓' : '—', " / \u5C0F\u5199", ' ', /[a-z]/.test(pw) ? '✓' : '—', " / \u6570\u5B57", ' ', /[0-9]/.test(pw) ? '✓' : '—', " / \u7B26\u53F7", ' ', /[^0-9a-zA-Z]/.test(pw) ? '✓' : '—', "\uFF08\u5DF2\u6EE1\u8DB3 ", kinds, "/3 \u7C7B\uFF09"] }))] }), _jsx(FormField, { label: "\u6635\u79F0\uFF08\u53EF\u9009\uFF09", error: form.formState.errors.display_name?.message, htmlFor: "reg-name", children: _jsx(Input, { id: "reg-name", type: "text", maxLength: 100, placeholder: "\u600E\u4E48\u79F0\u547C\u4F60\uFF1F", ...form.register('display_name') }) }), serverError && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: serverError })), _jsx(Button, { type: "submit", disabled: submitting, children: submitting ? '注册中…' : '创建账号' }), _jsx(FieldSeparator, { children: "\u5DF2\u6709\u8D26\u53F7\uFF1F" }), _jsx(Button, { type: "button", variant: "outline", onClick: () => {
78
+ window.location.href = loginPath;
79
+ }, children: "\u767B\u5F55" })] }) })] }));
80
+ }
@@ -0,0 +1,4 @@
1
+ export interface ResetPasswordPageProps {
2
+ onDone?: string;
3
+ }
4
+ export declare function ResetPasswordPage({ onDone }: ResetPasswordPageProps): import("react").JSX.Element;