@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
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';
|
|
5
6
|
import { PageError, PageHeader } from '../../components/Page';
|
|
@@ -29,6 +30,11 @@ import { SITE_CONFIG_QUERY_KEY } from '@ploykit/hooks';
|
|
|
29
30
|
import { adminError } from './shared';
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
interface OAuthProviderInfo {
|
|
34
|
+
name: string;
|
|
35
|
+
callback_url: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
interface AdminSettingsEnvelope {
|
|
33
39
|
|
|
34
40
|
items: Record<string, string>;
|
|
@@ -36,6 +42,10 @@ interface AdminSettingsEnvelope {
|
|
|
36
42
|
effective: Record<string, string>;
|
|
37
43
|
defaults: Record<string, string>;
|
|
38
44
|
descriptions: Record<string, string>;
|
|
45
|
+
/** 出站邮件通道(UP-1):smtp | dev | "" */
|
|
46
|
+
mail_mode: string;
|
|
47
|
+
/** OAuth provider 状态(UP-6);字段缺失 = 后端未接线,整卡隐藏 */
|
|
48
|
+
oauth?: OAuthProviderInfo[];
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
|
|
@@ -72,6 +82,22 @@ function parseRateLimit(
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
|
|
85
|
+
// SMTP 端口:留空合法(后端缺省 587),非空必须是 1..65535 的整数
|
|
86
|
+
function parseSmtpPort(
|
|
87
|
+
raw: string,
|
|
88
|
+
): { ok: true; v: string } | { ok: false; msg: string } {
|
|
89
|
+
const s = raw.trim();
|
|
90
|
+
if (s === '') {
|
|
91
|
+
return { ok: true, v: '' };
|
|
92
|
+
}
|
|
93
|
+
const n = Number(s);
|
|
94
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
95
|
+
return { ok: false, msg: '必须是 1..65535 的整数(留空 = 用缺省 587)' };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, v: s };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
75
101
|
async function putKeys(
|
|
76
102
|
api: { put: (url: string, body: unknown) => Promise<unknown> },
|
|
77
103
|
entries: { key: string; label: string; value: string }[],
|
|
@@ -88,6 +114,19 @@ async function putKeys(
|
|
|
88
114
|
return results;
|
|
89
115
|
}
|
|
90
116
|
|
|
117
|
+
|
|
118
|
+
// UP-6 登录方式卡:复制回调地址(与 OverviewPage 复制工作区 ID 同款降级策略)
|
|
119
|
+
function copyCallbackUrl(name: string, url: string) {
|
|
120
|
+
if (typeof navigator.clipboard?.writeText === 'function') {
|
|
121
|
+
void navigator.clipboard
|
|
122
|
+
.writeText(url)
|
|
123
|
+
.then(() => toast.success(`${name} 回调地址已复制`))
|
|
124
|
+
.catch(() => toast.error(`复制失败,完整地址:${url}`));
|
|
125
|
+
} else {
|
|
126
|
+
toast.info(`当前环境不支持自动复制,完整地址:${url}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
91
130
|
export function AdminSettingsPage() {
|
|
92
131
|
const api = useApi();
|
|
93
132
|
const qc = useQueryClient();
|
|
@@ -108,6 +147,15 @@ export function AdminSettingsPage() {
|
|
|
108
147
|
const [rlPerIP, setRlPerIP] = useState('30');
|
|
109
148
|
const [rlAllowlist, setRlAllowlist] = useState('');
|
|
110
149
|
const [rlError, setRlError] = useState<string | null>(null);
|
|
150
|
+
const [smtpHost, setSmtpHost] = useState('');
|
|
151
|
+
const [smtpPort, setSmtpPort] = useState('');
|
|
152
|
+
const [smtpUser, setSmtpUser] = useState('');
|
|
153
|
+
// UP-1 水合规则:smtp_pass 永不回填(真实值与掩码回显都不进 state),
|
|
154
|
+
// state 保持空串、placeholder 显示掩码;留空保存 = 不修改密码
|
|
155
|
+
const [smtpPass, setSmtpPass] = useState('');
|
|
156
|
+
const [smtpFrom, setSmtpFrom] = useState('');
|
|
157
|
+
const [smtpError, setSmtpError] = useState<string | null>(null);
|
|
158
|
+
const [verifyMeta, setVerifyMeta] = useState('');
|
|
111
159
|
const [hydrated, setHydrated] = useState(false);
|
|
112
160
|
|
|
113
161
|
const eff = settingsQ.data?.effective;
|
|
@@ -121,6 +169,11 @@ export function AdminSettingsPage() {
|
|
|
121
169
|
setRlPerUser(eff.rate_limit_per_user_per_min ?? '0');
|
|
122
170
|
setRlPerIP(eff.rate_limit_per_ip_per_min ?? '30');
|
|
123
171
|
setRlAllowlist(eff.rate_limit_allowlist ?? '');
|
|
172
|
+
setSmtpHost(eff.smtp_host ?? '');
|
|
173
|
+
setSmtpPort(eff.smtp_port ?? '');
|
|
174
|
+
setSmtpUser(eff.smtp_user ?? '');
|
|
175
|
+
setSmtpFrom(eff.smtp_from ?? '');
|
|
176
|
+
setVerifyMeta(eff.site_verification_meta ?? '');
|
|
124
177
|
setHydrated(true);
|
|
125
178
|
}, [eff, hydrated]);
|
|
126
179
|
|
|
@@ -235,6 +288,53 @@ export function AdminSettingsPage() {
|
|
|
235
288
|
},
|
|
236
289
|
});
|
|
237
290
|
|
|
291
|
+
|
|
292
|
+
// SMTP 发信配置(UP-1):五键批量提交;smtp_pass 留空(含掩码未动)时
|
|
293
|
+
// 从提交列表剔除,绝不把空值/掩码落库
|
|
294
|
+
const smtpM = useMutation({
|
|
295
|
+
mutationFn: async () => {
|
|
296
|
+
const port = parseSmtpPort(smtpPort);
|
|
297
|
+
if (!port.ok) throw new Error(`SMTP 端口:${port.msg}`);
|
|
298
|
+
setSmtpError(null);
|
|
299
|
+
const entries: { key: string; label: string; value: string }[] = [
|
|
300
|
+
{ key: 'smtp_host', label: 'SMTP 主机', value: smtpHost.trim() },
|
|
301
|
+
{ key: 'smtp_port', label: 'SMTP 端口', value: port.v },
|
|
302
|
+
{ key: 'smtp_user', label: 'SMTP 用户名', value: smtpUser.trim() },
|
|
303
|
+
...(smtpPass !== ''
|
|
304
|
+
? [{ key: 'smtp_pass', label: 'SMTP 密码', value: smtpPass }]
|
|
305
|
+
: []),
|
|
306
|
+
{ key: 'smtp_from', label: '发件人地址', value: smtpFrom.trim() },
|
|
307
|
+
];
|
|
308
|
+
return putKeys(api, entries);
|
|
309
|
+
},
|
|
310
|
+
onSuccess: (results) =>
|
|
311
|
+
reportKeySaves(results, 'SMTP 配置已保存(发信热读,即时生效)', 'SMTP 配置'),
|
|
312
|
+
onError: (e) => {
|
|
313
|
+
const msg = e instanceof Error ? e.message : 'SMTP 配置保存失败';
|
|
314
|
+
setSmtpError(msg);
|
|
315
|
+
toast.error(msg, { duration: ERROR_TOAST_MS });
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
// 站长验证(UP-2):原样提交,后端归一为 name|content 存储(400 = 校验拒绝)
|
|
321
|
+
const verifyM = useMutation({
|
|
322
|
+
mutationFn: () =>
|
|
323
|
+
api.put('/api/admin/settings/site_verification_meta', {
|
|
324
|
+
value: verifyMeta.trim(),
|
|
325
|
+
}),
|
|
326
|
+
onSuccess: () => {
|
|
327
|
+
toast.success('站长验证配置已保存');
|
|
328
|
+
afterSave();
|
|
329
|
+
},
|
|
330
|
+
onError: (e) => {
|
|
331
|
+
toast.error(adminError(e, '站长验证配置保存失败'), {
|
|
332
|
+
duration: ERROR_TOAST_MS,
|
|
333
|
+
});
|
|
334
|
+
void rollbackToServer();
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
238
338
|
const [testTo, setTestTo] = useState('');
|
|
239
339
|
const [testMsg, setTestMsg] = useState<string | null>(null);
|
|
240
340
|
const testEmailM = useMutation({
|
|
@@ -264,13 +364,15 @@ export function AdminSettingsPage() {
|
|
|
264
364
|
);
|
|
265
365
|
}
|
|
266
366
|
const desc = settingsQ.data?.descriptions ?? {};
|
|
367
|
+
const mailMode = settingsQ.data?.mail_mode ?? '';
|
|
368
|
+
const oauth = settingsQ.data?.oauth;
|
|
267
369
|
|
|
268
370
|
return (
|
|
269
371
|
<div className="space-y-6">
|
|
270
372
|
<PageHeader
|
|
271
373
|
title="系统"
|
|
272
374
|
|
|
273
|
-
description="站点公告、维护模式、注册管控、API
|
|
375
|
+
description="站点公告、维护模式、注册管控、API 限流、邮件通道、站长验证与登录方式——保存后全站即时生效(API 限流除外:重启后生效)"
|
|
274
376
|
/>
|
|
275
377
|
|
|
276
378
|
{/* ① 站点公告 */}
|
|
@@ -518,6 +620,100 @@ export function AdminSettingsPage() {
|
|
|
518
620
|
</CardContent>
|
|
519
621
|
</Card>
|
|
520
622
|
|
|
623
|
+
{/* SMTP 发信配置(UP-1):形态对齐限流卡;smtp_pass 掩码不回填、留空跳过提交 */}
|
|
624
|
+
<Card>
|
|
625
|
+
<CardHeader>
|
|
626
|
+
<CardDescription>发信通道</CardDescription>
|
|
627
|
+
<CardTitle>SMTP 发信配置</CardTitle>
|
|
628
|
+
<CardAction>
|
|
629
|
+
<Badge variant={mailMode === 'smtp' ? 'secondary' : 'outline'}>
|
|
630
|
+
{mailMode === 'smtp'
|
|
631
|
+
? 'SMTP 直发'
|
|
632
|
+
: mailMode === 'dev'
|
|
633
|
+
? '开发通道'
|
|
634
|
+
: '未接入'}
|
|
635
|
+
</Badge>
|
|
636
|
+
</CardAction>
|
|
637
|
+
</CardHeader>
|
|
638
|
+
<CardContent className="space-y-4">
|
|
639
|
+
<p className="text-sm text-muted-foreground">
|
|
640
|
+
{desc.smtp_host} 配置后出站邮件走 SMTP 直发,保存即时生效(发信热读,
|
|
641
|
+
无需重启)。出于安全,密码不回显——输入框恒为空、占位符显示掩码,
|
|
642
|
+
留空保存表示不修改当前密码。
|
|
643
|
+
</p>
|
|
644
|
+
<div className="grid max-w-3xl gap-4 sm:grid-cols-2">
|
|
645
|
+
<FormField label="SMTP 服务器" htmlFor="smtp-host">
|
|
646
|
+
<Input
|
|
647
|
+
id="smtp-host"
|
|
648
|
+
placeholder="如 smtp.example.com;留空 = 开发通道"
|
|
649
|
+
value={smtpHost}
|
|
650
|
+
onChange={(e) => setSmtpHost(e.target.value)}
|
|
651
|
+
/>
|
|
652
|
+
</FormField>
|
|
653
|
+
<FormField
|
|
654
|
+
label="端口(留空 = 缺省 587)"
|
|
655
|
+
htmlFor="smtp-port"
|
|
656
|
+
error={smtpError?.startsWith('SMTP 端口') ? smtpError : undefined}
|
|
657
|
+
>
|
|
658
|
+
{/* 与限流卡同款处置:text + inputMode(type=number 对垃圾字符静默置空);
|
|
659
|
+
非法端口保存前拒绝并就地提示 */}
|
|
660
|
+
<Input
|
|
661
|
+
id="smtp-port"
|
|
662
|
+
type="text"
|
|
663
|
+
inputMode="numeric"
|
|
664
|
+
placeholder="缺省 587"
|
|
665
|
+
value={smtpPort}
|
|
666
|
+
onChange={(e) => {
|
|
667
|
+
setSmtpPort(e.target.value);
|
|
668
|
+
if (smtpError?.startsWith('SMTP 端口')) setSmtpError(null);
|
|
669
|
+
}}
|
|
670
|
+
aria-invalid={
|
|
671
|
+
smtpError?.startsWith('SMTP 端口') ? true : undefined
|
|
672
|
+
}
|
|
673
|
+
/>
|
|
674
|
+
</FormField>
|
|
675
|
+
<FormField label="SMTP 用户名" htmlFor="smtp-user">
|
|
676
|
+
<Input
|
|
677
|
+
id="smtp-user"
|
|
678
|
+
className="font-mono"
|
|
679
|
+
placeholder="如 no-reply@example.com"
|
|
680
|
+
value={smtpUser}
|
|
681
|
+
onChange={(e) => setSmtpUser(e.target.value)}
|
|
682
|
+
/>
|
|
683
|
+
</FormField>
|
|
684
|
+
<FormField label="SMTP 密码(留空 = 不修改)" htmlFor="smtp-pass">
|
|
685
|
+
<Input
|
|
686
|
+
id="smtp-pass"
|
|
687
|
+
type="password"
|
|
688
|
+
placeholder="******"
|
|
689
|
+
autoComplete="new-password"
|
|
690
|
+
value={smtpPass}
|
|
691
|
+
onChange={(e) => setSmtpPass(e.target.value)}
|
|
692
|
+
/>
|
|
693
|
+
</FormField>
|
|
694
|
+
<FormField label="发件人地址" htmlFor="smtp-from">
|
|
695
|
+
<Input
|
|
696
|
+
id="smtp-from"
|
|
697
|
+
type="email"
|
|
698
|
+
placeholder="如 no-reply@example.com"
|
|
699
|
+
value={smtpFrom}
|
|
700
|
+
onChange={(e) => setSmtpFrom(e.target.value)}
|
|
701
|
+
/>
|
|
702
|
+
</FormField>
|
|
703
|
+
</div>
|
|
704
|
+
<div>
|
|
705
|
+
<Button
|
|
706
|
+
size="sm"
|
|
707
|
+
variant="outline"
|
|
708
|
+
disabled={smtpM.isPending || !hydrated}
|
|
709
|
+
onClick={() => smtpM.mutate()}
|
|
710
|
+
>
|
|
711
|
+
{smtpM.isPending ? '保存中…' : '保存 SMTP 配置'}
|
|
712
|
+
</Button>
|
|
713
|
+
</div>
|
|
714
|
+
</CardContent>
|
|
715
|
+
</Card>
|
|
716
|
+
|
|
521
717
|
{/* ⑤ 测试邮件(P2-14:去产品指代——框架文案不点名 example 接线) */}
|
|
522
718
|
<Card>
|
|
523
719
|
<CardHeader>
|
|
@@ -559,6 +755,105 @@ export function AdminSettingsPage() {
|
|
|
559
755
|
)}
|
|
560
756
|
</CardContent>
|
|
561
757
|
</Card>
|
|
758
|
+
|
|
759
|
+
{/* 站长验证(UP-2):粘贴完整 meta 标签或 name|content 对,后端归一存储;
|
|
760
|
+
注入由站点侧中间件完成,用户输入的 HTML 片段永不回显 */}
|
|
761
|
+
<Card>
|
|
762
|
+
<CardHeader>
|
|
763
|
+
<CardDescription>站长验证</CardDescription>
|
|
764
|
+
<CardTitle>搜索引擎站点验证</CardTitle>
|
|
765
|
+
<CardAction>
|
|
766
|
+
<Badge variant={verifyMeta.trim() ? 'secondary' : 'outline'}>
|
|
767
|
+
{verifyMeta.trim() ? '已注入' : '未配置'}
|
|
768
|
+
</Badge>
|
|
769
|
+
</CardAction>
|
|
770
|
+
</CardHeader>
|
|
771
|
+
<CardContent className="space-y-4">
|
|
772
|
+
<p className="text-sm text-muted-foreground">
|
|
773
|
+
{desc.site_verification_meta} 粘贴搜索平台(Google Search
|
|
774
|
+
Console / Bing 等)提供的完整 meta 标签,或 name|content
|
|
775
|
+
对;保存后归一为 name|content 存储,页面注入时由服务端重组标签,
|
|
776
|
+
输入的原文不回显。
|
|
777
|
+
</p>
|
|
778
|
+
<FormField
|
|
779
|
+
label="验证标记(完整 meta 标签或 name|content 对)"
|
|
780
|
+
htmlFor="site-verification-meta"
|
|
781
|
+
>
|
|
782
|
+
<textarea
|
|
783
|
+
id="site-verification-meta"
|
|
784
|
+
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"
|
|
785
|
+
placeholder={
|
|
786
|
+
'<meta name="google-site-verification" content="abc123XYZ" /> 或 google-site-verification|abc123XYZ'
|
|
787
|
+
}
|
|
788
|
+
value={verifyMeta}
|
|
789
|
+
onChange={(e) => setVerifyMeta(e.target.value)}
|
|
790
|
+
/>
|
|
791
|
+
</FormField>
|
|
792
|
+
<div>
|
|
793
|
+
<Button
|
|
794
|
+
size="sm"
|
|
795
|
+
variant="outline"
|
|
796
|
+
disabled={verifyM.isPending || !hydrated}
|
|
797
|
+
onClick={() => verifyM.mutate()}
|
|
798
|
+
>
|
|
799
|
+
{verifyM.isPending ? '保存中…' : '保存验证配置'}
|
|
800
|
+
</Button>
|
|
801
|
+
</div>
|
|
802
|
+
</CardContent>
|
|
803
|
+
</Card>
|
|
804
|
+
|
|
805
|
+
{/* 登录方式(UP-6):只读状态卡;信封无 oauth 字段 = 后端未接线,整卡隐藏 */}
|
|
806
|
+
{oauth && (
|
|
807
|
+
<Card>
|
|
808
|
+
<CardHeader>
|
|
809
|
+
<CardDescription>登录方式</CardDescription>
|
|
810
|
+
<CardTitle>OAuth 第三方登录</CardTitle>
|
|
811
|
+
<CardAction>
|
|
812
|
+
<Badge variant={oauth.length > 0 ? 'secondary' : 'outline'}>
|
|
813
|
+
{oauth.length > 0 ? `已启用 ${oauth.length} 个` : '未启用'}
|
|
814
|
+
</Badge>
|
|
815
|
+
</CardAction>
|
|
816
|
+
</CardHeader>
|
|
817
|
+
<CardContent className="space-y-4">
|
|
818
|
+
<p className="text-sm text-muted-foreground">
|
|
819
|
+
下方回调地址为按站点 base URL 推算的期望值,实际以请求 Host
|
|
820
|
+
现算——反向代理必须转发 Host 与 X-Forwarded-Proto,
|
|
821
|
+
否则登录会报 redirect_uri_mismatch。
|
|
822
|
+
</p>
|
|
823
|
+
{oauth.length === 0 ? (
|
|
824
|
+
<p className="text-sm text-muted-foreground">
|
|
825
|
+
尚未配置任何 OAuth 登录方式——设置 GOOGLE_CLIENT_ID /
|
|
826
|
+
GOOGLE_SECRET、GITHUB_CLIENT_ID / GITHUB_SECRET
|
|
827
|
+
环境变量并重启后在此显示。
|
|
828
|
+
</p>
|
|
829
|
+
) : (
|
|
830
|
+
<ul className="space-y-2">
|
|
831
|
+
{oauth.map((p) => (
|
|
832
|
+
<li
|
|
833
|
+
key={p.name}
|
|
834
|
+
className="flex flex-wrap items-center gap-2 text-sm"
|
|
835
|
+
>
|
|
836
|
+
<Badge variant="secondary">{p.name}</Badge>
|
|
837
|
+
<code className="font-mono text-xs break-all text-muted-foreground">
|
|
838
|
+
{p.callback_url}
|
|
839
|
+
</code>
|
|
840
|
+
<Button
|
|
841
|
+
variant="ghost"
|
|
842
|
+
size="sm"
|
|
843
|
+
className="size-6 shrink-0 p-0 text-muted-foreground"
|
|
844
|
+
aria-label={`复制 ${p.name} 回调地址`}
|
|
845
|
+
title={`复制 ${p.name} 回调地址`}
|
|
846
|
+
onClick={() => copyCallbackUrl(p.name, p.callback_url)}
|
|
847
|
+
>
|
|
848
|
+
<Copy size={12} aria-hidden="true" />
|
|
849
|
+
</Button>
|
|
850
|
+
</li>
|
|
851
|
+
))}
|
|
852
|
+
</ul>
|
|
853
|
+
)}
|
|
854
|
+
</CardContent>
|
|
855
|
+
</Card>
|
|
856
|
+
)}
|
|
562
857
|
</div>
|
|
563
858
|
);
|
|
564
859
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Activity,
|
|
6
6
|
ArrowLeft,
|
|
7
7
|
Building2,
|
|
8
|
+
CreditCard,
|
|
8
9
|
KeyRound,
|
|
9
10
|
LayoutDashboard,
|
|
10
11
|
Receipt,
|
|
@@ -43,6 +44,11 @@ const NAV: AdminNavItem[] = [
|
|
|
43
44
|
label: '工作区',
|
|
44
45
|
icon: <Building2 size={16} aria-hidden="true" />,
|
|
45
46
|
},
|
|
47
|
+
{
|
|
48
|
+
to: '/admin/plans',
|
|
49
|
+
label: '套餐',
|
|
50
|
+
icon: <CreditCard size={16} aria-hidden="true" />,
|
|
51
|
+
},
|
|
46
52
|
{
|
|
47
53
|
to: '/admin/orders',
|
|
48
54
|
label: '订单',
|