@ploykit/ui 0.7.4 → 0.8.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.
package/dist/index.d.ts CHANGED
@@ -87,6 +87,7 @@ export { UsersPage } from './pages/admin/UsersPage';
87
87
  export { UserDetailPage } from './pages/admin/UserDetailPage';
88
88
  export { WorkspacesPage } from './pages/admin/WorkspacesPage';
89
89
  export { WorkspaceDetailPage } from './pages/admin/WorkspaceDetailPage';
90
+ export { PlansPage } from './pages/admin/PlansPage';
90
91
  export { OrdersPage } from './pages/admin/OrdersPage';
91
92
  export { WebhookDeliveriesPage } from './pages/admin/WebhookDeliveriesPage';
92
93
  export { AuditPage } from './pages/admin/AuditPage';
package/dist/index.js CHANGED
@@ -71,6 +71,7 @@ export { UsersPage } from './pages/admin/UsersPage.js';
71
71
  export { UserDetailPage } from './pages/admin/UserDetailPage.js';
72
72
  export { WorkspacesPage } from './pages/admin/WorkspacesPage.js';
73
73
  export { WorkspaceDetailPage } from './pages/admin/WorkspaceDetailPage.js';
74
+ export { PlansPage } from './pages/admin/PlansPage.js';
74
75
  export { OrdersPage } from './pages/admin/OrdersPage.js';
75
76
  export { WebhookDeliveriesPage } from './pages/admin/WebhookDeliveriesPage.js';
76
77
  export { AuditPage } from './pages/admin/AuditPage.js';
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
+ import { Copy } from 'lucide-react';
3
4
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
5
  import { FormField } from '../../components/FormField.js';
5
6
  import { PageError, PageHeader } from '../../components/Page.js';
@@ -26,6 +27,18 @@ function parseRateLimit(raw) {
26
27
  }
27
28
  return { ok: true, v: s };
28
29
  }
30
+ // SMTP 端口:留空合法(后端缺省 587),非空必须是 1..65535 的整数
31
+ function parseSmtpPort(raw) {
32
+ const s = raw.trim();
33
+ if (s === '') {
34
+ return { ok: true, v: '' };
35
+ }
36
+ const n = Number(s);
37
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
38
+ return { ok: false, msg: '必须是 1..65535 的整数(留空 = 用缺省 587)' };
39
+ }
40
+ return { ok: true, v: s };
41
+ }
29
42
  async function putKeys(api, entries) {
30
43
  const results = [];
31
44
  for (const e of entries) {
@@ -39,6 +52,18 @@ async function putKeys(api, entries) {
39
52
  }
40
53
  return results;
41
54
  }
55
+ // UP-6 登录方式卡:复制回调地址(与 OverviewPage 复制工作区 ID 同款降级策略)
56
+ function copyCallbackUrl(name, url) {
57
+ if (typeof navigator.clipboard?.writeText === 'function') {
58
+ void navigator.clipboard
59
+ .writeText(url)
60
+ .then(() => toast.success(`${name} 回调地址已复制`))
61
+ .catch(() => toast.error(`复制失败,完整地址:${url}`));
62
+ }
63
+ else {
64
+ toast.info(`当前环境不支持自动复制,完整地址:${url}`);
65
+ }
66
+ }
42
67
  export function AdminSettingsPage() {
43
68
  const api = useApi();
44
69
  const qc = useQueryClient();
@@ -55,6 +80,15 @@ export function AdminSettingsPage() {
55
80
  const [rlPerIP, setRlPerIP] = useState('30');
56
81
  const [rlAllowlist, setRlAllowlist] = useState('');
57
82
  const [rlError, setRlError] = useState(null);
83
+ const [smtpHost, setSmtpHost] = useState('');
84
+ const [smtpPort, setSmtpPort] = useState('');
85
+ const [smtpUser, setSmtpUser] = useState('');
86
+ // UP-1 水合规则:smtp_pass 永不回填(真实值与掩码回显都不进 state),
87
+ // state 保持空串、placeholder 显示掩码;留空保存 = 不修改密码
88
+ const [smtpPass, setSmtpPass] = useState('');
89
+ const [smtpFrom, setSmtpFrom] = useState('');
90
+ const [smtpError, setSmtpError] = useState(null);
91
+ const [verifyMeta, setVerifyMeta] = useState('');
58
92
  const [hydrated, setHydrated] = useState(false);
59
93
  const eff = settingsQ.data?.effective;
60
94
  useEffect(() => {
@@ -68,6 +102,11 @@ export function AdminSettingsPage() {
68
102
  setRlPerUser(eff.rate_limit_per_user_per_min ?? '0');
69
103
  setRlPerIP(eff.rate_limit_per_ip_per_min ?? '30');
70
104
  setRlAllowlist(eff.rate_limit_allowlist ?? '');
105
+ setSmtpHost(eff.smtp_host ?? '');
106
+ setSmtpPort(eff.smtp_port ?? '');
107
+ setSmtpUser(eff.smtp_user ?? '');
108
+ setSmtpFrom(eff.smtp_from ?? '');
109
+ setVerifyMeta(eff.site_verification_meta ?? '');
71
110
  setHydrated(true);
72
111
  }, [eff, hydrated]);
73
112
  const afterSave = () => {
@@ -157,6 +196,48 @@ export function AdminSettingsPage() {
157
196
  toast.error(msg, { duration: ERROR_TOAST_MS });
158
197
  },
159
198
  });
199
+ // SMTP 发信配置(UP-1):五键批量提交;smtp_pass 留空(含掩码未动)时
200
+ // 从提交列表剔除,绝不把空值/掩码落库
201
+ const smtpM = useMutation({
202
+ mutationFn: async () => {
203
+ const port = parseSmtpPort(smtpPort);
204
+ if (!port.ok)
205
+ throw new Error(`SMTP 端口:${port.msg}`);
206
+ setSmtpError(null);
207
+ const entries = [
208
+ { key: 'smtp_host', label: 'SMTP 主机', value: smtpHost.trim() },
209
+ { key: 'smtp_port', label: 'SMTP 端口', value: port.v },
210
+ { key: 'smtp_user', label: 'SMTP 用户名', value: smtpUser.trim() },
211
+ ...(smtpPass !== ''
212
+ ? [{ key: 'smtp_pass', label: 'SMTP 密码', value: smtpPass }]
213
+ : []),
214
+ { key: 'smtp_from', label: '发件人地址', value: smtpFrom.trim() },
215
+ ];
216
+ return putKeys(api, entries);
217
+ },
218
+ onSuccess: (results) => reportKeySaves(results, 'SMTP 配置已保存(发信热读,即时生效)', 'SMTP 配置'),
219
+ onError: (e) => {
220
+ const msg = e instanceof Error ? e.message : 'SMTP 配置保存失败';
221
+ setSmtpError(msg);
222
+ toast.error(msg, { duration: ERROR_TOAST_MS });
223
+ },
224
+ });
225
+ // 站长验证(UP-2):原样提交,后端归一为 name|content 存储(400 = 校验拒绝)
226
+ const verifyM = useMutation({
227
+ mutationFn: () => api.put('/api/admin/settings/site_verification_meta', {
228
+ value: verifyMeta.trim(),
229
+ }),
230
+ onSuccess: () => {
231
+ toast.success('站长验证配置已保存');
232
+ afterSave();
233
+ },
234
+ onError: (e) => {
235
+ toast.error(adminError(e, '站长验证配置保存失败'), {
236
+ duration: ERROR_TOAST_MS,
237
+ });
238
+ void rollbackToServer();
239
+ },
240
+ });
160
241
  const [testTo, setTestTo] = useState('');
161
242
  const [testMsg, setTestMsg] = useState(null);
162
243
  const testEmailM = useMutation({
@@ -177,7 +258,9 @@ export function AdminSettingsPage() {
177
258
  return (_jsx(PageError, { message: adminError(settingsQ.error, '设置加载失败'), onRetry: () => void settingsQ.refetch() }));
178
259
  }
179
260
  const desc = settingsQ.data?.descriptions ?? {};
180
- return (_jsxs("div", { className: "space-y-6", children: [_jsx(PageHeader, { title: "\u7CFB\u7EDF", description: "\u7AD9\u70B9\u516C\u544A\u3001\u7EF4\u62A4\u6A21\u5F0F\u3001\u6CE8\u518C\u7BA1\u63A7\u3001API \u9650\u6D41\u4E0E\u90AE\u4EF6\u901A\u9053\u2014\u2014\u4FDD\u5B58\u540E\u5168\u7AD9\u5373\u65F6\u751F\u6548\uFF08API \u9650\u6D41\u9664\u5916\uFF1A\u91CD\u542F\u540E\u751F\u6548\uFF09" }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u7AD9\u70B9\u516C\u544A" }), _jsx(CardTitle, { children: "\u9876\u90E8\u516C\u544A\u6A2A\u6761" }), _jsx(CardAction, { children: _jsx(Badge, { variant: bannerText.trim() ? 'secondary' : 'outline', children: bannerText.trim() ? '展示中' : '未展示' }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: desc.banner_text }), _jsxs("div", { className: "rounded-lg border border-dashed", children: [_jsx(SiteBanner, { text: bannerText, kind: bannerKind }), !bannerText.trim() && (_jsx("p", { className: "px-4 py-2 text-xs text-muted-foreground", children: "\u9884\u89C8\uFF1A\u516C\u544A\u4E3A\u7A7A\u65F6\u4E0D\u6E32\u67D3\u6A2A\u6761" }))] }), _jsx(FormField, { label: "\u516C\u544A\u6587\u672C", htmlFor: "banner-text", children: _jsx("textarea", { id: "banner-text", 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", placeholder: "\u5982\uFF1A\u4ECA\u665A 22:00\u201323:00 \u4F8B\u884C\u7EF4\u62A4\uFF0C\u671F\u95F4\u53EF\u80FD\u77ED\u6682\u4E0D\u53EF\u7528", maxLength: 500, value: bannerText, onChange: (e) => setBannerText(e.target.value) }) }), _jsx(FormField, { label: "\u6837\u5F0F", htmlFor: "banner-kind", children: _jsxs(Select, { value: bannerKind, onValueChange: (v) => setBannerKind(v === 'warn' ? 'warn' : 'info'), children: [_jsx(SelectTrigger, { id: "banner-kind", size: "sm", "aria-label": "\u516C\u544A\u6837\u5F0F", className: "w-40", children: _jsx(SelectValue, { children: bannerKind === 'warn' ? '警示(橙红)' : '信息(蓝)' }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "info", children: "\u4FE1\u606F\uFF08\u84DD\uFF09" }), _jsx(SelectItem, { value: "warn", children: "\u8B66\u793A\uFF08\u6A59\u7EA2\uFF09" })] })] }) }), _jsx("div", { children: _jsx(Button, { size: "sm", disabled: bannerM.isPending || !hydrated, onClick: () => bannerM.mutate(), children: bannerM.isPending ? '保存中…' : '保存公告' }) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u7EF4\u62A4\u6A21\u5F0F" }), _jsx(CardTitle, { children: "\u5168\u7AD9\"\u7EF4\u62A4\u4E2D\"\u63D0\u793A\u6761" }), _jsx(CardAction, { children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: maintenance ? 'destructive' : 'outline', children: maintenance ? '维护中' : '正常运行' }), _jsx(Switch, { checked: maintenance, onCheckedChange: (v) => {
261
+ const mailMode = settingsQ.data?.mail_mode ?? '';
262
+ const oauth = settingsQ.data?.oauth;
263
+ return (_jsxs("div", { className: "space-y-6", children: [_jsx(PageHeader, { title: "\u7CFB\u7EDF", description: "\u7AD9\u70B9\u516C\u544A\u3001\u7EF4\u62A4\u6A21\u5F0F\u3001\u6CE8\u518C\u7BA1\u63A7\u3001API \u9650\u6D41\u3001\u90AE\u4EF6\u901A\u9053\u3001\u7AD9\u957F\u9A8C\u8BC1\u4E0E\u767B\u5F55\u65B9\u5F0F\u2014\u2014\u4FDD\u5B58\u540E\u5168\u7AD9\u5373\u65F6\u751F\u6548\uFF08API \u9650\u6D41\u9664\u5916\uFF1A\u91CD\u542F\u540E\u751F\u6548\uFF09" }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u7AD9\u70B9\u516C\u544A" }), _jsx(CardTitle, { children: "\u9876\u90E8\u516C\u544A\u6A2A\u6761" }), _jsx(CardAction, { children: _jsx(Badge, { variant: bannerText.trim() ? 'secondary' : 'outline', children: bannerText.trim() ? '展示中' : '未展示' }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: desc.banner_text }), _jsxs("div", { className: "rounded-lg border border-dashed", children: [_jsx(SiteBanner, { text: bannerText, kind: bannerKind }), !bannerText.trim() && (_jsx("p", { className: "px-4 py-2 text-xs text-muted-foreground", children: "\u9884\u89C8\uFF1A\u516C\u544A\u4E3A\u7A7A\u65F6\u4E0D\u6E32\u67D3\u6A2A\u6761" }))] }), _jsx(FormField, { label: "\u516C\u544A\u6587\u672C", htmlFor: "banner-text", children: _jsx("textarea", { id: "banner-text", 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", placeholder: "\u5982\uFF1A\u4ECA\u665A 22:00\u201323:00 \u4F8B\u884C\u7EF4\u62A4\uFF0C\u671F\u95F4\u53EF\u80FD\u77ED\u6682\u4E0D\u53EF\u7528", maxLength: 500, value: bannerText, onChange: (e) => setBannerText(e.target.value) }) }), _jsx(FormField, { label: "\u6837\u5F0F", htmlFor: "banner-kind", children: _jsxs(Select, { value: bannerKind, onValueChange: (v) => setBannerKind(v === 'warn' ? 'warn' : 'info'), children: [_jsx(SelectTrigger, { id: "banner-kind", size: "sm", "aria-label": "\u516C\u544A\u6837\u5F0F", className: "w-40", children: _jsx(SelectValue, { children: bannerKind === 'warn' ? '警示(橙红)' : '信息(蓝)' }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "info", children: "\u4FE1\u606F\uFF08\u84DD\uFF09" }), _jsx(SelectItem, { value: "warn", children: "\u8B66\u793A\uFF08\u6A59\u7EA2\uFF09" })] })] }) }), _jsx("div", { children: _jsx(Button, { size: "sm", disabled: bannerM.isPending || !hydrated, onClick: () => bannerM.mutate(), children: bannerM.isPending ? '保存中…' : '保存公告' }) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u7EF4\u62A4\u6A21\u5F0F" }), _jsx(CardTitle, { children: "\u5168\u7AD9\"\u7EF4\u62A4\u4E2D\"\u63D0\u793A\u6761" }), _jsx(CardAction, { children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: maintenance ? 'destructive' : 'outline', children: maintenance ? '维护中' : '正常运行' }), _jsx(Switch, { checked: maintenance, onCheckedChange: (v) => {
181
264
  if (saveM.isPending)
182
265
  return;
183
266
  setMaintenance(v);
@@ -203,8 +286,16 @@ export function AdminSettingsPage() {
203
286
  setRlPerIP(e.target.value);
204
287
  if (rlError?.startsWith('每 IP'))
205
288
  setRlError(null);
206
- }, "aria-invalid": rlError?.startsWith('每 IP') ? true : undefined }) })] }), _jsx(FormField, { label: "\u8C41\u514D\u540D\u5355\uFF08\u9017\u53F7\u5206\u9694 IP \u6216 CIDR\uFF0C\u7A7A = \u65E0\u8C41\u514D\uFF09", htmlFor: "rl-allowlist", children: _jsx(Input, { id: "rl-allowlist", className: "font-mono", placeholder: "\u5982 10.0.0.5, 192.168.0.0/16", value: rlAllowlist, onChange: (e) => setRlAllowlist(e.target.value) }) }), _jsx("div", { children: _jsx(Button, { size: "sm", variant: "outline", disabled: rateLimitM.isPending || !hydrated, onClick: () => rateLimitM.mutate(), children: rateLimitM.isPending ? '保存中…' : '保存限流配置' }) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u90AE\u4EF6\u901A\u9053" }), _jsx(CardTitle, { children: "\u53D1\u9001\u6D4B\u8BD5\u90AE\u4EF6" })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "\u9A8C\u8BC1\u51FA\u7AD9\u90AE\u4EF6\u901A\u9053\u8FDE\u901A\u6027\u3002\u5B9E\u9645\u884C\u4E3A\u53D6\u51B3\u4E8E\u90E8\u7F72\u65F6\u7684\u90AE\u4EF6\u901A\u9053\uFF1A\u5F00\u53D1\u901A\u9053\u53EA\u6253\u65E5\u5FD7\u3001 \u4E0D\u771F\u53D1\u4FE1\uFF1B\u63A5\u5165 SMTP \u540E\u6B64\u6309\u94AE\u53D1\u9001\u771F\u5B9E\u90AE\u4EF6\u3002" }), _jsxs("div", { className: "flex max-w-md gap-2", children: [_jsx(Input, { type: "email", placeholder: "\u6536\u4EF6\u90AE\u7BB1\uFF0C\u5982 ops@example.com", value: testTo, onChange: (e) => setTestTo(e.target.value), "aria-label": "\u6D4B\u8BD5\u90AE\u4EF6\u6536\u4EF6\u90AE\u7BB1" }), _jsx(Button, { size: "sm", className: "shrink-0", disabled: testEmailM.isPending || !testTo.trim(), onClick: () => {
289
+ }, "aria-invalid": rlError?.startsWith('每 IP') ? true : undefined }) })] }), _jsx(FormField, { label: "\u8C41\u514D\u540D\u5355\uFF08\u9017\u53F7\u5206\u9694 IP \u6216 CIDR\uFF0C\u7A7A = \u65E0\u8C41\u514D\uFF09", htmlFor: "rl-allowlist", children: _jsx(Input, { id: "rl-allowlist", className: "font-mono", placeholder: "\u5982 10.0.0.5, 192.168.0.0/16", value: rlAllowlist, onChange: (e) => setRlAllowlist(e.target.value) }) }), _jsx("div", { children: _jsx(Button, { size: "sm", variant: "outline", disabled: rateLimitM.isPending || !hydrated, onClick: () => rateLimitM.mutate(), children: rateLimitM.isPending ? '保存中…' : '保存限流配置' }) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u53D1\u4FE1\u901A\u9053" }), _jsx(CardTitle, { children: "SMTP \u53D1\u4FE1\u914D\u7F6E" }), _jsx(CardAction, { children: _jsx(Badge, { variant: mailMode === 'smtp' ? 'secondary' : 'outline', children: mailMode === 'smtp'
290
+ ? 'SMTP 直发'
291
+ : mailMode === 'dev'
292
+ ? '开发通道'
293
+ : '未接入' }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: [desc.smtp_host, " \u914D\u7F6E\u540E\u51FA\u7AD9\u90AE\u4EF6\u8D70 SMTP \u76F4\u53D1\uFF0C\u4FDD\u5B58\u5373\u65F6\u751F\u6548\uFF08\u53D1\u4FE1\u70ED\u8BFB\uFF0C \u65E0\u9700\u91CD\u542F\uFF09\u3002\u51FA\u4E8E\u5B89\u5168\uFF0C\u5BC6\u7801\u4E0D\u56DE\u663E\u2014\u2014\u8F93\u5165\u6846\u6052\u4E3A\u7A7A\u3001\u5360\u4F4D\u7B26\u663E\u793A\u63A9\u7801\uFF0C \u7559\u7A7A\u4FDD\u5B58\u8868\u793A\u4E0D\u4FEE\u6539\u5F53\u524D\u5BC6\u7801\u3002"] }), _jsxs("div", { className: "grid max-w-3xl gap-4 sm:grid-cols-2", children: [_jsx(FormField, { label: "SMTP \u670D\u52A1\u5668", htmlFor: "smtp-host", children: _jsx(Input, { id: "smtp-host", placeholder: "\u5982 smtp.example.com\uFF1B\u7559\u7A7A = \u5F00\u53D1\u901A\u9053", value: smtpHost, onChange: (e) => setSmtpHost(e.target.value) }) }), _jsx(FormField, { label: "\u7AEF\u53E3\uFF08\u7559\u7A7A = \u7F3A\u7701 587\uFF09", htmlFor: "smtp-port", error: smtpError?.startsWith('SMTP 端口') ? smtpError : undefined, children: _jsx(Input, { id: "smtp-port", type: "text", inputMode: "numeric", placeholder: "\u7F3A\u7701 587", value: smtpPort, onChange: (e) => {
294
+ setSmtpPort(e.target.value);
295
+ if (smtpError?.startsWith('SMTP 端口'))
296
+ setSmtpError(null);
297
+ }, "aria-invalid": smtpError?.startsWith('SMTP 端口') ? true : undefined }) }), _jsx(FormField, { label: "SMTP \u7528\u6237\u540D", htmlFor: "smtp-user", children: _jsx(Input, { id: "smtp-user", className: "font-mono", placeholder: "\u5982 no-reply@example.com", value: smtpUser, onChange: (e) => setSmtpUser(e.target.value) }) }), _jsx(FormField, { label: "SMTP \u5BC6\u7801\uFF08\u7559\u7A7A = \u4E0D\u4FEE\u6539\uFF09", htmlFor: "smtp-pass", children: _jsx(Input, { id: "smtp-pass", type: "password", placeholder: "******", autoComplete: "new-password", value: smtpPass, onChange: (e) => setSmtpPass(e.target.value) }) }), _jsx(FormField, { label: "\u53D1\u4EF6\u4EBA\u5730\u5740", htmlFor: "smtp-from", children: _jsx(Input, { id: "smtp-from", type: "email", placeholder: "\u5982 no-reply@example.com", value: smtpFrom, onChange: (e) => setSmtpFrom(e.target.value) }) })] }), _jsx("div", { children: _jsx(Button, { size: "sm", variant: "outline", disabled: smtpM.isPending || !hydrated, onClick: () => smtpM.mutate(), children: smtpM.isPending ? '保存中…' : '保存 SMTP 配置' }) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u90AE\u4EF6\u901A\u9053" }), _jsx(CardTitle, { children: "\u53D1\u9001\u6D4B\u8BD5\u90AE\u4EF6" })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "\u9A8C\u8BC1\u51FA\u7AD9\u90AE\u4EF6\u901A\u9053\u8FDE\u901A\u6027\u3002\u5B9E\u9645\u884C\u4E3A\u53D6\u51B3\u4E8E\u90E8\u7F72\u65F6\u7684\u90AE\u4EF6\u901A\u9053\uFF1A\u5F00\u53D1\u901A\u9053\u53EA\u6253\u65E5\u5FD7\u3001 \u4E0D\u771F\u53D1\u4FE1\uFF1B\u63A5\u5165 SMTP \u540E\u6B64\u6309\u94AE\u53D1\u9001\u771F\u5B9E\u90AE\u4EF6\u3002" }), _jsxs("div", { className: "flex max-w-md gap-2", children: [_jsx(Input, { type: "email", placeholder: "\u6536\u4EF6\u90AE\u7BB1\uFF0C\u5982 ops@example.com", value: testTo, onChange: (e) => setTestTo(e.target.value), "aria-label": "\u6D4B\u8BD5\u90AE\u4EF6\u6536\u4EF6\u90AE\u7BB1" }), _jsx(Button, { size: "sm", className: "shrink-0", disabled: testEmailM.isPending || !testTo.trim(), onClick: () => {
207
298
  setTestMsg(null);
208
299
  testEmailM.mutate();
209
- }, children: testEmailM.isPending ? '发送中…' : '发送' })] }), testMsg && (_jsx("p", { role: "status", className: "rounded-md border bg-muted px-3 py-2 text-sm text-muted-foreground", children: testMsg }))] })] })] }));
300
+ }, children: testEmailM.isPending ? '发送中…' : '发送' })] }), testMsg && (_jsx("p", { role: "status", className: "rounded-md border bg-muted px-3 py-2 text-sm text-muted-foreground", children: testMsg }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u7AD9\u957F\u9A8C\u8BC1" }), _jsx(CardTitle, { children: "\u641C\u7D22\u5F15\u64CE\u7AD9\u70B9\u9A8C\u8BC1" }), _jsx(CardAction, { children: _jsx(Badge, { variant: verifyMeta.trim() ? 'secondary' : 'outline', children: verifyMeta.trim() ? '已注入' : '未配置' }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: [desc.site_verification_meta, " \u7C98\u8D34\u641C\u7D22\u5E73\u53F0\uFF08Google Search Console / Bing \u7B49\uFF09\u63D0\u4F9B\u7684\u5B8C\u6574 meta \u6807\u7B7E\uFF0C\u6216 name|content \u5BF9\uFF1B\u4FDD\u5B58\u540E\u5F52\u4E00\u4E3A name|content \u5B58\u50A8\uFF0C\u9875\u9762\u6CE8\u5165\u65F6\u7531\u670D\u52A1\u7AEF\u91CD\u7EC4\u6807\u7B7E\uFF0C \u8F93\u5165\u7684\u539F\u6587\u4E0D\u56DE\u663E\u3002"] }), _jsx(FormField, { label: "\u9A8C\u8BC1\u6807\u8BB0\uFF08\u5B8C\u6574 meta \u6807\u7B7E\u6216 name|content \u5BF9\uFF09", htmlFor: "site-verification-meta", children: _jsx("textarea", { id: "site-verification-meta", 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", placeholder: '<meta name="google-site-verification" content="abc123XYZ" /> 或 google-site-verification|abc123XYZ', value: verifyMeta, onChange: (e) => setVerifyMeta(e.target.value) }) }), _jsx("div", { children: _jsx(Button, { size: "sm", variant: "outline", disabled: verifyM.isPending || !hydrated, onClick: () => verifyM.mutate(), children: verifyM.isPending ? '保存中…' : '保存验证配置' }) })] })] }), oauth && (_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardDescription, { children: "\u767B\u5F55\u65B9\u5F0F" }), _jsx(CardTitle, { children: "OAuth \u7B2C\u4E09\u65B9\u767B\u5F55" }), _jsx(CardAction, { children: _jsx(Badge, { variant: oauth.length > 0 ? 'secondary' : 'outline', children: oauth.length > 0 ? `已启用 ${oauth.length} 个` : '未启用' }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "\u4E0B\u65B9\u56DE\u8C03\u5730\u5740\u4E3A\u6309\u7AD9\u70B9 base URL \u63A8\u7B97\u7684\u671F\u671B\u503C\uFF0C\u5B9E\u9645\u4EE5\u8BF7\u6C42 Host \u73B0\u7B97\u2014\u2014\u53CD\u5411\u4EE3\u7406\u5FC5\u987B\u8F6C\u53D1 Host \u4E0E X-Forwarded-Proto\uFF0C \u5426\u5219\u767B\u5F55\u4F1A\u62A5 redirect_uri_mismatch\u3002" }), oauth.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u5C1A\u672A\u914D\u7F6E\u4EFB\u4F55 OAuth \u767B\u5F55\u65B9\u5F0F\u2014\u2014\u8BBE\u7F6E GOOGLE_CLIENT_ID / GOOGLE_SECRET\u3001GITHUB_CLIENT_ID / GITHUB_SECRET \u73AF\u5883\u53D8\u91CF\u5E76\u91CD\u542F\u540E\u5728\u6B64\u663E\u793A\u3002" })) : (_jsx("ul", { className: "space-y-2", children: oauth.map((p) => (_jsxs("li", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx(Badge, { variant: "secondary", children: p.name }), _jsx("code", { className: "font-mono text-xs break-all text-muted-foreground", children: p.callback_url }), _jsx(Button, { variant: "ghost", size: "sm", className: "size-6 shrink-0 p-0 text-muted-foreground", "aria-label": `复制 ${p.name} 回调地址`, title: `复制 ${p.name} 回调地址`, onClick: () => copyCallbackUrl(p.name, p.callback_url), children: _jsx(Copy, { size: 12, "aria-hidden": "true" }) })] }, p.name))) }))] })] }))] }));
210
301
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Link, Navigate, useLocation } from 'react-router';
3
- import { Activity, ArrowLeft, Building2, KeyRound, LayoutDashboard, Receipt, ScrollText, Settings, ShieldCheck, Users, Webhook, } from 'lucide-react';
3
+ import { Activity, ArrowLeft, Building2, CreditCard, KeyRound, LayoutDashboard, Receipt, ScrollText, Settings, ShieldCheck, Users, Webhook, } from 'lucide-react';
4
4
  import { buttonVariants } from '../../components/ui/button.js';
5
5
  import { UserMenu } from '../../components/UserMenu.js';
6
6
  import { useAuth } from '@ploykit/hooks';
@@ -21,6 +21,11 @@ const NAV = [
21
21
  label: '工作区',
22
22
  icon: _jsx(Building2, { size: 16, "aria-hidden": "true" }),
23
23
  },
24
+ {
25
+ to: '/admin/plans',
26
+ label: '套餐',
27
+ icon: _jsx(CreditCard, { size: 16, "aria-hidden": "true" }),
28
+ },
24
29
  {
25
30
  to: '/admin/orders',
26
31
  label: '订单',
@@ -0,0 +1,9 @@
1
+ export interface AdminPlanView {
2
+ code: string;
3
+ name: string;
4
+ currency: string;
5
+ trial_days: number;
6
+ sort_no: number;
7
+ limits: Record<string, number>;
8
+ }
9
+ export declare function PlansPage(): import("react").JSX.Element;
@@ -0,0 +1,163 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
5
+ import { FormField } from '../../components/FormField.js';
6
+ import { PageEmpty, PageError, PageHeader } from '../../components/Page.js';
7
+ import { toast } from '../../components/toast.js';
8
+ import { Badge } from '../../components/ui/badge.js';
9
+ import { Button } from '../../components/ui/button.js';
10
+ import { Input } from '../../components/ui/input.js';
11
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select.js';
12
+ import { useApi } from '@ploykit/hooks';
13
+ import { adminError } from './shared.js';
14
+ // 服务端校验兜底(limits 键 ^[a-z][a-z0-9_]{0,63}$、值整数、-1=无限哨兵);
15
+ // 这里做同规则的轻量前置,避免明显的坏请求打过去。
16
+ const LIMIT_KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;
17
+ const CURRENCY_RE = /^[A-Z]{3}$/;
18
+ const INT_RE = /^-?\d+$/;
19
+ const COMMON_CURRENCIES = ['USD', 'EUR', 'CNY', 'GBP'];
20
+ // 已知价格键:渲染为数值输入并给单位提示;其余键给"高级键"弱提示但放行
21
+ // (limits 是自由 KV,billing 不认识的键无害)。
22
+ const KNOWN_PRICE_KEYS = new Set([
23
+ 'price_monthly_cents',
24
+ 'price_yearly_cents',
25
+ 'price_one_time_cents',
26
+ ]);
27
+ const UNLIMITED = -1;
28
+ function draftFrom(plan) {
29
+ return {
30
+ name: plan.name,
31
+ currency: plan.currency,
32
+ sortNo: String(plan.sort_no),
33
+ limits: Object.entries(plan.limits ?? {}).map(([key, value]) => ({
34
+ key,
35
+ value: String(value),
36
+ })),
37
+ };
38
+ }
39
+ function buildBody(d) {
40
+ const name = d.name.trim();
41
+ if (!name)
42
+ return { error: '名称不能为空' };
43
+ if (!CURRENCY_RE.test(d.currency.trim()))
44
+ return { error: '币种需为 3 位大写字母(如 USD)' };
45
+ const sortNo = d.sortNo.trim();
46
+ if (!INT_RE.test(sortNo))
47
+ return { error: '排序需为整数' };
48
+ const limits = {};
49
+ for (const row of d.limits) {
50
+ const k = row.key.trim();
51
+ const v = row.value.trim();
52
+ // 整行留空的行忽略(误触加行不阻塞保存)
53
+ if (!k && !v)
54
+ continue;
55
+ if (!k)
56
+ return { error: '存在未填写键名的限制行' };
57
+ if (!LIMIT_KEY_RE.test(k))
58
+ return {
59
+ error: `限制键 ${k} 格式有误:小写字母开头,仅小写字母/数字/下划线`,
60
+ };
61
+ if (k in limits)
62
+ return { error: `限制键 ${k} 重复` };
63
+ if (!INT_RE.test(v))
64
+ return { error: `限制 ${k} 的值需为整数(${UNLIMITED} 表示无限)` };
65
+ limits[k] = Number(v);
66
+ }
67
+ return { body: { name, currency: d.currency.trim(), sort_no: Number(sortNo), limits } };
68
+ }
69
+ function PlanCard({ plan, open, saving, apiError, onToggle, onSave }) {
70
+ const [draft, setDraft] = useState(() => draftFrom(plan));
71
+ const [customCurrency, setCustomCurrency] = useState(!COMMON_CURRENCIES.includes(plan.currency));
72
+ const [formError, setFormError] = useState(null);
73
+ // 保存成功后列表失效重取,新 plan 到达即重置草稿(列表即时反映)
74
+ useEffect(() => {
75
+ setDraft(draftFrom(plan));
76
+ setCustomCurrency(!COMMON_CURRENCIES.includes(plan.currency));
77
+ setFormError(null);
78
+ }, [plan]);
79
+ const limitCount = draft.limits.length;
80
+ const addRow = () => setDraft((d) => ({ ...d, limits: [...d.limits, { key: '', value: '' }] }));
81
+ const removeRow = (i) => setDraft((d) => ({
82
+ ...d,
83
+ limits: d.limits.filter((_, idx) => idx !== i),
84
+ }));
85
+ const setRow = (i, patch) => setDraft((d) => ({
86
+ ...d,
87
+ limits: d.limits.map((row, idx) => (idx === i ? { ...row, ...patch } : row)),
88
+ }));
89
+ const submit = () => {
90
+ setFormError(null);
91
+ const { body, error } = buildBody(draft);
92
+ if (!body) {
93
+ setFormError(error ?? '表单有误');
94
+ return;
95
+ }
96
+ onSave(plan.code, body);
97
+ };
98
+ const shownError = formError ?? apiError;
99
+ return (_jsxs("li", { className: "rounded-lg border", children: [_jsxs("button", { type: "button", onClick: onToggle, "aria-expanded": open, "aria-label": `编辑套餐 ${plan.code}`, className: "flex w-full flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-left transition-colors hover:bg-muted/40", children: [open ? (_jsx(ChevronDown, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })) : (_jsx(ChevronRight, { size: 16, "aria-hidden": "true", className: "shrink-0 text-muted-foreground" })), _jsx("span", { className: "font-medium", children: plan.name }), _jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground", title: "\u5957\u9910\u4EE3\u7801\u4E0D\u53EF\u4FEE\u6539", children: plan.code }), _jsx(Badge, { variant: "outline", children: plan.currency }), _jsxs("span", { className: "text-xs text-muted-foreground", children: ["\u6392\u5E8F ", plan.sort_no] }), plan.trial_days > 0 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["\u8BD5\u7528 ", plan.trial_days, " \u5929"] })), _jsxs("span", { className: "text-xs text-muted-foreground", children: [limitCount, " \u9879\u9650\u5236"] }), _jsx("span", { className: "ml-auto text-xs text-muted-foreground", children: open ? '收起' : '展开编辑' })] }), open && (_jsxs("form", { className: "border-t px-4 py-4", onSubmit: (e) => {
100
+ e.preventDefault();
101
+ submit();
102
+ }, children: [_jsx("p", { className: "mb-4 text-xs text-muted-foreground", children: "\u4FDD\u5B58\u540E\u7ACB\u5373\u751F\u6548\uFF08\u76F4\u8BFB\u5E93\u65E0\u7F13\u5B58\uFF09\uFF1B\u5DF2\u521B\u5EFA\u8BA2\u5355\u7684\u91D1\u989D\u4E0D\u53D7\u5F71\u54CD\u3002" }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsx(FormField, { label: "\u540D\u79F0", htmlFor: `plans-name-${plan.code}`, children: _jsx(Input, { id: `plans-name-${plan.code}`, value: draft.name, onChange: (e) => setDraft((d) => ({ ...d, name: e.target.value })) }) }), _jsxs(FormField, { label: "\u5E01\u79CD", description: "3 \u4F4D\u5927\u5199\u5B57\u6BCD\uFF1B\u5E38\u7528\u5E01\u79CD\u53EF\u9009\uFF0C\u4E5F\u53EF\u81EA\u7531\u8F93\u5165", children: [_jsxs(Select, { value: customCurrency ? 'custom' : draft.currency, onValueChange: (v) => {
103
+ if (v === 'custom') {
104
+ setCustomCurrency(true);
105
+ }
106
+ else {
107
+ setCustomCurrency(false);
108
+ setDraft((d) => ({ ...d, currency: v }));
109
+ }
110
+ }, children: [_jsx(SelectTrigger, { "aria-label": "\u5E01\u79CD", size: "sm", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { align: "start", children: [COMMON_CURRENCIES.map((c) => (_jsx(SelectItem, { value: c, children: c }, c))), _jsx(SelectItem, { value: "custom", children: "\u81EA\u5B9A\u4E49\u2026" })] })] }), customCurrency && (_jsx(Input, { className: "mt-2 font-mono", "aria-label": "\u81EA\u5B9A\u4E49\u5E01\u79CD", placeholder: "\u5982 JPY", value: draft.currency, onChange: (e) => setDraft((d) => ({
111
+ ...d,
112
+ currency: e.target.value
113
+ .toUpperCase()
114
+ .replace(/[^A-Z]/g, '')
115
+ .slice(0, 3),
116
+ })) }))] }), _jsx(FormField, { label: "\u6392\u5E8F", htmlFor: `plans-sort-${plan.code}`, description: "\u5C55\u793A\u987A\u5E8F\uFF08\u4EFB\u610F\u6574\u6570\uFF09", children: _jsx(Input, { id: `plans-sort-${plan.code}`, type: "text", inputMode: "numeric", value: draft.sortNo, onChange: (e) => setDraft((d) => ({ ...d, sortNo: e.target.value })) }) })] }), _jsxs("div", { className: "mt-6", children: [_jsxs("div", { className: "mb-2 flex flex-wrap items-center justify-between gap-2", children: [_jsx("p", { className: "text-sm font-medium", children: "\u9650\u5236\uFF08limits\uFF09" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 14, "aria-hidden": "true" }), " \u6DFB\u52A0\u9650\u5236\u952E"] })] }), _jsxs("p", { className: "mb-2 text-xs text-muted-foreground", children: ["\u503C\u4E3A\u6574\u6570\uFF0C", UNLIMITED, " \u8868\u793A\u65E0\u9650\uFF08Unlimited\uFF09\uFF1B\u952E\u540D\u5C0F\u5199\u5B57\u6BCD\u5F00\u5934\uFF0C\u4EC5\u5C0F\u5199\u5B57\u6BCD/\u6570\u5B57/\u4E0B\u5212\u7EBF\u3002\u672A\u77E5\u952E\u539F\u6837\u4FDD\u5B58\uFF08\u9AD8\u7EA7\u952E\uFF0C\u670D\u52A1\u7AEF\u653E\u884C\uFF09\u3002"] }), _jsxs("div", { className: "space-y-2", children: [draft.limits.map((row, i) => {
117
+ const key = row.key.trim();
118
+ const knownPrice = KNOWN_PRICE_KEYS.has(key);
119
+ const unknownKey = key !== '' && !knownPrice;
120
+ const unlimited = row.value.trim() === String(UNLIMITED);
121
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Input, { "aria-label": "\u9650\u5236\u952E\u540D", className: "w-48 font-mono", placeholder: "\u952E\u540D\uFF0C\u5982 credits", value: row.key, onChange: (e) => setRow(i, { key: e.target.value }) }), _jsx(Input, { "aria-label": "\u9650\u5236\u503C", type: "text", inputMode: "numeric", className: "w-36", placeholder: `整数;${UNLIMITED} = 无限`, value: row.value, onChange: (e) => setRow(i, { value: e.target.value }) }), knownPrice && (_jsx("span", { className: "text-xs text-muted-foreground", children: "\u4EF7\u683C\uFF08\u5355\u4F4D\uFF1A\u5206\uFF09" })), unknownKey && (_jsx(Badge, { variant: "outline", className: "text-muted-foreground", title: "billing \u4E0D\u8BA4\u8BC6\u7684\u9AD8\u7EA7\u952E\uFF1A\u539F\u6837\u4FDD\u5B58\uFF0C\u670D\u52A1\u7AEF\u653E\u884C", children: "\u9AD8\u7EA7\u952E" })), unlimited && (_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: "Unlimited\uFF08\u65E0\u9650\uFF09" })), _jsx("span", { className: "flex-1" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `删除限制 ${key || i + 1}`, onClick: () => removeRow(i), children: "\u5220\u9664" })] }, i));
122
+ }), draft.limits.length === 0 && (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u6682\u65E0\u9650\u5236\u952E\uFF08limits \u4E3A\u7A7A\u5BF9\u8C61\uFF09" }))] })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-3", children: [_jsx(Button, { type: "submit", size: "sm", disabled: saving, children: saving ? '保存中…' : '保存' }), shownError && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: shownError }))] })] }))] }));
123
+ }
124
+ export function PlansPage() {
125
+ const api = useApi();
126
+ const qc = useQueryClient();
127
+ const [openCodes, setOpenCodes] = useState(() => new Set());
128
+ const toggle = (code) => setOpenCodes((prev) => {
129
+ const next = new Set(prev);
130
+ if (next.has(code))
131
+ next.delete(code);
132
+ else
133
+ next.add(code);
134
+ return next;
135
+ });
136
+ const plansQ = useQuery({
137
+ queryKey: ['admin', 'plans'],
138
+ queryFn: () => api.get('/api/admin/plans'),
139
+ });
140
+ // 每卡独立的保存错误(404/400 后端消息就地展示,不弹 toast 抢焦点)
141
+ const [cardErrors, setCardErrors] = useState({});
142
+ const saveM = useMutation({
143
+ mutationFn: (v) => api.patch(`/api/admin/plans/${encodeURIComponent(v.code)}`, v.body),
144
+ onSuccess: (_d, v) => {
145
+ toast.success(`套餐 ${v.code} 已保存`);
146
+ setCardErrors((prev) => {
147
+ if (!(v.code in prev))
148
+ return prev;
149
+ const next = { ...prev };
150
+ delete next[v.code];
151
+ return next;
152
+ });
153
+ // 列表即时反映(WorkspacesPage 的套餐下拉同键,一并失效)
154
+ void qc.invalidateQueries({ queryKey: ['admin', 'plans'] });
155
+ },
156
+ onError: (e, v) => setCardErrors((prev) => ({
157
+ ...prev,
158
+ [v.code]: adminError(e, `套餐 ${v.code} 保存失败`),
159
+ })),
160
+ });
161
+ const plans = [...(plansQ.data?.plans ?? [])].sort((a, b) => a.sort_no - b.sort_no || a.code.localeCompare(b.code));
162
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u5957\u9910", description: "\u5957\u9910\u76EE\u5F55\u7F16\u8F91\uFF1A\u540D\u79F0 / \u5E01\u79CD / \u6392\u5E8F\u4E0E limits\uFF0C\u4FDD\u5B58\u5373\u751F\u6548\uFF08\u76F4\u8BFB\u5E93\u65E0\u7F13\u5B58\uFF09\uFF1B\u5DF2\u521B\u5EFA\u8BA2\u5355\u91D1\u989D\u4E0D\u53D7\u5F71\u54CD" }), plansQ.isError ? (_jsx(PageError, { message: adminError(plansQ.error, '套餐列表加载失败'), onRetry: () => void plansQ.refetch() })) : plansQ.isPending ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u52A0\u8F7D\u5957\u9910\u4E2D\u2026" })) : plans.length === 0 ? (_jsx(PageEmpty, { label: "\u6682\u65E0\u5957\u9910", hint: "\u5957\u9910\u7531\u8BA1\u8D39\u79CD\u5B50\u6570\u636E\u521B\u5EFA\uFF1B\u6B64\u9875\u7F16\u8F91\u5DF2\u5B58\u5728\u7684\u5957\u9910" })) : (_jsx("ul", { className: "space-y-3", children: plans.map((plan) => (_jsx(PlanCard, { plan: plan, open: openCodes.has(plan.code), saving: saveM.isPending && saveM.variables?.code === plan.code, apiError: cardErrors[plan.code], onToggle: () => toggle(plan.code), onSave: (code, body) => saveM.mutate({ code, body }) }, plan.code))) }))] }));
163
+ }
@@ -87,7 +87,10 @@ export function BillingPage({ channel = 'stripe' } = {}) {
87
87
  : null;
88
88
  const orders = subscription?.orders ?? [];
89
89
  const currentPlanCode = subscription?.plan_code ?? null;
90
- const sortedPlans = [...plans].sort((a, b) => a.sort_no - b.sort_no);
90
+ // UP-8/D4 (docs/plan-upstream-shuijing-admin-billing.md): filter by price-key capability, not code-prefix — a plan with neither price_monthly_cents nor price_yearly_cents (one_time pack) must not render as a ¥0 subscription card.
91
+ const sortedPlans = [...plans]
92
+ .filter((p) => p.limits.price_monthly_cents !== undefined || p.limits.price_yearly_cents !== undefined)
93
+ .sort((a, b) => a.sort_no - b.sort_no);
91
94
  const checkoutDescription = payChannel === 'manual'
92
95
  ? '升级立即生效;下单后请联系管理员完成线下转账,订单在管理员核销后生效。'
93
96
  : payChannel === 'stripe'
@@ -169,7 +172,7 @@ export function BillingPage({ channel = 'stripe' } = {}) {
169
172
  setPayChannelSel(v);
170
173
  syncParams({ channel: v });
171
174
  }
172
- }, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u652F\u4ED8\u6E20\u9053", children: _jsx(SelectValue, { children: channelLabel(payChannel) }) }), _jsx(SelectContent, { children: channelOptions.map((code) => (_jsx(SelectItem, { value: code, children: channelLabel(code) }, code))) })] }) })] }), manualNote && (_jsx("p", { role: "note", className: "mb-4 rounded-lg border-(--warning)/40 bg-(--warning)/15 px-3 py-2 text-sm text-(--warning)", children: manualNote })), _jsx("div", { className: "grid gap-4 lg:grid-cols-3", children: sortedPlans.map((plan) => {
175
+ }, children: [_jsx(SelectTrigger, { size: "sm", className: "w-44", "aria-label": "\u652F\u4ED8\u6E20\u9053", children: _jsx(SelectValue, { children: channelLabel(payChannel) }) }), _jsx(SelectContent, { children: channelOptions.map((code) => (_jsx(SelectItem, { value: code, children: channelLabel(code) }, code))) })] }) })] }), manualNote && (_jsx("p", { role: "note", className: "mb-4 rounded-lg border-(--warning)/40 bg-(--warning)/15 px-3 py-2 text-sm text-(--warning)", children: manualNote })), sortedPlans.length === 0 && (_jsx(PageEmpty, { label: "\u6682\u65E0\u53EF\u8BA2\u9605\u5957\u9910", hint: "\u7AD9\u70B9\u5C1A\u672A\u914D\u7F6E\u6309\u6708\u6216\u6309\u5E74\u8BA2\u9605\u7684\u5957\u9910\u3002" })), _jsx("div", { className: "grid gap-4 lg:grid-cols-3", children: sortedPlans.map((plan) => {
173
176
  const isCurrent = plan.code === currentPlanCode;
174
177
  const price = priceCentsOf(plan, interval);
175
178
  const features = Object.entries(plan.limits)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ploykit/ui",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@base-ui/react": "^1.8.0",
35
35
  "@ploykit/client": "^0.7.4",
36
- "@ploykit/hooks": "^0.7.4",
36
+ "@ploykit/hooks": "^0.8.0",
37
37
  "class-variance-authority": "^0.7.1",
38
38
  "clsx": "^2.1.1",
39
39
  "cn": "^0.4.0",
package/src/index.ts CHANGED
@@ -143,6 +143,7 @@ export { UsersPage } from './pages/admin/UsersPage'
143
143
  export { UserDetailPage } from './pages/admin/UserDetailPage'
144
144
  export { WorkspacesPage } from './pages/admin/WorkspacesPage'
145
145
  export { WorkspaceDetailPage } from './pages/admin/WorkspaceDetailPage'
146
+ export { PlansPage } from './pages/admin/PlansPage'
146
147
  export { OrdersPage } from './pages/admin/OrdersPage'
147
148
  export { WebhookDeliveriesPage } from './pages/admin/WebhookDeliveriesPage'
148
149
  export { AuditPage } from './pages/admin/AuditPage'