mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-29 19:08:38 +00:00
✨ feat(admin): add user subscription management and refine UI/pagination
Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice
This commit is contained in:
@@ -208,7 +208,7 @@ const renderOperations = (
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -219,8 +219,8 @@ const renderOperations = (
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('绑定订阅套餐'),
|
||||
onClick: () => showBindSubscriptionModal(record),
|
||||
name: t('订阅管理'),
|
||||
onClick: () => showUserSubscriptionsModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
@@ -308,7 +308,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
showUserSubscriptionsModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -365,7 +365,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showBindSubscriptionModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
import BindSubscriptionModal from './modals/BindSubscriptionModal';
|
||||
import UserSubscriptionsModal from './modals/UserSubscriptionsModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -62,7 +62,7 @@ const UsersTable = (usersData) => {
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
const [showBindSubscriptionModal, setShowBindSubscriptionModal] =
|
||||
const [showUserSubscriptionsModal, setShowUserSubscriptionsModal] =
|
||||
useState(false);
|
||||
|
||||
// Modal handlers
|
||||
@@ -97,9 +97,9 @@ const UsersTable = (usersData) => {
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
const showBindSubscriptionUserModal = (user) => {
|
||||
const showUserSubscriptionsUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowBindSubscriptionModal(true);
|
||||
setShowUserSubscriptionsModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
@@ -140,7 +140,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
showBindSubscriptionModal: showBindSubscriptionUserModal,
|
||||
showUserSubscriptionsModal: showUserSubscriptionsUserModal,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -152,7 +152,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
showBindSubscriptionUserModal,
|
||||
showUserSubscriptionsUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
@@ -253,9 +253,9 @@ const UsersTable = (usersData) => {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<BindSubscriptionModal
|
||||
visible={showBindSubscriptionModal}
|
||||
onCancel={() => setShowBindSubscriptionModal(false)}
|
||||
<UserSubscriptionsModal
|
||||
visible={showUserSubscriptionsModal}
|
||||
onCancel={() => setShowUserSubscriptionsModal(false)}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
onSuccess={() => refresh?.()}
|
||||
|
||||
431
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
431
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function renderStatusTag(sub, t) {
|
||||
const now = Date.now() / 1000;
|
||||
const end = sub?.end_time || 0;
|
||||
const status = sub?.status || '';
|
||||
|
||||
const isExpiredByTime = end > 0 && end < now;
|
||||
const isActive = status === 'active' && !isExpiredByTime;
|
||||
if (isActive) {
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已作废')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const [subs, setSubs] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const id = p?.plan?.id;
|
||||
const title = p?.plan?.title;
|
||||
if (id) map.set(id, title || `#${id}`);
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const pagedSubs = useMemo(() => {
|
||||
const start = Math.max(0, (Number(currentPage || 1) - 1) * pageSize);
|
||||
const end = start + pageSize;
|
||||
return (subs || []).slice(start, end);
|
||||
}, [subs, currentPage]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setPlansLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserSubscriptions = async () => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/subscription/admin/users/${user.id}/subscriptions`);
|
||||
if (res.data?.success) {
|
||||
const next = res.data.data || [];
|
||||
setSubs(next);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setSelectedPlanId(null);
|
||||
setCurrentPage(1);
|
||||
loadPlans();
|
||||
loadUserSubscriptions();
|
||||
}, [visible]);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const createSubscription = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await API.post(`/api/subscription/admin/users/${user.id}/subscriptions`, {
|
||||
plan_id: selectedPlanId,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('新增成功'));
|
||||
setSelectedPlanId(null);
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('新增失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认作废'),
|
||||
content: t('作废后该订阅将立即失效,历史记录不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已作废'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('删除会彻底移除该订阅记录(含权益明细)。是否继续?'),
|
||||
centered: true,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.delete(`/api/subscription/admin/user_subscriptions/${subId}`);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已删除'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['subscription', 'id'],
|
||||
key: 'id',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
key: 'plan',
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const planId = sub?.plan_id;
|
||||
const title = planTitleMap.get(planId) || (planId ? `#${planId}` : '-');
|
||||
return (
|
||||
<div className='min-w-0'>
|
||||
<div className='font-medium truncate'>{title}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('来源')}: {sub?.source || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (_, record) => renderStatusTag(record?.subscription, t),
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
key: 'validity',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
return (
|
||||
<div className='text-xs text-gray-600'>
|
||||
<div>
|
||||
{t('开始')}: {formatTs(sub?.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('结束')}: {formatTs(sub?.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('权益'),
|
||||
key: 'items',
|
||||
width: 80,
|
||||
render: (_, record) => {
|
||||
const items = record?.items || [];
|
||||
if (items.length === 0) return <Text type='tertiary'>-</Text>;
|
||||
const content = (
|
||||
<div className='max-w-[320px] space-y-1'>
|
||||
{items.map((it) => (
|
||||
<div key={`${it.id}-${it.model_name}`} className='flex justify-between text-xs'>
|
||||
<span className='truncate mr-2'>{it.model_name}</span>
|
||||
<span className='text-gray-600'>
|
||||
{it.amount_used}/{it.amount_total}
|
||||
{it.quota_type === 1 ? t('次') : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} position='top' showArrow>
|
||||
<Tag color='white' shape='circle'>
|
||||
{items.length} {t('项')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'operate',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired = (sub?.end_time || 0) > 0 && (sub?.end_time || 0) < now;
|
||||
const isActive = sub?.status === 'active' && !isExpired;
|
||||
const isCancelled = sub?.status === 'cancelled';
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
theme='light'
|
||||
disabled={!isActive || isCancelled}
|
||||
onClick={() => invalidateSubscription(sub?.id)}
|
||||
>
|
||||
{t('作废')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={() => deleteSubscription(sub?.id)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [t, planTitleMap]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
visible={visible}
|
||||
placement='right'
|
||||
width={isMobile ? '100%' : 920}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
onCancel={onCancel}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Typography.Title heading={4} className='m-0'>
|
||||
{t('用户订阅管理')}
|
||||
</Typography.Title>
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{user?.username || '-'} (ID: {user?.id || '-'})
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className='p-4'>
|
||||
{/* 顶部操作栏:新增订阅 */}
|
||||
<div className='flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4'>
|
||||
<div className='flex gap-2 flex-1'>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={plansLoading}
|
||||
filter
|
||||
style={{ minWidth: isMobile ? undefined : 300, flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconPlusCircle />}
|
||||
loading={creating}
|
||||
onClick={createSubscription}
|
||||
>
|
||||
{t('新增订阅')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订阅列表 */}
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={pagedSubs}
|
||||
rowKey={(row) => row?.subscription?.id}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
hidePagination={false}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize,
|
||||
total: subs.length,
|
||||
pageSizeOpts: [10, 20, 50],
|
||||
showSizeChanger: false,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅记录')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
size='middle'
|
||||
/>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSubscriptionsModal;
|
||||
|
||||
Reference in New Issue
Block a user