TopupHistoryModal.jsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useEffect, useMemo } from 'react';
  16. import {
  17. Modal,
  18. Table,
  19. Badge,
  20. Typography,
  21. Toast,
  22. Empty,
  23. Button,
  24. Input,
  25. } from '@douyinfe/semi-ui';
  26. import {
  27. IllustrationNoResult,
  28. IllustrationNoResultDark,
  29. } from '@douyinfe/semi-illustrations';
  30. import { Coins } from 'lucide-react';
  31. import { IconSearch } from '@douyinfe/semi-icons';
  32. import { API, timestamp2string } from '../../../helpers';
  33. import { isAdmin } from '../../../helpers/utils';
  34. import { useIsMobile } from '../../../hooks/common/useIsMobile';
  35. const { Text } = Typography;
  36. // 状态映射配置
  37. const STATUS_CONFIG = {
  38. success: { type: 'success', key: '成功' },
  39. pending: { type: 'warning', key: '待支付' },
  40. expired: { type: 'danger', key: '已过期' },
  41. };
  42. // 支付方式映射
  43. const PAYMENT_METHOD_MAP = {
  44. stripe: 'Stripe',
  45. alipay: '支付宝',
  46. wxpay: '微信',
  47. };
  48. const TopupHistoryModal = ({ visible, onCancel, t }) => {
  49. const [loading, setLoading] = useState(false);
  50. const [topups, setTopups] = useState([]);
  51. const [total, setTotal] = useState(0);
  52. const [page, setPage] = useState(1);
  53. const [pageSize, setPageSize] = useState(10);
  54. const [keyword, setKeyword] = useState('');
  55. const isMobile = useIsMobile();
  56. const loadTopups = async (currentPage, currentPageSize) => {
  57. setLoading(true);
  58. try {
  59. const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
  60. const qs =
  61. `p=${currentPage}&page_size=${currentPageSize}` +
  62. (keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
  63. const endpoint = `${base}?${qs}`;
  64. const res = await API.get(endpoint);
  65. const { success, message, data } = res.data;
  66. if (success) {
  67. setTopups(data.items || []);
  68. setTotal(data.total || 0);
  69. } else {
  70. Toast.error({ content: message || t('加载失败') });
  71. }
  72. } catch (error) {
  73. console.error('Load topups error:', error);
  74. Toast.error({ content: t('加载账单失败') });
  75. } finally {
  76. setLoading(false);
  77. }
  78. };
  79. useEffect(() => {
  80. if (visible) {
  81. loadTopups(page, pageSize);
  82. }
  83. }, [visible, page, pageSize, keyword]);
  84. const handlePageChange = (currentPage) => {
  85. setPage(currentPage);
  86. };
  87. const handlePageSizeChange = (currentPageSize) => {
  88. setPageSize(currentPageSize);
  89. setPage(1);
  90. };
  91. // 管理员补单
  92. const handleAdminComplete = async (tradeNo) => {
  93. try {
  94. const res = await API.post('/api/user/topup/complete', {
  95. trade_no: tradeNo,
  96. });
  97. const { success, message } = res.data;
  98. if (success) {
  99. Toast.success({ content: t('补单成功') });
  100. await loadTopups(page, pageSize);
  101. } else {
  102. Toast.error({ content: message || t('补单失败') });
  103. }
  104. } catch (e) {
  105. Toast.error({ content: t('补单失败') });
  106. }
  107. };
  108. const confirmAdminComplete = (tradeNo) => {
  109. Modal.confirm({
  110. title: t('确认补单'),
  111. content: t('是否将该订单标记为成功并为用户入账?'),
  112. onOk: () => handleAdminComplete(tradeNo),
  113. });
  114. };
  115. // 渲染状态徽章
  116. const renderStatusBadge = (status) => {
  117. const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
  118. return (
  119. <span className='flex items-center gap-2'>
  120. <Badge dot type={config.type} />
  121. <span>{t(config.key)}</span>
  122. </span>
  123. );
  124. };
  125. // 渲染支付方式
  126. const renderPaymentMethod = (pm) => {
  127. const displayName = PAYMENT_METHOD_MAP[pm];
  128. return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
  129. };
  130. // 检查是否为管理员
  131. const userIsAdmin = useMemo(() => isAdmin(), []);
  132. const columns = useMemo(() => {
  133. const baseColumns = [
  134. {
  135. title: t('订单号'),
  136. dataIndex: 'trade_no',
  137. key: 'trade_no',
  138. render: (text) => <Text copyable>{text}</Text>,
  139. },
  140. {
  141. title: t('支付方式'),
  142. dataIndex: 'payment_method',
  143. key: 'payment_method',
  144. render: renderPaymentMethod,
  145. },
  146. {
  147. title: t('充值额度'),
  148. dataIndex: 'amount',
  149. key: 'amount',
  150. render: (amount) => (
  151. <span className='flex items-center gap-1'>
  152. <Coins size={16} />
  153. <Text>{amount}</Text>
  154. </span>
  155. ),
  156. },
  157. {
  158. title: t('支付金额'),
  159. dataIndex: 'money',
  160. key: 'money',
  161. render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
  162. },
  163. {
  164. title: t('状态'),
  165. dataIndex: 'status',
  166. key: 'status',
  167. render: renderStatusBadge,
  168. },
  169. ];
  170. // 管理员才显示操作列
  171. if (userIsAdmin) {
  172. baseColumns.push({
  173. title: t('操作'),
  174. key: 'action',
  175. render: (_, record) => {
  176. if (record.status !== 'pending') return null;
  177. return (
  178. <Button
  179. size='small'
  180. type='primary'
  181. theme='outline'
  182. onClick={() => confirmAdminComplete(record.trade_no)}
  183. >
  184. {t('补单')}
  185. </Button>
  186. );
  187. },
  188. });
  189. }
  190. baseColumns.push({
  191. title: t('创建时间'),
  192. dataIndex: 'create_time',
  193. key: 'create_time',
  194. render: (time) => timestamp2string(time),
  195. });
  196. return baseColumns;
  197. }, [t, userIsAdmin]);
  198. return (
  199. <Modal
  200. title={t('充值账单')}
  201. visible={visible}
  202. onCancel={onCancel}
  203. footer={null}
  204. size={isMobile ? 'full-width' : 'large'}
  205. >
  206. <div className='mb-3'>
  207. <Input
  208. prefix={<IconSearch />}
  209. placeholder={t('订单号')}
  210. value={keyword}
  211. onChange={setKeyword}
  212. showClear
  213. />
  214. </div>
  215. <Table
  216. columns={columns}
  217. dataSource={topups}
  218. loading={loading}
  219. rowKey='id'
  220. pagination={{
  221. currentPage: page,
  222. pageSize: pageSize,
  223. total: total,
  224. showSizeChanger: true,
  225. pageSizeOpts: [10, 20, 50, 100],
  226. onPageChange: handlePageChange,
  227. onPageSizeChange: handlePageSizeChange,
  228. }}
  229. size='small'
  230. empty={
  231. <Empty
  232. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  233. darkModeImage={
  234. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  235. }
  236. description={t('暂无充值记录')}
  237. style={{ padding: 30 }}
  238. />
  239. }
  240. />
  241. </Modal>
  242. );
  243. };
  244. export default TopupHistoryModal;