- Refactor LogsTable, MjLogsTable, TokensTable, UsersTable, and ChannelsTable to use Semi-UI Form components - Replace individual input state management with centralized Form API - Add form validation and consistent form handling across all tables - Implement auto-search functionality with proper state update timing - Add reset functionality to clear all search filters - Improve responsive layout design for better mobile experience - Remove duplicate form initial values and consolidate form logic - Remove column visibility feature from ChannelsTable to simplify UI - Standardize search form structure and styling across all table components - Fix state update timing issues in search functionality - Add proper form submission handling with loading states BREAKING CHANGE: Form state management has been completely rewritten. All table components now use Form API instead of individual useState hooks. Column visibility settings for ChannelsTable have been removed.
590 lines
16 KiB
JavaScript
590 lines
16 KiB
JavaScript
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
API,
|
||
copy,
|
||
showError,
|
||
showSuccess,
|
||
timestamp2string,
|
||
renderQuota
|
||
} from '../../helpers';
|
||
|
||
import { ITEMS_PER_PAGE } from '../../constants';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Divider,
|
||
Dropdown,
|
||
Form,
|
||
Modal,
|
||
Popover,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconPlus,
|
||
IconCopy,
|
||
IconSearch,
|
||
IconEyeOpened,
|
||
IconEdit,
|
||
IconDelete,
|
||
IconStop,
|
||
IconPlay,
|
||
IconMore,
|
||
} from '@douyinfe/semi-icons';
|
||
import EditRedemption from '../../pages/Redemption/EditRedemption';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
const { Text } = Typography;
|
||
|
||
function renderTimestamp(timestamp) {
|
||
return <>{timestamp2string(timestamp)}</>;
|
||
}
|
||
|
||
const RedemptionsTable = () => {
|
||
const { t } = useTranslation();
|
||
|
||
const renderStatus = (status) => {
|
||
switch (status) {
|
||
case 1:
|
||
return (
|
||
<Tag color='green' size='large' shape='circle'>
|
||
{t('未使用')}
|
||
</Tag>
|
||
);
|
||
case 2:
|
||
return (
|
||
<Tag color='red' size='large' shape='circle'>
|
||
{t('已禁用')}
|
||
</Tag>
|
||
);
|
||
case 3:
|
||
return (
|
||
<Tag color='grey' size='large' shape='circle'>
|
||
{t('已使用')}
|
||
</Tag>
|
||
);
|
||
default:
|
||
return (
|
||
<Tag color='black' size='large' 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)}</div>;
|
||
},
|
||
},
|
||
{
|
||
title: t('额度'),
|
||
dataIndex: 'quota',
|
||
render: (text, record, index) => {
|
||
return <div>{renderQuota(parseInt(text))}</div>;
|
||
},
|
||
},
|
||
{
|
||
title: t('创建时间'),
|
||
dataIndex: 'created_time',
|
||
render: (text, record, index) => {
|
||
return <div>{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',
|
||
render: (text, record, index) => {
|
||
// 创建更多操作的下拉菜单项
|
||
const moreMenuItems = [
|
||
{
|
||
node: 'item',
|
||
name: t('删除'),
|
||
icon: <IconDelete />,
|
||
type: 'danger',
|
||
onClick: () => {
|
||
Modal.confirm({
|
||
title: t('确定是否要删除此兑换码?'),
|
||
content: t('此修改将不可逆'),
|
||
onOk: () => {
|
||
manageRedemption(record.id, 'delete', record).then(() => {
|
||
removeRecord(record.key);
|
||
});
|
||
},
|
||
});
|
||
},
|
||
}
|
||
];
|
||
|
||
// 动态添加启用/禁用按钮
|
||
if (record.status === 1) {
|
||
moreMenuItems.push({
|
||
node: 'item',
|
||
name: t('禁用'),
|
||
icon: <IconStop />,
|
||
type: 'warning',
|
||
onClick: () => {
|
||
manageRedemption(record.id, 'disable', record);
|
||
},
|
||
});
|
||
} else {
|
||
moreMenuItems.push({
|
||
node: 'item',
|
||
name: t('启用'),
|
||
icon: <IconPlay />,
|
||
type: 'secondary',
|
||
onClick: () => {
|
||
manageRedemption(record.id, 'enable', record);
|
||
},
|
||
disabled: record.status === 3,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Space>
|
||
<Popover content={record.key} style={{ padding: 20 }} position='top'>
|
||
<Button
|
||
icon={<IconEyeOpened />}
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
>
|
||
{t('查看')}
|
||
</Button>
|
||
</Popover>
|
||
<Button
|
||
icon={<IconCopy />}
|
||
theme='light'
|
||
type='secondary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
onClick={async () => {
|
||
await copyText(record.key);
|
||
}}
|
||
>
|
||
{t('复制')}
|
||
</Button>
|
||
<Button
|
||
icon={<IconEdit />}
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
onClick={() => {
|
||
setEditingRedemption(record);
|
||
setShowEdit(true);
|
||
}}
|
||
disabled={record.status !== 1}
|
||
>
|
||
{t('编辑')}
|
||
</Button>
|
||
<Dropdown
|
||
trigger='click'
|
||
position='bottomRight'
|
||
menu={moreMenuItems}
|
||
>
|
||
<Button
|
||
icon={<IconMore />}
|
||
theme='light'
|
||
type='tertiary'
|
||
size="small"
|
||
className="!rounded-full"
|
||
/>
|
||
</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);
|
||
|
||
// Form 初始值
|
||
const formInitValues = {
|
||
searchKeyword: '',
|
||
};
|
||
|
||
// Form API 引用
|
||
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 (startIdx, pageSize) => {
|
||
const res = await API.get(
|
||
`/api/redemption/?p=${startIdx}&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);
|
||
}
|
||
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'
|
||
});
|
||
}
|
||
};
|
||
|
||
const onPaginationChange = (e, { activePage }) => {
|
||
(async () => {
|
||
if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
|
||
await loadRedemptions(activePage - 1, pageSize);
|
||
}
|
||
setActivePage(activePage);
|
||
})();
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadRedemptions(0, pageSize)
|
||
.then()
|
||
.catch((reason) => {
|
||
showError(reason);
|
||
});
|
||
}, [pageSize]);
|
||
|
||
const refresh = async () => {
|
||
await loadRedemptions(activePage - 1, 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 sortRedemption = (key) => {
|
||
if (redemptions.length === 0) return;
|
||
setLoading(true);
|
||
let sortedRedemptions = [...redemptions];
|
||
sortedRedemptions.sort((a, b) => {
|
||
return ('' + a[key]).localeCompare(b[key]);
|
||
});
|
||
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||
sortedRedemptions.reverse();
|
||
}
|
||
setRedemptions(sortedRedemptions);
|
||
setLoading(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) {
|
||
return {
|
||
style: {
|
||
background: 'var(--semi-color-disabled-border)',
|
||
},
|
||
};
|
||
} else {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const renderHeader = () => (
|
||
<div className="flex flex-col w-full">
|
||
<div className="mb-2">
|
||
<div className="flex items-center text-orange-500">
|
||
<IconEyeOpened className="mr-2" />
|
||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider margin="12px" />
|
||
|
||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||
<Button
|
||
theme='light'
|
||
type='primary'
|
||
icon={<IconPlus />}
|
||
className="!rounded-full w-full md:w-auto"
|
||
onClick={() => {
|
||
setEditingRedemption({
|
||
id: undefined,
|
||
});
|
||
setShowEdit(true);
|
||
}}
|
||
>
|
||
{t('添加兑换码')}
|
||
</Button>
|
||
<Button
|
||
type='warning'
|
||
icon={<IconCopy />}
|
||
className="!rounded-full w-full md: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);
|
||
}}
|
||
>
|
||
{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或者名称)')}
|
||
className="!rounded-full"
|
||
showClear
|
||
pure
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2 w-full md:w-auto">
|
||
<Button
|
||
type="primary"
|
||
htmlType="submit"
|
||
loading={searching}
|
||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||
>
|
||
{t('查询')}
|
||
</Button>
|
||
<Button
|
||
theme="light"
|
||
onClick={() => {
|
||
if (formApi) {
|
||
formApi.reset();
|
||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||
setTimeout(() => {
|
||
setActivePage(1);
|
||
loadRedemptions(1, pageSize);
|
||
}, 100);
|
||
}
|
||
}}
|
||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||
>
|
||
{t('重置')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<EditRedemption
|
||
refresh={refresh}
|
||
editingRedemption={editingRedemption}
|
||
visiable={showEdit}
|
||
handleClose={closeEdit}
|
||
></EditRedemption>
|
||
|
||
<Card
|
||
className="!rounded-2xl"
|
||
title={renderHeader()}
|
||
shadows='always'
|
||
bordered={false}
|
||
>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={pageData}
|
||
scroll={{ x: 'max-content' }}
|
||
pagination={{
|
||
currentPage: activePage,
|
||
pageSize: pageSize,
|
||
total: tokenCount,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [10, 20, 50, 100],
|
||
formatPageText: (page) =>
|
||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||
start: page.currentStart,
|
||
end: page.currentEnd,
|
||
total: tokenCount,
|
||
}),
|
||
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}
|
||
className="rounded-xl overflow-hidden"
|
||
size="middle"
|
||
></Table>
|
||
</Card>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default RedemptionsTable;
|