TopupHistoryModal.jsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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. const handleKeywordChange = (value) => {
  92. setKeyword(value);
  93. setPage(1);
  94. };
  95. // 管理员补单
  96. const handleAdminComplete = async (tradeNo) => {
  97. try {
  98. const res = await API.post('/api/user/topup/complete', {
  99. trade_no: tradeNo,
  100. });
  101. const { success, message } = res.data;
  102. if (success) {
  103. Toast.success({ content: t('补单成功') });
  104. await loadTopups(page, pageSize);
  105. } else {
  106. Toast.error({ content: message || t('补单失败') });
  107. }
  108. } catch (e) {
  109. Toast.error({ content: t('补单失败') });
  110. }
  111. };
  112. const confirmAdminComplete = (tradeNo) => {
  113. Modal.confirm({
  114. title: t('确认补单'),
  115. content: t('是否将该订单标记为成功并为用户入账?'),
  116. onOk: () => handleAdminComplete(tradeNo),
  117. });
  118. };
  119. // 渲染状态徽章
  120. const renderStatusBadge = (status) => {
  121. const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
  122. return (
  123. <span className='flex items-center gap-2'>
  124. <Badge dot type={config.type} />
  125. <span>{t(config.key)}</span>
  126. </span>
  127. );
  128. };
  129. // 渲染支付方式
  130. const renderPaymentMethod = (pm) => {
  131. const displayName = PAYMENT_METHOD_MAP[pm];
  132. return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
  133. };
  134. // 检查是否为管理员
  135. const userIsAdmin = useMemo(() => isAdmin(), []);
  136. const columns = useMemo(() => {
  137. const baseColumns = [
  138. {
  139. title: t('订单号'),
  140. dataIndex: 'trade_no',
  141. key: 'trade_no',
  142. render: (text) => <Text copyable>{text}</Text>,
  143. },
  144. {
  145. title: t('支付方式'),
  146. dataIndex: 'payment_method',
  147. key: 'payment_method',
  148. render: renderPaymentMethod,
  149. },
  150. {
  151. title: t('充值额度'),
  152. dataIndex: 'amount',
  153. key: 'amount',
  154. render: (amount) => (
  155. <span className='flex items-center gap-1'>
  156. <Coins size={16} />
  157. <Text>{amount}</Text>
  158. </span>
  159. ),
  160. },
  161. {
  162. title: t('支付金额'),
  163. dataIndex: 'money',
  164. key: 'money',
  165. render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
  166. },
  167. {
  168. title: t('状态'),
  169. dataIndex: 'status',
  170. key: 'status',
  171. render: renderStatusBadge,
  172. },
  173. ];
  174. // 管理员才显示操作列
  175. if (userIsAdmin) {
  176. baseColumns.push({
  177. title: t('操作'),
  178. key: 'action',
  179. render: (_, record) => {
  180. if (record.status !== 'pending') return null;
  181. return (
  182. <Button
  183. size='small'
  184. type='primary'
  185. theme='outline'
  186. onClick={() => confirmAdminComplete(record.trade_no)}
  187. >
  188. {t('补单')}
  189. </Button>
  190. );
  191. },
  192. });
  193. }
  194. baseColumns.push({
  195. title: t('创建时间'),
  196. dataIndex: 'create_time',
  197. key: 'create_time',
  198. render: (time) => timestamp2string(time),
  199. });
  200. return baseColumns;
  201. }, [t, userIsAdmin]);
  202. return (
  203. <Modal
  204. title={t('充值账单')}
  205. visible={visible}
  206. onCancel={onCancel}
  207. footer={null}
  208. size={isMobile ? 'full-width' : 'large'}
  209. >
  210. <div className='mb-3'>
  211. <Input
  212. prefix={<IconSearch />}
  213. placeholder={t('订单号')}
  214. value={keyword}
  215. onChange={handleKeywordChange}
  216. showClear
  217. />
  218. </div>
  219. <Table
  220. columns={columns}
  221. dataSource={topups}
  222. loading={loading}
  223. rowKey='id'
  224. pagination={{
  225. currentPage: page,
  226. pageSize: pageSize,
  227. total: total,
  228. showSizeChanger: true,
  229. pageSizeOpts: [10, 20, 50, 100],
  230. onPageChange: handlePageChange,
  231. onPageSizeChange: handlePageSizeChange,
  232. }}
  233. size='small'
  234. empty={
  235. <Empty
  236. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  237. darkModeImage={
  238. <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
  239. }
  240. description={t('暂无充值记录')}
  241. style={{ padding: 30 }}
  242. />
  243. }
  244. />
  245. </Modal>
  246. );
  247. };
  248. export default TopupHistoryModal;