@ploykit/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/dist/components/ConfirmDialog.d.ts +13 -0
- package/dist/components/ConfirmDialog.js +29 -0
- package/dist/components/CronInput.d.ts +15 -0
- package/dist/components/CronInput.js +112 -0
- package/dist/components/DataTable.d.ts +22 -0
- package/dist/components/DataTable.js +13 -0
- package/dist/components/ErrorBoundary.d.ts +16 -0
- package/dist/components/ErrorBoundary.js +24 -0
- package/dist/components/FormField.d.ts +10 -0
- package/dist/components/FormField.js +22 -0
- package/dist/components/ImpersonationBanner.d.ts +3 -0
- package/dist/components/ImpersonationBanner.js +10 -0
- package/dist/components/NotificationBell.d.ts +3 -0
- package/dist/components/NotificationBell.js +47 -0
- package/dist/components/OnboardingChecklist.d.ts +12 -0
- package/dist/components/OnboardingChecklist.js +43 -0
- package/dist/components/Page.d.ts +17 -0
- package/dist/components/Page.js +16 -0
- package/dist/components/SecretModal.d.ts +16 -0
- package/dist/components/SecretModal.js +45 -0
- package/dist/components/SettingsCard.d.ts +10 -0
- package/dist/components/SettingsCard.js +6 -0
- package/dist/components/SiteBanner.d.ts +6 -0
- package/dist/components/SiteBanner.js +11 -0
- package/dist/components/SiteBannerLayer.d.ts +4 -0
- package/dist/components/SiteBannerLayer.js +9 -0
- package/dist/components/UserMenu.d.ts +13 -0
- package/dist/components/UserMenu.js +30 -0
- package/dist/components/WorkspaceSwitcher.d.ts +1 -0
- package/dist/components/WorkspaceSwitcher.js +53 -0
- package/dist/components/toast.d.ts +7 -0
- package/dist/components/toast.js +11 -0
- package/dist/components/ui/Badge.d.ts +13 -0
- package/dist/components/ui/Badge.js +21 -0
- package/dist/components/ui/Button.d.ts +10 -0
- package/dist/components/ui/Button.js +33 -0
- package/dist/components/ui/Input.d.ts +3 -0
- package/dist/components/ui/Input.js +10 -0
- package/dist/components/ui/alert-dialog.d.ts +18 -0
- package/dist/components/ui/alert-dialog.js +41 -0
- package/dist/components/ui/avatar.d.ts +11 -0
- package/dist/components/ui/avatar.js +22 -0
- package/dist/components/ui/breadcrumb.d.ts +10 -0
- package/dist/components/ui/breadcrumb.js +36 -0
- package/dist/components/ui/card.d.ts +13 -0
- package/dist/components/ui/card.js +24 -0
- package/dist/components/ui/checkbox.d.ts +3 -0
- package/dist/components/ui/checkbox.js +8 -0
- package/dist/components/ui/dialog.d.ts +15 -0
- package/dist/components/ui/dialog.js +26 -0
- package/dist/components/ui/empty.d.ts +13 -0
- package/dist/components/ui/empty.js +36 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +6 -0
- package/dist/components/ui/menu.d.ts +29 -0
- package/dist/components/ui/menu.js +50 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +22 -0
- package/dist/components/ui/progress.d.ts +7 -0
- package/dist/components/ui/progress.js +19 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +33 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +7 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +36 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +265 -0
- package/dist/components/ui/skeleton.d.ts +3 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/switch.d.ts +5 -0
- package/dist/components/ui/switch.js +7 -0
- package/dist/components/ui/tabs.d.ts +10 -0
- package/dist/components/ui/tabs.js +28 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/hooks/useApi.d.ts +10 -0
- package/dist/hooks/useApi.js +11 -0
- package/dist/hooks/useAudit.d.ts +39 -0
- package/dist/hooks/useAudit.js +55 -0
- package/dist/hooks/useAuth.d.ts +1 -0
- package/dist/hooks/useAuth.js +4 -0
- package/dist/hooks/useBilling.d.ts +44 -0
- package/dist/hooks/useBilling.js +64 -0
- package/dist/hooks/useLogin.d.ts +10 -0
- package/dist/hooks/useLogin.js +39 -0
- package/dist/hooks/useMembers.d.ts +125 -0
- package/dist/hooks/useMembers.js +127 -0
- package/dist/hooks/useNotificationPrefs.d.ts +20 -0
- package/dist/hooks/useNotificationPrefs.js +42 -0
- package/dist/hooks/usePasswordReset.d.ts +5 -0
- package/dist/hooks/usePasswordReset.js +12 -0
- package/dist/hooks/useSEO.d.ts +6 -0
- package/dist/hooks/useSEO.js +14 -0
- package/dist/hooks/useSessions.d.ts +20 -0
- package/dist/hooks/useSessions.js +38 -0
- package/dist/hooks/useSiteConfig.d.ts +12 -0
- package/dist/hooks/useSiteConfig.js +11 -0
- package/dist/hooks/useTokens.d.ts +36 -0
- package/dist/hooks/useTokens.js +35 -0
- package/dist/hooks/useUsage.d.ts +20 -0
- package/dist/hooks/useUsage.js +21 -0
- package/dist/hooks/useWorkspace.d.ts +26 -0
- package/dist/hooks/useWorkspace.js +32 -0
- package/dist/hooks/useZodForm.d.ts +6 -0
- package/dist/hooks/useZodForm.js +6 -0
- package/dist/hooks/ws.d.ts +4 -0
- package/dist/hooks/ws.js +21 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +94 -0
- package/dist/layouts/AppShell.d.ts +20 -0
- package/dist/layouts/AppShell.js +21 -0
- package/dist/layouts/AuthShell.d.ts +7 -0
- package/dist/layouts/AuthShell.js +4 -0
- package/dist/layouts/SettingsShell.d.ts +19 -0
- package/dist/layouts/SettingsShell.js +21 -0
- package/dist/lib/api-error.d.ts +1 -0
- package/dist/lib/api-error.js +74 -0
- package/dist/lib/perm.d.ts +2 -0
- package/dist/lib/perm.js +7 -0
- package/dist/lib/utils.d.ts +12 -0
- package/dist/lib/utils.js +84 -0
- package/dist/lib/ws-query-bridge.d.ts +7 -0
- package/dist/lib/ws-query-bridge.js +35 -0
- package/dist/pages/LandingPage.d.ts +22 -0
- package/dist/pages/LandingPage.js +5 -0
- package/dist/pages/LoginPage.d.ts +15 -0
- package/dist/pages/LoginPage.js +87 -0
- package/dist/pages/account/NotificationPreferencesPage.d.ts +13 -0
- package/dist/pages/account/NotificationPreferencesPage.js +41 -0
- package/dist/pages/account/ProfileSettingsPage.d.ts +4 -0
- package/dist/pages/account/ProfileSettingsPage.js +96 -0
- package/dist/pages/account/SecuritySettingsPage.d.ts +1 -0
- package/dist/pages/account/SecuritySettingsPage.js +134 -0
- package/dist/pages/account/TokensPage.d.ts +1 -0
- package/dist/pages/account/TokensPage.js +117 -0
- package/dist/pages/admin/AdminSettingsPage.d.ts +1 -0
- package/dist/pages/admin/AdminSettingsPage.js +210 -0
- package/dist/pages/admin/AdminShell.d.ts +7 -0
- package/dist/pages/admin/AdminShell.js +79 -0
- package/dist/pages/admin/AuditPage.d.ts +1 -0
- package/dist/pages/admin/AuditPage.js +164 -0
- package/dist/pages/admin/OrdersPage.d.ts +1 -0
- package/dist/pages/admin/OrdersPage.js +236 -0
- package/dist/pages/admin/OverviewPage.d.ts +1 -0
- package/dist/pages/admin/OverviewPage.js +200 -0
- package/dist/pages/admin/SSOPage.d.ts +1 -0
- package/dist/pages/admin/SSOPage.js +53 -0
- package/dist/pages/admin/UserDetailPage.d.ts +1 -0
- package/dist/pages/admin/UserDetailPage.js +266 -0
- package/dist/pages/admin/UsersPage.d.ts +4 -0
- package/dist/pages/admin/UsersPage.js +201 -0
- package/dist/pages/admin/WebhookDeliveriesPage.d.ts +1 -0
- package/dist/pages/admin/WebhookDeliveriesPage.js +128 -0
- package/dist/pages/admin/WorkspaceDetailPage.d.ts +1 -0
- package/dist/pages/admin/WorkspaceDetailPage.js +202 -0
- package/dist/pages/admin/WorkspacesPage.d.ts +1 -0
- package/dist/pages/admin/WorkspacesPage.js +133 -0
- package/dist/pages/admin/shared.d.ts +8 -0
- package/dist/pages/admin/shared.js +23 -0
- package/dist/pages/auth/ForgotPasswordPage.d.ts +6 -0
- package/dist/pages/auth/ForgotPasswordPage.js +32 -0
- package/dist/pages/auth/InviteAcceptPage.d.ts +5 -0
- package/dist/pages/auth/InviteAcceptPage.js +40 -0
- package/dist/pages/auth/RegisterPage.d.ts +15 -0
- package/dist/pages/auth/RegisterPage.js +80 -0
- package/dist/pages/auth/ResetPasswordPage.d.ts +4 -0
- package/dist/pages/auth/ResetPasswordPage.js +71 -0
- package/dist/pages/auth/VerifyEmailPage.d.ts +1 -0
- package/dist/pages/auth/VerifyEmailPage.js +45 -0
- package/dist/pages/workspace/BillingPage.d.ts +4 -0
- package/dist/pages/workspace/BillingPage.js +183 -0
- package/dist/pages/workspace/BillingSuccessPage.d.ts +4 -0
- package/dist/pages/workspace/BillingSuccessPage.js +72 -0
- package/dist/pages/workspace/MembersPage.d.ts +3 -0
- package/dist/pages/workspace/MembersPage.js +262 -0
- package/dist/pages/workspace/SchedulesPage.d.ts +4 -0
- package/dist/pages/workspace/SchedulesPage.js +177 -0
- package/dist/pages/workspace/UsagePage.d.ts +15 -0
- package/dist/pages/workspace/UsagePage.js +74 -0
- package/dist/pages/workspace/WebhooksPage.d.ts +3 -0
- package/dist/pages/workspace/WebhooksPage.js +242 -0
- package/dist/pages/workspace/WorkspaceAuditPage.d.ts +3 -0
- package/dist/pages/workspace/WorkspaceAuditPage.js +109 -0
- package/dist/pages/workspace/WorkspaceGeneralPage.d.ts +4 -0
- package/dist/pages/workspace/WorkspaceGeneralPage.js +101 -0
- package/dist/provider/PloykitProvider.d.ts +50 -0
- package/dist/provider/PloykitProvider.js +240 -0
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.js +11 -0
- package/package.json +61 -4
|
@@ -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,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,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
|
+
}
|