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:
t0ng7u
2026-01-30 14:29:56 +08:00
parent 009910b960
commit 348ae6df73
7 changed files with 563 additions and 18 deletions

View File

@@ -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,
}),
},

View File

@@ -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?.()}

View 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;