Просмотр исходного кода

♻️ refactor(components): restructure RedemptionsTable to modular architecture

Refactor the monolithic RedemptionsTable component (614 lines) into a clean,
modular structure following the established tokens component pattern.

### Changes Made:

**New Components:**
- `RedemptionsColumnDefs.js` - Extract table column definitions and render logic
- `RedemptionsActions.jsx` - Extract action buttons (add, batch copy, clear invalid)
- `RedemptionsFilters.jsx` - Extract search and filter form components
- `RedemptionsDescription.jsx` - Extract description area component
- `redemptions/index.jsx` - Main container component managing state and composition

**New Hook:**
- `useRedemptionsData.js` - Extract all data management, CRUD operations, and business logic

**New Constants:**
- `redemption.constants.js` - Extract redemption status, actions, and form constants

**Architecture Changes:**
- Transform RedemptionsTable.jsx into pure table rendering component
- Move state management and component composition to index.jsx
- Implement consistent prop drilling pattern matching tokens module
- Add memoization for performance optimization
- Centralize translation function distribution

### Benefits:
- **Maintainability**: Each component has single responsibility
- **Reusability**: Components and hooks can be used elsewhere
- **Testability**: Individual modules can be unit tested
- **Team Collaboration**: Multiple developers can work on different modules
- **Consistency**: Follows established architectural patterns

### File Structure:
```
redemptions/
├── index.jsx                    # Main container (state + composition)
├── RedemptionsTable.jsx        # Pure table component
├── RedemptionsActions.jsx      # Action buttons
├── RedemptionsFilters.jsx      # Search/filter form
├── RedemptionsDescription.jsx  # Description area
└── RedemptionsColumnDefs.js    # Column definitions
t0ng7u 7 месяцев назад
Родитель
Сommit
c05d6f7cdf

+ 2 - 613
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 (
-        <Tag color='orange' shape='circle'>{t('已过期')}</Tag>
-      );
-    }
-    switch (status) {
-      case 1:
-        return (
-          <Tag color='green' shape='circle'>
-            {t('未使用')}
-          </Tag>
-        );
-      case 2:
-        return (
-          <Tag color='red' shape='circle'>
-            {t('已禁用')}
-          </Tag>
-        );
-      case 3:
-        return (
-          <Tag color='grey' shape='circle'>
-            {t('已使用')}
-          </Tag>
-        );
-      default:
-        return (
-          <Tag color='black' shape='circle'>
-            {t('未知状态')}
-          </Tag>
-        );
-    }
-  };
-
-  const columns = [
-    {
-      title: t('ID'),
-      dataIndex: 'id',
-    },
-    {
-      title: t('名称'),
-      dataIndex: 'name',
-    },
-    {
-      title: t('状态'),
-      dataIndex: 'status',
-      key: 'status',
-      render: (text, record, index) => {
-        return <div>{renderStatus(text, record)}</div>;
-      },
-    },
-    {
-      title: t('额度'),
-      dataIndex: 'quota',
-      render: (text, record, index) => {
-        return (
-          <div>
-            <Tag color='grey' shape='circle'>
-              {renderQuota(parseInt(text))}
-            </Tag>
-          </div>
-        );
-      },
-    },
-    {
-      title: t('创建时间'),
-      dataIndex: 'created_time',
-      render: (text, record, index) => {
-        return <div>{renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('过期时间'),
-      dataIndex: 'expired_time',
-      render: (text) => {
-        return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
-      },
-    },
-    {
-      title: t('兑换人ID'),
-      dataIndex: 'used_user_id',
-      render: (text, record, index) => {
-        return <div>{text === 0 ? t('无') : text}</div>;
-      },
-    },
-    {
-      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 (
-          <Space>
-            <Popover content={record.key} style={{ padding: 20 }} position='top'>
-              <Button
-                type='tertiary'
-                size="small"
-              >
-                {t('查看')}
-              </Button>
-            </Popover>
-            <Button
-              size="small"
-              onClick={async () => {
-                await copyText(record.key);
-              }}
-            >
-              {t('复制')}
-            </Button>
-            <Button
-              type='tertiary'
-              size="small"
-              onClick={() => {
-                setEditingRedemption(record);
-                setShowEdit(true);
-              }}
-              disabled={record.status !== 1}
-            >
-              {t('编辑')}
-            </Button>
-            <Dropdown
-              trigger='click'
-              position='bottomRight'
-              menu={moreMenuItems}
-            >
-              <Button
-                type='tertiary'
-                size="small"
-                icon={<IconMore />}
-              />
-            </Dropdown>
-          </Space>
-        );
-      },
-    },
-  ];
-
-  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 (
-    <>
-      <EditRedemption
-        refresh={refresh}
-        editingRedemption={editingRedemption}
-        visiable={showEdit}
-        handleClose={closeEdit}
-      ></EditRedemption>
-
-      <CardPro
-        type="type1"
-        descriptionArea={
-          <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
-            <div className="flex items-center text-orange-500">
-              <Ticket size={16} className="mr-2" />
-              <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
-            </div>
-            <Button
-              type='tertiary'
-              className="w-full md:w-auto"
-              onClick={() => setCompactMode(!compactMode)}
-              size="small"
-            >
-              {compactMode ? t('自适应列表') : t('紧凑列表')}
-            </Button>
-          </div>
-        }
-        actionsArea={
-          <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-            <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
-              <div className="flex gap-2 w-full sm:w-auto">
-                <Button
-                  type='primary'
-                  className="w-full sm:w-auto"
-                  onClick={() => {
-                    setEditingRedemption({
-                      id: undefined,
-                    });
-                    setShowEdit(true);
-                  }}
-                  size="small"
-                >
-                  {t('添加兑换码')}
-                </Button>
-                <Button
-                  type='tertiary'
-                  className="w-full sm:w-auto"
-                  onClick={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);
-                  }}
-                  size="small"
-                >
-                  {t('复制所选兑换码到剪贴板')}
-                </Button>
-              </div>
-              <Button
-                type='danger'
-                className="w-full sm:w-auto"
-                onClick={() => {
-                  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);
-                    },
-                  });
-                }}
-                size="small"
-              >
-                {t('清除失效兑换码')}
-              </Button>
-            </div>
-
-            <Form
-              initValues={formInitValues}
-              getFormApi={(api) => setFormApi(api)}
-              onSubmit={() => {
-                setActivePage(1);
-                searchRedemptions(null, 1, pageSize);
-              }}
-              allowEmpty={true}
-              autoComplete="off"
-              layout="horizontal"
-              trigger="change"
-              stopValidateWithError={false}
-              className="w-full md:w-auto order-1 md:order-2"
-            >
-              <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
-                <div className="relative w-full md:w-64">
-                  <Form.Input
-                    field="searchKeyword"
-                    prefix={<IconSearch />}
-                    placeholder={t('关键字(id或者名称)')}
-                    showClear
-                    pure
-                    size="small"
-                  />
-                </div>
-                <div className="flex gap-2 w-full md:w-auto">
-                  <Button
-                    type="tertiary"
-                    htmlType="submit"
-                    loading={loading || searching}
-                    className="flex-1 md:flex-initial md:w-auto"
-                    size="small"
-                  >
-                    {t('查询')}
-                  </Button>
-                  <Button
-                    type="tertiary"
-                    onClick={() => {
-                      if (formApi) {
-                        formApi.reset();
-                        setTimeout(() => {
-                          setActivePage(1);
-                          loadRedemptions(1, pageSize);
-                        }, 100);
-                      }
-                    }}
-                    className="flex-1 md:flex-initial md:w-auto"
-                    size="small"
-                  >
-                    {t('重置')}
-                  </Button>
-                </div>
-              </div>
-            </Form>
-          </div>
-        }
-      >
-        <Table
-          columns={compactMode ? columns.map(({ fixed, ...rest }) => 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={
-            <Empty
-              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
-              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
-              description={t('搜索无结果')}
-              style={{ padding: 30 }}
-            />
-          }
-          className="rounded-xl overflow-hidden"
-          size="middle"
-        ></Table>
-      </CardPro>
-    </>
-  );
-};
-
-export default RedemptionsTable;
+// 重构后的 RedemptionsTable - 使用新的模块化架构
+export { default } from './redemptions/index.jsx';

+ 2 - 7
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';

+ 53 - 0
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 (
+    <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
+      <Button
+        type="primary"
+        className="flex-1 md:flex-initial"
+        onClick={handleAddRedemption}
+        size="small"
+      >
+        {t('添加兑换码')}
+      </Button>
+
+      <Button
+        type='tertiary'
+        className="flex-1 md:flex-initial"
+        onClick={batchCopyRedemptions}
+        size="small"
+      >
+        {t('复制所选兑换码到剪贴板')}
+      </Button>
+
+      <Button
+        type='danger'
+        className="w-full md:w-auto"
+        onClick={batchDeleteRedemptions}
+        size="small"
+      >
+        {t('清除失效兑换码')}
+      </Button>
+    </div>
+  );
+};
+
+export default RedemptionsActions; 

+ 198 - 0
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 (
+      <Tag color='orange' shape='circle'>{t('已过期')}</Tag>
+    );
+  }
+
+  const statusConfig = REDEMPTION_STATUS_MAP[status];
+  if (statusConfig) {
+    return (
+      <Tag color={statusConfig.color} shape='circle'>
+        {t(statusConfig.text)}
+      </Tag>
+    );
+  }
+
+  return (
+    <Tag color='black' shape='circle'>
+      {t('未知状态')}
+    </Tag>
+  );
+};
+
+/**
+ * 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 <div>{renderStatus(text, record, t)}</div>;
+      },
+    },
+    {
+      title: t('额度'),
+      dataIndex: 'quota',
+      render: (text) => {
+        return (
+          <div>
+            <Tag color='grey' shape='circle'>
+              {renderQuota(parseInt(text))}
+            </Tag>
+          </div>
+        );
+      },
+    },
+    {
+      title: t('创建时间'),
+      dataIndex: 'created_time',
+      render: (text) => {
+        return <div>{renderTimestamp(text)}</div>;
+      },
+    },
+    {
+      title: t('过期时间'),
+      dataIndex: 'expired_time',
+      render: (text) => {
+        return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
+      },
+    },
+    {
+      title: t('兑换人ID'),
+      dataIndex: 'used_user_id',
+      render: (text) => {
+        return <div>{text === 0 ? t('无') : text}</div>;
+      },
+    },
+    {
+      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 (
+          <Space>
+            <Popover content={record.key} style={{ padding: 20 }} position='top'>
+              <Button
+                type='tertiary'
+                size="small"
+              >
+                {t('查看')}
+              </Button>
+            </Popover>
+            <Button
+              size="small"
+              onClick={async () => {
+                await copyText(record.key);
+              }}
+            >
+              {t('复制')}
+            </Button>
+            <Button
+              type='tertiary'
+              size="small"
+              onClick={() => {
+                setEditingRedemption(record);
+                setShowEdit(true);
+              }}
+              disabled={record.status !== REDEMPTION_STATUS.UNUSED}
+            >
+              {t('编辑')}
+            </Button>
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={moreMenuItems}
+            >
+              <Button
+                type='tertiary'
+                size="small"
+                icon={<IconMore />}
+              />
+            </Dropdown>
+          </Space>
+        );
+      },
+    },
+  ];
+}; 

+ 27 - 0
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 (
+    <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+      <div className="flex items-center text-orange-500">
+        <Ticket size={16} className="mr-2" />
+        <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
+      </div>
+
+      <Button
+        type="tertiary"
+        className="w-full md:w-auto"
+        onClick={() => setCompactMode(!compactMode)}
+        size="small"
+      >
+        {compactMode ? t('自适应列表') : t('紧凑列表')}
+      </Button>
+    </div>
+  );
+};
+
+export default RedemptionsDescription; 

+ 72 - 0
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 (
+    <Form
+      initValues={formInitValues}
+      getFormApi={(api) => setFormApi(api)}
+      onSubmit={searchRedemptions}
+      allowEmpty={true}
+      autoComplete="off"
+      layout="horizontal"
+      trigger="change"
+      stopValidateWithError={false}
+      className="w-full md:w-auto order-1 md:order-2"
+    >
+      <div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
+        <div className="relative w-full md:w-64">
+          <Form.Input
+            field="searchKeyword"
+            prefix={<IconSearch />}
+            placeholder={t('关键字(id或者名称)')}
+            showClear
+            pure
+            size="small"
+          />
+        </div>
+        <div className="flex gap-2 w-full md:w-auto">
+          <Button
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('查询')}
+          </Button>
+          <Button
+            type="tertiary"
+            onClick={(_, formApi) => handleReset(formApi)}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('重置')}
+          </Button>
+        </div>
+      </div>
+    </Form>
+  );
+};
+
+export default RedemptionsFilters; 

+ 119 - 0
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 (
+    <>
+      <Table
+        columns={tableColumns}
+        dataSource={redemptions}
+        scroll={compactMode ? undefined : { x: 'max-content' }}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: tokenCount,
+          showSizeChanger: true,
+          pageSizeOptions: [10, 20, 50, 100],
+          onPageSizeChange: redemptionsData.handlePageSizeChange,
+          onPageChange: handlePageChange,
+        }}
+        loading={loading}
+        rowSelection={rowSelection}
+        onRow={handleRow}
+        empty={
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+            description={t('搜索无结果')}
+            style={{ padding: 30 }}
+          />
+        }
+        className="rounded-xl overflow-hidden"
+        size="middle"
+      />
+
+      <DeleteRedemptionModal
+        visible={showDeleteModal}
+        onCancel={() => setShowDeleteModal(false)}
+        record={deletingRecord}
+        manageRedemption={manageRedemption}
+        refresh={refresh}
+        redemptions={redemptions}
+        activePage={activePage}
+        t={t}
+      />
+    </>
+  );
+};
+
+export default RedemptionsTable; 

+ 90 - 0
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 (
+    <>
+      <EditRedemptionModal
+        refresh={refresh}
+        editingRedemption={editingRedemption}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      />
+
+      <CardPro
+        type="type1"
+        descriptionArea={
+          <RedemptionsDescription
+            compactMode={compactMode}
+            setCompactMode={setCompactMode}
+            t={t}
+          />
+        }
+        actionsArea={
+          <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
+            <RedemptionsActions
+              selectedKeys={selectedKeys}
+              setEditingRedemption={setEditingRedemption}
+              setShowEdit={setShowEdit}
+              batchCopyRedemptions={batchCopyRedemptions}
+              batchDeleteRedemptions={batchDeleteRedemptions}
+              t={t}
+            />
+
+            <div className="w-full md:w-full lg:w-auto order-1 md:order-2">
+              <RedemptionsFilters
+                formInitValues={formInitValues}
+                setFormApi={setFormApi}
+                searchRedemptions={searchRedemptions}
+                loading={loading}
+                searching={searching}
+                t={t}
+              />
+            </div>
+          </div>
+        }
+      >
+        <RedemptionsTable {...redemptionsData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default RedemptionsPage; 

+ 39 - 0
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 (
+    <Modal
+      title={t('确定是否要删除此兑换码?')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={handleConfirm}
+      type="warning"
+    >
+      {t('此修改将不可逆')}
+    </Modal>
+  );
+};
+
+export default DeleteRedemptionModal; 

+ 4 - 4
web/src/pages/Redemption/EditRedemption.js → 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; 

+ 64 - 78
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: (
-        <Space>
-          <Button
-            type='tertiary'
-            onClick={async () => {
-              let content = '';
-              for (let i = 0; i < selectedKeys.length; i++) {
-                content +=
-                  selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-              }
-              await copyText(content);
-              Modal.destroyAll();
-            }}
-          >
-            {t('名称+密钥')}
-          </Button>
-          <Button
-            onClick={async () => {
-              let content = '';
-              for (let i = 0; i < selectedKeys.length; i++) {
-                content += 'sk-' + selectedKeys[i].key + '\n';
-              }
-              await copyText(content);
-              Modal.destroyAll();
-            }}
-          >
-            {t('仅密钥')}
-          </Button>
-        </Space>
-      ),
-    });
+    setShowCopyModal(true);
   };
 
   // Handle delete selected tokens with confirmation
@@ -61,52 +32,67 @@ const TokensActions = ({
       showError(t('请至少选择一个令牌!'));
       return;
     }
+    setShowDeleteModal(true);
+  };
 
-    Modal.confirm({
-      title: t('批量删除令牌'),
-      content: (
-        <div>
-          {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
-        </div>
-      ),
-      onOk: () => batchDeleteTokens(),
-    });
+  // Handle delete confirmation
+  const handleConfirmDelete = () => {
+    batchDeleteTokens();
+    setShowDeleteModal(false);
   };
 
   return (
-    <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
-      <Button
-        type="primary"
-        className="flex-1 md:flex-initial"
-        onClick={() => {
-          setEditingToken({
-            id: undefined,
-          });
-          setShowEdit(true);
-        }}
-        size="small"
-      >
-        {t('添加令牌')}
-      </Button>
+    <>
+      <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
+        <Button
+          type="primary"
+          className="flex-1 md:flex-initial"
+          onClick={() => {
+            setEditingToken({
+              id: undefined,
+            });
+            setShowEdit(true);
+          }}
+          size="small"
+        >
+          {t('添加令牌')}
+        </Button>
+
+        <Button
+          type='tertiary'
+          className="flex-1 md:flex-initial"
+          onClick={handleCopySelectedTokens}
+          size="small"
+        >
+          {t('复制所选令牌')}
+        </Button>
+
+        <Button
+          type='danger'
+          className="w-full md:w-auto"
+          onClick={handleDeleteSelectedTokens}
+          size="small"
+        >
+          {t('删除所选令牌')}
+        </Button>
+      </div>
 
-      <Button
-        type='tertiary'
-        className="flex-1 md:flex-initial"
-        onClick={handleCopySelectedTokens}
-        size="small"
-      >
-        {t('复制所选令牌')}
-      </Button>
+      <CopyTokensModal
+        visible={showCopyModal}
+        onCancel={() => setShowCopyModal(false)}
+        selectedKeys={selectedKeys}
+        copyText={copyText}
+        t={t}
+      />
 
-      <Button
-        type='danger'
-        className="w-full md:w-auto"
-        onClick={handleDeleteSelectedTokens}
-        size="small"
-      >
-        {t('删除所选令牌')}
-      </Button>
-    </div>
+      <DeleteTokensModal
+        visible={showDeleteModal}
+        onCancel={() => setShowDeleteModal(false)}
+        onConfirm={handleConfirmDelete}
+        selectedKeys={selectedKeys}
+        t={t}
+      />
+    </>
   );
 };
 

+ 4 - 2
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 (
     <>
-      <EditToken
+      <EditTokenModal
         refresh={refresh}
         editingToken={editingToken}
         visiable={showEdit}
@@ -63,6 +64,7 @@ const TokensPage = () => {
               selectedKeys={selectedKeys}
               setEditingToken={setEditingToken}
               setShowEdit={setShowEdit}
+              batchCopyTokens={batchCopyTokens}
               batchDeleteTokens={batchDeleteTokens}
               copyText={copyText}
               t={t}

+ 52 - 0
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 (
+    <Modal
+      title={t('复制令牌')}
+      icon={null}
+      visible={visible}
+      onCancel={onCancel}
+      footer={
+        <Space>
+          <Button
+            type='tertiary'
+            onClick={handleCopyWithName}
+          >
+            {t('名称+密钥')}
+          </Button>
+          <Button
+            onClick={handleCopyKeyOnly}
+          >
+            {t('仅密钥')}
+          </Button>
+        </Space>
+      }
+    >
+      {t('请选择你的复制方式')}
+    </Modal>
+  );
+};
+
+export default CopyTokensModal; 

+ 20 - 0
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 (
+    <Modal
+      title={t('批量删除令牌')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onConfirm}
+      type="warning"
+    >
+      <div>
+        {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
+      </div>
+    </Modal>
+  );
+};
+
+export default DeleteTokensModal; 

+ 5 - 5
web/src/pages/Token/EditToken.js → 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; 

+ 1 - 0
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';

+ 29 - 0
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'
+}; 

+ 336 - 0
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,
+  };
+}; 

+ 0 - 21
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,