payment-kit 1.18.56 → 1.19.1

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 (214) hide show
  1. package/.eslintrc.js +6 -0
  2. package/api/src/crons/index.ts +8 -0
  3. package/api/src/index.ts +4 -0
  4. package/api/src/libs/credit-grant.ts +146 -0
  5. package/api/src/libs/env.ts +1 -0
  6. package/api/src/libs/invoice.ts +4 -3
  7. package/api/src/libs/notification/template/base.ts +388 -2
  8. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  9. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  10. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  11. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  12. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  13. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  14. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  15. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  16. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  17. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  18. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  19. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  20. package/api/src/libs/payment.ts +69 -0
  21. package/api/src/libs/queue/index.ts +3 -2
  22. package/api/src/libs/session.ts +8 -0
  23. package/api/src/libs/subscription.ts +74 -3
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +715 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/index.ts +8 -0
  37. package/api/src/routes/meter-events.ts +347 -0
  38. package/api/src/routes/meters.ts +219 -0
  39. package/api/src/routes/payment-currencies.ts +14 -2
  40. package/api/src/routes/payment-links.ts +1 -1
  41. package/api/src/routes/payment-methods.ts +14 -2
  42. package/api/src/routes/prices.ts +43 -0
  43. package/api/src/routes/pricing-table.ts +13 -7
  44. package/api/src/routes/products.ts +63 -4
  45. package/api/src/routes/settings.ts +1 -1
  46. package/api/src/routes/subscriptions.ts +4 -0
  47. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  48. package/api/src/store/models/credit-grant.ts +486 -0
  49. package/api/src/store/models/credit-transaction.ts +268 -0
  50. package/api/src/store/models/customer.ts +8 -0
  51. package/api/src/store/models/index.ts +52 -1
  52. package/api/src/store/models/meter-event.ts +423 -0
  53. package/api/src/store/models/meter.ts +176 -0
  54. package/api/src/store/models/payment-currency.ts +66 -14
  55. package/api/src/store/models/price.ts +6 -0
  56. package/api/src/store/models/product.ts +2 -2
  57. package/api/src/store/models/subscription.ts +24 -0
  58. package/api/src/store/models/types.ts +28 -2
  59. package/api/tests/libs/subscription.spec.ts +53 -0
  60. package/blocklet.yml +9 -1
  61. package/package.json +57 -58
  62. package/scripts/sdk.js +233 -1
  63. package/src/app.tsx +10 -0
  64. package/src/components/actions.tsx +22 -9
  65. package/src/components/balance-list.tsx +40 -12
  66. package/src/components/collapse.tsx +33 -15
  67. package/src/components/copyable.tsx +8 -7
  68. package/src/components/currency.tsx +15 -7
  69. package/src/components/customer/actions.tsx +1 -5
  70. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  71. package/src/components/customer/credit-overview.tsx +233 -0
  72. package/src/components/customer/form.tsx +7 -2
  73. package/src/components/customer/link.tsx +4 -12
  74. package/src/components/customer/notification-preference.tsx +18 -9
  75. package/src/components/customer/overdraft-protection.tsx +112 -41
  76. package/src/components/drawer-form.tsx +42 -18
  77. package/src/components/error.tsx +1 -5
  78. package/src/components/event/list.tsx +9 -10
  79. package/src/components/filter-toolbar.tsx +20 -19
  80. package/src/components/info-card.tsx +32 -18
  81. package/src/components/info-metric.tsx +16 -6
  82. package/src/components/info-row-group.tsx +1 -7
  83. package/src/components/info-row.tsx +30 -24
  84. package/src/components/invoice/action.tsx +1 -7
  85. package/src/components/invoice/list.tsx +34 -26
  86. package/src/components/invoice/recharge.tsx +5 -7
  87. package/src/components/invoice/table.tsx +17 -12
  88. package/src/components/layout/user.tsx +1 -1
  89. package/src/components/metadata/form.tsx +290 -94
  90. package/src/components/metadata/list.tsx +11 -3
  91. package/src/components/meter/actions.tsx +101 -0
  92. package/src/components/meter/add-usage-dialog.tsx +239 -0
  93. package/src/components/meter/events-list.tsx +657 -0
  94. package/src/components/meter/form.tsx +245 -0
  95. package/src/components/meter/products.tsx +264 -0
  96. package/src/components/meter/usage-guide.tsx +174 -0
  97. package/src/components/passport/actions.tsx +9 -4
  98. package/src/components/payment-currency/add.tsx +16 -3
  99. package/src/components/payment-currency/form.tsx +14 -6
  100. package/src/components/payment-intent/actions.tsx +24 -16
  101. package/src/components/payment-intent/list.tsx +30 -9
  102. package/src/components/payment-link/actions.tsx +1 -5
  103. package/src/components/payment-link/after-pay.tsx +4 -2
  104. package/src/components/payment-link/before-pay.tsx +14 -4
  105. package/src/components/payment-link/item.tsx +27 -6
  106. package/src/components/payment-link/preview.tsx +9 -9
  107. package/src/components/payment-link/product-select.tsx +69 -15
  108. package/src/components/payment-method/arcblock.tsx +8 -1
  109. package/src/components/payment-method/base.tsx +8 -1
  110. package/src/components/payment-method/bitcoin.tsx +8 -1
  111. package/src/components/payment-method/ethereum.tsx +8 -1
  112. package/src/components/payment-method/evm-rpc-input.tsx +11 -7
  113. package/src/components/payment-method/form.tsx +2 -7
  114. package/src/components/payment-method/stripe.tsx +2 -0
  115. package/src/components/payouts/actions.tsx +1 -5
  116. package/src/components/payouts/list.tsx +30 -10
  117. package/src/components/payouts/portal/list.tsx +11 -9
  118. package/src/components/price/currency-select.tsx +63 -32
  119. package/src/components/price/form.tsx +895 -370
  120. package/src/components/price/upsell-select.tsx +10 -2
  121. package/src/components/price/upsell.tsx +7 -2
  122. package/src/components/pricing-table/actions.tsx +1 -5
  123. package/src/components/pricing-table/customer-settings.tsx +5 -1
  124. package/src/components/pricing-table/payment-settings.tsx +14 -4
  125. package/src/components/pricing-table/preview.tsx +9 -9
  126. package/src/components/pricing-table/price-item.tsx +6 -1
  127. package/src/components/pricing-table/product-item.tsx +6 -1
  128. package/src/components/pricing-table/product-settings.tsx +17 -4
  129. package/src/components/product/actions.tsx +1 -5
  130. package/src/components/product/add-price.tsx +9 -7
  131. package/src/components/product/create.tsx +8 -9
  132. package/src/components/product/cross-sell-select.tsx +5 -1
  133. package/src/components/product/cross-sell.tsx +7 -2
  134. package/src/components/product/edit-price.tsx +21 -12
  135. package/src/components/product/features.tsx +26 -6
  136. package/src/components/product/form.tsx +115 -72
  137. package/src/components/progress-bar.tsx +1 -1
  138. package/src/components/refund/actions.tsx +1 -7
  139. package/src/components/refund/list.tsx +31 -18
  140. package/src/components/section/header.tsx +12 -14
  141. package/src/components/subscription/actions/cancel.tsx +22 -5
  142. package/src/components/subscription/actions/index.tsx +9 -10
  143. package/src/components/subscription/actions/pause.tsx +32 -6
  144. package/src/components/subscription/actions/slash-stake.tsx +5 -3
  145. package/src/components/subscription/description.tsx +12 -8
  146. package/src/components/subscription/items/index.tsx +31 -16
  147. package/src/components/subscription/items/usage-records.tsx +19 -5
  148. package/src/components/subscription/list.tsx +5 -7
  149. package/src/components/subscription/metrics.tsx +62 -15
  150. package/src/components/subscription/portal/actions.tsx +78 -71
  151. package/src/components/subscription/portal/cancel.tsx +10 -3
  152. package/src/components/subscription/portal/list.tsx +48 -26
  153. package/src/components/uploader.tsx +5 -13
  154. package/src/components/webhook/attempts.tsx +51 -16
  155. package/src/components/webhook/request-info.tsx +8 -6
  156. package/src/contexts/products.tsx +27 -10
  157. package/src/hooks/subscription.ts +34 -0
  158. package/src/libs/meter-utils.ts +196 -0
  159. package/src/libs/util.ts +4 -0
  160. package/src/locales/en.tsx +385 -4
  161. package/src/locales/zh.tsx +364 -0
  162. package/src/pages/admin/billing/index.tsx +61 -33
  163. package/src/pages/admin/billing/invoices/detail.tsx +49 -13
  164. package/src/pages/admin/billing/meters/create.tsx +60 -0
  165. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  166. package/src/pages/admin/billing/meters/index.tsx +210 -0
  167. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  168. package/src/pages/admin/billing/subscriptions/detail.tsx +90 -25
  169. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  170. package/src/pages/admin/customers/customers/detail.tsx +67 -14
  171. package/src/pages/admin/customers/customers/index.tsx +6 -1
  172. package/src/pages/admin/customers/index.tsx +5 -0
  173. package/src/pages/admin/developers/events/detail.tsx +37 -11
  174. package/src/pages/admin/developers/index.tsx +1 -1
  175. package/src/pages/admin/developers/webhooks/detail.tsx +41 -11
  176. package/src/pages/admin/index.tsx +15 -2
  177. package/src/pages/admin/overview.tsx +107 -19
  178. package/src/pages/admin/payments/intents/detail.tsx +58 -14
  179. package/src/pages/admin/payments/payouts/detail.tsx +63 -15
  180. package/src/pages/admin/payments/refunds/detail.tsx +58 -14
  181. package/src/pages/admin/products/index.tsx +11 -4
  182. package/src/pages/admin/products/links/create.tsx +22 -4
  183. package/src/pages/admin/products/links/detail.tsx +43 -14
  184. package/src/pages/admin/products/passports/index.tsx +23 -4
  185. package/src/pages/admin/products/prices/actions.tsx +16 -9
  186. package/src/pages/admin/products/prices/detail.tsx +73 -14
  187. package/src/pages/admin/products/prices/list.tsx +15 -3
  188. package/src/pages/admin/products/pricing-tables/create.tsx +45 -12
  189. package/src/pages/admin/products/pricing-tables/detail.tsx +45 -14
  190. package/src/pages/admin/products/products/create.tsx +233 -54
  191. package/src/pages/admin/products/products/detail.tsx +74 -18
  192. package/src/pages/admin/settings/index.tsx +8 -1
  193. package/src/pages/admin/settings/payment-methods/index.tsx +87 -19
  194. package/src/pages/admin/settings/vault-config/edit-form.tsx +42 -28
  195. package/src/pages/admin/settings/vault-config/index.tsx +57 -10
  196. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  197. package/src/pages/customer/index.tsx +76 -17
  198. package/src/pages/customer/invoice/detail.tsx +63 -14
  199. package/src/pages/customer/invoice/past-due.tsx +11 -3
  200. package/src/pages/customer/payout/detail.tsx +56 -13
  201. package/src/pages/customer/recharge/account.tsx +78 -18
  202. package/src/pages/customer/recharge/subscription.tsx +86 -25
  203. package/src/pages/customer/refund/list.tsx +60 -24
  204. package/src/pages/customer/subscription/change-payment.tsx +17 -6
  205. package/src/pages/customer/subscription/change-plan.tsx +34 -7
  206. package/src/pages/customer/subscription/detail.tsx +134 -34
  207. package/src/pages/customer/subscription/embed.tsx +25 -5
  208. package/src/pages/home.tsx +26 -4
  209. package/src/pages/integrations/donations/edit-form.tsx +25 -9
  210. package/src/pages/integrations/donations/index.tsx +26 -9
  211. package/src/pages/integrations/donations/preview.tsx +59 -15
  212. package/src/pages/integrations/index.tsx +10 -1
  213. package/src/pages/integrations/overview.tsx +78 -17
  214. package/vite.config.ts +60 -30
@@ -107,12 +107,21 @@ export default function InvoiceDetail(props: { id: string }) {
107
107
  return (
108
108
  <Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
109
109
  <Box>
110
- <Stack className="page-header" direction="row" justifyContent="space-between" alignItems="center">
110
+ <Stack
111
+ className="page-header"
112
+ direction="row"
113
+ sx={{
114
+ justifyContent: 'space-between',
115
+ alignItems: 'center',
116
+ }}>
111
117
  <Stack
112
118
  direction="row"
113
- alignItems="center"
114
- sx={{ fontWeight: 'normal', cursor: 'pointer' }}
115
- onClick={() => goBackOrFallback('/admin/billing/invoices')}>
119
+ onClick={() => goBackOrFallback('/admin/billing/invoices')}
120
+ sx={{
121
+ alignItems: 'center',
122
+ fontWeight: 'normal',
123
+ cursor: 'pointer',
124
+ }}>
116
125
  <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
117
126
  <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
118
127
  {t('admin.invoices')}
@@ -124,29 +133,41 @@ export default function InvoiceDetail(props: { id: string }) {
124
133
  </Box>
125
134
  </Stack>
126
135
  <Box
127
- mt={4}
128
- mb={3}
129
136
  sx={{
137
+ mt: 4,
138
+ mb: 3,
130
139
  display: 'flex',
140
+
131
141
  gap: {
132
142
  xs: 2,
133
143
  sm: 2,
134
144
  md: 5,
135
145
  },
146
+
136
147
  flexWrap: 'wrap',
148
+
137
149
  flexDirection: {
138
150
  xs: 'column',
139
151
  sm: 'column',
140
152
  md: 'row',
141
153
  },
154
+
142
155
  alignItems: {
143
156
  xs: 'flex-start',
144
157
  sm: 'flex-start',
145
158
  md: 'center',
146
159
  },
147
160
  }}>
148
- <Stack direction="column" gap={1}>
149
- <Stack direction="row" alignItems="center">
161
+ <Stack
162
+ direction="column"
163
+ sx={{
164
+ gap: 1,
165
+ }}>
166
+ <Stack
167
+ direction="row"
168
+ sx={{
169
+ alignItems: 'center',
170
+ }}>
150
171
  <Typography variant="h2" sx={{ fontWeight: 600 }}>
151
172
  {data.number}
152
173
  </Typography>
@@ -161,18 +182,22 @@ export default function InvoiceDetail(props: { id: string }) {
161
182
  </Stack>
162
183
  <Stack
163
184
  className="section-body"
164
- justifyContent="flex-start"
165
- flexWrap="wrap"
166
185
  sx={{
186
+ justifyContent: 'flex-start',
187
+ flexWrap: 'wrap',
188
+
167
189
  'hr.MuiDivider-root:last-child': {
168
190
  display: 'none',
169
191
  },
192
+
170
193
  flexDirection: {
171
194
  xs: 'column',
172
195
  sm: 'column',
173
196
  md: 'row',
174
197
  },
198
+
175
199
  alignItems: 'flex-start',
200
+
176
201
  gap: {
177
202
  xs: 1,
178
203
  sm: 1,
@@ -189,7 +214,11 @@ export default function InvoiceDetail(props: { id: string }) {
189
214
  label={t('admin.subscription.name')}
190
215
  value={
191
216
  <Link to={`/admin/billing/${data.subscription.id}`}>
192
- <Typography variant="body1" color="text.link">
217
+ <Typography
218
+ variant="body1"
219
+ sx={{
220
+ color: 'text.link',
221
+ }}>
193
222
  {data.subscription.description || data.subscription.id}
194
223
  </Typography>
195
224
  </Link>
@@ -238,7 +267,14 @@ export default function InvoiceDetail(props: { id: string }) {
238
267
  },
239
268
  },
240
269
  }}>
241
- <Box flex={1} className="payment-link-column-1" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
270
+ <Box
271
+ className="payment-link-column-1"
272
+ sx={{
273
+ flex: 1,
274
+ gap: 2.5,
275
+ display: 'flex',
276
+ flexDirection: 'column',
277
+ }}>
242
278
  <Box className="section" sx={{ containerType: 'inline-size' }}>
243
279
  <SectionHeader title={t('admin.details')} />
244
280
  <InfoRowGroup
@@ -370,7 +406,7 @@ export default function InvoiceDetail(props: { id: string }) {
370
406
  </Box>
371
407
  <Divider />
372
408
  <Box className="section">
373
- <SectionHeader title={t('admin.events')} />
409
+ <SectionHeader title={t('admin.events.title')} />
374
410
  <Box className="section-body">
375
411
  <EventList
376
412
  features={{ toolbar: false }}
@@ -0,0 +1,60 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { AddOutlined } from '@mui/icons-material';
3
+ import { Button } from '@mui/material';
4
+ import { api, formatError, usePaymentContext } from '@blocklet/payment-react';
5
+ import { useForm, FormProvider } from 'react-hook-form';
6
+ import Toast from '@arcblock/ux/lib/Toast';
7
+ import { dispatch } from 'use-bus';
8
+
9
+ import type { TMeter } from '@blocklet/payment-types';
10
+ import MeterForm from '../../../../components/meter/form';
11
+ import DrawerForm from '../../../../components/drawer-form';
12
+
13
+ export default function MeterCreate() {
14
+ const { t } = useLocaleContext();
15
+ const { refresh } = usePaymentContext();
16
+ const methods = useForm<TMeter>({
17
+ defaultValues: {
18
+ name: '',
19
+ event_name: '',
20
+ aggregation_method: 'sum',
21
+ unit: '',
22
+ description: '',
23
+ metadata: {},
24
+ },
25
+ });
26
+
27
+ const { handleSubmit, reset, clearErrors } = methods;
28
+
29
+ const onSubmit = async (data: TMeter) => {
30
+ try {
31
+ await api.post('/api/meters', data);
32
+ Toast.success(t('admin.meter.saved'));
33
+ reset();
34
+ clearErrors();
35
+ dispatch('meter.created');
36
+ dispatch('drawer.submitted');
37
+ refresh(true);
38
+ } catch (err) {
39
+ console.error(err);
40
+ Toast.error(formatError(err));
41
+ }
42
+ };
43
+
44
+ return (
45
+ <DrawerForm
46
+ icon={<AddOutlined />}
47
+ text={t('admin.meter.add')}
48
+ onClose={() => clearErrors()}
49
+ width={640}
50
+ addons={
51
+ <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>
52
+ {t('admin.meter.save')}
53
+ </Button>
54
+ }>
55
+ <FormProvider {...methods}>
56
+ <MeterForm />
57
+ </FormProvider>
58
+ </DrawerForm>
59
+ );
60
+ }
@@ -0,0 +1,435 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import { api, formatError, formatTime, useMobile } from '@blocklet/payment-react';
4
+ import { ArrowBackOutlined, EditOutlined } from '@mui/icons-material';
5
+ import { Alert, AlertTitle, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
6
+ import { styled } from '@mui/system';
7
+ import { useRequest, useSetState } from 'ahooks';
8
+ import { useNavigate } from 'react-router-dom';
9
+ import { useForm, FormProvider } from 'react-hook-form';
10
+
11
+ import type { TPaymentCurrency, TMeter } from '@blocklet/payment-types';
12
+ import Copyable from '../../../../components/copyable';
13
+ import InfoMetric from '../../../../components/info-metric';
14
+ import InfoRow from '../../../../components/info-row';
15
+ import InfoRowGroup from '../../../../components/info-row-group';
16
+ import MetadataEditor from '../../../../components/metadata/editor';
17
+ import MetadataList from '../../../../components/metadata/list';
18
+ import MeterActions from '../../../../components/meter/actions';
19
+ import SectionHeader from '../../../../components/section/header';
20
+ import { goBackOrFallback } from '../../../../libs/util';
21
+ import MeterEventsList from '../../../../components/meter/events-list';
22
+ import MeterProducts from '../../../../components/meter/products';
23
+ import MeterForm from '../../../../components/meter/form';
24
+ import DrawerForm from '../../../../components/drawer-form';
25
+
26
+ const getMeter = (id: string): Promise<TMeter & { paymentCurrency: TPaymentCurrency }> => {
27
+ return api.get(`/api/meters/${id}`).then((res: any) => res.data);
28
+ };
29
+
30
+ export default function MeterDetail(props: { id: string }) {
31
+ const { t } = useLocaleContext();
32
+ const { isMobile } = useMobile();
33
+ const navigate = useNavigate();
34
+
35
+ // 获取聚合方法的简洁描述
36
+ const getAggregationMethodHint = (method: string) => {
37
+ switch (method) {
38
+ case 'sum':
39
+ return t('admin.meter.aggregationMethod.sumHint');
40
+ case 'count':
41
+ return t('admin.meter.aggregationMethod.countHint');
42
+ case 'last':
43
+ return t('admin.meter.aggregationMethod.lastHint');
44
+ default:
45
+ return '';
46
+ }
47
+ };
48
+ const [state, setState] = useSetState({
49
+ editing: {
50
+ metadata: false,
51
+ meter: false,
52
+ },
53
+ loading: {
54
+ metadata: false,
55
+ meter: false,
56
+ },
57
+ creating: {
58
+ product: false,
59
+ creditProduct: false,
60
+ },
61
+ });
62
+
63
+ const methods = useForm<TMeter>({
64
+ defaultValues: {
65
+ name: '',
66
+ event_name: '',
67
+ aggregation_method: 'sum',
68
+ unit: '',
69
+ description: '',
70
+ metadata: {},
71
+ },
72
+ });
73
+
74
+ const { loading, error, data, runAsync } = useRequest(() => getMeter(props.id));
75
+
76
+ if (error) {
77
+ return <Alert severity="error">{error.message}</Alert>;
78
+ }
79
+
80
+ if (loading || !data) {
81
+ return <CircularProgress />;
82
+ }
83
+
84
+ const onUpdateMetadata = async (updates: any) => {
85
+ try {
86
+ setState((prev) => ({ loading: { ...prev.loading, metadata: true } }));
87
+ await api.put(`/api/meters/${props.id}`, updates);
88
+ Toast.success(t('common.saved'));
89
+ runAsync();
90
+ } catch (err) {
91
+ console.error(err);
92
+ Toast.error(formatError(err));
93
+ } finally {
94
+ setState((prev) => ({ loading: { ...prev.loading, metadata: false } }));
95
+ }
96
+ };
97
+
98
+ const onChange = (action?: string) => {
99
+ if (action === 'delete') {
100
+ navigate('/admin/billing/meters');
101
+ } else {
102
+ runAsync();
103
+ }
104
+ };
105
+
106
+ const handleEditMetadata = () => {
107
+ setState((prev) => ({ editing: { ...prev.editing, metadata: true } }));
108
+ };
109
+
110
+ const handleEditMeter = () => {
111
+ // 设置表单默认值
112
+ methods.reset({
113
+ name: data.name,
114
+ event_name: data.event_name,
115
+ aggregation_method: data.aggregation_method,
116
+ unit: data.unit,
117
+ description: data.description || '',
118
+ metadata: data.metadata || {},
119
+ });
120
+ setState((prev) => ({ editing: { ...prev.editing, meter: true } }));
121
+ };
122
+
123
+ const onUpdateMeter = async (formData: TMeter) => {
124
+ try {
125
+ setState((prev) => ({ loading: { ...prev.loading, meter: true } }));
126
+ await api.put(`/api/meters/${props.id}`, formData);
127
+ Toast.success(t('admin.meter.saved'));
128
+ setState((prev) => ({ editing: { ...prev.editing, meter: false } }));
129
+ runAsync();
130
+ } catch (err) {
131
+ console.error(err);
132
+ Toast.error(formatError(err));
133
+ } finally {
134
+ setState((prev) => ({ loading: { ...prev.loading, meter: false } }));
135
+ }
136
+ };
137
+
138
+ return (
139
+ <Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
140
+ <Box>
141
+ {data.status === 'inactive' && (
142
+ <Alert severity="warning" sx={{ mb: 2 }}>
143
+ <AlertTitle>{t('admin.meter.inactive')}</AlertTitle>
144
+ {t('admin.meter.inactiveTip')}
145
+ </Alert>
146
+ )}
147
+ <Stack
148
+ className="page-header"
149
+ direction="row"
150
+ sx={{
151
+ justifyContent: 'space-between',
152
+ alignItems: 'center',
153
+ }}>
154
+ <Stack
155
+ direction="row"
156
+ onClick={() => goBackOrFallback('/admin/meters')}
157
+ sx={{
158
+ alignItems: 'center',
159
+ fontWeight: 'normal',
160
+ cursor: 'pointer',
161
+ }}>
162
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
163
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
164
+ {t('admin.meters')}
165
+ </Typography>
166
+ </Stack>
167
+ <MeterActions data={data} variant="normal" onChange={onChange} />
168
+ </Stack>
169
+ <Box
170
+ sx={{
171
+ mt: 4,
172
+ mb: 3,
173
+ display: 'flex',
174
+
175
+ gap: {
176
+ xs: 2,
177
+ sm: 2,
178
+ md: 5,
179
+ },
180
+
181
+ flexWrap: 'wrap',
182
+
183
+ flexDirection: {
184
+ xs: 'column',
185
+ sm: 'column',
186
+ md: 'row',
187
+ },
188
+
189
+ alignItems: {
190
+ xs: 'flex-start',
191
+ sm: 'flex-start',
192
+ md: 'center',
193
+ },
194
+ }}>
195
+ <Stack
196
+ direction="row"
197
+ sx={{
198
+ justifyContent: 'space-between',
199
+ alignItems: 'center',
200
+ }}>
201
+ <Stack
202
+ direction="row"
203
+ spacing={1}
204
+ sx={{
205
+ alignItems: 'center',
206
+ }}>
207
+ <Stack
208
+ direction="column"
209
+ sx={{
210
+ alignItems: 'flex-start',
211
+ justifyContent: 'space-around',
212
+ }}>
213
+ <Typography
214
+ variant="h2"
215
+ sx={{
216
+ color: 'text.primary',
217
+ }}>
218
+ {data.name}
219
+ </Typography>
220
+ <Typography
221
+ variant="subtitle1"
222
+ sx={{
223
+ color: 'text.lighter',
224
+ }}>
225
+ {data.event_name} • {data.unit}
226
+ </Typography>
227
+ </Stack>
228
+ </Stack>
229
+ </Stack>
230
+ <Stack
231
+ className="section-body"
232
+ sx={{
233
+ justifyContent: 'flex-start',
234
+ flexWrap: 'wrap',
235
+
236
+ 'hr.MuiDivider-root:last-child': {
237
+ display: 'none',
238
+ },
239
+
240
+ flexDirection: {
241
+ xs: 'column',
242
+ sm: 'column',
243
+ md: 'row',
244
+ },
245
+
246
+ alignItems: 'flex-start',
247
+
248
+ gap: {
249
+ xs: 1,
250
+ sm: 1,
251
+ md: 3,
252
+ },
253
+ }}>
254
+ <InfoMetric label={t('common.id')} value={<Copyable text={props.id} style={{ marginLeft: 4 }} />} divider />
255
+ <InfoMetric label={t('common.createdAt')} value={formatTime(data.created_at)} divider />
256
+ </Stack>
257
+ </Box>
258
+ <Divider />
259
+ </Box>
260
+ <Stack
261
+ sx={{
262
+ flexDirection: {
263
+ xs: 'column',
264
+ lg: 'row',
265
+ },
266
+ gap: {
267
+ xs: 2.5,
268
+ md: 4,
269
+ },
270
+ '.meter-column-1': {
271
+ minWidth: {
272
+ xs: '100%',
273
+ lg: '600px',
274
+ },
275
+ },
276
+ '.meter-column-2': {
277
+ width: {
278
+ xs: '100%',
279
+ md: '100%',
280
+ lg: '320px',
281
+ },
282
+ maxWidth: {
283
+ xs: '100%',
284
+ md: '33%',
285
+ },
286
+ },
287
+ }}>
288
+ <Box
289
+ className="meter-column-1"
290
+ sx={{
291
+ flex: 1,
292
+ gap: 2.5,
293
+ display: 'flex',
294
+ flexDirection: 'column',
295
+ }}>
296
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
297
+ <SectionHeader title={t('admin.details')}>
298
+ <Button
299
+ variant="text"
300
+ color="inherit"
301
+ size="small"
302
+ sx={{ color: 'text.link' }}
303
+ disabled={state.editing.meter}
304
+ onClick={handleEditMeter}>
305
+ {t('common.edit')}
306
+ </Button>
307
+ </SectionHeader>
308
+ <InfoRowGroup
309
+ sx={{
310
+ display: 'grid',
311
+ gridTemplateColumns: {
312
+ xs: 'repeat(1, 1fr)',
313
+ xl: 'repeat(2, 1fr)',
314
+ },
315
+ '@container (min-width: 1000px)': {
316
+ gridTemplateColumns: 'repeat(2, 1fr)',
317
+ },
318
+ '.info-row-wrapper': {
319
+ gap: 1,
320
+ flexDirection: {
321
+ xs: 'column',
322
+ xl: 'row',
323
+ },
324
+ alignItems: {
325
+ xs: 'flex-start',
326
+ xl: 'center',
327
+ },
328
+ '@container (min-width: 1000px)': {
329
+ flexDirection: 'row',
330
+ alignItems: 'center',
331
+ },
332
+ },
333
+ }}>
334
+ <InfoRow label={t('admin.meter.name.label')} value={data.name} />
335
+ <InfoRow label={t('admin.meter.eventName.label')} value={data.event_name} />
336
+ <InfoRow label={t('admin.meter.aggregationMethod.label')} value={data.aggregation_method} />
337
+ <InfoRow label={t('admin.meter.unit.label')} value={data.unit} />
338
+ <InfoRow label={t('admin.meter.description.label')} value={data.description || '-'} />
339
+ <InfoRow label={t('common.updatedAt')} value={formatTime(data.updated_at)} />
340
+ </InfoRowGroup>
341
+ </Box>
342
+ <Divider />
343
+
344
+ <Box className="section">
345
+ <SectionHeader title={t('admin.meter.events.title')} />
346
+ <Box className="section-body">
347
+ <MeterEventsList meterId={data.id} paymentCurrency={data.paymentCurrency} />
348
+ </Box>
349
+ </Box>
350
+ <Divider />
351
+
352
+ <Box className="section">
353
+ <Box
354
+ sx={{
355
+ display: 'flex',
356
+ justifyContent: 'space-between',
357
+ alignItems: 'center',
358
+ }}>
359
+ <SectionHeader title={t('admin.meter.products.title')} />
360
+ </Box>
361
+ <Box className="section-body">
362
+ <MeterProducts meterId={data.id} meter={data} />
363
+ </Box>
364
+ </Box>
365
+ </Box>
366
+ {isMobile && <Divider />}
367
+ <Box className="meter-column-2" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
368
+ <Box className="section">
369
+ <SectionHeader title={t('admin.meter.usageGuide.title')} />
370
+ <Box className="section-body">
371
+ <Alert severity="info" sx={{ mb: 2 }}>
372
+ <Typography variant="body2">
373
+ {t('admin.meter.usageGuide.tip', {
374
+ eventName: data.event_name,
375
+ aggregationMethodHint: getAggregationMethodHint(data.aggregation_method),
376
+ unit: data.unit,
377
+ })}
378
+ </Typography>
379
+ </Alert>
380
+ </Box>
381
+ </Box>
382
+ <Divider />
383
+ <Box className="section">
384
+ <SectionHeader title={t('common.metadata.label')}>
385
+ <Button
386
+ variant="text"
387
+ color="inherit"
388
+ size="small"
389
+ sx={{ color: 'text.link' }}
390
+ disabled={state.editing.metadata}
391
+ onClick={handleEditMetadata}>
392
+ {t('common.edit')}
393
+ </Button>
394
+ </SectionHeader>
395
+ <Box className="section-body">
396
+ <MetadataList data={data.metadata} handleEditMetadata={handleEditMetadata} />
397
+ {state.editing.metadata && (
398
+ <MetadataEditor
399
+ data={data}
400
+ loading={state.loading.metadata}
401
+ onSave={onUpdateMetadata}
402
+ onCancel={() => setState((prev) => ({ editing: { ...prev.editing, metadata: false } }))}
403
+ />
404
+ )}
405
+ </Box>
406
+ </Box>
407
+ </Box>
408
+ </Stack>
409
+ {/* 编辑计量器表单 */}
410
+ {state.editing.meter && (
411
+ <DrawerForm
412
+ icon={<EditOutlined />}
413
+ text={t('admin.meter.edit')}
414
+ open={state.editing.meter}
415
+ onClose={() => setState((prev) => ({ editing: { ...prev.editing, meter: false } }))}
416
+ width={640}
417
+ addons={
418
+ <Button
419
+ variant="contained"
420
+ size="small"
421
+ disabled={state.loading.meter}
422
+ onClick={methods.handleSubmit(onUpdateMeter)}>
423
+ {t('admin.meter.save')}
424
+ </Button>
425
+ }>
426
+ <FormProvider {...methods}>
427
+ <MeterForm mode="edit" />
428
+ </FormProvider>
429
+ </DrawerForm>
430
+ )}
431
+ </Root>
432
+ );
433
+ }
434
+
435
+ const Root = styled(Stack)``;