payment-kit 1.20.5 → 1.20.6
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/api/src/crons/index.ts +11 -3
- package/api/src/index.ts +18 -14
- package/api/src/libs/adapters/launcher-adapter.ts +177 -0
- package/api/src/libs/env.ts +7 -0
- package/api/src/libs/url.ts +77 -0
- package/api/src/libs/vendor-adapter-factory.ts +22 -0
- package/api/src/libs/vendor-adapter.ts +109 -0
- package/api/src/libs/vendor-fulfillment.ts +321 -0
- package/api/src/queues/payment.ts +14 -10
- package/api/src/queues/payout.ts +1 -0
- package/api/src/queues/vendor/vendor-commission.ts +192 -0
- package/api/src/queues/vendor/vendor-fulfillment-coordinator.ts +627 -0
- package/api/src/queues/vendor/vendor-fulfillment.ts +97 -0
- package/api/src/queues/vendor/vendor-status-check.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +3 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/products.ts +72 -1
- package/api/src/routes/vendor.ts +526 -0
- package/api/src/store/migrations/20250820-add-product-vendor.ts +102 -0
- package/api/src/store/migrations/20250822-add-vendor-config-to-products.ts +56 -0
- package/api/src/store/models/checkout-session.ts +84 -18
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/payout.ts +11 -0
- package/api/src/store/models/product-vendor.ts +118 -0
- package/api/src/store/models/product.ts +15 -0
- package/blocklet.yml +8 -2
- package/doc/vendor_fulfillment_system.md +931 -0
- package/package.json +5 -4
- package/src/components/collapse.tsx +1 -0
- package/src/components/product/edit.tsx +9 -0
- package/src/components/product/form.tsx +11 -0
- package/src/components/product/vendor-config.tsx +249 -0
- package/src/components/vendor/actions.tsx +145 -0
- package/src/locales/en.tsx +89 -0
- package/src/locales/zh.tsx +89 -0
- package/src/pages/admin/products/index.tsx +11 -1
- package/src/pages/admin/products/products/detail.tsx +79 -2
- package/src/pages/admin/products/vendors/create.tsx +418 -0
- package/src/pages/admin/products/vendors/index.tsx +313 -0
package/src/locales/zh.tsx
CHANGED
|
@@ -39,6 +39,7 @@ export default flat({
|
|
|
39
39
|
advancedFeatures: '高级功能',
|
|
40
40
|
quickStarts: '快速入门',
|
|
41
41
|
copy: '复制',
|
|
42
|
+
copied: '已复制',
|
|
42
43
|
copySuccess: '复制成功',
|
|
43
44
|
copyFailed: '复制失败',
|
|
44
45
|
copyTip: '请手动复制',
|
|
@@ -97,6 +98,13 @@ export default flat({
|
|
|
97
98
|
overview: '总览',
|
|
98
99
|
payments: '支付管理',
|
|
99
100
|
connections: '连接',
|
|
101
|
+
back: '返回',
|
|
102
|
+
actions: '操作',
|
|
103
|
+
create: '创建',
|
|
104
|
+
creating: '创建中...',
|
|
105
|
+
save: '保存',
|
|
106
|
+
saving: '保存中...',
|
|
107
|
+
cancel: '取消',
|
|
100
108
|
paymentLinks: '支付链接',
|
|
101
109
|
paymentMethods: '支付方式',
|
|
102
110
|
customers: '客户管理',
|
|
@@ -104,6 +112,7 @@ export default flat({
|
|
|
104
112
|
coupons: '优惠券',
|
|
105
113
|
pricing: '定价',
|
|
106
114
|
pricingTables: '定价表',
|
|
115
|
+
vendors: '供应商',
|
|
107
116
|
billing: '订阅和账单',
|
|
108
117
|
invoices: '账单',
|
|
109
118
|
subscriptions: '订阅',
|
|
@@ -392,6 +401,25 @@ export default flat({
|
|
|
392
401
|
credit: 'Credit',
|
|
393
402
|
good: '商品',
|
|
394
403
|
},
|
|
404
|
+
vendorConfig: {
|
|
405
|
+
title: '供应商配置',
|
|
406
|
+
add: '添加供应商',
|
|
407
|
+
empty: '未配置供应商。点击"添加供应商"来配置供应商服务。',
|
|
408
|
+
vendor: '供应商',
|
|
409
|
+
vendorRequired: '供应商是必填项',
|
|
410
|
+
productCode: '产品代码',
|
|
411
|
+
productCodeRequired: '产品代码是必填项',
|
|
412
|
+
commissionType: '分成类型',
|
|
413
|
+
commissionRate: '分成比例',
|
|
414
|
+
commissionRateRequired: '分成比例是必填项',
|
|
415
|
+
commissionRateMin: '分成比例必须大于或等于0',
|
|
416
|
+
commissionRateMax: '分成比例不能超过100%',
|
|
417
|
+
amount: '固定金额',
|
|
418
|
+
noVendor: '无供应商可选',
|
|
419
|
+
amountRequired: '固定金额是必填项',
|
|
420
|
+
totalCommission: '总分成比例:{total}%',
|
|
421
|
+
totalCommissionExceeded: '总分成比例超过100%:{total}%',
|
|
422
|
+
},
|
|
395
423
|
},
|
|
396
424
|
price: {
|
|
397
425
|
name: '价格',
|
|
@@ -813,6 +841,67 @@ export default flat({
|
|
|
813
841
|
success: '账单作废成功',
|
|
814
842
|
},
|
|
815
843
|
},
|
|
844
|
+
vendor: {
|
|
845
|
+
create: '创建供应商',
|
|
846
|
+
edit: '编辑供应商',
|
|
847
|
+
delete: '删除供应商',
|
|
848
|
+
save: '保存供应商',
|
|
849
|
+
saved: '供应商保存成功',
|
|
850
|
+
test: '测试',
|
|
851
|
+
testConnection: '测试连接',
|
|
852
|
+
testSuccess: '连接测试成功',
|
|
853
|
+
testAfterSave: '供应商保存成功。您可以测试连接以验证配置是否正确。',
|
|
854
|
+
testToEnable: '供应商保存成功。请测试连接以启用供应商。',
|
|
855
|
+
enabled: '已启用',
|
|
856
|
+
disabled: '未启用',
|
|
857
|
+
addressCheckFailed: '地址检测失败,启用状态下无法保存',
|
|
858
|
+
updateSuccess: '供应商更新成功',
|
|
859
|
+
testFailed: '连接测试失败 (状态码: {status})',
|
|
860
|
+
testError: '连接测试失败 - 网络错误',
|
|
861
|
+
vendorKeyInvalid: '供应商类型只能包含小写字母、数字和下划线',
|
|
862
|
+
deleteTitle: '删除供应商',
|
|
863
|
+
deleteContent: '确定要删除供应商 "{name}" 吗?此操作无法撤销。',
|
|
864
|
+
deleteTip: '确定要删除供应商 "{name}" 吗?此操作无法撤销。',
|
|
865
|
+
activate: '激活供应商',
|
|
866
|
+
activateTip: '确定要激活供应商 "{name}" 吗?',
|
|
867
|
+
deactivate: '停用供应商',
|
|
868
|
+
deactivateTip: '确定要停用供应商 "{name}" 吗?',
|
|
869
|
+
name: '供应商名称',
|
|
870
|
+
nameRequired: '供应商名称是必填项',
|
|
871
|
+
vendorKey: '供应商类型',
|
|
872
|
+
vendorKeyRequired: '供应商类型是必填项',
|
|
873
|
+
vendorKeyHelp: '供应商的唯一标识符(如:launcher、did_names)',
|
|
874
|
+
description: '描述',
|
|
875
|
+
appUrl: '应用地址',
|
|
876
|
+
appUrlRequired: '应用地址是必填项',
|
|
877
|
+
appUrlInvalid: '请输入以http://或https://开头的有效URL',
|
|
878
|
+
appUrlHelp: '供应商应用的基础URL',
|
|
879
|
+
webhookPath: 'Webhook路径',
|
|
880
|
+
webhookPathInvalid: '请输入以/开头的有效路径',
|
|
881
|
+
webhookPathHelp: '可选的webhook回调路径(如:/webhooks/status)',
|
|
882
|
+
blockletMetaUrl: 'Blocklet元数据URL',
|
|
883
|
+
blockletMetaUrlRequired: 'Blocklet元数据URL是必填项',
|
|
884
|
+
blockletMetaUrlInvalid: '请输入有效的URL,以http://或https://开头',
|
|
885
|
+
blockletMetaUrlHelp: '必填的blocklet元数据URL,用于获取应用信息',
|
|
886
|
+
commission: '分成',
|
|
887
|
+
commissionRate: '分成比例',
|
|
888
|
+
commissionRateRequired: '分成比例是必填项',
|
|
889
|
+
commissionRateMin: '分成比例必须大于或等于0',
|
|
890
|
+
commissionRateMax: '分成比例过高',
|
|
891
|
+
commissionType: '分成类型',
|
|
892
|
+
commissionRateHelp: '分成比例,以百分比形式',
|
|
893
|
+
commissionAmountHelp: '固定分成金额',
|
|
894
|
+
percentage: '百分比',
|
|
895
|
+
fixedAmount: '固定金额',
|
|
896
|
+
active: '启用',
|
|
897
|
+
inactive: '禁用',
|
|
898
|
+
basicInfo: '基本信息',
|
|
899
|
+
apiConfig: 'API配置',
|
|
900
|
+
commissionConfig: '分成配置',
|
|
901
|
+
status: '状态',
|
|
902
|
+
servicePublicKey: '服务公钥',
|
|
903
|
+
servicePublicKeyDescription: '用于供应商通信认证',
|
|
904
|
+
},
|
|
816
905
|
subscription: {
|
|
817
906
|
view: '查看订阅',
|
|
818
907
|
name: '订阅',
|
|
@@ -13,12 +13,14 @@ const PaymentLinkCreate = React.lazy(() => import('./links/create'));
|
|
|
13
13
|
const PaymentLinkDetail = React.lazy(() => import('./links/detail'));
|
|
14
14
|
const PricingTableCreate = React.lazy(() => import('./pricing-tables/create'));
|
|
15
15
|
const PricingTableDetail = React.lazy(() => import('./pricing-tables/detail'));
|
|
16
|
+
const VendorCreate = React.lazy(() => import('./vendors/create'));
|
|
16
17
|
|
|
17
18
|
const pages = {
|
|
18
19
|
products: React.lazy(() => import('./products')),
|
|
19
20
|
links: React.lazy(() => import('./links')),
|
|
20
21
|
'pricing-tables': React.lazy(() => import('./pricing-tables')),
|
|
21
22
|
passports: React.lazy(() => import('./passports')),
|
|
23
|
+
vendors: React.lazy(() => import('./vendors')),
|
|
22
24
|
// coupons: React.lazy(() => import('./coupons')),
|
|
23
25
|
};
|
|
24
26
|
|
|
@@ -47,11 +49,17 @@ export default function Products() {
|
|
|
47
49
|
|
|
48
50
|
// @ts-ignore
|
|
49
51
|
const TabComponent = pages[page] || pages.products;
|
|
52
|
+
|
|
53
|
+
if (page.startsWith('pv_')) {
|
|
54
|
+
// 供应商详情页面暂时跳转到列表
|
|
55
|
+
return <TabComponent />;
|
|
56
|
+
}
|
|
50
57
|
const tabs = [
|
|
51
58
|
{ label: t('admin.products'), value: 'products' },
|
|
52
59
|
{ label: t('admin.paymentLinks'), value: 'links' },
|
|
53
60
|
{ label: t('admin.pricingTables'), value: 'pricing-tables' },
|
|
54
61
|
{ label: t('admin.passports'), value: 'passports' },
|
|
62
|
+
{ label: t('admin.vendors'), value: 'vendors' },
|
|
55
63
|
// { label: t('admin.coupons'), value: 'coupons' },
|
|
56
64
|
];
|
|
57
65
|
|
|
@@ -62,6 +70,8 @@ export default function Products() {
|
|
|
62
70
|
extra = <PaymentLinkCreate />;
|
|
63
71
|
} else if (page === 'pricing-tables') {
|
|
64
72
|
extra = <PricingTableCreate />;
|
|
73
|
+
} else if (page === 'vendors') {
|
|
74
|
+
extra = <VendorCreate open={createProduct} onClose={() => setCreateProduct(false)} />;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
return (
|
|
@@ -105,7 +115,7 @@ export default function Products() {
|
|
|
105
115
|
},
|
|
106
116
|
}}
|
|
107
117
|
/>
|
|
108
|
-
<Box>
|
|
118
|
+
<Box>{extra}</Box>
|
|
109
119
|
</Stack>
|
|
110
120
|
{isValidElement(TabComponent) ? TabComponent : <TabComponent />}
|
|
111
121
|
</>
|
|
@@ -37,7 +37,7 @@ import { formatProductPrice, goBackOrFallback } from '../../../../libs/util';
|
|
|
37
37
|
import PricesList from '../prices/list';
|
|
38
38
|
|
|
39
39
|
const getProduct = (id: string): Promise<TProductExpanded> => {
|
|
40
|
-
return api.get(`/api/products/${id}`).then((res) => res.data);
|
|
40
|
+
return api.get(`/api/products/${id}`).then((res: any) => res.data);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export default function ProductDetail(props: { id: string }) {
|
|
@@ -77,7 +77,7 @@ export default function ProductDetail(props: { id: string }) {
|
|
|
77
77
|
const createProductUpdater = (key: string) => async (updates: TProduct) => {
|
|
78
78
|
try {
|
|
79
79
|
setState((prev) => ({ loading: { ...prev.loading, [key]: true } }));
|
|
80
|
-
await api.put(`/api/products/${props.id}`, updates).then((res) => res.data);
|
|
80
|
+
await api.put(`/api/products/${props.id}`, updates).then((res: any) => res.data);
|
|
81
81
|
Toast.success(t('common.saved'));
|
|
82
82
|
dispatch('product.updated');
|
|
83
83
|
runAsync();
|
|
@@ -389,6 +389,83 @@ export default function ProductDetail(props: { id: string }) {
|
|
|
389
389
|
</Box>
|
|
390
390
|
</Box>
|
|
391
391
|
<Divider />
|
|
392
|
+
{/* 供应商配置展示 */}
|
|
393
|
+
{data.type === 'service' && (
|
|
394
|
+
<>
|
|
395
|
+
<Box className="section">
|
|
396
|
+
<SectionHeader title={t('admin.product.vendorConfig.title')}>
|
|
397
|
+
<Button
|
|
398
|
+
variant="text"
|
|
399
|
+
color="inherit"
|
|
400
|
+
size="small"
|
|
401
|
+
sx={{ color: 'text.link' }}
|
|
402
|
+
onClick={() => setState((prev) => ({ editing: { ...prev.editing, product: true } }))}>
|
|
403
|
+
{t('common.edit')}
|
|
404
|
+
</Button>
|
|
405
|
+
</SectionHeader>
|
|
406
|
+
<Box className="section-body">
|
|
407
|
+
{data.vendor_config && data.vendor_config.length > 0 ? (
|
|
408
|
+
<Stack spacing={2}>
|
|
409
|
+
{/* 总分成比例信息 */}
|
|
410
|
+
{(() => {
|
|
411
|
+
const totalPercentage = data.vendor_config
|
|
412
|
+
.filter((v: any) => v.commission_type === 'percentage')
|
|
413
|
+
.reduce((sum: number, v: any) => sum + (v.commission_rate || 0), 0);
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
|
417
|
+
{t('admin.product.vendorConfig.totalCommission', {
|
|
418
|
+
total: totalPercentage.toFixed(0),
|
|
419
|
+
})}
|
|
420
|
+
</Typography>
|
|
421
|
+
);
|
|
422
|
+
})()}
|
|
423
|
+
{data.vendor_config.map((vendor: any) => (
|
|
424
|
+
<Box
|
|
425
|
+
key={`vendor-${vendor.vendor_id}`}
|
|
426
|
+
sx={{
|
|
427
|
+
p: 2,
|
|
428
|
+
border: '1px solid',
|
|
429
|
+
borderColor: 'divider',
|
|
430
|
+
borderRadius: 1,
|
|
431
|
+
backgroundColor: 'background.paper',
|
|
432
|
+
}}>
|
|
433
|
+
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
434
|
+
<Box>
|
|
435
|
+
<Typography variant="body1" sx={{ color: 'text.primary', fontWeight: 500 }}>
|
|
436
|
+
{vendor.name || vendor.vendor_key || vendor.vendor_id}
|
|
437
|
+
</Typography>
|
|
438
|
+
{vendor.description && (
|
|
439
|
+
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
440
|
+
{vendor.description}
|
|
441
|
+
</Typography>
|
|
442
|
+
)}
|
|
443
|
+
</Box>
|
|
444
|
+
<Stack direction="row" spacing={3} alignItems="center">
|
|
445
|
+
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
446
|
+
{vendor.commission_type === 'percentage'
|
|
447
|
+
? t('admin.vendor.percentage')
|
|
448
|
+
: t('admin.vendor.fixedAmount')}
|
|
449
|
+
</Typography>
|
|
450
|
+
<Typography variant="body1" sx={{ color: 'text.primary', fontWeight: 600 }}>
|
|
451
|
+
{vendor.commission_rate}
|
|
452
|
+
{vendor.commission_type === 'percentage' ? '%' : ''}
|
|
453
|
+
</Typography>
|
|
454
|
+
</Stack>
|
|
455
|
+
</Stack>
|
|
456
|
+
</Box>
|
|
457
|
+
))}
|
|
458
|
+
</Stack>
|
|
459
|
+
) : (
|
|
460
|
+
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 2 }}>
|
|
461
|
+
{t('admin.product.vendorConfig.empty')}
|
|
462
|
+
</Typography>
|
|
463
|
+
)}
|
|
464
|
+
</Box>
|
|
465
|
+
</Box>
|
|
466
|
+
<Divider />
|
|
467
|
+
</>
|
|
468
|
+
)}
|
|
392
469
|
<Box className="section">
|
|
393
470
|
<SectionHeader title={t('admin.events.title')} />
|
|
394
471
|
<Box className="section-body">
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
3
|
+
import { api, formatError } from '@blocklet/payment-react';
|
|
4
|
+
import { AddOutlined } from '@mui/icons-material';
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
CircularProgress,
|
|
8
|
+
Stack,
|
|
9
|
+
TextField,
|
|
10
|
+
FormControlLabel,
|
|
11
|
+
Switch,
|
|
12
|
+
FormControl,
|
|
13
|
+
InputLabel,
|
|
14
|
+
Select,
|
|
15
|
+
MenuItem,
|
|
16
|
+
} from '@mui/material';
|
|
17
|
+
import { useState } from 'react';
|
|
18
|
+
import { useForm, Controller } from 'react-hook-form';
|
|
19
|
+
import { dispatch } from 'use-bus';
|
|
20
|
+
|
|
21
|
+
import { joinURL, withQuery } from 'ufo';
|
|
22
|
+
import DrawerForm from '../../../../components/drawer-form';
|
|
23
|
+
import { formatProxyUrl } from '../../../../libs/util';
|
|
24
|
+
|
|
25
|
+
interface Vendor {
|
|
26
|
+
id: string;
|
|
27
|
+
vendor_key: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
app_url: string;
|
|
31
|
+
webhook_path: string;
|
|
32
|
+
default_commission_rate: number;
|
|
33
|
+
default_commission_type: 'percentage' | 'fixed_amount';
|
|
34
|
+
status: 'active' | 'inactive';
|
|
35
|
+
order_create_params: Record<string, any>;
|
|
36
|
+
metadata: Record<string, any>;
|
|
37
|
+
created_at: string;
|
|
38
|
+
updated_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface VendorCreateProps {
|
|
42
|
+
open?: boolean;
|
|
43
|
+
onClose?: () => void;
|
|
44
|
+
onSubmit?: () => void;
|
|
45
|
+
vendorData?: Vendor;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface VendorFormData {
|
|
49
|
+
vendor_key: string;
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
app_url: string;
|
|
53
|
+
webhook_path: string;
|
|
54
|
+
blocklet_meta_url: string;
|
|
55
|
+
default_commission_rate: number;
|
|
56
|
+
default_commission_type: 'percentage' | 'fixed_amount';
|
|
57
|
+
status: 'active' | 'inactive';
|
|
58
|
+
order_create_params: Record<string, any>;
|
|
59
|
+
metadata: Record<string, any>;
|
|
60
|
+
app_pid?: string;
|
|
61
|
+
app_logo?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function VendorCreate({
|
|
65
|
+
open = true,
|
|
66
|
+
onClose = () => {},
|
|
67
|
+
onSubmit: onSubmitCallback = () => {},
|
|
68
|
+
vendorData = undefined,
|
|
69
|
+
}: VendorCreateProps) {
|
|
70
|
+
const { t } = useLocaleContext();
|
|
71
|
+
const [loading, setLoading] = useState(false);
|
|
72
|
+
|
|
73
|
+
const isEditMode = !!vendorData;
|
|
74
|
+
|
|
75
|
+
const {
|
|
76
|
+
control,
|
|
77
|
+
handleSubmit,
|
|
78
|
+
reset,
|
|
79
|
+
formState: { errors, isValid },
|
|
80
|
+
watch,
|
|
81
|
+
} = useForm<VendorFormData>({
|
|
82
|
+
mode: 'onChange',
|
|
83
|
+
defaultValues: vendorData
|
|
84
|
+
? {
|
|
85
|
+
...vendorData,
|
|
86
|
+
default_commission_rate: vendorData.default_commission_rate,
|
|
87
|
+
blocklet_meta_url: vendorData.metadata?.blockletMetaUrl || '',
|
|
88
|
+
}
|
|
89
|
+
: {
|
|
90
|
+
vendor_key: '',
|
|
91
|
+
name: '',
|
|
92
|
+
description: '',
|
|
93
|
+
app_url: '',
|
|
94
|
+
webhook_path: '',
|
|
95
|
+
blocklet_meta_url: '',
|
|
96
|
+
default_commission_rate: 80,
|
|
97
|
+
default_commission_type: 'percentage',
|
|
98
|
+
status: 'inactive',
|
|
99
|
+
order_create_params: {},
|
|
100
|
+
metadata: {},
|
|
101
|
+
app_pid: '',
|
|
102
|
+
app_logo: '',
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const watchedValues = watch();
|
|
107
|
+
|
|
108
|
+
const isPercentage = watchedValues.default_commission_type === 'percentage';
|
|
109
|
+
|
|
110
|
+
const validateUrl = (url: string) => {
|
|
111
|
+
if (!url) return t('admin.vendor.appUrlRequired');
|
|
112
|
+
try {
|
|
113
|
+
// eslint-disable-next-line no-new
|
|
114
|
+
new URL(url);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return t('admin.vendor.appUrlInvalid');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const validateWebhookPath = (path: string) => {
|
|
122
|
+
if (!path) return true;
|
|
123
|
+
if (!path.startsWith('/')) {
|
|
124
|
+
return t('admin.vendor.webhookPathInvalid');
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const validateBlockletMetaUrl = (url: string) => {
|
|
130
|
+
if (!url) return t('admin.vendor.blockletMetaUrlRequired'); // 必填字段
|
|
131
|
+
try {
|
|
132
|
+
// eslint-disable-next-line no-new
|
|
133
|
+
new URL(url);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return t('admin.vendor.blockletMetaUrlInvalid');
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onSubmit = async (data: VendorFormData) => {
|
|
141
|
+
try {
|
|
142
|
+
setLoading(true);
|
|
143
|
+
|
|
144
|
+
// 轻量级校验:检查URL格式
|
|
145
|
+
try {
|
|
146
|
+
// eslint-disable-next-line no-new
|
|
147
|
+
new URL(data.app_url);
|
|
148
|
+
} catch {
|
|
149
|
+
Toast.error(t('admin.vendor.appUrlInvalid'));
|
|
150
|
+
setLoading(false);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 准备提交数据,将 blocklet_meta_url 放入 metadata
|
|
155
|
+
const { blocklet_meta_url: blockletMetaUrl, ...restData } = data;
|
|
156
|
+
const submitData = {
|
|
157
|
+
...restData,
|
|
158
|
+
metadata: {
|
|
159
|
+
...data.metadata,
|
|
160
|
+
...(blockletMetaUrl && { blockletMetaUrl }),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// 如果状态为启用,则检测应用地址可用性
|
|
165
|
+
if (submitData.status === 'active') {
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(
|
|
168
|
+
formatProxyUrl(withQuery(joinURL(submitData.app_url, '__blocklet__.js'), { type: 'json' })),
|
|
169
|
+
{
|
|
170
|
+
method: 'GET',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
Toast.error(t('admin.vendor.addressCheckFailed'));
|
|
177
|
+
setLoading(false);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 从响应中获取appPid和appLogo
|
|
182
|
+
const blockletInfo = await response.json();
|
|
183
|
+
if (blockletInfo) {
|
|
184
|
+
submitData.app_pid = blockletInfo.pid || blockletInfo.appPid;
|
|
185
|
+
submitData.app_logo = blockletInfo.logo || blockletInfo.appLogo;
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
Toast.error(t('admin.vendor.addressCheckFailed'));
|
|
189
|
+
setLoading(false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (isEditMode && vendorData) {
|
|
195
|
+
// 编辑模式:更新供应商
|
|
196
|
+
await api.put(`/api/vendors/${vendorData.id}`, submitData);
|
|
197
|
+
Toast.success(t('admin.vendor.updateSuccess'));
|
|
198
|
+
dispatch('vendor.updated');
|
|
199
|
+
} else {
|
|
200
|
+
// 创建模式:新建供应商
|
|
201
|
+
await api.post('/api/vendors', submitData);
|
|
202
|
+
Toast.success(t('admin.vendor.saved'));
|
|
203
|
+
dispatch('vendor.created');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
reset();
|
|
207
|
+
dispatch('drawer.submitted');
|
|
208
|
+
if (onSubmitCallback) {
|
|
209
|
+
onSubmitCallback();
|
|
210
|
+
}
|
|
211
|
+
if (onClose) {
|
|
212
|
+
onClose();
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error('Failed to create vendor:', error);
|
|
216
|
+
Toast.error(formatError(error));
|
|
217
|
+
} finally {
|
|
218
|
+
setLoading(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleClose = () => {
|
|
223
|
+
reset();
|
|
224
|
+
if (onClose) {
|
|
225
|
+
onClose();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<DrawerForm
|
|
231
|
+
icon={isEditMode ? null : <AddOutlined />}
|
|
232
|
+
text={isEditMode ? t('admin.vendor.edit') : t('admin.vendor.create')}
|
|
233
|
+
open={open}
|
|
234
|
+
onClose={handleClose}
|
|
235
|
+
width={800}
|
|
236
|
+
addons={
|
|
237
|
+
<Button variant="contained" size="small" onClick={handleSubmit(onSubmit)} disabled={loading || !isValid}>
|
|
238
|
+
{loading && <CircularProgress size="small" sx={{ mr: 1 }} />}
|
|
239
|
+
{isEditMode ? t('admin.save') : t('admin.vendor.save')}
|
|
240
|
+
</Button>
|
|
241
|
+
}>
|
|
242
|
+
<Stack spacing={3}>
|
|
243
|
+
<Stack direction="row" spacing={2}>
|
|
244
|
+
<Controller
|
|
245
|
+
name="vendor_key"
|
|
246
|
+
control={control}
|
|
247
|
+
rules={{
|
|
248
|
+
required: t('admin.vendor.vendorKeyRequired'),
|
|
249
|
+
pattern: {
|
|
250
|
+
value: /^[a-z0-9_]+$/,
|
|
251
|
+
message: t('admin.vendor.vendorKeyInvalid'),
|
|
252
|
+
},
|
|
253
|
+
}}
|
|
254
|
+
render={({ field }) => (
|
|
255
|
+
<TextField
|
|
256
|
+
{...field}
|
|
257
|
+
label={t('admin.vendor.vendorKey')}
|
|
258
|
+
required
|
|
259
|
+
fullWidth
|
|
260
|
+
disabled={isEditMode}
|
|
261
|
+
error={!!errors.vendor_key}
|
|
262
|
+
helperText={errors.vendor_key?.message || t('admin.vendor.vendorKeyHelp')}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
/>
|
|
266
|
+
<Controller
|
|
267
|
+
name="name"
|
|
268
|
+
control={control}
|
|
269
|
+
rules={{
|
|
270
|
+
required: t('admin.vendor.nameRequired'),
|
|
271
|
+
}}
|
|
272
|
+
render={({ field }) => (
|
|
273
|
+
<TextField
|
|
274
|
+
{...field}
|
|
275
|
+
label={t('admin.vendor.name')}
|
|
276
|
+
required
|
|
277
|
+
fullWidth
|
|
278
|
+
error={!!errors.name}
|
|
279
|
+
helperText={errors.name?.message}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
/>
|
|
283
|
+
</Stack>
|
|
284
|
+
|
|
285
|
+
<Controller
|
|
286
|
+
name="description"
|
|
287
|
+
control={control}
|
|
288
|
+
render={({ field }) => (
|
|
289
|
+
<TextField {...field} label={t('admin.vendor.description')} multiline rows={3} fullWidth />
|
|
290
|
+
)}
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<Controller
|
|
294
|
+
name="app_url"
|
|
295
|
+
control={control}
|
|
296
|
+
rules={{
|
|
297
|
+
required: t('admin.vendor.appUrlRequired'),
|
|
298
|
+
validate: validateUrl,
|
|
299
|
+
}}
|
|
300
|
+
render={({ field }) => (
|
|
301
|
+
<TextField
|
|
302
|
+
{...field}
|
|
303
|
+
label={t('admin.vendor.appUrl')}
|
|
304
|
+
required
|
|
305
|
+
fullWidth
|
|
306
|
+
error={!!errors.app_url}
|
|
307
|
+
helperText={errors.app_url?.message || t('admin.vendor.appUrlHelp')}
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
/>
|
|
311
|
+
|
|
312
|
+
<Controller
|
|
313
|
+
name="webhook_path"
|
|
314
|
+
control={control}
|
|
315
|
+
rules={{
|
|
316
|
+
validate: validateWebhookPath,
|
|
317
|
+
}}
|
|
318
|
+
render={({ field }) => (
|
|
319
|
+
<TextField
|
|
320
|
+
{...field}
|
|
321
|
+
label={t('admin.vendor.webhookPath')}
|
|
322
|
+
fullWidth
|
|
323
|
+
error={!!errors.webhook_path}
|
|
324
|
+
helperText={errors.webhook_path?.message || t('admin.vendor.webhookPathHelp')}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
/>
|
|
328
|
+
|
|
329
|
+
<Controller
|
|
330
|
+
name="blocklet_meta_url"
|
|
331
|
+
control={control}
|
|
332
|
+
rules={{
|
|
333
|
+
required: t('admin.vendor.blockletMetaUrlRequired'),
|
|
334
|
+
validate: validateBlockletMetaUrl,
|
|
335
|
+
}}
|
|
336
|
+
render={({ field }) => (
|
|
337
|
+
<TextField
|
|
338
|
+
{...field}
|
|
339
|
+
label={t('admin.vendor.blockletMetaUrl')}
|
|
340
|
+
required
|
|
341
|
+
fullWidth
|
|
342
|
+
error={!!errors.blocklet_meta_url}
|
|
343
|
+
helperText={errors.blocklet_meta_url?.message || t('admin.vendor.blockletMetaUrlHelp')}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
/>
|
|
347
|
+
|
|
348
|
+
<Stack direction="row" spacing={0} sx={{ width: '30%' }}>
|
|
349
|
+
{/* 分成类型 - 隐藏,固定为比例分成 */}
|
|
350
|
+
<Controller
|
|
351
|
+
name="default_commission_type"
|
|
352
|
+
control={control}
|
|
353
|
+
render={({ field }) => (
|
|
354
|
+
<FormControl fullWidth sx={{ display: 'none' }}>
|
|
355
|
+
<InputLabel>{t('admin.vendor.commissionType')}</InputLabel>
|
|
356
|
+
<Select {...field} label={t('admin.vendor.commissionType')}>
|
|
357
|
+
<MenuItem value="percentage">{t('admin.vendor.percentage')}</MenuItem>
|
|
358
|
+
<MenuItem value="fixed_amount">{t('admin.vendor.fixedAmount')}</MenuItem>
|
|
359
|
+
</Select>
|
|
360
|
+
</FormControl>
|
|
361
|
+
)}
|
|
362
|
+
/>
|
|
363
|
+
<Controller
|
|
364
|
+
name="default_commission_rate"
|
|
365
|
+
control={control}
|
|
366
|
+
rules={{
|
|
367
|
+
required: t('admin.vendor.commissionRateRequired'),
|
|
368
|
+
min: {
|
|
369
|
+
value: 0,
|
|
370
|
+
message: t('admin.vendor.commissionRateMin'),
|
|
371
|
+
},
|
|
372
|
+
max: {
|
|
373
|
+
value: isPercentage ? 100 : 999999,
|
|
374
|
+
message: t('admin.vendor.commissionRateMax'),
|
|
375
|
+
},
|
|
376
|
+
}}
|
|
377
|
+
render={({ field }) => (
|
|
378
|
+
<TextField
|
|
379
|
+
{...field}
|
|
380
|
+
label={`${t('admin.vendor.commissionRate')} ${isPercentage ? '(%)' : ''}`}
|
|
381
|
+
type="number"
|
|
382
|
+
required
|
|
383
|
+
fullWidth
|
|
384
|
+
placeholder="20"
|
|
385
|
+
error={!!errors.default_commission_rate}
|
|
386
|
+
helperText={
|
|
387
|
+
errors.default_commission_rate?.message ||
|
|
388
|
+
(isPercentage ? t('admin.vendor.commissionRateHelp') : t('admin.vendor.commissionAmountHelp'))
|
|
389
|
+
}
|
|
390
|
+
inputProps={{
|
|
391
|
+
min: 0,
|
|
392
|
+
max: isPercentage ? 100 : 999999,
|
|
393
|
+
step: isPercentage ? 10 : 1,
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
/>
|
|
398
|
+
</Stack>
|
|
399
|
+
|
|
400
|
+
<Controller
|
|
401
|
+
name="status"
|
|
402
|
+
control={control}
|
|
403
|
+
render={({ field }) => (
|
|
404
|
+
<FormControlLabel
|
|
405
|
+
control={
|
|
406
|
+
<Switch
|
|
407
|
+
checked={field.value === 'active'}
|
|
408
|
+
onChange={(e) => field.onChange(e.target.checked ? 'active' : 'inactive')}
|
|
409
|
+
/>
|
|
410
|
+
}
|
|
411
|
+
label={watchedValues.status === 'active' ? t('admin.vendor.enabled') : t('admin.vendor.disabled')}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
/>
|
|
415
|
+
</Stack>
|
|
416
|
+
</DrawerForm>
|
|
417
|
+
);
|
|
418
|
+
}
|