payment-kit 1.20.5 → 1.20.7

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.
Files changed (39) hide show
  1. package/api/src/crons/index.ts +11 -3
  2. package/api/src/index.ts +18 -14
  3. package/api/src/libs/env.ts +7 -0
  4. package/api/src/libs/url.ts +77 -0
  5. package/api/src/libs/vendor/adapters/factory.ts +40 -0
  6. package/api/src/libs/vendor/adapters/launcher-adapter.ts +179 -0
  7. package/api/src/libs/vendor/adapters/types.ts +91 -0
  8. package/api/src/libs/vendor/fulfillment.ts +317 -0
  9. package/api/src/queues/payment.ts +14 -10
  10. package/api/src/queues/payout.ts +1 -0
  11. package/api/src/queues/vendor/commission.ts +192 -0
  12. package/api/src/queues/vendor/fulfillment-coordinator.ts +625 -0
  13. package/api/src/queues/vendor/fulfillment.ts +98 -0
  14. package/api/src/queues/vendor/status-check.ts +178 -0
  15. package/api/src/routes/checkout-sessions.ts +12 -0
  16. package/api/src/routes/index.ts +2 -0
  17. package/api/src/routes/products.ts +72 -1
  18. package/api/src/routes/vendor.ts +527 -0
  19. package/api/src/store/migrations/20250820-add-product-vendor.ts +102 -0
  20. package/api/src/store/migrations/20250822-add-vendor-config-to-products.ts +56 -0
  21. package/api/src/store/models/checkout-session.ts +84 -18
  22. package/api/src/store/models/index.ts +3 -0
  23. package/api/src/store/models/payout.ts +11 -0
  24. package/api/src/store/models/product-vendor.ts +118 -0
  25. package/api/src/store/models/product.ts +15 -0
  26. package/blocklet.yml +8 -2
  27. package/doc/vendor_fulfillment_system.md +929 -0
  28. package/package.json +5 -4
  29. package/src/components/collapse.tsx +1 -0
  30. package/src/components/product/edit.tsx +9 -0
  31. package/src/components/product/form.tsx +11 -0
  32. package/src/components/product/vendor-config.tsx +249 -0
  33. package/src/components/vendor/actions.tsx +145 -0
  34. package/src/locales/en.tsx +89 -0
  35. package/src/locales/zh.tsx +89 -0
  36. package/src/pages/admin/products/index.tsx +11 -1
  37. package/src/pages/admin/products/products/detail.tsx +79 -2
  38. package/src/pages/admin/products/vendors/create.tsx +418 -0
  39. package/src/pages/admin/products/vendors/index.tsx +313 -0
@@ -0,0 +1,313 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { getDurableData } from '@arcblock/ux/lib/Datatable';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { api, formatTime, Status, Table } from '@blocklet/payment-react';
5
+ import { ContentCopy } from '@mui/icons-material';
6
+ import { Box, Chip, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material';
7
+ import { useEffect, useState } from 'react';
8
+ import useBus from 'use-bus';
9
+
10
+ import { useLocalStorageState } from 'ahooks';
11
+ import FilterToolbar from '../../../../components/filter-toolbar';
12
+ import InfoCard from '../../../../components/info-card';
13
+ import VendorActions from '../../../../components/vendor/actions';
14
+ import VendorCreate from './create';
15
+
16
+ interface Vendor {
17
+ id: string;
18
+ vendor_key: string;
19
+ name: string;
20
+ description: string;
21
+ app_url: string;
22
+ webhook_path: string;
23
+ default_commission_rate: number;
24
+ default_commission_type: 'percentage' | 'fixed_amount';
25
+ status: 'active' | 'inactive';
26
+ order_create_params: Record<string, any>;
27
+ metadata: Record<string, any>;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ const fetchData = (params: Record<string, any> = {}): Promise<{ list: Vendor[]; count: number; pk?: string }> => {
33
+ const search = new URLSearchParams();
34
+ Object.keys(params).forEach((key) => {
35
+ let v = params[key];
36
+ if (key === 'q') {
37
+ v = (Object.entries(v) || []).map((x) => x.join(':')).join(' ');
38
+ }
39
+ search.set(key, String(v));
40
+ });
41
+ return api.get(`/api/vendors?${search.toString()}`).then((res: any) => ({
42
+ list: res.data.data || [],
43
+ count: res.data.total || 0,
44
+ pk: res.data.pk || '',
45
+ }));
46
+ };
47
+
48
+ type SearchProps = {
49
+ status?: string;
50
+ pageSize: number;
51
+ page: number;
52
+ q?: any;
53
+ o?: string;
54
+ };
55
+
56
+ export default function VendorsList() {
57
+ const listKey = 'vendors';
58
+ const persisted = getDurableData(listKey);
59
+
60
+ const { t } = useLocaleContext();
61
+ const [search, setSearch] = useLocalStorageState<SearchProps>(listKey, {
62
+ defaultValue: {
63
+ pageSize: persisted.rowsPerPage || 20,
64
+ page: persisted.page ? persisted.page + 1 : 1,
65
+ },
66
+ });
67
+
68
+ const [data, setData] = useState({}) as any;
69
+ const [detailOpen, setDetailOpen] = useState(false);
70
+ const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
71
+ const [copySuccess, setCopySuccess] = useState(false);
72
+
73
+ const refresh = () =>
74
+ fetchData(search).then((res: any) => {
75
+ setData(res);
76
+ });
77
+
78
+ useBus('vendor.created', () => refresh(), []);
79
+ useBus('vendor.updated', () => refresh(), []);
80
+ useBus('vendor.deleted', () => refresh(), []);
81
+
82
+ useEffect(() => {
83
+ refresh();
84
+ }, [search]);
85
+
86
+ if (!data.list) {
87
+ return <CircularProgress />;
88
+ }
89
+
90
+ const columns = [
91
+ {
92
+ label: t('admin.vendor.name'),
93
+ name: 'name',
94
+ options: {
95
+ filter: true,
96
+ customBodyRenderLite: (_: string, index: number) => {
97
+ const item = data.list[index] as Vendor;
98
+ return <InfoCard name={item.name} description={item.vendor_key} logo={undefined} />;
99
+ },
100
+ },
101
+ },
102
+ {
103
+ label: t('admin.vendor.description'),
104
+ name: 'description',
105
+ options: {
106
+ filter: true,
107
+ customBodyRenderLite: (_: string, index: number) => {
108
+ const item = data.list[index] as Vendor;
109
+ return (
110
+ <Typography variant="body2" color="text.secondary">
111
+ {item.description || '-'}
112
+ </Typography>
113
+ );
114
+ },
115
+ },
116
+ },
117
+ {
118
+ label: t('admin.vendor.commission'),
119
+ name: 'commission',
120
+ options: {
121
+ filter: true,
122
+ customBodyRenderLite: (_: string, index: number) => {
123
+ const item = data.list[index] as Vendor;
124
+ const commissionText =
125
+ item.default_commission_type === 'percentage'
126
+ ? `${item.default_commission_rate}%`
127
+ : `${item.default_commission_rate}`;
128
+ return <Typography variant="body2">{commissionText}</Typography>;
129
+ },
130
+ },
131
+ },
132
+ {
133
+ label: t('common.status'),
134
+ name: 'status',
135
+ options: {
136
+ filter: true,
137
+ customBodyRenderLite: (_: string, index: number) => {
138
+ const item = data.list[index] as Vendor;
139
+ return (
140
+ <Status
141
+ label={item?.status === 'active' ? t('admin.vendor.active') : t('admin.vendor.inactive')}
142
+ color={item?.status === 'active' ? 'success' : 'default'}
143
+ />
144
+ );
145
+ },
146
+ },
147
+ },
148
+ {
149
+ label: t('common.createdAt'),
150
+ name: 'created_at',
151
+ options: {
152
+ sort: true,
153
+ customBodyRenderLite: (_: string, index: number) => {
154
+ const item = data.list[index] as Vendor;
155
+ return formatTime(item.created_at);
156
+ },
157
+ },
158
+ },
159
+ {
160
+ label: t('common.actions'),
161
+ name: 'id',
162
+ width: 100,
163
+ align: 'center',
164
+ options: {
165
+ sort: false,
166
+ customBodyRenderLite: (_: string, index: number) => {
167
+ const vendor = data.list[index] as Vendor;
168
+ return <VendorActions data={vendor} onChange={refresh} />;
169
+ },
170
+ },
171
+ },
172
+ ];
173
+
174
+ const onTableChange = ({ page, rowsPerPage }: any) => {
175
+ if (search!.pageSize !== rowsPerPage) {
176
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
177
+ } else if (search!.page !== page + 1) {
178
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
179
+ }
180
+ };
181
+
182
+ const handleRowClick = (vendor: Vendor) => {
183
+ setSelectedVendor(vendor);
184
+ setDetailOpen(true);
185
+ };
186
+
187
+ const handleCopyPublicKey = async () => {
188
+ try {
189
+ await navigator.clipboard.writeText(data.pk);
190
+ setCopySuccess(true);
191
+ setTimeout(() => setCopySuccess(false), 2000);
192
+ } catch (err) {
193
+ console.error('Failed to copy public key:', err);
194
+ }
195
+ };
196
+
197
+ return (
198
+ <>
199
+ {/* 供应商服务公钥展示 */}
200
+ {data.pk && (
201
+ <Box
202
+ sx={{
203
+ display: 'flex',
204
+ flexDirection: { xs: 'column', md: 'row' },
205
+ alignItems: { xs: 'flex-start', md: 'center' },
206
+ gap: { xs: 1, md: 0 },
207
+ p: { xs: 1.5, md: 2 },
208
+ mt: { xs: 1.5, md: 1 },
209
+ borderRadius: 1,
210
+ border: '1px solid',
211
+ borderColor: 'divider',
212
+ }}>
213
+ <Typography variant="body2" color="text.secondary" sx={{ minWidth: 'fit-content' }}>
214
+ {t('admin.vendor.servicePublicKey')}:
215
+ <Tooltip title={copySuccess ? t('common.copied') : t('common.copy')}>
216
+ <IconButton
217
+ size="small"
218
+ onClick={handleCopyPublicKey}
219
+ sx={{
220
+ color: copySuccess ? 'success.main' : 'text.secondary',
221
+ minWidth: { xs: '24px', md: '32px' },
222
+ width: { xs: '24px', md: '32px' },
223
+ height: { xs: '24px', md: '32px' },
224
+ '&:hover': { backgroundColor: 'grey.100' },
225
+ }}>
226
+ <ContentCopy sx={{ fontSize: { xs: 16, md: 18 } }} />
227
+ </IconButton>
228
+ </Tooltip>
229
+ </Typography>
230
+ <Box
231
+ sx={{
232
+ display: 'flex',
233
+ alignItems: 'center',
234
+ gap: 1,
235
+ width: { xs: '100%', md: 'auto' },
236
+ flex: { xs: 'none', md: 1 },
237
+ }}>
238
+ <Chip
239
+ sx={{ backgroundColor: 'grey.200', color: 'text.secondary' }}
240
+ label={data.pk}
241
+ variant="outlined"
242
+ size="small"
243
+ />
244
+ </Box>
245
+ </Box>
246
+ )}
247
+
248
+ <Table
249
+ hasRowLink
250
+ durable={`__${listKey}__`}
251
+ durableKeys={['page', 'rowsPerPage', 'searchText']}
252
+ title={<FilterToolbar setSearch={setSearch} search={search} status={['active', 'inactive']} />}
253
+ data={data.list || []}
254
+ columns={columns}
255
+ options={{
256
+ count: data.count,
257
+ page: search!.page - 1,
258
+ rowsPerPage: search!.pageSize,
259
+ onColumnSortChange(_: any, order: any) {
260
+ setSearch({
261
+ ...search!,
262
+ q: search!.q || {},
263
+ o: order,
264
+ });
265
+ },
266
+ onSearchChange: (text: string) => {
267
+ if (text) {
268
+ setSearch({
269
+ ...search!,
270
+ q: {
271
+ 'like-description': text,
272
+ 'like-name': text,
273
+ 'like-vendor_key': text,
274
+ },
275
+ pageSize: 100,
276
+ page: 1,
277
+ });
278
+ } else {
279
+ setSearch({
280
+ ...search!,
281
+ pageSize: 100,
282
+ page: 1,
283
+ q: {},
284
+ });
285
+ }
286
+ },
287
+ onRowClick: (_: object[], { dataIndex }: { dataIndex: number }) => {
288
+ const vendor = data.list[dataIndex] as Vendor;
289
+ handleRowClick(vendor);
290
+ },
291
+ }}
292
+ loading={!data.list}
293
+ onChange={onTableChange}
294
+ />
295
+
296
+ {selectedVendor && (
297
+ <VendorCreate
298
+ open={detailOpen}
299
+ onClose={() => {
300
+ setDetailOpen(false);
301
+ setSelectedVendor(null);
302
+ }}
303
+ onSubmit={() => {
304
+ setDetailOpen(false);
305
+ setSelectedVendor(null);
306
+ refresh();
307
+ }}
308
+ vendorData={selectedVendor}
309
+ />
310
+ )}
311
+ </>
312
+ );
313
+ }