payment-kit 1.19.0 → 1.19.2

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 (139) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/util.ts +3 -1
  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 +728 -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/customers.ts +34 -5
  37. package/api/src/routes/index.ts +8 -0
  38. package/api/src/routes/meter-events.ts +347 -0
  39. package/api/src/routes/meters.ts +219 -0
  40. package/api/src/routes/payment-currencies.ts +20 -2
  41. package/api/src/routes/payment-links.ts +1 -1
  42. package/api/src/routes/payment-methods.ts +14 -2
  43. package/api/src/routes/prices.ts +43 -0
  44. package/api/src/routes/pricing-table.ts +13 -7
  45. package/api/src/routes/products.ts +63 -4
  46. package/api/src/routes/settings.ts +1 -1
  47. package/api/src/routes/subscriptions.ts +4 -0
  48. package/api/src/routes/webhook-endpoints.ts +0 -3
  49. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  50. package/api/src/store/models/credit-grant.ts +486 -0
  51. package/api/src/store/models/credit-transaction.ts +268 -0
  52. package/api/src/store/models/customer.ts +8 -0
  53. package/api/src/store/models/index.ts +52 -1
  54. package/api/src/store/models/meter-event.ts +423 -0
  55. package/api/src/store/models/meter.ts +176 -0
  56. package/api/src/store/models/payment-currency.ts +66 -14
  57. package/api/src/store/models/price.ts +6 -0
  58. package/api/src/store/models/product.ts +2 -2
  59. package/api/src/store/models/subscription.ts +24 -0
  60. package/api/src/store/models/types.ts +28 -2
  61. package/api/tests/libs/subscription.spec.ts +53 -0
  62. package/blocklet.yml +9 -1
  63. package/package.json +4 -4
  64. package/scripts/sdk.js +233 -1
  65. package/src/app.tsx +10 -0
  66. package/src/components/collapse.tsx +11 -1
  67. package/src/components/conditional-section.tsx +87 -0
  68. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  69. package/src/components/customer/credit-overview.tsx +246 -0
  70. package/src/components/customer/form.tsx +7 -3
  71. package/src/components/invoice/list.tsx +19 -1
  72. package/src/components/metadata/form.tsx +287 -91
  73. package/src/components/meter/actions.tsx +101 -0
  74. package/src/components/meter/add-usage-dialog.tsx +239 -0
  75. package/src/components/meter/events-list.tsx +657 -0
  76. package/src/components/meter/form.tsx +245 -0
  77. package/src/components/meter/products.tsx +264 -0
  78. package/src/components/meter/usage-guide.tsx +174 -0
  79. package/src/components/payment-currency/form.tsx +2 -0
  80. package/src/components/payment-intent/list.tsx +19 -1
  81. package/src/components/payment-link/item.tsx +2 -2
  82. package/src/components/payment-link/preview.tsx +1 -1
  83. package/src/components/payment-link/product-select.tsx +52 -12
  84. package/src/components/payment-method/arcblock.tsx +2 -0
  85. package/src/components/payment-method/base.tsx +2 -0
  86. package/src/components/payment-method/bitcoin.tsx +2 -0
  87. package/src/components/payment-method/ethereum.tsx +2 -0
  88. package/src/components/payment-method/stripe.tsx +2 -0
  89. package/src/components/payouts/list.tsx +19 -1
  90. package/src/components/payouts/portal/list.tsx +6 -11
  91. package/src/components/price/currency-select.tsx +56 -32
  92. package/src/components/price/form.tsx +912 -407
  93. package/src/components/pricing-table/preview.tsx +1 -1
  94. package/src/components/product/add-price.tsx +9 -7
  95. package/src/components/product/create.tsx +7 -4
  96. package/src/components/product/edit-price.tsx +21 -12
  97. package/src/components/product/features.tsx +17 -7
  98. package/src/components/product/form.tsx +100 -90
  99. package/src/components/refund/list.tsx +19 -1
  100. package/src/components/section/header.tsx +5 -18
  101. package/src/components/subscription/items/index.tsx +1 -1
  102. package/src/components/subscription/metrics.tsx +37 -5
  103. package/src/components/subscription/portal/actions.tsx +2 -1
  104. package/src/contexts/products.tsx +26 -9
  105. package/src/hooks/subscription.ts +34 -0
  106. package/src/libs/meter-utils.ts +196 -0
  107. package/src/libs/util.ts +4 -0
  108. package/src/locales/en.tsx +389 -5
  109. package/src/locales/zh.tsx +368 -1
  110. package/src/pages/admin/billing/index.tsx +61 -33
  111. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  112. package/src/pages/admin/billing/meters/create.tsx +60 -0
  113. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  114. package/src/pages/admin/billing/meters/index.tsx +210 -0
  115. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  116. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  117. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  118. package/src/pages/admin/customers/customers/detail.tsx +14 -10
  119. package/src/pages/admin/customers/index.tsx +5 -0
  120. package/src/pages/admin/developers/events/detail.tsx +1 -1
  121. package/src/pages/admin/developers/index.tsx +1 -1
  122. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  123. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  124. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  125. package/src/pages/admin/products/index.tsx +3 -2
  126. package/src/pages/admin/products/links/detail.tsx +1 -1
  127. package/src/pages/admin/products/prices/actions.tsx +16 -4
  128. package/src/pages/admin/products/prices/detail.tsx +30 -3
  129. package/src/pages/admin/products/prices/list.tsx +8 -1
  130. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  131. package/src/pages/admin/products/products/create.tsx +233 -57
  132. package/src/pages/admin/products/products/detail.tsx +2 -1
  133. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  134. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  135. package/src/pages/customer/index.tsx +44 -9
  136. package/src/pages/customer/recharge/account.tsx +5 -5
  137. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  138. package/src/pages/customer/subscription/detail.tsx +48 -14
  139. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -0,0 +1,657 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import { api, formatTime, getCustomerAvatar, Table, useMobile, usePaymentContext } from '@blocklet/payment-react';
4
+ import type { TCustomer, TPaymentCurrency, TMeterEventExpanded } from '@blocklet/payment-types';
5
+ import {
6
+ Box,
7
+ Typography,
8
+ Alert,
9
+ Stack,
10
+ Card,
11
+ Autocomplete,
12
+ TextField,
13
+ Button,
14
+ Popover,
15
+ ToggleButton,
16
+ ToggleButtonGroup,
17
+ Skeleton,
18
+ Avatar,
19
+ } from '@mui/material';
20
+ import { CalendarTodayOutlined, Add } from '@mui/icons-material';
21
+ import { useEffect, useMemo } from 'react';
22
+ import { useSetState } from 'ahooks';
23
+ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
24
+ import { fromUnitToToken } from '@ocap/util';
25
+ import { Link } from 'react-router-dom';
26
+ import dayjs from '../../libs/dayjs';
27
+ import DateRangePicker from '../date-range-picker';
28
+ import { getDefaultRanges } from '../../pages/admin/overview';
29
+ import AddUsageDialog from './add-usage-dialog';
30
+ import CustomerLink from '../customer/link';
31
+
32
+ interface MeterEventsListProps {
33
+ meterId: string;
34
+ paymentCurrency: TPaymentCurrency;
35
+ }
36
+
37
+ interface TMeterEventStats {
38
+ date: string;
39
+ event_count: number;
40
+ total_value: number;
41
+ timestamp: string;
42
+ }
43
+
44
+ interface EventsState {
45
+ events: TMeterEventExpanded[];
46
+ stats: TMeterEventStats[];
47
+ customers: TCustomer[];
48
+ loading: boolean;
49
+ statsLoading: boolean;
50
+ error: string | null;
51
+ pagination: {
52
+ page: number;
53
+ limit: number;
54
+ total: number;
55
+ };
56
+ filters: {
57
+ customer_id?: string;
58
+ start?: string;
59
+ end?: string;
60
+ };
61
+ anchorEl: any;
62
+ startDate: Date;
63
+ endDate: Date;
64
+ selectedCustomer: TCustomer | null;
65
+ chartType: 'event_count' | 'total_value';
66
+ addUsageDialog: boolean;
67
+ }
68
+
69
+ const fetchEvents = (meterId: string, params: any): Promise<{ list: TMeterEventExpanded[]; count: number }> => {
70
+ const searchParams = new URLSearchParams();
71
+ searchParams.append('meter_id', meterId);
72
+ Object.keys(params).forEach((key) => {
73
+ if (params[key] !== undefined && params[key] !== '') {
74
+ searchParams.append(key, params[key]);
75
+ }
76
+ });
77
+ return api.get(`/api/meter-events?${searchParams.toString()}`).then((res: any) => res.data);
78
+ };
79
+
80
+ const fetchEventStats = (
81
+ meterId: string,
82
+ start: number,
83
+ end: number,
84
+ customerId?: string,
85
+ granularity?: 'hour' | 'day'
86
+ ): Promise<{ list: TMeterEventStats[]; count: number }> => {
87
+ const params = new URLSearchParams();
88
+ params.append('meter_id', meterId);
89
+ params.append('start', start.toString());
90
+ params.append('end', end.toString());
91
+ if (customerId) {
92
+ params.append('customer_id', customerId);
93
+ }
94
+ if (granularity) {
95
+ params.append('granularity', granularity);
96
+ }
97
+ return api.get(`/api/meter-events/stats?${params.toString()}`).then((res: any) => res.data);
98
+ };
99
+
100
+ const fetchCustomers = (): Promise<{ list: TCustomer[]; count: number }> => {
101
+ return api.get('/api/customers?limit=100').then((res: any) => res.data);
102
+ };
103
+
104
+ const generateEmptyChartData = (startDate: Date, endDate: Date, granularity: 'hour' | 'day') => {
105
+ const data = [];
106
+ const start = dayjs(startDate);
107
+ const end = dayjs(endDate);
108
+ const now = dayjs();
109
+
110
+ if (granularity === 'hour') {
111
+ let current = start.startOf('hour');
112
+ const actualEnd = end.isAfter(now) ? now : end;
113
+ while (current.isBefore(actualEnd) || current.isSame(actualEnd, 'hour')) {
114
+ data.push({
115
+ date: current.format('HH:mm'),
116
+ fullDate: current.format('YYYY-MM-DD HH:mm'),
117
+ timestamp: current.unix().toString(),
118
+ event_count: 0,
119
+ total_value: 0,
120
+ });
121
+ current = current.add(1, 'hour');
122
+ }
123
+ } else {
124
+ let current = start.startOf('day');
125
+ const actualEnd = end.isAfter(now, 'day') ? now.startOf('day') : end;
126
+ while (current.isBefore(actualEnd) || current.isSame(actualEnd, 'day')) {
127
+ data.push({
128
+ date: current.format('MM/DD'),
129
+ fullDate: current.format('YYYY-MM-DD'),
130
+ timestamp: current.unix().toString(),
131
+ event_count: 0,
132
+ total_value: 0,
133
+ });
134
+ current = current.add(1, 'day');
135
+ }
136
+ }
137
+
138
+ return data;
139
+ };
140
+
141
+ export default function MeterEventsList({ meterId, paymentCurrency }: MeterEventsListProps) {
142
+ const { t } = useLocaleContext();
143
+ const { isMobile } = useMobile('md');
144
+ const { livemode } = usePaymentContext();
145
+ const maxDate = dayjs().endOf('day').toDate();
146
+ const [state, setState] = useSetState<EventsState>({
147
+ events: [],
148
+ stats: [],
149
+ customers: [],
150
+ loading: false,
151
+ statsLoading: false,
152
+ error: null,
153
+ pagination: {
154
+ page: 0,
155
+ limit: 10,
156
+ total: 0,
157
+ },
158
+ filters: {},
159
+ anchorEl: null,
160
+ startDate: dayjs().subtract(30, 'day').startOf('day').toDate(),
161
+ endDate: maxDate,
162
+ selectedCustomer: null,
163
+ chartType: 'event_count',
164
+ addUsageDialog: false,
165
+ });
166
+
167
+ const granularity = useMemo(() => {
168
+ const start = dayjs(state.startDate);
169
+ const end = dayjs(state.endDate);
170
+ const diffDays = end.diff(start, 'day');
171
+
172
+ // 只有当天(0天差异)使用小时,其他都使用天
173
+ if (diffDays === 0) {
174
+ return 'hour' as const;
175
+ }
176
+ return 'day' as const;
177
+ }, [state.startDate, state.endDate]);
178
+
179
+ const chartData = useMemo(() => {
180
+ if (state.statsLoading) {
181
+ return [];
182
+ }
183
+
184
+ const emptyData = generateEmptyChartData(state.startDate, state.endDate, granularity);
185
+
186
+ if (!state.stats.length) {
187
+ return emptyData;
188
+ }
189
+
190
+ const dataMap = new Map();
191
+ state.stats.forEach((item) => {
192
+ let key;
193
+ let fullDate;
194
+ if (granularity === 'hour') {
195
+ key = dayjs(item.timestamp).format('HH:mm');
196
+ fullDate = dayjs(item.timestamp).format('YYYY-MM-DD HH:mm');
197
+ } else {
198
+ key = dayjs(item.timestamp).format('MM/DD');
199
+ fullDate = dayjs(item.timestamp).format('YYYY-MM-DD');
200
+ }
201
+
202
+ dataMap.set(key, {
203
+ ...item,
204
+ date: key,
205
+ fullDate,
206
+ });
207
+ });
208
+
209
+ return emptyData.map((empty) => dataMap.get(empty.date) || empty);
210
+ }, [state.stats, state.statsLoading, state.startDate, state.endDate, granularity]);
211
+
212
+ const loadEvents = async (page = 0, limit = 10) => {
213
+ setState({ loading: true, error: null });
214
+ try {
215
+ const params: any = {
216
+ page,
217
+ limit,
218
+ };
219
+
220
+ if (state.startDate && state.endDate) {
221
+ params.start = dayjs(state.startDate).unix();
222
+ params.end = dayjs(state.endDate).unix();
223
+ }
224
+
225
+ if (state.selectedCustomer) {
226
+ params.customer_id = state.selectedCustomer.id;
227
+ }
228
+
229
+ const result = await fetchEvents(meterId, params);
230
+ setState({
231
+ events: result.list,
232
+ pagination: { page, limit, total: result.count },
233
+ loading: false,
234
+ });
235
+ } catch (err) {
236
+ console.error('Failed to fetch events:', err);
237
+ setState({ error: 'Failed to load events', loading: false });
238
+ }
239
+ };
240
+
241
+ const loadStats = async () => {
242
+ setState({ statsLoading: true });
243
+ try {
244
+ const result = await fetchEventStats(
245
+ meterId,
246
+ dayjs(state.startDate).unix(),
247
+ dayjs(state.endDate).unix(),
248
+ state.selectedCustomer?.id,
249
+ granularity
250
+ );
251
+ setState({ stats: result.list, statsLoading: false });
252
+ } catch (err) {
253
+ console.error('Failed to fetch stats:', err);
254
+ setState({ statsLoading: false });
255
+ }
256
+ };
257
+
258
+ const loadCustomers = async () => {
259
+ try {
260
+ const result = await fetchCustomers();
261
+ setState({ customers: result.list });
262
+ } catch (err) {
263
+ console.error('Failed to fetch customers:', err);
264
+ }
265
+ };
266
+
267
+ useEffect(() => {
268
+ loadEvents();
269
+ loadStats();
270
+ loadCustomers();
271
+ }, [meterId]);
272
+
273
+ useEffect(() => {
274
+ loadEvents(0, state.pagination.limit);
275
+ loadStats();
276
+ }, [state.startDate, state.endDate, state.selectedCustomer, granularity]);
277
+
278
+ const handlePageChange = (page: number) => {
279
+ loadEvents(page, state.pagination.limit);
280
+ };
281
+
282
+ const handleRowsPerPageChange = (rowsPerPage: number) => {
283
+ loadEvents(0, rowsPerPage);
284
+ };
285
+
286
+ const onTogglePicker = (e: any) => {
287
+ if (state.anchorEl) {
288
+ setState({ anchorEl: null });
289
+ } else {
290
+ setState({ anchorEl: e.currentTarget });
291
+ }
292
+ };
293
+
294
+ const onRangeChange = (range: any) => {
295
+ setState({
296
+ startDate: range.startDate,
297
+ endDate: dayjs(range.endDate).endOf('day').toDate(),
298
+ anchorEl: null,
299
+ });
300
+ };
301
+
302
+ const handleChartTypeChange = (_: any, newType: 'event_count' | 'total_value') => {
303
+ if (newType !== null) {
304
+ setState({ chartType: newType });
305
+ }
306
+ };
307
+
308
+ const handleCustomerChange = (customer: TCustomer | null) => {
309
+ setState({ selectedCustomer: customer });
310
+ };
311
+
312
+ const getCustomerDisplayName = (customer: TCustomer) => {
313
+ return customer.name || customer.email || customer.did || customer.id;
314
+ };
315
+
316
+ const handleAddUsage = () => {
317
+ setState({ addUsageDialog: true });
318
+ };
319
+
320
+ const handleCloseAddUsage = () => {
321
+ setState({ addUsageDialog: false });
322
+ };
323
+
324
+ const handleAddUsageSuccess = () => {
325
+ loadEvents(0, state.pagination.limit);
326
+ loadStats();
327
+ };
328
+
329
+ if (state.error) {
330
+ return (
331
+ <Alert severity="error" sx={{ mt: 1 }}>
332
+ {state.error}
333
+ </Alert>
334
+ );
335
+ }
336
+
337
+ const open = Boolean(state.anchorEl);
338
+ const id = open ? 'date-range-picker-popover' : undefined;
339
+
340
+ const columns = [
341
+ {
342
+ label: t('admin.meter.events.value'),
343
+ name: 'value',
344
+ options: {
345
+ filter: false,
346
+ sort: false,
347
+ customBodyRenderLite: (_: string, index: number) => {
348
+ const item = state.events[index];
349
+ if (!item) return null;
350
+ const value = fromUnitToToken(item.payload.value || '0', paymentCurrency.decimal);
351
+ return <Link to={`/admin/billing/${item.id}`}>{`${value} ${paymentCurrency.symbol}`}</Link>;
352
+ },
353
+ },
354
+ },
355
+ {
356
+ label: t('admin.meter.events.customer'),
357
+ name: 'customer',
358
+ options: {
359
+ filter: false,
360
+ sort: false,
361
+ customBodyRenderLite: (_: string, index: number) => {
362
+ const item = state.events[index];
363
+ if (!item || !item?.customer) return '-';
364
+ return <CustomerLink customer={item.customer} size="small" />;
365
+ },
366
+ },
367
+ },
368
+ {
369
+ label: t('admin.meter.events.subscription'),
370
+ name: 'subscription',
371
+ options: {
372
+ filter: false,
373
+ sort: false,
374
+ customBodyRenderLite: (_: string, index: number) => {
375
+ const item = state.events[index];
376
+ if (!item) return '-';
377
+ const subscriptionId = (item as any).payload?.subscription_id;
378
+ if (!subscriptionId) return '-';
379
+ return (
380
+ <Link to={`/admin/billing/${subscriptionId}`}>
381
+ <Typography variant="body2" sx={{ color: 'text.link' }}>
382
+ {item?.subscription?.description || subscriptionId}
383
+ </Typography>
384
+ </Link>
385
+ );
386
+ },
387
+ },
388
+ },
389
+ {
390
+ label: t('admin.meter.events.reportTime'),
391
+ name: 'created_at',
392
+ options: {
393
+ filter: false,
394
+ sort: false,
395
+ customBodyRenderLite: (_: string, index: number) => {
396
+ const item = state.events[index];
397
+ return <Link to={`/admin/billing/${item?.id}`}>{item ? formatTime(item.created_at) : '-'}</Link>;
398
+ },
399
+ },
400
+ },
401
+ ];
402
+
403
+ return (
404
+ <Box>
405
+ {/* 过滤条件 - 移动端优化 */}
406
+ <Box
407
+ sx={{
408
+ mb: 3,
409
+ }}>
410
+ <Stack
411
+ direction={isMobile ? 'column' : 'row'}
412
+ spacing={2}
413
+ sx={{
414
+ alignItems: isMobile ? 'stretch' : 'center',
415
+ flexWrap: 'wrap',
416
+ }}>
417
+ <Button
418
+ variant="outlined"
419
+ startIcon={<CalendarTodayOutlined />}
420
+ onClick={onTogglePicker}
421
+ sx={{
422
+ minWidth: isMobile ? '100%' : 200,
423
+ justifyContent: isMobile ? 'flex-start' : 'center',
424
+ color: 'text.secondary',
425
+ }}>
426
+ {dayjs(state.startDate).format('YYYY-MM-DD')} - {dayjs(state.endDate).format('YYYY-MM-DD')}
427
+ </Button>
428
+
429
+ <Autocomplete
430
+ options={state.customers}
431
+ getOptionLabel={getCustomerDisplayName}
432
+ value={state.selectedCustomer}
433
+ onChange={(_, customer) => handleCustomerChange(customer)}
434
+ sx={{
435
+ minWidth: isMobile ? '100%' : 200,
436
+ flex: isMobile ? 1 : 'none',
437
+ }}
438
+ renderInput={(params) => (
439
+ <TextField {...params} label={t('admin.meter.events.filterByCustomer')} size="small" />
440
+ )}
441
+ renderOption={(props, option) => (
442
+ <Box component="li" {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
443
+ <Avatar
444
+ src={getCustomerAvatar(
445
+ option.did,
446
+ option?.updated_at ? new Date(option.updated_at).toISOString() : undefined,
447
+ 24
448
+ )}
449
+ sx={{
450
+ width: 24,
451
+ height: 24,
452
+ }}
453
+ alt={getCustomerDisplayName(option)}
454
+ />
455
+ {getCustomerDisplayName(option)}
456
+ </Box>
457
+ )}
458
+ />
459
+
460
+ {!livemode && (
461
+ <Button
462
+ variant="outlined"
463
+ startIcon={<Add />}
464
+ onClick={handleAddUsage}
465
+ sx={{
466
+ minWidth: isMobile ? '100%' : 'auto',
467
+ color: 'text.link',
468
+ }}>
469
+ {t('admin.meterEvent.add.button')}
470
+ </Button>
471
+ )}
472
+ </Stack>
473
+
474
+ {/* 图表类型切换 - 移动端优化 */}
475
+ <Box
476
+ sx={{
477
+ mt: 2,
478
+ display: 'flex',
479
+ justifyContent: isMobile ? 'center' : 'flex-end',
480
+ }}>
481
+ <ToggleButtonGroup
482
+ value={state.chartType}
483
+ exclusive
484
+ onChange={handleChartTypeChange}
485
+ size="small"
486
+ sx={{
487
+ width: isMobile ? '100%' : 'auto',
488
+ '& .MuiToggleButton-root': {
489
+ flex: isMobile ? 1 : 'none',
490
+ },
491
+ }}>
492
+ <ToggleButton value="event_count">{t('admin.meter.events.eventCount')}</ToggleButton>
493
+ <ToggleButton value="total_value">{t('admin.meter.events.totalValue')}</ToggleButton>
494
+ </ToggleButtonGroup>
495
+ </Box>
496
+ </Box>
497
+ {/* 图表 */}
498
+ <Card sx={{ mb: 3 }}>
499
+ <Box
500
+ sx={{
501
+ height: isMobile ? 250 : 300,
502
+ position: 'relative',
503
+ pr: isMobile ? 1 : 2,
504
+ pl: isMobile ? 0.5 : 1,
505
+ }}>
506
+ {state.statsLoading ? (
507
+ <Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
508
+ <Skeleton variant="rectangular" width="100%" height="100%" />
509
+ </Box>
510
+ ) : (
511
+ <ResponsiveContainer width="100%" height="100%">
512
+ <AreaChart
513
+ data={chartData}
514
+ margin={{
515
+ top: 10,
516
+ right: isMobile ? 10 : 30,
517
+ left: isMobile ? 5 : 10,
518
+ bottom: (() => {
519
+ if (granularity === 'hour') {
520
+ return isMobile ? 50 : 60;
521
+ }
522
+ return isMobile ? 30 : 40;
523
+ })(),
524
+ }}>
525
+ <CartesianGrid strokeDasharray="3 3" />
526
+ <XAxis
527
+ dataKey="date"
528
+ tick={{ fontSize: isMobile ? 10 : 11 }}
529
+ angle={granularity === 'hour' ? -45 : 0}
530
+ textAnchor={granularity === 'hour' ? 'end' : 'middle'}
531
+ height={(() => {
532
+ if (granularity === 'hour') {
533
+ return isMobile ? 50 : 60;
534
+ }
535
+ return isMobile ? 30 : 40;
536
+ })()}
537
+ interval={(() => {
538
+ const dataLength = chartData.length;
539
+ if (isMobile) {
540
+ // 移动端:根据数据量智能调整
541
+ if (dataLength > 20) return Math.floor(dataLength / 6);
542
+ if (dataLength > 10) return Math.floor(dataLength / 4);
543
+ if (dataLength > 5) return 1;
544
+ return 0;
545
+ }
546
+ // 桌面端:根据数据量和粒度调整
547
+ if (granularity === 'hour') {
548
+ if (dataLength > 24) return Math.floor(dataLength / 12);
549
+ if (dataLength > 12) return 1;
550
+ return 0;
551
+ }
552
+ if (dataLength > 30) return Math.floor(dataLength / 15);
553
+ if (dataLength > 15) return 1;
554
+ return 0;
555
+ })()}
556
+ />
557
+ <YAxis
558
+ tick={{ fontSize: isMobile ? 9 : 11 }}
559
+ width={isMobile ? 35 : 40}
560
+ tickFormatter={(value) => {
561
+ // 在移动端简化数字显示
562
+ if (isMobile && value >= 1000) {
563
+ return `${(value / 1000).toFixed(1)}k`;
564
+ }
565
+ return value.toString();
566
+ }}
567
+ />
568
+ <Tooltip
569
+ labelFormatter={(label: any) => {
570
+ const dataItem = chartData.find((item) => item.date === label);
571
+ return dataItem?.fullDate || label;
572
+ }}
573
+ formatter={(value: any, name: any) => [
574
+ name === 'event_count' ? value : fromUnitToToken(value, paymentCurrency.decimal),
575
+ name === 'event_count' ? t('admin.meter.events.eventCount') : t('admin.meter.events.totalValue'),
576
+ ]}
577
+ contentStyle={{
578
+ fontSize: isMobile ? '12px' : '14px',
579
+ padding: isMobile ? '8px' : '10px',
580
+ }}
581
+ />
582
+ <Area
583
+ type="monotone"
584
+ dataKey={state.chartType}
585
+ stroke={state.chartType === 'event_count' ? '#8884d8' : '#82ca9d'}
586
+ fill={state.chartType === 'event_count' ? '#8884d8' : '#82ca9d'}
587
+ fillOpacity={0.6}
588
+ strokeWidth={isMobile ? 1.5 : 2}
589
+ />
590
+ </AreaChart>
591
+ </ResponsiveContainer>
592
+ )}
593
+ </Box>
594
+ </Card>
595
+ <Box
596
+ sx={{
597
+ display: 'flex',
598
+ justifyContent: 'space-between',
599
+ alignItems: 'center',
600
+ mb: 2,
601
+ }}>
602
+ <Typography
603
+ variant="body2"
604
+ sx={{
605
+ color: 'text.secondary',
606
+ }}>
607
+ {t('admin.meter.events.count', { count: state.pagination.total })}
608
+ </Typography>
609
+ </Box>
610
+ <Table
611
+ data={state.events}
612
+ columns={columns}
613
+ options={{
614
+ count: state.pagination.total,
615
+ page: state.pagination.page,
616
+ rowsPerPage: state.pagination.limit,
617
+ onChangePage: handlePageChange,
618
+ onChangeRowsPerPage: handleRowsPerPageChange,
619
+ search: false,
620
+ filter: false,
621
+ sort: false,
622
+ viewColumns: false,
623
+ download: false,
624
+ print: false,
625
+ selectableRows: 'none',
626
+ responsive: isMobile ? 'vertical' : 'standard',
627
+ }}
628
+ loading={state.loading}
629
+ emptyNodeText={t('admin.meter.events.empty')}
630
+ />
631
+ {/* 日期选择器弹窗 */}
632
+ <Popover
633
+ id={id}
634
+ open={open}
635
+ anchorEl={state.anchorEl}
636
+ onClose={() => setState({ anchorEl: null })}
637
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
638
+ <DateRangePicker
639
+ open
640
+ toggle={onTogglePicker as any}
641
+ maxDate={maxDate}
642
+ definedRanges={getDefaultRanges(maxDate.toISOString())}
643
+ onChange={onRangeChange}
644
+ />
645
+ </Popover>
646
+ {/* 手动添加用量对话框 */}
647
+ <AddUsageDialog
648
+ open={state.addUsageDialog}
649
+ onClose={handleCloseAddUsage}
650
+ meterId={meterId}
651
+ customers={state.customers}
652
+ paymentCurrency={paymentCurrency}
653
+ onSuccess={handleAddUsageSuccess}
654
+ />
655
+ </Box>
656
+ );
657
+ }