@ploykit/ui 0.5.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/AppProviders.js +2 -2
  2. package/dist/components/ConfirmDialog.js +1 -1
  3. package/dist/components/CronInput.js +3 -3
  4. package/dist/components/DataTable.js +4 -4
  5. package/dist/components/FormField.js +1 -1
  6. package/dist/components/ImpersonationBanner.js +1 -1
  7. package/dist/components/NotificationBell.js +1 -1
  8. package/dist/components/OnboardingChecklist.js +3 -3
  9. package/dist/components/Page.js +2 -2
  10. package/dist/components/ReauthDialog.js +4 -4
  11. package/dist/components/SecretModal.js +2 -2
  12. package/dist/components/SettingsCard.js +2 -2
  13. package/dist/components/SiteBanner.js +1 -1
  14. package/dist/components/SiteBannerLayer.js +1 -1
  15. package/dist/components/StatusBadge.js +1 -1
  16. package/dist/components/UserMenu.js +3 -3
  17. package/dist/components/WorkspaceSwitcher.js +7 -7
  18. package/dist/components/ui/alert-dialog.js +2 -2
  19. package/dist/components/ui/avatar.js +1 -1
  20. package/dist/components/ui/badge.js +1 -1
  21. package/dist/components/ui/breadcrumb.js +1 -1
  22. package/dist/components/ui/button.js +1 -1
  23. package/dist/components/ui/card.js +1 -1
  24. package/dist/components/ui/checkbox.js +1 -1
  25. package/dist/components/ui/dialog.js +2 -2
  26. package/dist/components/ui/dropdown-menu.js +1 -1
  27. package/dist/components/ui/empty.js +1 -1
  28. package/dist/components/ui/field.js +3 -3
  29. package/dist/components/ui/input.js +1 -1
  30. package/dist/components/ui/label.js +1 -1
  31. package/dist/components/ui/popover.js +1 -1
  32. package/dist/components/ui/progress.js +1 -1
  33. package/dist/components/ui/select.js +1 -1
  34. package/dist/components/ui/separator.js +1 -1
  35. package/dist/components/ui/sheet.js +2 -2
  36. package/dist/components/ui/sidebar.js +8 -8
  37. package/dist/components/ui/skeleton.js +1 -1
  38. package/dist/components/ui/switch.js +1 -1
  39. package/dist/components/ui/tabs.js +1 -1
  40. package/dist/components/ui/textarea.js +1 -1
  41. package/dist/components/ui/tooltip.js +1 -1
  42. package/dist/index.js +78 -78
  43. package/dist/layouts/AppShell.js +4 -4
  44. package/dist/layouts/SettingsShell.js +4 -4
  45. package/dist/pages/LoginPage.js +5 -5
  46. package/dist/pages/account/NotificationPreferencesPage.js +3 -3
  47. package/dist/pages/account/ProfileSettingsPage.js +9 -9
  48. package/dist/pages/account/SecuritySettingsPage.js +13 -13
  49. package/dist/pages/account/TokensPage.js +12 -12
  50. package/dist/pages/admin/AdminSettingsPage.js +11 -11
  51. package/dist/pages/admin/AdminShell.js +3 -3
  52. package/dist/pages/admin/AuditPage.js +8 -8
  53. package/dist/pages/admin/OrdersPage.js +12 -12
  54. package/dist/pages/admin/OverviewPage.js +13 -13
  55. package/dist/pages/admin/SSOPage.js +6 -6
  56. package/dist/pages/admin/UserDetailPage.js +13 -13
  57. package/dist/pages/admin/UsersPage.js +13 -13
  58. package/dist/pages/admin/WebhookDeliveriesPage.js +9 -9
  59. package/dist/pages/admin/WorkspaceDetailPage.js +14 -14
  60. package/dist/pages/admin/WorkspacesPage.js +9 -9
  61. package/dist/pages/admin/shared.js +1 -1
  62. package/dist/pages/auth/ForgotPasswordPage.js +4 -4
  63. package/dist/pages/auth/InviteAcceptPage.js +6 -6
  64. package/dist/pages/auth/RegisterPage.js +7 -7
  65. package/dist/pages/auth/ResetPasswordPage.js +5 -5
  66. package/dist/pages/auth/VerifyEmailPage.js +3 -3
  67. package/dist/pages/workspace/BillingPage.js +11 -11
  68. package/dist/pages/workspace/BillingSuccessPage.js +2 -2
  69. package/dist/pages/workspace/MembersPage.js +15 -15
  70. package/dist/pages/workspace/RolePermissionsPage.js +9 -9
  71. package/dist/pages/workspace/SchedulesPage.js +12 -12
  72. package/dist/pages/workspace/UsagePage.js +6 -6
  73. package/dist/pages/workspace/WebhooksPage.js +16 -16
  74. package/dist/pages/workspace/WorkspaceAuditPage.js +7 -7
  75. package/dist/pages/workspace/WorkspaceGeneralPage.js +9 -9
  76. package/package.json +6 -5
  77. package/src/AppProviders.tsx +29 -0
  78. package/src/components/ConfirmDialog.tsx +77 -0
  79. package/src/components/CronInput.tsx +195 -0
  80. package/src/components/DataTable.tsx +152 -0
  81. package/src/components/ErrorBoundary.tsx +46 -0
  82. package/src/components/FormField.tsx +56 -0
  83. package/src/components/ImpersonationBanner.tsx +23 -0
  84. package/src/components/NotificationBell.tsx +135 -0
  85. package/src/components/OnboardingChecklist.tsx +114 -0
  86. package/src/components/Page.tsx +67 -0
  87. package/src/components/ReauthDialog.tsx +94 -0
  88. package/src/components/SecretModal.tsx +147 -0
  89. package/src/components/SettingsCard.tsx +39 -0
  90. package/src/components/SiteBanner.tsx +32 -0
  91. package/src/components/SiteBannerLayer.tsx +29 -0
  92. package/src/components/StatusBadge.tsx +12 -0
  93. package/src/components/UserMenu.tsx +103 -0
  94. package/src/components/WorkspaceSwitcher.tsx +159 -0
  95. package/src/components/__tests__/AppProviders.test.tsx +111 -0
  96. package/src/components/__tests__/CronInput.test.tsx +180 -0
  97. package/src/components/__tests__/DataTable.test.tsx +208 -0
  98. package/src/components/__tests__/ErrorBoundary.test.tsx +61 -0
  99. package/src/components/__tests__/ImpersonationBanner.test.tsx +59 -0
  100. package/src/components/__tests__/NotificationBell.test.tsx +207 -0
  101. package/src/components/__tests__/OnboardingChecklist.test.tsx +97 -0
  102. package/src/components/__tests__/SecretModal.test.tsx +112 -0
  103. package/src/components/__tests__/SiteBannerLayer.test.tsx +44 -0
  104. package/src/components/__tests__/UserMenu.test.tsx +98 -0
  105. package/src/components/__tests__/WorkspaceSwitcher.test.tsx +162 -0
  106. package/src/components/__tests__/form.test.tsx +133 -0
  107. package/src/components/__tests__/semantic-tokens.test.ts +41 -0
  108. package/src/components/__tests__/smoke.test.tsx +11 -0
  109. package/src/components/__tests__/toast.test.tsx +27 -0
  110. package/src/components/toast.tsx +23 -0
  111. package/src/components/ui/__tests__/alert-dialog.test.tsx +66 -0
  112. package/src/components/ui/__tests__/avatar.test.tsx +30 -0
  113. package/src/components/ui/__tests__/checkbox.test.tsx +18 -0
  114. package/src/components/ui/__tests__/dropdown-menu.test.tsx +33 -0
  115. package/src/components/ui/__tests__/field.test.tsx +43 -0
  116. package/src/components/ui/__tests__/overlay-size.test.tsx +84 -0
  117. package/src/components/ui/__tests__/popover.test.tsx +32 -0
  118. package/src/components/ui/__tests__/progress.test.tsx +24 -0
  119. package/src/components/ui/__tests__/select-callback.test.tsx +72 -0
  120. package/src/components/ui/__tests__/select.test.tsx +48 -0
  121. package/src/components/ui/__tests__/sidebar.test.tsx +121 -0
  122. package/src/components/ui/__tests__/stock-mirror.test.ts +39 -0
  123. package/src/components/ui/__tests__/subpath.test.tsx +38 -0
  124. package/src/components/ui/__tests__/switch.test.tsx +24 -0
  125. package/src/components/ui/__tests__/tabs.test.tsx +24 -0
  126. package/src/components/ui/__tests__/textarea.test.tsx +41 -0
  127. package/src/components/ui/alert-dialog.tsx +187 -0
  128. package/src/components/ui/avatar.tsx +108 -0
  129. package/src/components/ui/badge.tsx +51 -0
  130. package/src/components/ui/breadcrumb.tsx +124 -0
  131. package/src/components/ui/button.tsx +57 -0
  132. package/src/components/ui/card.tsx +102 -0
  133. package/src/components/ui/checkbox.tsx +28 -0
  134. package/src/components/ui/dialog.tsx +166 -0
  135. package/src/components/ui/dropdown-menu.tsx +265 -0
  136. package/src/components/ui/empty.tsx +103 -0
  137. package/src/components/ui/field.tsx +238 -0
  138. package/src/components/ui/input.tsx +19 -0
  139. package/src/components/ui/label.tsx +17 -0
  140. package/src/components/ui/popover.tsx +89 -0
  141. package/src/components/ui/progress.tsx +82 -0
  142. package/src/components/ui/select.tsx +235 -0
  143. package/src/components/ui/separator.tsx +24 -0
  144. package/src/components/ui/sheet.tsx +146 -0
  145. package/src/components/ui/sidebar.tsx +723 -0
  146. package/src/components/ui/skeleton.tsx +13 -0
  147. package/src/components/ui/switch.tsx +29 -0
  148. package/src/components/ui/tabs.tsx +79 -0
  149. package/src/components/ui/textarea.tsx +26 -0
  150. package/src/components/ui/tooltip.tsx +63 -0
  151. package/src/dev.css +1 -0
  152. package/src/hooks/use-mobile.ts +19 -0
  153. package/src/index.ts +150 -0
  154. package/src/layouts/AppShell.tsx +163 -0
  155. package/src/layouts/AuthShell.tsx +24 -0
  156. package/src/layouts/SettingsShell.tsx +129 -0
  157. package/src/layouts/__tests__/AppShell.test.tsx +163 -0
  158. package/src/layouts/__tests__/SettingsShell.test.tsx +146 -0
  159. package/src/lib/__tests__/api-error.test.ts +163 -0
  160. package/src/lib/__tests__/utils.test.ts +21 -0
  161. package/src/lib/api-error.ts +98 -0
  162. package/src/lib/utils.ts +109 -0
  163. package/src/pages/LandingPage.tsx +88 -0
  164. package/src/pages/LoginPage.tsx +291 -0
  165. package/src/pages/__tests__/LandingPage.test.tsx +58 -0
  166. package/src/pages/__tests__/LoginPage.test.tsx +229 -0
  167. package/src/pages/__tests__/RegisterPage.test.tsx +138 -0
  168. package/src/pages/__tests__/ssr-smoke.test.tsx +99 -0
  169. package/src/pages/account/NotificationPreferencesPage.tsx +138 -0
  170. package/src/pages/account/ProfileSettingsPage.tsx +189 -0
  171. package/src/pages/account/SecuritySettingsPage.tsx +214 -0
  172. package/src/pages/account/TokensPage.tsx +211 -0
  173. package/src/pages/account/__tests__/NotificationPreferencesPage.test.tsx +150 -0
  174. package/src/pages/account/__tests__/ProfileSettingsPage.test.tsx +173 -0
  175. package/src/pages/account/__tests__/SecuritySettingsPage.test.tsx +212 -0
  176. package/src/pages/account/__tests__/TokensPage.test.tsx +180 -0
  177. package/src/pages/admin/AdminSettingsPage.tsx +564 -0
  178. package/src/pages/admin/AdminShell.tsx +199 -0
  179. package/src/pages/admin/AuditPage.tsx +338 -0
  180. package/src/pages/admin/OrdersPage.tsx +642 -0
  181. package/src/pages/admin/OverviewPage.tsx +559 -0
  182. package/src/pages/admin/SSOPage.tsx +166 -0
  183. package/src/pages/admin/UserDetailPage.tsx +641 -0
  184. package/src/pages/admin/UsersPage.tsx +462 -0
  185. package/src/pages/admin/WebhookDeliveriesPage.tsx +318 -0
  186. package/src/pages/admin/WorkspaceDetailPage.tsx +551 -0
  187. package/src/pages/admin/WorkspacesPage.tsx +299 -0
  188. package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +260 -0
  189. package/src/pages/admin/__tests__/OrdersPageMarkPaid.test.tsx +207 -0
  190. package/src/pages/admin/__tests__/OverviewPageActivity.test.tsx +213 -0
  191. package/src/pages/admin/__tests__/SSOPage.test.tsx +128 -0
  192. package/src/pages/admin/__tests__/UsersPageActions.test.tsx +188 -0
  193. package/src/pages/admin/__tests__/WorkspaceDetailPageTransfer.test.tsx +170 -0
  194. package/src/pages/admin/__tests__/WorkspacesPagePlans.test.tsx +138 -0
  195. package/src/pages/admin/shared.ts +42 -0
  196. package/src/pages/auth/ForgotPasswordPage.tsx +83 -0
  197. package/src/pages/auth/InviteAcceptPage.tsx +105 -0
  198. package/src/pages/auth/RegisterPage.tsx +204 -0
  199. package/src/pages/auth/ResetPasswordPage.tsx +150 -0
  200. package/src/pages/auth/VerifyEmailPage.tsx +76 -0
  201. package/src/pages/auth/__tests__/ForgotPasswordPage.test.tsx +67 -0
  202. package/src/pages/auth/__tests__/InviteAcceptPage.test.tsx +86 -0
  203. package/src/pages/auth/__tests__/ResetPasswordPage.test.tsx +115 -0
  204. package/src/pages/auth/__tests__/VerifyEmailPage.test.tsx +74 -0
  205. package/src/pages/workspace/BillingPage.tsx +361 -0
  206. package/src/pages/workspace/BillingSuccessPage.tsx +115 -0
  207. package/src/pages/workspace/MembersPage.tsx +568 -0
  208. package/src/pages/workspace/RolePermissionsPage.tsx +202 -0
  209. package/src/pages/workspace/SchedulesPage.tsx +387 -0
  210. package/src/pages/workspace/UsagePage.tsx +207 -0
  211. package/src/pages/workspace/WebhooksPage.tsx +506 -0
  212. package/src/pages/workspace/WorkspaceAuditPage.tsx +229 -0
  213. package/src/pages/workspace/WorkspaceGeneralPage.tsx +184 -0
  214. package/src/pages/workspace/__tests__/BillingPage.test.tsx +302 -0
  215. package/src/pages/workspace/__tests__/BillingSuccessPage.test.tsx +134 -0
  216. package/src/pages/workspace/__tests__/MembersPage.test.tsx +347 -0
  217. package/src/pages/workspace/__tests__/RolePermissionsPage.test.tsx +138 -0
  218. package/src/pages/workspace/__tests__/SchedulesPage.test.tsx +239 -0
  219. package/src/pages/workspace/__tests__/UsagePage.test.tsx +223 -0
  220. package/src/pages/workspace/__tests__/WebhooksPage.test.tsx +309 -0
  221. package/src/pages/workspace/__tests__/WorkspaceAuditPage.test.tsx +112 -0
  222. package/src/pages/workspace/__tests__/WorkspaceGeneralPage.test.tsx +164 -0
@@ -0,0 +1,564 @@
1
+
2
+ import { useEffect, useState } from 'react';
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { FormField } from '../../components/FormField';
5
+ import { PageError, PageHeader } from '../../components/Page';
6
+ import { toast } from '../../components/toast';
7
+ import { Badge } from '../../components/ui/badge';
8
+ import { Button } from '../../components/ui/button';
9
+ import {
10
+ Card,
11
+ CardAction,
12
+ CardContent,
13
+ CardDescription,
14
+ CardHeader,
15
+ CardTitle,
16
+ } from '../../components/ui/card';
17
+ import { Input } from '../../components/ui/input';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '../../components/ui/select';
25
+ import { Switch } from '../../components/ui/switch';
26
+ import { useApi } from '@ploykit/hooks';
27
+ import { SiteBanner } from '../../components/SiteBanner';
28
+ import { SITE_CONFIG_QUERY_KEY } from '@ploykit/hooks';
29
+ import { adminError } from './shared';
30
+
31
+
32
+ interface AdminSettingsEnvelope {
33
+
34
+ items: Record<string, string>;
35
+
36
+ effective: Record<string, string>;
37
+ defaults: Record<string, string>;
38
+ descriptions: Record<string, string>;
39
+ }
40
+
41
+
42
+ interface TestEmailResult {
43
+ mode?: string;
44
+ hint?: string;
45
+ status?: string;
46
+ }
47
+
48
+
49
+ interface KeySaveResult {
50
+ key: string;
51
+ label: string;
52
+ ok: boolean;
53
+ err?: unknown;
54
+ }
55
+
56
+
57
+ const ERROR_TOAST_MS = 8_000;
58
+
59
+
60
+ function parseRateLimit(
61
+ raw: string,
62
+ ): { ok: true; v: string } | { ok: false; msg: string } {
63
+ const s = raw.trim();
64
+ if (s === '') {
65
+ return { ok: false, msg: '不能留空:填写 0 表示关闭限流' };
66
+ }
67
+ const n = Number(s);
68
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 100000) {
69
+ return { ok: false, msg: '必须是 0..100000 的整数(0 = 关闭限流)' };
70
+ }
71
+ return { ok: true, v: s };
72
+ }
73
+
74
+
75
+ async function putKeys(
76
+ api: { put: (url: string, body: unknown) => Promise<unknown> },
77
+ entries: { key: string; label: string; value: string }[],
78
+ ): Promise<KeySaveResult[]> {
79
+ const results: KeySaveResult[] = [];
80
+ for (const e of entries) {
81
+ try {
82
+ await api.put(`/api/admin/settings/${e.key}`, { value: e.value });
83
+ results.push({ key: e.key, label: e.label, ok: true });
84
+ } catch (err) {
85
+ results.push({ key: e.key, label: e.label, ok: false, err });
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+
91
+ export function AdminSettingsPage() {
92
+ const api = useApi();
93
+ const qc = useQueryClient();
94
+
95
+ const settingsQ = useQuery({
96
+ queryKey: ['admin', 'settings'],
97
+ queryFn: () => api.get<AdminSettingsEnvelope>('/api/admin/settings'),
98
+ });
99
+
100
+
101
+ const [bannerText, setBannerText] = useState('');
102
+ const [bannerKind, setBannerKind] = useState<'info' | 'warn'>('info');
103
+ const [maintenance, setMaintenance] = useState(false);
104
+ const [allowSignup, setAllowSignup] = useState(true);
105
+ const [domains, setDomains] = useState('');
106
+
107
+ const [rlPerUser, setRlPerUser] = useState('0');
108
+ const [rlPerIP, setRlPerIP] = useState('30');
109
+ const [rlAllowlist, setRlAllowlist] = useState('');
110
+ const [rlError, setRlError] = useState<string | null>(null);
111
+ const [hydrated, setHydrated] = useState(false);
112
+
113
+ const eff = settingsQ.data?.effective;
114
+ useEffect(() => {
115
+ if (!eff || hydrated) return;
116
+ setBannerText(eff.banner_text ?? '');
117
+ setBannerKind(eff.banner_kind === 'warn' ? 'warn' : 'info');
118
+ setMaintenance(eff.maintenance_mode === '1');
119
+ setAllowSignup(eff.allow_signup !== '0');
120
+ setDomains(eff.allowed_domains ?? '');
121
+ setRlPerUser(eff.rate_limit_per_user_per_min ?? '0');
122
+ setRlPerIP(eff.rate_limit_per_ip_per_min ?? '30');
123
+ setRlAllowlist(eff.rate_limit_allowlist ?? '');
124
+ setHydrated(true);
125
+ }, [eff, hydrated]);
126
+
127
+ const afterSave = () => {
128
+ qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
129
+
130
+ qc.invalidateQueries({ queryKey: SITE_CONFIG_QUERY_KEY });
131
+ };
132
+
133
+
134
+
135
+ const rollbackToServer = async () => {
136
+ await qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
137
+ setHydrated(false);
138
+ };
139
+
140
+
141
+ const reportKeySaves = (
142
+ results: KeySaveResult[],
143
+ successMsg: string,
144
+ what: string,
145
+ ) => {
146
+ const okCount = results.filter((r) => r.ok).length;
147
+ const failed = results.filter((r) => !r.ok);
148
+ afterSave();
149
+ if (failed.length === 0) {
150
+ toast.success(successMsg);
151
+ return;
152
+ }
153
+ const failedLabel = failed.map((f) => f.label).join('、');
154
+ const reason = adminError(failed[0].err, '保存失败');
155
+ if (okCount > 0) {
156
+ toast.error(
157
+ `${what}部分保存:成功 ${okCount}/${results.length} 项已生效;失败项(${failedLabel})原因:${reason}`,
158
+ { duration: ERROR_TOAST_MS },
159
+ );
160
+ } else {
161
+ toast.error(`${what}保存失败(${failedLabel}):${reason}`, {
162
+ duration: ERROR_TOAST_MS,
163
+ });
164
+ }
165
+ void rollbackToServer();
166
+ };
167
+
168
+
169
+ const saveM = useMutation({
170
+ mutationFn: (p: { key: string; value: string; label: string }) =>
171
+ api.put(`/api/admin/settings/${p.key}`, { value: p.value }),
172
+ onSuccess: (_r, p) => {
173
+ toast.success(`${p.label}已保存`);
174
+ afterSave();
175
+ },
176
+ onError: (e, p) => {
177
+ toast.error(adminError(e, `${p.label}保存失败`), {
178
+ duration: ERROR_TOAST_MS,
179
+ });
180
+ void rollbackToServer();
181
+ },
182
+ });
183
+
184
+
185
+ const bannerM = useMutation({
186
+ mutationFn: () =>
187
+ putKeys(api, [
188
+ { key: 'banner_text', label: '公告文本', value: bannerText },
189
+ { key: 'banner_kind', label: '公告样式', value: bannerKind },
190
+ ]),
191
+ onSuccess: (results) =>
192
+ reportKeySaves(results, '公告已保存,全站顶条即时生效', '公告'),
193
+ });
194
+
195
+
196
+ const domainsM = useMutation({
197
+ mutationFn: () =>
198
+ api.put('/api/admin/settings/allowed_domains', { value: domains.trim() }),
199
+ onSuccess: () => {
200
+ toast.success('域名白名单已保存');
201
+ afterSave();
202
+ },
203
+ onError: (e) => {
204
+ toast.error(adminError(e, '域名白名单保存失败'), {
205
+ duration: ERROR_TOAST_MS,
206
+ });
207
+ void rollbackToServer();
208
+ },
209
+ });
210
+
211
+
212
+ const rateLimitM = useMutation({
213
+ mutationFn: async () => {
214
+ const perUser = parseRateLimit(rlPerUser);
215
+ const perIP = parseRateLimit(rlPerIP);
216
+ if (!perUser.ok) throw new Error(`每用户限流:${perUser.msg}`);
217
+ if (!perIP.ok) throw new Error(`每 IP 限流:${perIP.msg}`);
218
+ setRlError(null);
219
+ return putKeys(api, [
220
+ {
221
+ key: 'rate_limit_per_user_per_min',
222
+ label: '每用户限流',
223
+ value: perUser.v,
224
+ },
225
+ { key: 'rate_limit_per_ip_per_min', label: '每 IP 限流', value: perIP.v },
226
+ { key: 'rate_limit_allowlist', label: '豁免名单', value: rlAllowlist.trim() },
227
+ ]);
228
+ },
229
+ onSuccess: (results) =>
230
+ reportKeySaves(results, '限流配置已保存(重启后生效)', '限流配置'),
231
+ onError: (e) => {
232
+ const msg = e instanceof Error ? e.message : '限流配置保存失败';
233
+ setRlError(msg);
234
+ toast.error(msg, { duration: ERROR_TOAST_MS });
235
+ },
236
+ });
237
+
238
+ const [testTo, setTestTo] = useState('');
239
+ const [testMsg, setTestMsg] = useState<string | null>(null);
240
+ const testEmailM = useMutation({
241
+ mutationFn: () =>
242
+ api.post<TestEmailResult>('/api/admin/settings/test-email', {
243
+ to: testTo.trim(),
244
+ }),
245
+ onSuccess: (r) => {
246
+ setTestMsg(
247
+ r?.mode === 'dev'
248
+ ? `开发通道(emaildev):${r.hint ?? '邮件内容已打日志,请到后端日志查看'}`
249
+ : '测试邮件已发出,请到收件箱(含垃圾箱)确认',
250
+ );
251
+ },
252
+ onError: (e) => setTestMsg(`发送失败:${adminError(e, '未知错误')}`),
253
+ });
254
+
255
+ if (settingsQ.isPending) {
256
+ return <p className="text-sm text-muted-foreground">加载设置中…</p>;
257
+ }
258
+ if (settingsQ.isError) {
259
+ return (
260
+ <PageError
261
+ message={adminError(settingsQ.error, '设置加载失败')}
262
+ onRetry={() => void settingsQ.refetch()}
263
+ />
264
+ );
265
+ }
266
+ const desc = settingsQ.data?.descriptions ?? {};
267
+
268
+ return (
269
+ <div className="space-y-6">
270
+ <PageHeader
271
+ title="系统"
272
+
273
+ description="站点公告、维护模式、注册管控、API 限流与邮件通道——保存后全站即时生效(API 限流除外:重启后生效)"
274
+ />
275
+
276
+ {/* ① 站点公告 */}
277
+ <Card>
278
+ <CardHeader>
279
+ <CardDescription>站点公告</CardDescription>
280
+ <CardTitle>顶部公告横条</CardTitle>
281
+ <CardAction>
282
+ <Badge variant={bannerText.trim() ? 'secondary' : 'outline'}>
283
+ {bannerText.trim() ? '展示中' : '未展示'}
284
+ </Badge>
285
+ </CardAction>
286
+ </CardHeader>
287
+ <CardContent className="space-y-4">
288
+ <p className="text-sm text-muted-foreground">{desc.banner_text}</p>
289
+ {/* 即时预览:与产品壳顶条同一组件/同一 props */}
290
+ <div className="rounded-lg border border-dashed">
291
+ <SiteBanner text={bannerText} kind={bannerKind} />
292
+ {!bannerText.trim() && (
293
+ <p className="px-4 py-2 text-xs text-muted-foreground">
294
+ 预览:公告为空时不渲染横条
295
+ </p>
296
+ )}
297
+ </div>
298
+ <FormField label="公告文本" htmlFor="banner-text">
299
+ <textarea
300
+ id="banner-text"
301
+ className="flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
302
+ placeholder="如:今晚 22:00–23:00 例行维护,期间可能短暂不可用"
303
+ maxLength={500}
304
+ value={bannerText}
305
+ onChange={(e) => setBannerText(e.target.value)}
306
+ />
307
+ </FormField>
308
+ <FormField label="样式" htmlFor="banner-kind">
309
+ <Select
310
+ value={bannerKind}
311
+ onValueChange={(v) =>
312
+ setBannerKind(v === 'warn' ? 'warn' : 'info')
313
+ }
314
+ >
315
+ <SelectTrigger
316
+ id="banner-kind"
317
+ size="sm"
318
+ aria-label="公告样式"
319
+ className="w-40"
320
+ >
321
+ <SelectValue>
322
+ {bannerKind === 'warn' ? '警示(橙红)' : '信息(蓝)'}
323
+ </SelectValue>
324
+ </SelectTrigger>
325
+ <SelectContent>
326
+ <SelectItem value="info">信息(蓝)</SelectItem>
327
+ <SelectItem value="warn">警示(橙红)</SelectItem>
328
+ </SelectContent>
329
+ </Select>
330
+ </FormField>
331
+ <div>
332
+ <Button
333
+ size="sm"
334
+ disabled={bannerM.isPending || !hydrated}
335
+ onClick={() => bannerM.mutate()}
336
+ >
337
+ {bannerM.isPending ? '保存中…' : '保存公告'}
338
+ </Button>
339
+ </div>
340
+ </CardContent>
341
+ </Card>
342
+
343
+ {/* ② 维护模式(B-settings-7:补状态徽章,与公告/注册/限流卡形态一致) */}
344
+ <Card>
345
+ <CardHeader>
346
+ <CardDescription>维护模式</CardDescription>
347
+ <CardTitle>全站"维护中"提示条</CardTitle>
348
+ <CardAction>
349
+ <div className="flex items-center gap-2">
350
+ <Badge variant={maintenance ? 'destructive' : 'outline'}>
351
+ {maintenance ? '维护中' : '正常运行'}
352
+ </Badge>
353
+ <Switch
354
+ checked={maintenance}
355
+ onCheckedChange={(v) => {
356
+
357
+ if (saveM.isPending) return;
358
+ setMaintenance(v);
359
+ saveM.mutate({
360
+ key: 'maintenance_mode',
361
+ value: v ? '1' : '0',
362
+ label: '维护模式',
363
+ });
364
+ }}
365
+ aria-label="维护模式开关"
366
+ />
367
+ </div>
368
+ </CardAction>
369
+ </CardHeader>
370
+ <CardContent>
371
+ <p className="text-sm text-muted-foreground">
372
+ {desc.maintenance_mode}
373
+ </p>
374
+ </CardContent>
375
+ </Card>
376
+
377
+ {/* ③ 注册管控 */}
378
+ <Card>
379
+ <CardHeader>
380
+ <CardDescription>注册管控</CardDescription>
381
+ <CardTitle>注册开关与域名白名单</CardTitle>
382
+ <CardAction>
383
+ <Switch
384
+ checked={allowSignup}
385
+ onCheckedChange={(v) => {
386
+
387
+ if (saveM.isPending) return;
388
+ setAllowSignup(v);
389
+ saveM.mutate({
390
+ key: 'allow_signup',
391
+ value: v ? '1' : '0',
392
+ label: '注册开关',
393
+ });
394
+ }}
395
+ aria-label="开放注册开关"
396
+ />
397
+ </CardAction>
398
+ </CardHeader>
399
+ <CardContent className="space-y-4">
400
+ <p className="text-sm text-muted-foreground">{desc.allow_signup}</p>
401
+ <div className="flex items-center gap-2 text-sm">
402
+ <Badge variant={allowSignup ? 'secondary' : 'destructive'}>
403
+ {allowSignup ? '开放注册' : '邀请制'}
404
+ </Badge>
405
+ {!allowSignup && (
406
+ <span className="text-muted-foreground">
407
+ 注册页会显示"当前为邀请制注册"提示(不阻断提交,服务端才是边界)
408
+ </span>
409
+ )}
410
+ </div>
411
+ <FormField
412
+ label="域名白名单(逗号分隔,空 = 不限)"
413
+ htmlFor="allowed-domains"
414
+ >
415
+ <Input
416
+ id="allowed-domains"
417
+ placeholder="如:corp.com, edu.cn"
418
+ value={domains}
419
+ onChange={(e) => setDomains(e.target.value)}
420
+ />
421
+ </FormField>
422
+ <div>
423
+ <Button
424
+ size="sm"
425
+ variant="outline"
426
+ disabled={domainsM.isPending || !hydrated}
427
+ onClick={() => domainsM.mutate()}
428
+ >
429
+ {domainsM.isPending ? '保存中…' : '保存白名单'}
430
+ </Button>
431
+ </div>
432
+ </CardContent>
433
+ </Card>
434
+
435
+ <Card>
436
+ <CardHeader>
437
+ <CardDescription>API 限流</CardDescription>
438
+ <CardTitle>每用户 / 每 IP 限流与豁免名单</CardTitle>
439
+ <CardAction>
440
+ <Badge
441
+ variant={
442
+ rlPerUser !== '0' || rlPerIP !== '0' ? 'secondary' : 'outline'
443
+ }
444
+ >
445
+ {rlPerUser !== '0' || rlPerIP !== '0' ? '启用中' : '按缺省'}
446
+ </Badge>
447
+ </CardAction>
448
+ </CardHeader>
449
+ <CardContent className="space-y-4">
450
+ <p className="text-sm text-muted-foreground">
451
+ {desc.rate_limit_per_user_per_min}{' '}
452
+ 修改后需重启生效(启动期静态注入,不做热重载)。
453
+ </p>
454
+ <div className="grid max-w-3xl gap-4 sm:grid-cols-2">
455
+ {/* B-settings-10:text + inputMode(type=number 对垃圾字符静默置空,
456
+ 会被当成留空→0=关闭限流落库);空值/非法值保存前拒绝并就地提示 */}
457
+ <FormField
458
+ label="每用户请求/分(0 = 关闭)"
459
+ htmlFor="rl-per-user"
460
+ error={
461
+ rlError?.startsWith('每用户') ? rlError : undefined
462
+ }
463
+ >
464
+ <Input
465
+ id="rl-per-user"
466
+ type="text"
467
+ inputMode="numeric"
468
+ placeholder="如 600;0 = 关闭"
469
+ value={rlPerUser}
470
+ onChange={(e) => {
471
+ setRlPerUser(e.target.value);
472
+ if (rlError?.startsWith('每用户')) setRlError(null);
473
+ }}
474
+ aria-invalid={rlError?.startsWith('每用户') ? true : undefined}
475
+ />
476
+ </FormField>
477
+ <FormField
478
+ label="每 IP 请求/分(认证路径;0 = 关闭)"
479
+ htmlFor="rl-per-ip"
480
+ error={rlError?.startsWith('每 IP') ? rlError : undefined}
481
+ >
482
+ <Input
483
+ id="rl-per-ip"
484
+ type="text"
485
+ inputMode="numeric"
486
+ placeholder="缺省 30;0 = 关闭"
487
+ value={rlPerIP}
488
+ onChange={(e) => {
489
+ setRlPerIP(e.target.value);
490
+ if (rlError?.startsWith('每 IP')) setRlError(null);
491
+ }}
492
+ aria-invalid={rlError?.startsWith('每 IP') ? true : undefined}
493
+ />
494
+ </FormField>
495
+ </div>
496
+ <FormField
497
+ label="豁免名单(逗号分隔 IP 或 CIDR,空 = 无豁免)"
498
+ htmlFor="rl-allowlist"
499
+ >
500
+ <Input
501
+ id="rl-allowlist"
502
+ className="font-mono"
503
+ placeholder="如 10.0.0.5, 192.168.0.0/16"
504
+ value={rlAllowlist}
505
+ onChange={(e) => setRlAllowlist(e.target.value)}
506
+ />
507
+ </FormField>
508
+ <div>
509
+ <Button
510
+ size="sm"
511
+ variant="outline"
512
+ disabled={rateLimitM.isPending || !hydrated}
513
+ onClick={() => rateLimitM.mutate()}
514
+ >
515
+ {rateLimitM.isPending ? '保存中…' : '保存限流配置'}
516
+ </Button>
517
+ </div>
518
+ </CardContent>
519
+ </Card>
520
+
521
+ {/* ⑤ 测试邮件(P2-14:去产品指代——框架文案不点名 example 接线) */}
522
+ <Card>
523
+ <CardHeader>
524
+ <CardDescription>邮件通道</CardDescription>
525
+ <CardTitle>发送测试邮件</CardTitle>
526
+ </CardHeader>
527
+ <CardContent className="space-y-4">
528
+ <p className="text-sm text-muted-foreground">
529
+ 验证出站邮件通道连通性。实际行为取决于部署时的邮件通道:开发通道只打日志、
530
+ 不真发信;接入 SMTP 后此按钮发送真实邮件。
531
+ </p>
532
+ <div className="flex max-w-md gap-2">
533
+ <Input
534
+ type="email"
535
+ placeholder="收件邮箱,如 ops@example.com"
536
+ value={testTo}
537
+ onChange={(e) => setTestTo(e.target.value)}
538
+ aria-label="测试邮件收件邮箱"
539
+ />
540
+ <Button
541
+ size="sm"
542
+ className="shrink-0"
543
+ disabled={testEmailM.isPending || !testTo.trim()}
544
+ onClick={() => {
545
+ setTestMsg(null);
546
+ testEmailM.mutate();
547
+ }}
548
+ >
549
+ {testEmailM.isPending ? '发送中…' : '发送'}
550
+ </Button>
551
+ </div>
552
+ {testMsg && (
553
+ <p
554
+ role="status"
555
+ className="rounded-md border bg-muted px-3 py-2 text-sm text-muted-foreground"
556
+ >
557
+ {testMsg}
558
+ </p>
559
+ )}
560
+ </CardContent>
561
+ </Card>
562
+ </div>
563
+ );
564
+ }