@ploykit/ui 0.7.2 → 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/README.md +15 -0
- package/dist/components/NotificationBell.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pages/LoginPage.js +1 -1
- package/dist/pages/admin/AdminSettingsPage.js +94 -3
- package/dist/pages/admin/AdminShell.js +6 -1
- package/dist/pages/admin/PlansPage.d.ts +9 -0
- package/dist/pages/admin/PlansPage.js +163 -0
- package/dist/pages/workspace/BillingPage.js +5 -2
- package/package.json +6 -4
- package/src/components/NotificationBell.tsx +1 -1
- package/src/index.ts +1 -0
- package/src/pages/LoginPage.tsx +1 -1
- package/src/pages/admin/AdminSettingsPage.tsx +296 -1
- package/src/pages/admin/AdminShell.tsx +6 -0
- package/src/pages/admin/PlansPage.tsx +469 -0
- package/src/pages/admin/__tests__/AdminSettingsPage.test.tsx +220 -1
- package/src/pages/admin/__tests__/PlansPage.test.tsx +214 -0
- package/src/pages/workspace/BillingPage.tsx +8 -1
- package/src/pages/workspace/__tests__/BillingPage.test.tsx +32 -2
- package/tailwind.css +7 -0
package/README.md
CHANGED
|
@@ -194,6 +194,21 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
|
|
|
194
194
|
- **工具**:`cn`、`formatDate` / `formatDateTime` / `shortID`、`statusTone` / `statusLabel`、`apiErrorMessage`、`FormField`、`CronInput`。
|
|
195
195
|
- 基础 UI 件(Button / Input / Card / Tabs / AlertDialog / DropdownMenu / Popover / Switch / Checkbox / Progress / Avatar / Breadcrumb / Empty / Field / Label / Tooltip / Skeleton / Separator…)全部从 barrel 导出;也可走子路径 `import { Button } from '@ploykit/ui/components/ui/button'`(推荐统一走 barrel)。
|
|
196
196
|
|
|
197
|
+
## Tailwind v4 集成
|
|
198
|
+
|
|
199
|
+
本包组件的 class 全部走语义令牌(`bg-card`、`text-muted-foreground`…),需要 Tailwind 扫描到本包源码才会生成对应规则。Tailwind v4 的自动内容探测会跳过 node_modules,因此按消费方式二选一:
|
|
200
|
+
|
|
201
|
+
- **monorepo 消费方**(workspace 软链):可保留自己写的 `@source "<相对路径>/packages/ui/src";`,也可改用下面的 import(推荐——相对路径在产品拆出独立仓库后会静默失效,曾有产品因此上线了无样式的登录页数周未察觉)。
|
|
202
|
+
- **独立(npm)消费方**:在 CSS 入口(如 `src/index.css`)加一行:
|
|
203
|
+
|
|
204
|
+
```css
|
|
205
|
+
@import "@ploykit/ui/tailwind.css";
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`@ploykit/ui/tailwind.css` 内部只有一条 `@source "./src";`。`@source` 相对样式表文件自身解析,因此无论包位于 monorepo workspace 还是 node_modules,都指向包内的 `src/`,无需消费方关心安装路径。
|
|
209
|
+
|
|
210
|
+
令牌定义(`--card` / `--popover` 等 CSS 变量与 `@theme` 映射)仍由消费方按 shadcn 惯例在自己的 CSS 里提供(参见 `example/web/src/index.css`)。
|
|
211
|
+
|
|
197
212
|
## 自定义三个层级
|
|
198
213
|
|
|
199
214
|
1. **换皮**:CSS/Tailwind 覆盖样式(组件全部走 design token:`bg-popover`、`text-muted-foreground` 等)。
|
|
@@ -40,7 +40,7 @@ export function NotificationBell({ onNavigate }) {
|
|
|
40
40
|
document.addEventListener('mousedown', onClickOutside);
|
|
41
41
|
return () => document.removeEventListener('mousedown', onClickOutside);
|
|
42
42
|
}, []);
|
|
43
|
-
return (_jsxs("div", { ref: ref, className: "relative", children: [_jsxs("button", { onClick: () => setOpen(!open), className: "relative p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md\n focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", "aria-label": "\u901A\u77E5", "aria-haspopup": "true", "aria-expanded": open, children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" }) }), unread > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px]\n px-1 text-xs font-bold text-white bg-destructive rounded-full", children: unread > 99 ? '99+' : unread }))] }), open && (_jsxs("div", { className: "absolute right-0 mt-2 w-80 bg-
|
|
43
|
+
return (_jsxs("div", { ref: ref, className: "relative", children: [_jsxs("button", { onClick: () => setOpen(!open), className: "relative p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md\n focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", "aria-label": "\u901A\u77E5", "aria-haspopup": "true", "aria-expanded": open, children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" }) }), unread > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px]\n px-1 text-xs font-bold text-white bg-destructive rounded-full", children: unread > 99 ? '99+' : unread }))] }), open && (_jsxs("div", { className: "absolute right-0 mt-2 w-80 bg-popover rounded-lg shadow-lg border z-50", role: "dialog", "aria-label": "\u901A\u77E5\u5217\u8868", onKeyDown: (e) => { if (e.key === 'Escape')
|
|
44
44
|
setOpen(false); }, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-b", children: [_jsx("span", { className: "text-sm font-semibold", children: "\u901A\u77E5" }), unread > 0 && (_jsx("button", { onClick: () => markAllReadM.mutate(), disabled: markAllReadM.isPending, className: "text-xs text-primary hover:underline disabled:opacity-50\n focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u5168\u90E8\u5DF2\u8BFB" }))] }), _jsx("ul", { className: "max-h-80 overflow-y-auto list-none p-0 m-0", "data-slot": "notification-list", children: listQ.isPending ? (_jsx("li", { className: "py-8 text-center text-sm text-muted-foreground", "aria-live": "polite", children: "\u52A0\u8F7D\u4E2D\u2026" })) : notifications.length === 0 ? (_jsx("li", { className: "py-8 text-center text-sm text-muted-foreground", children: "\u6682\u65E0\u901A\u77E5" })) : notifications.map((n) => (_jsx("li", { className: "border-b last:border-0", children: _jsxs("button", { type: "button", onClick: () => { if (n.link)
|
|
45
45
|
onNavigate?.(n.link); markReadM.mutate(n.id); setOpen(false); }, className: cn('w-full text-left px-4 py-3 cursor-pointer hover:bg-muted ' +
|
|
46
46
|
'focus-visible:outline-none focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring', !n.read_at && 'bg-accent/50'), children: [_jsxs("span", { className: "flex items-start justify-between gap-2", children: [_jsx("span", { className: "text-sm font-medium text-foreground", children: n.title }), n.count > 1 && (_jsxs("span", { className: "text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0", children: ["\u00D7", n.count] }))] }), n.body && _jsx("span", { className: "block mt-0.5 text-xs text-muted-foreground line-clamp-2", children: n.body }), _jsx("span", { className: "block mt-1 text-[10px] text-muted-foreground", children: formatDateTime(n.created_at) })] }) }, n.id))) })] }))] }));
|
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';
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -77,7 +77,7 @@ export function LoginPage({ resetPath = '/forgot-password', registerPath = '/reg
|
|
|
77
77
|
setPwLoading(false);
|
|
78
78
|
}
|
|
79
79
|
};
|
|
80
|
-
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-background", children: _jsxs("div", { className: "w-full max-w-sm space-y-4 p-8 bg-
|
|
80
|
+
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-background", children: _jsxs("div", { className: "w-full max-w-sm space-y-4 p-8 bg-card rounded-lg shadow", children: [_jsxs("div", { className: "text-center", children: [_jsx("h1", { className: "text-2xl font-bold", children: title }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })] }), passwordOnly ? (_jsxs("form", { onSubmit: handlePasswordSubmit, className: "space-y-4 pt-2", children: [_jsx(FormField, { label: "\u90AE\u7BB1", htmlFor: "login-pw-email", children: _jsx(Input, { id: "login-pw-email", type: "email", value: email, required: true, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", autoComplete: "email" }) }), _jsx(FormField, { label: "\u5BC6\u7801", htmlFor: "login-pw-password", children: _jsx(Input, { id: "login-pw-password", type: "password", value: password, required: true, onChange: (e) => setPassword(e.target.value), placeholder: "\u8F93\u5165\u5BC6\u7801", autoComplete: "current-password" }) }), pwError && _jsx("p", { role: "alert", className: "text-destructive text-sm", children: pwError }), _jsx(Button, { type: "submit", className: "w-full", disabled: pwLoading || !email || !password, children: pwLoading ? '登录中…' : '登录' })] })) : (_jsxs(Tabs, { defaultValue: "code", children: [_jsxs(TabsList, { className: "w-full", children: [_jsx(TabsTrigger, { value: "code", children: "\u9A8C\u8BC1\u7801\u767B\u5F55" }), _jsx(TabsTrigger, { value: "password", children: "\u5BC6\u7801\u767B\u5F55" })] }), _jsx(TabsContent, { value: "code", children: _jsxs("form", { onSubmit: handleCodeSubmit, className: "space-y-4 pt-2", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("label", { htmlFor: "login-email", className: "text-sm text-foreground", children: "\u90AE\u7BB1" }), _jsx("input", { id: "login-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", required: true, autoComplete: "email", className: "w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" })] }), _jsxs("div", { className: "space-y-1", children: [_jsx("label", { htmlFor: "login-code", className: "text-sm text-foreground", children: "\u9A8C\u8BC1\u7801" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: "login-code", type: "text", value: code, onChange: (e) => setCode(e.target.value), placeholder: "6\u4F4D\u6570\u5B57", maxLength: 6, required: true, autoComplete: "one-time-code", className: "flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" }), _jsx("button", { type: "button", onClick: handleSendCode, disabled: sending || cooldown > 0 || !email, className: "px-4 py-2 text-sm text-primary border border-primary/30 rounded-md hover:bg-primary/10 disabled:opacity-50", children: cooldown > 0
|
|
81
81
|
? `重发(${cooldown}s)`
|
|
82
82
|
: codeSent
|
|
83
83
|
? '重新发送'
|
|
@@ -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
|
-
|
|
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: "\
|
|
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,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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
"types": "./dist/components/ui/*.d.ts",
|
|
16
16
|
"default": "./dist/components/ui/*.js"
|
|
17
17
|
},
|
|
18
|
+
"./tailwind.css": "./tailwind.css",
|
|
18
19
|
"./package.json": "./package.json"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist",
|
|
22
|
-
"src"
|
|
23
|
+
"src",
|
|
24
|
+
"tailwind.css"
|
|
23
25
|
],
|
|
24
26
|
"peerDependencies": {
|
|
25
27
|
"@tanstack/react-query": "^5.0.0",
|
|
@@ -30,8 +32,8 @@
|
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|
|
32
34
|
"@base-ui/react": "^1.8.0",
|
|
33
|
-
"@ploykit/client": "^0.7.
|
|
34
|
-
"@ploykit/hooks": "^0.
|
|
35
|
+
"@ploykit/client": "^0.7.4",
|
|
36
|
+
"@ploykit/hooks": "^0.8.0",
|
|
35
37
|
"class-variance-authority": "^0.7.1",
|
|
36
38
|
"clsx": "^2.1.1",
|
|
37
39
|
"cn": "^0.4.0",
|
|
@@ -81,7 +81,7 @@ export function NotificationBell({ onNavigate }: { onNavigate?: (link: string) =
|
|
|
81
81
|
|
|
82
82
|
{open && (
|
|
83
83
|
<div
|
|
84
|
-
className="absolute right-0 mt-2 w-80 bg-
|
|
84
|
+
className="absolute right-0 mt-2 w-80 bg-popover rounded-lg shadow-lg border z-50"
|
|
85
85
|
role="dialog"
|
|
86
86
|
aria-label="通知列表"
|
|
87
87
|
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
|
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'
|
package/src/pages/LoginPage.tsx
CHANGED
|
@@ -127,7 +127,7 @@ export function LoginPage({
|
|
|
127
127
|
|
|
128
128
|
return (
|
|
129
129
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
130
|
-
<div className="w-full max-w-sm space-y-4 p-8 bg-
|
|
130
|
+
<div className="w-full max-w-sm space-y-4 p-8 bg-card rounded-lg shadow">
|
|
131
131
|
<div className="text-center">
|
|
132
132
|
<h1 className="text-2xl font-bold">{title}</h1>
|
|
133
133
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|