diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js
index 877990da..d2e89b97 100644
--- a/web/src/components/table/RedemptionsTable.js
+++ b/web/src/components/table/RedemptionsTable.js
@@ -1,613 +1,2 @@
-import React, { useEffect, useState } from 'react';
-import {
- API,
- copy,
- showError,
- showSuccess,
- timestamp2string,
- renderQuota
-} from '../../helpers';
-
-import { Ticket } from 'lucide-react';
-
-import { ITEMS_PER_PAGE } from '../../constants';
-import {
- Button,
- Dropdown,
- Empty,
- Form,
- Modal,
- Popover,
- Space,
- Table,
- Tag,
- Typography
-} from '@douyinfe/semi-ui';
-import CardPro from '../common/ui/CardPro';
-import {
- IllustrationNoResult,
- IllustrationNoResultDark
-} from '@douyinfe/semi-illustrations';
-import {
- IconSearch,
- IconMore,
-} from '@douyinfe/semi-icons';
-import EditRedemption from '../../pages/Redemption/EditRedemption';
-import { useTranslation } from 'react-i18next';
-import { useTableCompactMode } from '../../hooks/common/useTableCompactMode';
-
-const { Text } = Typography;
-
-function renderTimestamp(timestamp) {
- return <>{timestamp2string(timestamp)}>;
-}
-
-const RedemptionsTable = () => {
- const { t } = useTranslation();
-
- const isExpired = (rec) => {
- return rec.status === 1 && rec.expired_time !== 0 && rec.expired_time < Math.floor(Date.now() / 1000);
- };
-
- const renderStatus = (status, record) => {
- if (isExpired(record)) {
- return (
- {t('已过期')}
- );
- }
- switch (status) {
- case 1:
- return (
-
- {t('未使用')}
-
- );
- case 2:
- return (
-
- {t('已禁用')}
-
- );
- case 3:
- return (
-
- {t('已使用')}
-
- );
- default:
- return (
-
- {t('未知状态')}
-
- );
- }
- };
-
- const columns = [
- {
- title: t('ID'),
- dataIndex: 'id',
- },
- {
- title: t('名称'),
- dataIndex: 'name',
- },
- {
- title: t('状态'),
- dataIndex: 'status',
- key: 'status',
- render: (text, record, index) => {
- return
{renderStatus(text, record)}
;
- },
- },
- {
- title: t('额度'),
- dataIndex: 'quota',
- render: (text, record, index) => {
- return (
-
-
- {renderQuota(parseInt(text))}
-
-
- );
- },
- },
- {
- title: t('创建时间'),
- dataIndex: 'created_time',
- render: (text, record, index) => {
- return {renderTimestamp(text)}
;
- },
- },
- {
- title: t('过期时间'),
- dataIndex: 'expired_time',
- render: (text) => {
- return {text === 0 ? t('永不过期') : renderTimestamp(text)}
;
- },
- },
- {
- title: t('兑换人ID'),
- dataIndex: 'used_user_id',
- render: (text, record, index) => {
- return {text === 0 ? t('无') : text}
;
- },
- },
- {
- title: '',
- dataIndex: 'operate',
- fixed: 'right',
- width: 205,
- render: (text, record, index) => {
- // 创建更多操作的下拉菜单项
- const moreMenuItems = [
- {
- node: 'item',
- name: t('删除'),
- type: 'danger',
- onClick: () => {
- Modal.confirm({
- title: t('确定是否要删除此兑换码?'),
- content: t('此修改将不可逆'),
- onOk: () => {
- (async () => {
- await manageRedemption(record.id, 'delete', record);
- await refresh();
- setTimeout(() => {
- if (redemptions.length === 0 && activePage > 1) {
- refresh(activePage - 1);
- }
- }, 100);
- })();
- },
- });
- },
- }
- ];
-
- if (record.status === 1 && !isExpired(record)) {
- moreMenuItems.push({
- node: 'item',
- name: t('禁用'),
- type: 'warning',
- onClick: () => {
- manageRedemption(record.id, 'disable', record);
- },
- });
- } else if (!isExpired(record)) {
- moreMenuItems.push({
- node: 'item',
- name: t('启用'),
- type: 'secondary',
- onClick: () => {
- manageRedemption(record.id, 'enable', record);
- },
- disabled: record.status === 3,
- });
- }
-
- return (
-
-
-
-
-
-
-
- }
- />
-
-
- );
- },
- },
- ];
-
- const [redemptions, setRedemptions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [activePage, setActivePage] = useState(1);
- const [searching, setSearching] = useState(false);
- const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
- const [selectedKeys, setSelectedKeys] = useState([]);
- const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
- const [editingRedemption, setEditingRedemption] = useState({
- id: undefined,
- });
- const [showEdit, setShowEdit] = useState(false);
- const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
-
- const formInitValues = {
- searchKeyword: '',
- };
-
- const [formApi, setFormApi] = useState(null);
-
- const getFormValues = () => {
- const formValues = formApi ? formApi.getValues() : {};
- return {
- searchKeyword: formValues.searchKeyword || '',
- };
- };
-
- const closeEdit = () => {
- setShowEdit(false);
- setTimeout(() => {
- setEditingRedemption({
- id: undefined,
- });
- }, 500);
- };
-
- const setRedemptionFormat = (redeptions) => {
- setRedemptions(redeptions);
- };
-
- const loadRedemptions = async (page = 1, pageSize) => {
- setLoading(true);
- const res = await API.get(
- `/api/redemption/?p=${page}&page_size=${pageSize}`,
- );
- const { success, message, data } = res.data;
- if (success) {
- const newPageData = data.items;
- setActivePage(data.page <= 0 ? 1 : data.page);
- setTokenCount(data.total);
- setRedemptionFormat(newPageData);
- } else {
- showError(message);
- }
- setLoading(false);
- };
-
- const removeRecord = (key) => {
- let newDataSource = [...redemptions];
- if (key != null) {
- let idx = newDataSource.findIndex((data) => data.key === key);
-
- if (idx > -1) {
- newDataSource.splice(idx, 1);
- setRedemptions(newDataSource);
- }
- }
- };
-
- const copyText = async (text) => {
- if (await copy(text)) {
- showSuccess(t('已复制到剪贴板!'));
- } else {
- Modal.error({
- title: t('无法复制到剪贴板,请手动复制'),
- content: text,
- size: 'large'
- });
- }
- };
-
- useEffect(() => {
- loadRedemptions(1, pageSize)
- .then()
- .catch((reason) => {
- showError(reason);
- });
- }, [pageSize]);
-
- const refresh = async (page = activePage) => {
- const { searchKeyword } = getFormValues();
- if (searchKeyword === '') {
- await loadRedemptions(page, pageSize);
- } else {
- await searchRedemptions(searchKeyword, page, pageSize);
- }
- };
-
- const manageRedemption = async (id, action, record) => {
- setLoading(true);
- let data = { id };
- let res;
- switch (action) {
- case 'delete':
- res = await API.delete(`/api/redemption/${id}/`);
- break;
- case 'enable':
- data.status = 1;
- res = await API.put('/api/redemption/?status_only=true', data);
- break;
- case 'disable':
- data.status = 2;
- res = await API.put('/api/redemption/?status_only=true', data);
- break;
- }
- const { success, message } = res.data;
- if (success) {
- showSuccess(t('操作成功完成!'));
- let redemption = res.data.data;
- let newRedemptions = [...redemptions];
- if (action === 'delete') {
- } else {
- record.status = redemption.status;
- }
- setRedemptions(newRedemptions);
- } else {
- showError(message);
- }
- setLoading(false);
- };
-
- const searchRedemptions = async (keyword = null, page, pageSize) => {
- // 如果没有传递keyword参数,从表单获取值
- if (keyword === null) {
- const formValues = getFormValues();
- keyword = formValues.searchKeyword;
- }
-
- if (keyword === '') {
- await loadRedemptions(page, pageSize);
- return;
- }
- setSearching(true);
- const res = await API.get(
- `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`,
- );
- const { success, message, data } = res.data;
- if (success) {
- const newPageData = data.items;
- setActivePage(data.page);
- setTokenCount(data.total);
- setRedemptionFormat(newPageData);
- } else {
- showError(message);
- }
- setSearching(false);
- };
-
- const handlePageChange = (page) => {
- setActivePage(page);
- const { searchKeyword } = getFormValues();
- if (searchKeyword === '') {
- loadRedemptions(page, pageSize).then();
- } else {
- searchRedemptions(searchKeyword, page, pageSize).then();
- }
- };
-
- let pageData = redemptions;
- const rowSelection = {
- onSelect: (record, selected) => { },
- onSelectAll: (selected, selectedRows) => { },
- onChange: (selectedRowKeys, selectedRows) => {
- setSelectedKeys(selectedRows);
- },
- };
-
- const handleRow = (record, index) => {
- if (record.status !== 1 || isExpired(record)) {
- return {
- style: {
- background: 'var(--semi-color-disabled-border)',
- },
- };
- } else {
- return {};
- }
- };
-
- return (
- <>
-
-
-
-
-
- {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}
-
-
-
- }
- actionsArea={
-
-
-
-
-
-
-
-
-
-
-
- }
- >
- rest) : columns}
- dataSource={pageData}
- scroll={compactMode ? undefined : { x: 'max-content' }}
- pagination={{
- currentPage: activePage,
- pageSize: pageSize,
- total: tokenCount,
- showSizeChanger: true,
- pageSizeOptions: [10, 20, 50, 100],
- onPageSizeChange: (size) => {
- setPageSize(size);
- setActivePage(1);
- const { searchKeyword } = getFormValues();
- if (searchKeyword === '') {
- loadRedemptions(1, size).then();
- } else {
- searchRedemptions(searchKeyword, 1, size).then();
- }
- },
- onPageChange: handlePageChange,
- }}
- loading={loading}
- rowSelection={rowSelection}
- onRow={handleRow}
- empty={
- }
- darkModeImage={}
- description={t('搜索无结果')}
- style={{ padding: 30 }}
- />
- }
- className="rounded-xl overflow-hidden"
- size="middle"
- >
-
- >
- );
-};
-
-export default RedemptionsTable;
+// 重构后的 RedemptionsTable - 使用新的模块化架构
+export { default } from './redemptions/index.jsx';
\ No newline at end of file
diff --git a/web/src/components/table/TokensTable.js b/web/src/components/table/TokensTable.js
index a30cb36d..d74a49e2 100644
--- a/web/src/components/table/TokensTable.js
+++ b/web/src/components/table/TokensTable.js
@@ -1,7 +1,2 @@
-// Import the new modular tokens table
-import TokensPage from './tokens';
-
-// Export the new component for backward compatibility
-const TokensTable = TokensPage;
-
-export default TokensTable;
+// 重构后的 TokensTable - 使用新的模块化架构
+export { default } from './tokens/index.jsx';
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/RedemptionsActions.jsx b/web/src/components/table/redemptions/RedemptionsActions.jsx
new file mode 100644
index 00000000..1d86dd38
--- /dev/null
+++ b/web/src/components/table/redemptions/RedemptionsActions.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+
+const RedemptionsActions = ({
+ selectedKeys,
+ setEditingRedemption,
+ setShowEdit,
+ batchCopyRedemptions,
+ batchDeleteRedemptions,
+ t
+}) => {
+
+ // Add new redemption code
+ const handleAddRedemption = () => {
+ setEditingRedemption({
+ id: undefined,
+ });
+ setShowEdit(true);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default RedemptionsActions;
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/RedemptionsColumnDefs.js b/web/src/components/table/redemptions/RedemptionsColumnDefs.js
new file mode 100644
index 00000000..4f4cd808
--- /dev/null
+++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.js
@@ -0,0 +1,198 @@
+import React from 'react';
+import { Tag, Button, Space, Popover, Dropdown } from '@douyinfe/semi-ui';
+import { IconMore } from '@douyinfe/semi-icons';
+import { renderQuota, timestamp2string } from '../../../helpers';
+import { REDEMPTION_STATUS, REDEMPTION_STATUS_MAP, REDEMPTION_ACTIONS } from '../../../constants/redemption.constants';
+
+/**
+ * Check if redemption code is expired
+ */
+export const isExpired = (record) => {
+ return record.status === REDEMPTION_STATUS.UNUSED &&
+ record.expired_time !== 0 &&
+ record.expired_time < Math.floor(Date.now() / 1000);
+};
+
+/**
+ * Render timestamp
+ */
+const renderTimestamp = (timestamp) => {
+ return <>{timestamp2string(timestamp)}>;
+};
+
+/**
+ * Render redemption code status
+ */
+const renderStatus = (status, record, t) => {
+ if (isExpired(record)) {
+ return (
+ {t('已过期')}
+ );
+ }
+
+ const statusConfig = REDEMPTION_STATUS_MAP[status];
+ if (statusConfig) {
+ return (
+
+ {t(statusConfig.text)}
+
+ );
+ }
+
+ return (
+
+ {t('未知状态')}
+
+ );
+};
+
+/**
+ * Get redemption code table column definitions
+ */
+export const getRedemptionsColumns = ({
+ t,
+ manageRedemption,
+ copyText,
+ setEditingRedemption,
+ setShowEdit,
+ refresh,
+ redemptions,
+ activePage,
+ showDeleteRedemptionModal
+}) => {
+ return [
+ {
+ title: t('ID'),
+ dataIndex: 'id',
+ },
+ {
+ title: t('名称'),
+ dataIndex: 'name',
+ },
+ {
+ title: t('状态'),
+ dataIndex: 'status',
+ key: 'status',
+ render: (text, record) => {
+ return {renderStatus(text, record, t)}
;
+ },
+ },
+ {
+ title: t('额度'),
+ dataIndex: 'quota',
+ render: (text) => {
+ return (
+
+
+ {renderQuota(parseInt(text))}
+
+
+ );
+ },
+ },
+ {
+ title: t('创建时间'),
+ dataIndex: 'created_time',
+ render: (text) => {
+ return {renderTimestamp(text)}
;
+ },
+ },
+ {
+ title: t('过期时间'),
+ dataIndex: 'expired_time',
+ render: (text) => {
+ return {text === 0 ? t('永不过期') : renderTimestamp(text)}
;
+ },
+ },
+ {
+ title: t('兑换人ID'),
+ dataIndex: 'used_user_id',
+ render: (text) => {
+ return {text === 0 ? t('无') : text}
;
+ },
+ },
+ {
+ title: '',
+ dataIndex: 'operate',
+ fixed: 'right',
+ width: 205,
+ render: (text, record) => {
+ // Create dropdown menu items for more operations
+ const moreMenuItems = [
+ {
+ node: 'item',
+ name: t('删除'),
+ type: 'danger',
+ onClick: () => {
+ showDeleteRedemptionModal(record);
+ },
+ }
+ ];
+
+ if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) {
+ moreMenuItems.push({
+ node: 'item',
+ name: t('禁用'),
+ type: 'warning',
+ onClick: () => {
+ manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record);
+ },
+ });
+ } else if (!isExpired(record)) {
+ moreMenuItems.push({
+ node: 'item',
+ name: t('启用'),
+ type: 'secondary',
+ onClick: () => {
+ manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record);
+ },
+ disabled: record.status === REDEMPTION_STATUS.USED,
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+ }
+ />
+
+
+ );
+ },
+ },
+ ];
+};
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/RedemptionsDescription.jsx b/web/src/components/table/redemptions/RedemptionsDescription.jsx
new file mode 100644
index 00000000..ef5e1b06
--- /dev/null
+++ b/web/src/components/table/redemptions/RedemptionsDescription.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Button, Typography } from '@douyinfe/semi-ui';
+import { Ticket } from 'lucide-react';
+
+const { Text } = Typography;
+
+const RedemptionsDescription = ({ compactMode, setCompactMode, t }) => {
+ return (
+
+
+
+ {t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}
+
+
+
+
+ );
+};
+
+export default RedemptionsDescription;
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/RedemptionsFilters.jsx b/web/src/components/table/redemptions/RedemptionsFilters.jsx
new file mode 100644
index 00000000..888f016e
--- /dev/null
+++ b/web/src/components/table/redemptions/RedemptionsFilters.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { Form, Button } from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+
+const RedemptionsFilters = ({
+ formInitValues,
+ setFormApi,
+ searchRedemptions,
+ loading,
+ searching,
+ t
+}) => {
+
+ // Handle form reset and immediate search
+ const handleReset = (formApi) => {
+ if (formApi) {
+ formApi.reset();
+ // Reset and search immediately
+ setTimeout(() => {
+ searchRedemptions();
+ }, 100);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default RedemptionsFilters;
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/RedemptionsTable.jsx b/web/src/components/table/redemptions/RedemptionsTable.jsx
new file mode 100644
index 00000000..e039df0c
--- /dev/null
+++ b/web/src/components/table/redemptions/RedemptionsTable.jsx
@@ -0,0 +1,119 @@
+import React, { useMemo, useState } from 'react';
+import { Table, Empty } from '@douyinfe/semi-ui';
+import {
+ IllustrationNoResult,
+ IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { getRedemptionsColumns, isExpired } from './RedemptionsColumnDefs';
+import DeleteRedemptionModal from './modals/DeleteRedemptionModal';
+
+const RedemptionsTable = (redemptionsData) => {
+ const {
+ redemptions,
+ loading,
+ activePage,
+ pageSize,
+ tokenCount,
+ compactMode,
+ handlePageChange,
+ rowSelection,
+ handleRow,
+ manageRedemption,
+ copyText,
+ setEditingRedemption,
+ setShowEdit,
+ refresh,
+ t,
+ } = redemptionsData;
+
+ // Modal states
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [deletingRecord, setDeletingRecord] = useState(null);
+
+ // Handle show delete modal
+ const showDeleteRedemptionModal = (record) => {
+ setDeletingRecord(record);
+ setShowDeleteModal(true);
+ };
+
+ // Get all columns
+ const columns = useMemo(() => {
+ return getRedemptionsColumns({
+ t,
+ manageRedemption,
+ copyText,
+ setEditingRedemption,
+ setShowEdit,
+ refresh,
+ redemptions,
+ activePage,
+ showDeleteRedemptionModal
+ });
+ }, [
+ t,
+ manageRedemption,
+ copyText,
+ setEditingRedemption,
+ setShowEdit,
+ refresh,
+ redemptions,
+ activePage,
+ showDeleteRedemptionModal,
+ ]);
+
+ // Handle compact mode by removing fixed positioning
+ const tableColumns = useMemo(() => {
+ return compactMode ? columns.map(col => {
+ if (col.dataIndex === 'operate') {
+ const { fixed, ...rest } = col;
+ return rest;
+ }
+ return col;
+ }) : columns;
+ }, [compactMode, columns]);
+
+ return (
+ <>
+ }
+ darkModeImage={}
+ description={t('搜索无结果')}
+ style={{ padding: 30 }}
+ />
+ }
+ className="rounded-xl overflow-hidden"
+ size="middle"
+ />
+
+ setShowDeleteModal(false)}
+ record={deletingRecord}
+ manageRedemption={manageRedemption}
+ refresh={refresh}
+ redemptions={redemptions}
+ activePage={activePage}
+ t={t}
+ />
+ >
+ );
+};
+
+export default RedemptionsTable;
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/index.jsx b/web/src/components/table/redemptions/index.jsx
new file mode 100644
index 00000000..064743d5
--- /dev/null
+++ b/web/src/components/table/redemptions/index.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import CardPro from '../../common/ui/CardPro';
+import RedemptionsTable from './RedemptionsTable.jsx';
+import RedemptionsActions from './RedemptionsActions.jsx';
+import RedemptionsFilters from './RedemptionsFilters.jsx';
+import RedemptionsDescription from './RedemptionsDescription.jsx';
+import EditRedemptionModal from './modals/EditRedemptionModal';
+import { useRedemptionsData } from '../../../hooks/redemptions/useRedemptionsData';
+
+const RedemptionsPage = () => {
+ const redemptionsData = useRedemptionsData();
+
+ const {
+ // Edit state
+ showEdit,
+ editingRedemption,
+ closeEdit,
+ refresh,
+
+ // Actions state
+ selectedKeys,
+ setEditingRedemption,
+ setShowEdit,
+ batchCopyRedemptions,
+ batchDeleteRedemptions,
+
+ // Filters state
+ formInitValues,
+ setFormApi,
+ searchRedemptions,
+ loading,
+ searching,
+
+ // UI state
+ compactMode,
+ setCompactMode,
+
+ // Translation
+ t,
+ } = redemptionsData;
+
+ return (
+ <>
+
+
+
+ }
+ actionsArea={
+
+ }
+ >
+
+
+ >
+ );
+};
+
+export default RedemptionsPage;
\ No newline at end of file
diff --git a/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx
new file mode 100644
index 00000000..3b7668d9
--- /dev/null
+++ b/web/src/components/table/redemptions/modals/DeleteRedemptionModal.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+import { REDEMPTION_ACTIONS } from '../../../../constants/redemption.constants';
+
+const DeleteRedemptionModal = ({
+ visible,
+ onCancel,
+ record,
+ manageRedemption,
+ refresh,
+ redemptions,
+ activePage,
+ t
+}) => {
+ const handleConfirm = async () => {
+ await manageRedemption(record.id, REDEMPTION_ACTIONS.DELETE, record);
+ await refresh();
+ setTimeout(() => {
+ if (redemptions.length === 0 && activePage > 1) {
+ refresh(activePage - 1);
+ }
+ }, 100);
+ onCancel(); // Close modal after success
+ };
+
+ return (
+
+ {t('此修改将不可逆')}
+
+ );
+};
+
+export default DeleteRedemptionModal;
\ No newline at end of file
diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
similarity index 98%
rename from web/src/pages/Redemption/EditRedemption.js
rename to web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
index 310fdcd0..9d06866f 100644
--- a/web/src/pages/Redemption/EditRedemption.js
+++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
@@ -7,8 +7,8 @@ import {
showSuccess,
renderQuota,
renderQuotaWithPrompt,
-} from '../../helpers';
-import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+} from '../../../../helpers';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
import {
Button,
Modal,
@@ -32,7 +32,7 @@ import {
const { Text, Title } = Typography;
-const EditRedemption = (props) => {
+const EditRedemptionModal = (props) => {
const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
@@ -302,4 +302,4 @@ const EditRedemption = (props) => {
);
};
-export default EditRedemption;
+export default EditRedemptionModal;
\ No newline at end of file
diff --git a/web/src/components/table/tokens/TokensActions.jsx b/web/src/components/table/tokens/TokensActions.jsx
index 09cb29eb..85703d24 100644
--- a/web/src/components/table/tokens/TokensActions.jsx
+++ b/web/src/components/table/tokens/TokensActions.jsx
@@ -1,6 +1,8 @@
-import React from 'react';
-import { Button, Modal, Space } from '@douyinfe/semi-ui';
+import React, { useState } from 'react';
+import { Button, Space } from '@douyinfe/semi-ui';
import { showError } from '../../../helpers';
+import CopyTokensModal from './modals/CopyTokensModal';
+import DeleteTokensModal from './modals/DeleteTokensModal';
const TokensActions = ({
selectedKeys,
@@ -11,48 +13,17 @@ const TokensActions = ({
copyText,
t,
}) => {
+ // Modal states
+ const [showCopyModal, setShowCopyModal] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+
// Handle copy selected tokens with options
const handleCopySelectedTokens = () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
-
- Modal.info({
- title: t('复制令牌'),
- icon: null,
- content: t('请选择你的复制方式'),
- footer: (
-
-
-
-
- ),
- });
+ setShowCopyModal(true);
};
// Handle delete selected tokens with confirmation
@@ -61,52 +32,67 @@ const TokensActions = ({
showError(t('请至少选择一个令牌!'));
return;
}
+ setShowDeleteModal(true);
+ };
- Modal.confirm({
- title: t('批量删除令牌'),
- content: (
-
- {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
-
- ),
- onOk: () => batchDeleteTokens(),
- });
+ // Handle delete confirmation
+ const handleConfirmDelete = () => {
+ batchDeleteTokens();
+ setShowDeleteModal(false);
};
return (
-
-
+ <>
+
+
-
+
-
-
+
+
+
+ setShowCopyModal(false)}
+ selectedKeys={selectedKeys}
+ copyText={copyText}
+ t={t}
+ />
+
+ setShowDeleteModal(false)}
+ onConfirm={handleConfirmDelete}
+ selectedKeys={selectedKeys}
+ t={t}
+ />
+ >
);
};
diff --git a/web/src/components/table/tokens/index.jsx b/web/src/components/table/tokens/index.jsx
index 3a3a8fb7..91d14054 100644
--- a/web/src/components/table/tokens/index.jsx
+++ b/web/src/components/table/tokens/index.jsx
@@ -4,7 +4,7 @@ import TokensTable from './TokensTable.jsx';
import TokensActions from './TokensActions.jsx';
import TokensFilters from './TokensFilters.jsx';
import TokensDescription from './TokensDescription.jsx';
-import EditToken from '../../../pages/Token/EditToken';
+import EditTokenModal from './modals/EditTokenModal';
import { useTokensData } from '../../../hooks/tokens/useTokensData';
const TokensPage = () => {
@@ -21,6 +21,7 @@ const TokensPage = () => {
selectedKeys,
setEditingToken,
setShowEdit,
+ batchCopyTokens,
batchDeleteTokens,
copyText,
@@ -41,7 +42,7 @@ const TokensPage = () => {
return (
<>
- {
selectedKeys={selectedKeys}
setEditingToken={setEditingToken}
setShowEdit={setShowEdit}
+ batchCopyTokens={batchCopyTokens}
batchDeleteTokens={batchDeleteTokens}
copyText={copyText}
t={t}
diff --git a/web/src/components/table/tokens/modals/CopyTokensModal.jsx b/web/src/components/table/tokens/modals/CopyTokensModal.jsx
new file mode 100644
index 00000000..41f9627b
--- /dev/null
+++ b/web/src/components/table/tokens/modals/CopyTokensModal.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Modal, Button, Space } from '@douyinfe/semi-ui';
+
+const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
+ // Handle copy with name and key format
+ const handleCopyWithName = async () => {
+ let content = '';
+ for (let i = 0; i < selectedKeys.length; i++) {
+ content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
+ }
+ await copyText(content);
+ onCancel();
+ };
+
+ // Handle copy with key only format
+ const handleCopyKeyOnly = async () => {
+ let content = '';
+ for (let i = 0; i < selectedKeys.length; i++) {
+ content += 'sk-' + selectedKeys[i].key + '\n';
+ }
+ await copyText(content);
+ onCancel();
+ };
+
+ return (
+
+
+
+
+ }
+ >
+ {t('请选择你的复制方式')}
+
+ );
+};
+
+export default CopyTokensModal;
\ No newline at end of file
diff --git a/web/src/components/table/tokens/modals/DeleteTokensModal.jsx b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx
new file mode 100644
index 00000000..5bc3ee5a
--- /dev/null
+++ b/web/src/components/table/tokens/modals/DeleteTokensModal.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+
+const DeleteTokensModal = ({ visible, onCancel, onConfirm, selectedKeys, t }) => {
+ return (
+
+
+ {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
+
+
+ );
+};
+
+export default DeleteTokensModal;
\ No newline at end of file
diff --git a/web/src/pages/Token/EditToken.js b/web/src/components/table/tokens/modals/EditTokenModal.jsx
similarity index 98%
rename from web/src/pages/Token/EditToken.js
rename to web/src/components/table/tokens/modals/EditTokenModal.jsx
index 7c7a61e9..119cc41c 100644
--- a/web/src/pages/Token/EditToken.js
+++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx
@@ -7,8 +7,8 @@ import {
renderGroupOption,
renderQuotaWithPrompt,
getModelCategories,
-} from '../../helpers';
-import { useIsMobile } from '../../hooks/common/useIsMobile.js';
+} from '../../../../helpers';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
import {
Button,
SideSheet,
@@ -30,11 +30,11 @@ import {
IconKey,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
-import { StatusContext } from '../../context/Status';
+import { StatusContext } from '../../../../context/Status';
const { Text, Title } = Typography;
-const EditToken = (props) => {
+const EditTokenModal = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [loading, setLoading] = useState(false);
@@ -522,4 +522,4 @@ const EditToken = (props) => {
);
};
-export default EditToken;
+export default EditTokenModal;
\ No newline at end of file
diff --git a/web/src/constants/index.js b/web/src/constants/index.js
index f92e2b19..27107eea 100644
--- a/web/src/constants/index.js
+++ b/web/src/constants/index.js
@@ -3,3 +3,4 @@ export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './playground.constants';
+export * from './redemption.constants';
diff --git a/web/src/constants/redemption.constants.js b/web/src/constants/redemption.constants.js
new file mode 100644
index 00000000..418b4393
--- /dev/null
+++ b/web/src/constants/redemption.constants.js
@@ -0,0 +1,29 @@
+// Redemption code status constants
+export const REDEMPTION_STATUS = {
+ UNUSED: 1, // Unused
+ DISABLED: 2, // Disabled
+ USED: 3, // Used
+};
+
+// Redemption code status display mapping
+export const REDEMPTION_STATUS_MAP = {
+ [REDEMPTION_STATUS.UNUSED]: {
+ color: 'green',
+ text: '未使用'
+ },
+ [REDEMPTION_STATUS.DISABLED]: {
+ color: 'red',
+ text: '已禁用'
+ },
+ [REDEMPTION_STATUS.USED]: {
+ color: 'grey',
+ text: '已使用'
+ }
+};
+
+// Action type constants
+export const REDEMPTION_ACTIONS = {
+ DELETE: 'delete',
+ ENABLE: 'enable',
+ DISABLE: 'disable'
+};
\ No newline at end of file
diff --git a/web/src/hooks/redemptions/useRedemptionsData.js b/web/src/hooks/redemptions/useRedemptionsData.js
new file mode 100644
index 00000000..e31ddd76
--- /dev/null
+++ b/web/src/hooks/redemptions/useRedemptionsData.js
@@ -0,0 +1,336 @@
+import { useState, useEffect } from 'react';
+import { API, showError, showSuccess, copy } from '../../helpers';
+import { ITEMS_PER_PAGE } from '../../constants';
+import { REDEMPTION_ACTIONS, REDEMPTION_STATUS } from '../../constants/redemption.constants';
+import { Modal } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { useTableCompactMode } from '../common/useTableCompactMode';
+
+export const useRedemptionsData = () => {
+ const { t } = useTranslation();
+
+ // Basic state
+ const [redemptions, setRedemptions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searching, setSearching] = useState(false);
+ const [activePage, setActivePage] = useState(1);
+ const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+ const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
+ const [selectedKeys, setSelectedKeys] = useState([]);
+
+ // Edit state
+ const [editingRedemption, setEditingRedemption] = useState({
+ id: undefined,
+ });
+ const [showEdit, setShowEdit] = useState(false);
+
+ // Form API
+ const [formApi, setFormApi] = useState(null);
+
+ // UI state
+ const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
+
+ // Form state
+ const formInitValues = {
+ searchKeyword: '',
+ };
+
+ // Get form values
+ const getFormValues = () => {
+ const formValues = formApi ? formApi.getValues() : {};
+ return {
+ searchKeyword: formValues.searchKeyword || '',
+ };
+ };
+
+ // Set redemption data format
+ const setRedemptionFormat = (redemptions) => {
+ setRedemptions(redemptions);
+ };
+
+ // Load redemption list
+ const loadRedemptions = async (page = 1, pageSize) => {
+ setLoading(true);
+ try {
+ const res = await API.get(
+ `/api/redemption/?p=${page}&page_size=${pageSize}`,
+ );
+ const { success, message, data } = res.data;
+ if (success) {
+ const newPageData = data.items;
+ setActivePage(data.page <= 0 ? 1 : data.page);
+ setTokenCount(data.total);
+ setRedemptionFormat(newPageData);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ showError(error.message);
+ }
+ setLoading(false);
+ };
+
+ // Search redemption codes
+ const searchRedemptions = async () => {
+ const { searchKeyword } = getFormValues();
+ if (searchKeyword === '') {
+ await loadRedemptions(1, pageSize);
+ return;
+ }
+
+ setSearching(true);
+ try {
+ const res = await API.get(
+ `/api/redemption/search?keyword=${searchKeyword}&p=1&page_size=${pageSize}`,
+ );
+ const { success, message, data } = res.data;
+ if (success) {
+ const newPageData = data.items;
+ setActivePage(data.page || 1);
+ setTokenCount(data.total);
+ setRedemptionFormat(newPageData);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ showError(error.message);
+ }
+ setSearching(false);
+ };
+
+ // Manage redemption codes (CRUD operations)
+ const manageRedemption = async (id, action, record) => {
+ setLoading(true);
+ let data = { id };
+ let res;
+
+ try {
+ switch (action) {
+ case REDEMPTION_ACTIONS.DELETE:
+ res = await API.delete(`/api/redemption/${id}/`);
+ break;
+ case REDEMPTION_ACTIONS.ENABLE:
+ data.status = REDEMPTION_STATUS.UNUSED;
+ res = await API.put('/api/redemption/?status_only=true', data);
+ break;
+ case REDEMPTION_ACTIONS.DISABLE:
+ data.status = REDEMPTION_STATUS.DISABLED;
+ res = await API.put('/api/redemption/?status_only=true', data);
+ break;
+ default:
+ throw new Error('Unknown operation type');
+ }
+
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess('操作成功完成!');
+ let redemption = res.data.data;
+ let newRedemptions = [...redemptions];
+ if (action !== REDEMPTION_ACTIONS.DELETE) {
+ record.status = redemption.status;
+ }
+ setRedemptions(newRedemptions);
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ showError(error.message);
+ }
+ setLoading(false);
+ };
+
+ // Refresh data
+ const refresh = async (page = activePage) => {
+ const { searchKeyword } = getFormValues();
+ if (searchKeyword === '') {
+ await loadRedemptions(page, pageSize);
+ } else {
+ await searchRedemptions();
+ }
+ };
+
+ // Handle page change
+ const handlePageChange = (page) => {
+ setActivePage(page);
+ const { searchKeyword } = getFormValues();
+ if (searchKeyword === '') {
+ loadRedemptions(page, pageSize);
+ } else {
+ searchRedemptions();
+ }
+ };
+
+ // Handle page size change
+ const handlePageSizeChange = (size) => {
+ setPageSize(size);
+ setActivePage(1);
+ const { searchKeyword } = getFormValues();
+ if (searchKeyword === '') {
+ loadRedemptions(1, size);
+ } else {
+ searchRedemptions();
+ }
+ };
+
+ // Row selection configuration
+ const rowSelection = {
+ onSelect: (record, selected) => { },
+ onSelectAll: (selected, selectedRows) => { },
+ onChange: (selectedRowKeys, selectedRows) => {
+ setSelectedKeys(selectedRows);
+ },
+ };
+
+ // Row style handling - using isExpired function
+ const handleRow = (record, index) => {
+ // Local isExpired function
+ const isExpired = (rec) => {
+ return rec.status === REDEMPTION_STATUS.UNUSED &&
+ rec.expired_time !== 0 &&
+ rec.expired_time < Math.floor(Date.now() / 1000);
+ };
+
+ if (record.status !== REDEMPTION_STATUS.UNUSED || isExpired(record)) {
+ return {
+ style: {
+ background: 'var(--semi-color-disabled-border)',
+ },
+ };
+ } else {
+ return {};
+ }
+ };
+
+ // Copy text
+ const copyText = async (text) => {
+ if (await copy(text)) {
+ showSuccess('已复制到剪贴板!');
+ } else {
+ Modal.error({
+ title: '无法复制到剪贴板,请手动复制',
+ content: text,
+ size: 'large'
+ });
+ }
+ };
+
+ // Batch copy redemption codes
+ const batchCopyRedemptions = async () => {
+ if (selectedKeys.length === 0) {
+ showError(t('请至少选择一个兑换码!'));
+ return;
+ }
+
+ let keys = '';
+ for (let i = 0; i < selectedKeys.length; i++) {
+ keys += selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
+ }
+ await copyText(keys);
+ };
+
+ // Batch delete redemption codes (clear invalid)
+ const batchDeleteRedemptions = async () => {
+ Modal.confirm({
+ title: t('确定清除所有失效兑换码?'),
+ content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
+ onOk: async () => {
+ setLoading(true);
+ const res = await API.delete('/api/redemption/invalid');
+ const { success, message, data } = res.data;
+ if (success) {
+ showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
+ await refresh();
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ },
+ });
+ };
+
+ // Close edit modal
+ const closeEdit = () => {
+ setShowEdit(false);
+ setTimeout(() => {
+ setEditingRedemption({
+ id: undefined,
+ });
+ }, 500);
+ };
+
+ // Remove record (for UI update after deletion)
+ const removeRecord = (key) => {
+ let newDataSource = [...redemptions];
+ if (key != null) {
+ let idx = newDataSource.findIndex((data) => data.key === key);
+ if (idx > -1) {
+ newDataSource.splice(idx, 1);
+ setRedemptions(newDataSource);
+ }
+ }
+ };
+
+ // Initialize data loading
+ useEffect(() => {
+ loadRedemptions(1, pageSize)
+ .then()
+ .catch((reason) => {
+ showError(reason);
+ });
+ }, [pageSize]);
+
+ return {
+ // Data state
+ redemptions,
+ loading,
+ searching,
+ activePage,
+ pageSize,
+ tokenCount,
+ selectedKeys,
+
+ // Edit state
+ editingRedemption,
+ showEdit,
+
+ // Form state
+ formApi,
+ formInitValues,
+
+ // UI state
+ compactMode,
+ setCompactMode,
+
+ // Data operations
+ loadRedemptions,
+ searchRedemptions,
+ manageRedemption,
+ refresh,
+ copyText,
+ removeRecord,
+
+ // State updates
+ setActivePage,
+ setPageSize,
+ setSelectedKeys,
+ setEditingRedemption,
+ setShowEdit,
+ setFormApi,
+ setLoading,
+
+ // Event handlers
+ handlePageChange,
+ handlePageSizeChange,
+ rowSelection,
+ handleRow,
+ closeEdit,
+ getFormValues,
+
+ // Batch operations
+ batchCopyRedemptions,
+ batchDeleteRedemptions,
+
+ // Translation function
+ t,
+ };
+};
\ No newline at end of file
diff --git a/web/src/index.css b/web/src/index.css
index 742ec5ca..6a102b31 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -432,27 +432,6 @@ code {
background: transparent;
}
-/* ==================== 响应式/移动端样式 ==================== */
-@media only screen and (max-width: 767px) {
-
- /* 移动端表格样式调整 */
- .semi-table-tbody,
- .semi-table-row,
- .semi-table-row-cell {
- display: block !important;
- width: auto !important;
- padding: 2px !important;
- }
-
- .semi-table-row-cell {
- border-bottom: 0 !important;
- }
-
- .semi-table-tbody>.semi-table-row {
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
- }
-}
-
/* ==================== 同步倍率 - 渠道选择器 ==================== */
.components-transfer-source-item,