feat(admin): streamline subscription plan benefits editor with bulk actions

Restore the avatar/icon header for the “Model Benefits” section
Replace scattered controls with a compact toolbar-style workflow
Support multi-select add with a default quota for new items
Add row selection with bulk apply-to-selected / apply-to-all quota updates
Enable delete-selected to manage benefits faster and reduce mistakes
This commit is contained in:
t0ng7u
2026-01-30 16:24:51 +08:00
parent 348ae6df73
commit a60783e99f
3 changed files with 220 additions and 22 deletions

View File

@@ -25,9 +25,12 @@ import SubscriptionsActions from './SubscriptionsActions';
import SubscriptionsDescription from './SubscriptionsDescription';
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const SubscriptionsPage = () => {
const subscriptionsData = useSubscriptionsData();
const isMobile = useIsMobile();
const {
showEdit,
@@ -79,6 +82,15 @@ const SubscriptionsPage = () => {
/>
</div>
}
paginationArea={createCardProPagination({
currentPage: subscriptionsData.activePage,
pageSize: subscriptionsData.pageSize,
total: subscriptionsData.planCount,
onPageChange: subscriptionsData.handlePageChange,
onPageSizeChange: subscriptionsData.handlePageSizeChange,
isMobile,
t: subscriptionsData.t,
})}
t={t}
>
<SubscriptionsTable {...subscriptionsData} />

View File

@@ -40,9 +40,10 @@ import {
IconCalendarClock,
IconClose,
IconCreditCard,
IconPlusCircle,
IconSave,
} from '@douyinfe/semi-icons';
import { Trash2, Clock } from 'lucide-react';
import { Trash2, Clock, Boxes } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@@ -88,6 +89,11 @@ const AddEditSubscriptionModal = ({
});
const [items, setItems] = useState([]);
// Model benefits UX
const [pendingModels, setPendingModels] = useState([]);
const [defaultNewAmountTotal, setDefaultNewAmountTotal] = useState(0);
const [bulkAmountTotal, setBulkAmountTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const buildFormValues = () => {
const base = getInitValues();
@@ -143,6 +149,79 @@ const AddEditSubscriptionModal = ({
]);
};
const addPendingModels = () => {
const selected = (pendingModels || []).filter(Boolean);
if (selected.length === 0) {
showError(t('请选择要添加的模型'));
return;
}
const existing = new Set((items || []).map((it) => it.model_name));
const toAdd = selected.filter((name) => !existing.has(name));
if (toAdd.length === 0) {
showError(t('所选模型已全部存在'));
return;
}
const defaultAmount = Number(defaultNewAmountTotal || 0);
const next = [...items];
toAdd.forEach((modelName) => {
const modelMeta = modelOptions.find((m) => m.value === modelName);
if (!modelMeta) return;
next.push({
model_name: modelName,
quota_type: modelMeta.quota_type,
amount_total: Number.isFinite(defaultAmount) && defaultAmount >= 0 ? defaultAmount : 0,
});
});
setItems(next);
setPendingModels([]);
showSuccess(t('已添加'));
};
const applyBulkAmountTotal = ({ scope }) => {
const n = Number(bulkAmountTotal || 0);
if (!Number.isFinite(n) || n < 0) {
showError(t('请输入有效的数量'));
return;
}
if (!items || items.length === 0) {
showError(t('请先添加模型权益'));
return;
}
if (scope === 'selected') {
if (!selectedRowKeys || selectedRowKeys.length === 0) {
showError(t('请先勾选要批量设置的权益'));
return;
}
const keySet = new Set(selectedRowKeys);
setItems(
items.map((it) => {
const k = `${it.model_name}-${it.quota_type}`;
if (!keySet.has(k)) return it;
return { ...it, amount_total: n };
}),
);
showSuccess(t('已对选中项批量设置'));
return;
}
// scope === 'all'
setItems(items.map((it) => ({ ...it, amount_total: n })));
showSuccess(t('已对全部批量设置'));
};
const deleteSelectedItems = () => {
if (!selectedRowKeys || selectedRowKeys.length === 0) {
showError(t('请先勾选要删除的权益'));
return;
}
const keySet = new Set(selectedRowKeys);
const next = (items || []).filter((it) => !keySet.has(`${it.model_name}-${it.quota_type}`));
setItems(next);
setSelectedRowKeys([]);
showSuccess(t('已删除选中项'));
};
const updateItem = (idx, patch) => {
const next = [...items];
next[idx] = { ...next[idx], ...patch };
@@ -496,32 +575,108 @@ const AddEditSubscriptionModal = ({
{/* 模型权益 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-3'>
<div>
<Text className='text-lg font-medium'>
{t('模型权益')}
</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐可使用的模型及额度')}
<div className='flex items-center justify-between mb-3 gap-3'>
<div className='flex items-center'>
<Avatar
size='small'
color='orange'
className='mr-2 shadow-md'
>
<Boxes size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('模型权益')}</Text>
<div className='text-xs text-gray-600'>
{t('配置套餐可使用的模型及额度')}
</div>
</div>
</div>
<Select
placeholder={t('添加模型')}
style={{ width: 280 }}
filter
onChange={addItem}
>
{modelOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
</div>
{/* 工具栏:最少步骤完成“添加 + 批量设置” */}
<div className='flex flex-col gap-2 mb-3'>
<div className='flex flex-col md:flex-row gap-2 md:items-center'>
<Select
placeholder={t('选择模型(可多选)')}
multiple
filter
value={pendingModels}
onChange={setPendingModels}
style={{ width: '100%', flex: 1 }}
>
{modelOptions.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
<InputNumber
value={Number(defaultNewAmountTotal || 0)}
min={0}
precision={0}
onChange={(v) => setDefaultNewAmountTotal(v)}
style={{ width: isMobile ? '100%' : 180 }}
placeholder={t('默认数量')}
/>
<Button
theme='solid'
type='primary'
icon={<IconPlusCircle />}
onClick={addPendingModels}
>
{t('添加')}
</Button>
</div>
<div className='flex flex-col md:flex-row gap-2 md:items-center md:justify-between'>
<div className='flex items-center gap-2'>
<Tag color='white' shape='circle'>
{t('已选')} {selectedRowKeys?.length || 0}
</Tag>
<InputNumber
value={Number(bulkAmountTotal || 0)}
min={0}
precision={0}
onChange={(v) => setBulkAmountTotal(v)}
style={{ width: isMobile ? '100%' : 220 }}
placeholder={t('统一设置数量')}
/>
</div>
<div className='flex items-center gap-2 justify-end'>
<Button
theme='light'
type='primary'
onClick={() => applyBulkAmountTotal({ scope: 'selected' })}
>
{t('应用到选中')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => applyBulkAmountTotal({ scope: 'all' })}
>
{t('应用到全部')}
</Button>
<Button
theme='light'
type='danger'
icon={<Trash2 size={14} />}
onClick={deleteSelectedItems}
>
{t('删除选中')}
</Button>
</div>
</div>
</div>
<Table
columns={itemColumns}
dataSource={items}
pagination={false}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys || []),
}}
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
empty={
<div className='py-6 text-center text-gray-500'>

View File

@@ -27,10 +27,14 @@ export const useSubscriptionsData = () => {
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
// State management
const [plans, setPlans] = useState([]);
const [allPlans, setAllPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [pricingModels, setPricingModels] = useState([]);
// Pagination (client-side for now)
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// Drawer states
const [showEdit, setShowEdit] = useState(false);
const [editingPlan, setEditingPlan] = useState(null);
@@ -54,7 +58,12 @@ export const useSubscriptionsData = () => {
try {
const res = await API.get('/api/subscription/admin/plans');
if (res.data?.success) {
setPlans(res.data.data || []);
const next = res.data.data || [];
setAllPlans(next);
// Keep page in range after data changes
const totalPages = Math.max(1, Math.ceil(next.length / pageSize));
setActivePage((p) => Math.min(p || 1, totalPages));
} else {
showError(res.data?.message || t('加载失败'));
}
@@ -70,6 +79,15 @@ export const useSubscriptionsData = () => {
await loadPlans();
};
const handlePageChange = (page) => {
setActivePage(page);
};
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
};
// Disable plan
const disablePlan = async (planId) => {
if (!planId) return;
@@ -113,9 +131,16 @@ export const useSubscriptionsData = () => {
loadPlans();
}, []);
const planCount = allPlans.length;
const plans = allPlans.slice(
Math.max(0, (activePage - 1) * pageSize),
Math.max(0, (activePage - 1) * pageSize) + pageSize,
);
return {
// Data state
plans,
planCount,
loading,
pricingModels,
@@ -130,6 +155,12 @@ export const useSubscriptionsData = () => {
compactMode,
setCompactMode,
// Pagination
activePage,
pageSize,
handlePageChange,
handlePageSizeChange,
// Actions
loadPlans,
disablePlan,