From a60783e99f5ce9e1fe20927c41964d241afd1b81 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 30 Jan 2026 16:24:51 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20streamline=20subscri?= =?UTF-8?q?ption=20plan=20benefits=20editor=20with=20bulk=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/table/subscriptions/index.jsx | 12 ++ .../modals/AddEditSubscriptionModal.jsx | 195 ++++++++++++++++-- .../subscriptions/useSubscriptionsData.jsx | 35 +++- 3 files changed, 220 insertions(+), 22 deletions(-) diff --git a/web/src/components/table/subscriptions/index.jsx b/web/src/components/table/subscriptions/index.jsx index 74df1a85b..cc4afe12b 100644 --- a/web/src/components/table/subscriptions/index.jsx +++ b/web/src/components/table/subscriptions/index.jsx @@ -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 = () => { /> } + paginationArea={createCardProPagination({ + currentPage: subscriptionsData.activePage, + pageSize: subscriptionsData.pageSize, + total: subscriptionsData.planCount, + onPageChange: subscriptionsData.handlePageChange, + onPageSizeChange: subscriptionsData.handlePageSizeChange, + isMobile, + t: subscriptionsData.t, + })} t={t} > diff --git a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx index 129012f18..6d2f8eb2a 100644 --- a/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx +++ b/web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx @@ -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 = ({ {/* 模型权益 */} -
-
- - {t('模型权益')} - -
- {t('配置套餐可使用的模型及额度')} +
+
+ + + +
+ {t('模型权益')} +
+ {t('配置套餐可使用的模型及额度')} +
-
+ + {/* 工具栏:最少步骤完成“添加 + 批量设置” */} +
+
+ + setDefaultNewAmountTotal(v)} + style={{ width: isMobile ? '100%' : 180 }} + placeholder={t('默认数量')} + /> + +
+ +
+
+ + {t('已选')} {selectedRowKeys?.length || 0} + + setBulkAmountTotal(v)} + style={{ width: isMobile ? '100%' : 220 }} + placeholder={t('统一设置数量')} + /> +
+
+ + + +
+
+
+ setSelectedRowKeys(keys || []), + }} rowKey={(row) => `${row.model_name}-${row.quota_type}`} empty={
diff --git a/web/src/hooks/subscriptions/useSubscriptionsData.jsx b/web/src/hooks/subscriptions/useSubscriptionsData.jsx index bf1094a45..2fc0703b1 100644 --- a/web/src/hooks/subscriptions/useSubscriptionsData.jsx +++ b/web/src/hooks/subscriptions/useSubscriptionsData.jsx @@ -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,