@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
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
|
|
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';
|
|
6
|
+
import { PageEmpty, PageError, PageHeader } from '../../components/Page';
|
|
7
|
+
import { toast } from '../../components/toast';
|
|
8
|
+
import { Badge } from '../../components/ui/badge';
|
|
9
|
+
import { Button } from '../../components/ui/button';
|
|
10
|
+
import { Input } from '../../components/ui/input';
|
|
11
|
+
import {
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from '../../components/ui/select';
|
|
18
|
+
import { useApi } from '@ploykit/hooks';
|
|
19
|
+
import { adminError } from './shared';
|
|
20
|
+
|
|
21
|
+
// UP-5:套餐目录编辑。GET /api/admin/plans → {items, plans: PlanView[]};
|
|
22
|
+
// PATCH /api/admin/plans/{code} 全量语义(body 整卡提交,code 不可改走路径)。
|
|
23
|
+
// PATCH 落库即生效(所有读取方直读库无缓存),已创建订单金额不受影响。
|
|
24
|
+
|
|
25
|
+
export interface AdminPlanView {
|
|
26
|
+
code: string;
|
|
27
|
+
name: string;
|
|
28
|
+
currency: string;
|
|
29
|
+
trial_days: number;
|
|
30
|
+
sort_no: number;
|
|
31
|
+
limits: Record<string, number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AdminPlansEnvelope {
|
|
35
|
+
items: string[];
|
|
36
|
+
plans: AdminPlanView[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PlanUpdateBody {
|
|
40
|
+
name: string;
|
|
41
|
+
currency: string;
|
|
42
|
+
sort_no: number;
|
|
43
|
+
limits: Record<string, number>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 服务端校验兜底(limits 键 ^[a-z][a-z0-9_]{0,63}$、值整数、-1=无限哨兵);
|
|
47
|
+
// 这里做同规则的轻量前置,避免明显的坏请求打过去。
|
|
48
|
+
const LIMIT_KEY_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
49
|
+
const CURRENCY_RE = /^[A-Z]{3}$/;
|
|
50
|
+
const INT_RE = /^-?\d+$/;
|
|
51
|
+
|
|
52
|
+
const COMMON_CURRENCIES = ['USD', 'EUR', 'CNY', 'GBP'];
|
|
53
|
+
|
|
54
|
+
// 已知价格键:渲染为数值输入并给单位提示;其余键给"高级键"弱提示但放行
|
|
55
|
+
// (limits 是自由 KV,billing 不认识的键无害)。
|
|
56
|
+
const KNOWN_PRICE_KEYS = new Set([
|
|
57
|
+
'price_monthly_cents',
|
|
58
|
+
'price_yearly_cents',
|
|
59
|
+
'price_one_time_cents',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const UNLIMITED = -1;
|
|
63
|
+
|
|
64
|
+
interface LimitRow {
|
|
65
|
+
key: string;
|
|
66
|
+
value: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PlanDraft {
|
|
70
|
+
name: string;
|
|
71
|
+
currency: string;
|
|
72
|
+
sortNo: string;
|
|
73
|
+
limits: LimitRow[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function draftFrom(plan: AdminPlanView): PlanDraft {
|
|
77
|
+
return {
|
|
78
|
+
name: plan.name,
|
|
79
|
+
currency: plan.currency,
|
|
80
|
+
sortNo: String(plan.sort_no),
|
|
81
|
+
limits: Object.entries(plan.limits ?? {}).map(([key, value]) => ({
|
|
82
|
+
key,
|
|
83
|
+
value: String(value),
|
|
84
|
+
})),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildBody(
|
|
89
|
+
d: PlanDraft,
|
|
90
|
+
): { body?: PlanUpdateBody; error?: string } {
|
|
91
|
+
const name = d.name.trim();
|
|
92
|
+
if (!name) return { error: '名称不能为空' };
|
|
93
|
+
if (!CURRENCY_RE.test(d.currency.trim()))
|
|
94
|
+
return { error: '币种需为 3 位大写字母(如 USD)' };
|
|
95
|
+
const sortNo = d.sortNo.trim();
|
|
96
|
+
if (!INT_RE.test(sortNo)) return { error: '排序需为整数' };
|
|
97
|
+
const limits: Record<string, number> = {};
|
|
98
|
+
for (const row of d.limits) {
|
|
99
|
+
const k = row.key.trim();
|
|
100
|
+
const v = row.value.trim();
|
|
101
|
+
// 整行留空的行忽略(误触加行不阻塞保存)
|
|
102
|
+
if (!k && !v) continue;
|
|
103
|
+
if (!k) return { error: '存在未填写键名的限制行' };
|
|
104
|
+
if (!LIMIT_KEY_RE.test(k))
|
|
105
|
+
return {
|
|
106
|
+
error: `限制键 ${k} 格式有误:小写字母开头,仅小写字母/数字/下划线`,
|
|
107
|
+
};
|
|
108
|
+
if (k in limits) return { error: `限制键 ${k} 重复` };
|
|
109
|
+
if (!INT_RE.test(v))
|
|
110
|
+
return { error: `限制 ${k} 的值需为整数(${UNLIMITED} 表示无限)` };
|
|
111
|
+
limits[k] = Number(v);
|
|
112
|
+
}
|
|
113
|
+
return { body: { name, currency: d.currency.trim(), sort_no: Number(sortNo), limits } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface PlanCardProps {
|
|
117
|
+
plan: AdminPlanView;
|
|
118
|
+
open: boolean;
|
|
119
|
+
saving: boolean;
|
|
120
|
+
apiError?: string;
|
|
121
|
+
onToggle: () => void;
|
|
122
|
+
onSave: (code: string, body: PlanUpdateBody) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function PlanCard({ plan, open, saving, apiError, onToggle, onSave }: PlanCardProps) {
|
|
126
|
+
const [draft, setDraft] = useState<PlanDraft>(() => draftFrom(plan));
|
|
127
|
+
const [customCurrency, setCustomCurrency] = useState(
|
|
128
|
+
!COMMON_CURRENCIES.includes(plan.currency),
|
|
129
|
+
);
|
|
130
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
131
|
+
|
|
132
|
+
// 保存成功后列表失效重取,新 plan 到达即重置草稿(列表即时反映)
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
setDraft(draftFrom(plan));
|
|
135
|
+
setCustomCurrency(!COMMON_CURRENCIES.includes(plan.currency));
|
|
136
|
+
setFormError(null);
|
|
137
|
+
}, [plan]);
|
|
138
|
+
|
|
139
|
+
const limitCount = draft.limits.length;
|
|
140
|
+
|
|
141
|
+
const addRow = () =>
|
|
142
|
+
setDraft((d) => ({ ...d, limits: [...d.limits, { key: '', value: '' }] }));
|
|
143
|
+
|
|
144
|
+
const removeRow = (i: number) =>
|
|
145
|
+
setDraft((d) => ({
|
|
146
|
+
...d,
|
|
147
|
+
limits: d.limits.filter((_, idx) => idx !== i),
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
const setRow = (i: number, patch: Partial<LimitRow>) =>
|
|
151
|
+
setDraft((d) => ({
|
|
152
|
+
...d,
|
|
153
|
+
limits: d.limits.map((row, idx) => (idx === i ? { ...row, ...patch } : row)),
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
const submit = () => {
|
|
157
|
+
setFormError(null);
|
|
158
|
+
const { body, error } = buildBody(draft);
|
|
159
|
+
if (!body) {
|
|
160
|
+
setFormError(error ?? '表单有误');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
onSave(plan.code, body);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const shownError = formError ?? apiError;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<li className="rounded-lg border">
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={onToggle}
|
|
173
|
+
aria-expanded={open}
|
|
174
|
+
aria-label={`编辑套餐 ${plan.code}`}
|
|
175
|
+
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"
|
|
176
|
+
>
|
|
177
|
+
{open ? (
|
|
178
|
+
<ChevronDown size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
|
|
179
|
+
) : (
|
|
180
|
+
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
|
|
181
|
+
)}
|
|
182
|
+
<span className="font-medium">{plan.name}</span>
|
|
183
|
+
<code
|
|
184
|
+
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
|
|
185
|
+
title="套餐代码不可修改"
|
|
186
|
+
>
|
|
187
|
+
{plan.code}
|
|
188
|
+
</code>
|
|
189
|
+
<Badge variant="outline">{plan.currency}</Badge>
|
|
190
|
+
<span className="text-xs text-muted-foreground">排序 {plan.sort_no}</span>
|
|
191
|
+
{plan.trial_days > 0 && (
|
|
192
|
+
<span className="text-xs text-muted-foreground">
|
|
193
|
+
试用 {plan.trial_days} 天
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
196
|
+
<span className="text-xs text-muted-foreground">
|
|
197
|
+
{limitCount} 项限制
|
|
198
|
+
</span>
|
|
199
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
200
|
+
{open ? '收起' : '展开编辑'}
|
|
201
|
+
</span>
|
|
202
|
+
</button>
|
|
203
|
+
|
|
204
|
+
{open && (
|
|
205
|
+
<form
|
|
206
|
+
className="border-t px-4 py-4"
|
|
207
|
+
onSubmit={(e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
submit();
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<p className="mb-4 text-xs text-muted-foreground">
|
|
213
|
+
保存后立即生效(直读库无缓存);已创建订单的金额不受影响。
|
|
214
|
+
</p>
|
|
215
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
216
|
+
<FormField label="名称" htmlFor={`plans-name-${plan.code}`}>
|
|
217
|
+
<Input
|
|
218
|
+
id={`plans-name-${plan.code}`}
|
|
219
|
+
value={draft.name}
|
|
220
|
+
onChange={(e) =>
|
|
221
|
+
setDraft((d) => ({ ...d, name: e.target.value }))
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
224
|
+
</FormField>
|
|
225
|
+
<FormField
|
|
226
|
+
label="币种"
|
|
227
|
+
description="3 位大写字母;常用币种可选,也可自由输入"
|
|
228
|
+
>
|
|
229
|
+
<Select
|
|
230
|
+
value={customCurrency ? 'custom' : draft.currency}
|
|
231
|
+
onValueChange={(v) => {
|
|
232
|
+
if (v === 'custom') {
|
|
233
|
+
setCustomCurrency(true);
|
|
234
|
+
} else {
|
|
235
|
+
setCustomCurrency(false);
|
|
236
|
+
setDraft((d) => ({ ...d, currency: v }));
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<SelectTrigger aria-label="币种" size="sm">
|
|
241
|
+
<SelectValue />
|
|
242
|
+
</SelectTrigger>
|
|
243
|
+
<SelectContent align="start">
|
|
244
|
+
{COMMON_CURRENCIES.map((c) => (
|
|
245
|
+
<SelectItem key={c} value={c}>
|
|
246
|
+
{c}
|
|
247
|
+
</SelectItem>
|
|
248
|
+
))}
|
|
249
|
+
<SelectItem value="custom">自定义…</SelectItem>
|
|
250
|
+
</SelectContent>
|
|
251
|
+
</Select>
|
|
252
|
+
{customCurrency && (
|
|
253
|
+
<Input
|
|
254
|
+
className="mt-2 font-mono"
|
|
255
|
+
aria-label="自定义币种"
|
|
256
|
+
placeholder="如 JPY"
|
|
257
|
+
value={draft.currency}
|
|
258
|
+
onChange={(e) =>
|
|
259
|
+
setDraft((d) => ({
|
|
260
|
+
...d,
|
|
261
|
+
currency: e.target.value
|
|
262
|
+
.toUpperCase()
|
|
263
|
+
.replace(/[^A-Z]/g, '')
|
|
264
|
+
.slice(0, 3),
|
|
265
|
+
}))
|
|
266
|
+
}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</FormField>
|
|
270
|
+
<FormField
|
|
271
|
+
label="排序"
|
|
272
|
+
htmlFor={`plans-sort-${plan.code}`}
|
|
273
|
+
description="展示顺序(任意整数)"
|
|
274
|
+
>
|
|
275
|
+
{/* B-settings-10 同款处置:text + inputMode(type=number 对垃圾
|
|
276
|
+
字符静默置空);非法值保存前拒绝并就地提示 */}
|
|
277
|
+
<Input
|
|
278
|
+
id={`plans-sort-${plan.code}`}
|
|
279
|
+
type="text"
|
|
280
|
+
inputMode="numeric"
|
|
281
|
+
value={draft.sortNo}
|
|
282
|
+
onChange={(e) =>
|
|
283
|
+
setDraft((d) => ({ ...d, sortNo: e.target.value }))
|
|
284
|
+
}
|
|
285
|
+
/>
|
|
286
|
+
</FormField>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div className="mt-6">
|
|
290
|
+
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
291
|
+
<p className="text-sm font-medium">限制(limits)</p>
|
|
292
|
+
<Button
|
|
293
|
+
type="button"
|
|
294
|
+
variant="outline"
|
|
295
|
+
size="sm"
|
|
296
|
+
onClick={addRow}
|
|
297
|
+
>
|
|
298
|
+
<Plus size={14} aria-hidden="true" /> 添加限制键
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
<p className="mb-2 text-xs text-muted-foreground">
|
|
302
|
+
值为整数,{UNLIMITED} 表示无限(Unlimited);键名小写字母开头,仅小写字母/数字/下划线。未知键原样保存(高级键,服务端放行)。
|
|
303
|
+
</p>
|
|
304
|
+
<div className="space-y-2">
|
|
305
|
+
{draft.limits.map((row, i) => {
|
|
306
|
+
const key = row.key.trim();
|
|
307
|
+
const knownPrice = KNOWN_PRICE_KEYS.has(key);
|
|
308
|
+
const unknownKey = key !== '' && !knownPrice;
|
|
309
|
+
const unlimited = row.value.trim() === String(UNLIMITED);
|
|
310
|
+
return (
|
|
311
|
+
<div key={i} className="flex flex-wrap items-center gap-2">
|
|
312
|
+
<Input
|
|
313
|
+
aria-label="限制键名"
|
|
314
|
+
className="w-48 font-mono"
|
|
315
|
+
placeholder="键名,如 credits"
|
|
316
|
+
value={row.key}
|
|
317
|
+
onChange={(e) => setRow(i, { key: e.target.value })}
|
|
318
|
+
/>
|
|
319
|
+
<Input
|
|
320
|
+
aria-label="限制值"
|
|
321
|
+
type="text"
|
|
322
|
+
inputMode="numeric"
|
|
323
|
+
className="w-36"
|
|
324
|
+
placeholder={`整数;${UNLIMITED} = 无限`}
|
|
325
|
+
value={row.value}
|
|
326
|
+
onChange={(e) => setRow(i, { value: e.target.value })}
|
|
327
|
+
/>
|
|
328
|
+
{knownPrice && (
|
|
329
|
+
<span className="text-xs text-muted-foreground">
|
|
330
|
+
价格(单位:分)
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
{unknownKey && (
|
|
334
|
+
<Badge
|
|
335
|
+
variant="outline"
|
|
336
|
+
className="text-muted-foreground"
|
|
337
|
+
title="billing 不认识的高级键:原样保存,服务端放行"
|
|
338
|
+
>
|
|
339
|
+
高级键
|
|
340
|
+
</Badge>
|
|
341
|
+
)}
|
|
342
|
+
{unlimited && (
|
|
343
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
344
|
+
Unlimited(无限)
|
|
345
|
+
</span>
|
|
346
|
+
)}
|
|
347
|
+
<span className="flex-1" />
|
|
348
|
+
<Button
|
|
349
|
+
type="button"
|
|
350
|
+
variant="ghost"
|
|
351
|
+
size="sm"
|
|
352
|
+
aria-label={`删除限制 ${key || i + 1}`}
|
|
353
|
+
onClick={() => removeRow(i)}
|
|
354
|
+
>
|
|
355
|
+
删除
|
|
356
|
+
</Button>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
})}
|
|
360
|
+
{draft.limits.length === 0 && (
|
|
361
|
+
<p className="text-sm text-muted-foreground">
|
|
362
|
+
暂无限制键(limits 为空对象)
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
369
|
+
<Button type="submit" size="sm" disabled={saving}>
|
|
370
|
+
{saving ? '保存中…' : '保存'}
|
|
371
|
+
</Button>
|
|
372
|
+
{shownError && (
|
|
373
|
+
<p role="alert" className="text-sm text-destructive">
|
|
374
|
+
{shownError}
|
|
375
|
+
</p>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
</form>
|
|
379
|
+
)}
|
|
380
|
+
</li>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function PlansPage() {
|
|
385
|
+
const api = useApi();
|
|
386
|
+
const qc = useQueryClient();
|
|
387
|
+
|
|
388
|
+
const [openCodes, setOpenCodes] = useState<Set<string>>(() => new Set());
|
|
389
|
+
const toggle = (code: string) =>
|
|
390
|
+
setOpenCodes((prev) => {
|
|
391
|
+
const next = new Set(prev);
|
|
392
|
+
if (next.has(code)) next.delete(code);
|
|
393
|
+
else next.add(code);
|
|
394
|
+
return next;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const plansQ = useQuery({
|
|
398
|
+
queryKey: ['admin', 'plans'],
|
|
399
|
+
queryFn: () => api.get<AdminPlansEnvelope>('/api/admin/plans'),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// 每卡独立的保存错误(404/400 后端消息就地展示,不弹 toast 抢焦点)
|
|
403
|
+
const [cardErrors, setCardErrors] = useState<Record<string, string>>({});
|
|
404
|
+
|
|
405
|
+
const saveM = useMutation({
|
|
406
|
+
mutationFn: (v: { code: string; body: PlanUpdateBody }) =>
|
|
407
|
+
api.patch<AdminPlanView>(
|
|
408
|
+
`/api/admin/plans/${encodeURIComponent(v.code)}`,
|
|
409
|
+
v.body,
|
|
410
|
+
),
|
|
411
|
+
onSuccess: (_d, v) => {
|
|
412
|
+
toast.success(`套餐 ${v.code} 已保存`);
|
|
413
|
+
setCardErrors((prev) => {
|
|
414
|
+
if (!(v.code in prev)) return prev;
|
|
415
|
+
const next = { ...prev };
|
|
416
|
+
delete next[v.code];
|
|
417
|
+
return next;
|
|
418
|
+
});
|
|
419
|
+
// 列表即时反映(WorkspacesPage 的套餐下拉同键,一并失效)
|
|
420
|
+
void qc.invalidateQueries({ queryKey: ['admin', 'plans'] });
|
|
421
|
+
},
|
|
422
|
+
onError: (e, v) =>
|
|
423
|
+
setCardErrors((prev) => ({
|
|
424
|
+
...prev,
|
|
425
|
+
[v.code]: adminError(e, `套餐 ${v.code} 保存失败`),
|
|
426
|
+
})),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const plans = [...(plansQ.data?.plans ?? [])].sort(
|
|
430
|
+
(a, b) => a.sort_no - b.sort_no || a.code.localeCompare(b.code),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div>
|
|
435
|
+
<PageHeader
|
|
436
|
+
title="套餐"
|
|
437
|
+
description="套餐目录编辑:名称 / 币种 / 排序与 limits,保存即生效(直读库无缓存);已创建订单金额不受影响"
|
|
438
|
+
/>
|
|
439
|
+
|
|
440
|
+
{plansQ.isError ? (
|
|
441
|
+
<PageError
|
|
442
|
+
message={adminError(plansQ.error, '套餐列表加载失败')}
|
|
443
|
+
onRetry={() => void plansQ.refetch()}
|
|
444
|
+
/>
|
|
445
|
+
) : plansQ.isPending ? (
|
|
446
|
+
<p className="text-sm text-muted-foreground">加载套餐中…</p>
|
|
447
|
+
) : plans.length === 0 ? (
|
|
448
|
+
<PageEmpty
|
|
449
|
+
label="暂无套餐"
|
|
450
|
+
hint="套餐由计费种子数据创建;此页编辑已存在的套餐"
|
|
451
|
+
/>
|
|
452
|
+
) : (
|
|
453
|
+
<ul className="space-y-3">
|
|
454
|
+
{plans.map((plan) => (
|
|
455
|
+
<PlanCard
|
|
456
|
+
key={plan.code}
|
|
457
|
+
plan={plan}
|
|
458
|
+
open={openCodes.has(plan.code)}
|
|
459
|
+
saving={saveM.isPending && saveM.variables?.code === plan.code}
|
|
460
|
+
apiError={cardErrors[plan.code]}
|
|
461
|
+
onToggle={() => toggle(plan.code)}
|
|
462
|
+
onSave={(code, body) => saveM.mutate({ code, body })}
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|