diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 220a100e..e34dfbdf 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -13,7 +13,7 @@ import { assignChannelToOrderItems, deleteChannelRule, deleteChannelRules, - listChannelRules, + listChannelRulesPage, listPendingChannelGroups, switchChannelForOrderItems, unlockChannel, @@ -179,8 +179,10 @@ router.get( requireAdminPermission('finance.view'), createJsonHandler( (req) => - listChannelRules({ + listChannelRulesPage({ channel: String(req.query.channel || ''), + page: Number(req.query.page), + pageSize: Number(req.query.pageSize), ...(req.query.enabled === undefined ? {} : { enabled: String(req.query.enabled) !== 'false' }), diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 0df16aa4..eae8b531 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -63,6 +63,13 @@ export type ChannelRule = { enabled: boolean } +export type ChannelRulePage = { + items: ChannelRule[] + total: number + page: number + pageSize: number +} + export type ChannelCandidateItem = { id: number provider: string @@ -1033,12 +1040,12 @@ export async function assignChannelBySku(input: { }) } -export async function listChannelRules( - input: { - channel?: string - enabled?: boolean - } = {}, -): Promise { +type ChannelRuleFilterInput = { + channel?: string + enabled?: boolean +} + +function buildChannelRuleFilter(input: ChannelRuleFilterInput) { const params: unknown[] = [] const clauses: string[] = [] if (input.channel) { @@ -1050,25 +1057,70 @@ export async function listChannelRules( clauses.push(`enabled = $${params.length}`) } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' - const rows = await query( - ` - SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled - FROM ( - SELECT id, ''::text AS provider, ''::text AS shop_id, - 'internal_sku'::text AS match_key_type, internal_sku AS match_key, - channel, priority, enabled - FROM internal_sku_channels - ) internal_rules - ${where} - ORDER BY priority, channel, match_key - `, - params, - ) - return rows.rows.map((row) => ({ + return { where, params } +} + +const CHANNEL_RULE_FROM_SQL = ` + FROM ( + SELECT id, ''::text AS provider, ''::text AS shop_id, + 'internal_sku'::text AS match_key_type, internal_sku AS match_key, + channel, priority, enabled + FROM internal_sku_channels + ) internal_rules +` + +function mapChannelRule(row: ChannelRule): ChannelRule { + return { ...row, id: Number(row.id), priority: Number(row.priority), - })) + } +} + +export async function listChannelRules(input: ChannelRuleFilterInput = {}): Promise { + const { where, params } = buildChannelRuleFilter(input) + const rows = await query( + ` + SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + ${CHANNEL_RULE_FROM_SQL} + ${where} + ORDER BY priority, channel, match_key, id + `, + params, + ) + return rows.rows.map(mapChannelRule) +} + +export async function listChannelRulesPage( + input: ChannelRuleFilterInput & { page?: number; pageSize?: number } = {}, +): Promise { + const page = Math.max(1, Math.trunc(Number(input.page) || 1)) + const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20))) + const offset = (page - 1) * pageSize + const { where, params } = buildChannelRuleFilter(input) + const pageParams = [...params, pageSize, offset] + const [countResult, rows] = await Promise.all([ + query<{ total: number }>( + `SELECT COUNT(*)::int AS total ${CHANNEL_RULE_FROM_SQL} ${where}`, + params, + ), + query( + ` + SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + ${CHANNEL_RULE_FROM_SQL} + ${where} + ORDER BY priority, channel, match_key, id + LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} + `, + pageParams, + ), + ]) + return { + items: rows.rows.map(mapChannelRule), + total: Number(countResult.rows[0]?.total || 0), + page, + pageSize, + } } export async function upsertChannelRule(input: { diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 8db7760f..b32dae6b 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -23,6 +23,7 @@ test( deleteChannelRules, ensureVoucherRuleForSku, listChannelRules, + listChannelRulesPage, switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') @@ -171,6 +172,16 @@ test( assert.ok( listedRules.some((rule) => rule.id === savedRule.id && rule.match_key === voucherSku), ) + const firstRulePage = await listChannelRulesPage({ + channel: 'third_party_voucher', + enabled: true, + page: 1, + pageSize: 2, + }) + assert.equal(firstRulePage.page, 1) + assert.equal(firstRulePage.pageSize, 2) + assert.ok(firstRulePage.items.length <= 2) + assert.equal(firstRulePage.total, listedRules.length) const deletedRules = await deleteChannelRules([savedRule.id]) assert.equal(deletedRules.deletedCount, 1) assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) diff --git a/apps/frontend/src/components/common/PercentTable.tsx b/apps/frontend/src/components/common/PercentTable.tsx index 48c088c3..46c56fa9 100644 --- a/apps/frontend/src/components/common/PercentTable.tsx +++ b/apps/frontend/src/components/common/PercentTable.tsx @@ -1,5 +1,6 @@ import { Table as AntTable } from 'antd' -import type { TableProps } from 'antd' +import type { TablePaginationConfig, TableProps } from 'antd' +import { useEffect, useState } from 'react' import { normalizePercentColumns } from '@/utils/percent-table-layout' @@ -12,7 +13,56 @@ import { normalizePercentColumns } from '@/utils/percent-table-layout' export default function PercentTable>( props: TableProps, ) { - const { columns, scroll, tableLayout: _tableLayout, ...rest } = props + const { columns, scroll, tableLayout: _tableLayout, pagination, dataSource, ...rest } = props + const paginationConfig = pagination && typeof pagination === 'object' ? pagination : null + const paginationIsExternallyControlled = paginationConfig?.current !== undefined + // 本地分页也必须由组件持有 current;只交给 AntD 内部状态时,父级重渲染会丢失翻页。 + const [localPagination, setLocalPagination] = useState(() => ({ + current: 1, + pageSize: Number(paginationConfig?.defaultPageSize || paginationConfig?.pageSize || 10), + })) + + useEffect(() => { + if (paginationIsExternallyControlled || !paginationConfig?.pageSize) return + const nextPageSize = Number(paginationConfig.pageSize) + if (!Number.isFinite(nextPageSize) || nextPageSize <= 0) return + setLocalPagination((current) => + current.pageSize === nextPageSize ? current : { current: 1, pageSize: nextPageSize }, + ) + }, [paginationConfig?.pageSize, paginationIsExternallyControlled]) + + useEffect(() => { + if (paginationIsExternallyControlled || !paginationConfig) return + const total = Math.max(0, Number(paginationConfig.total ?? dataSource?.length ?? 0)) + const maxPage = Math.max(1, Math.ceil(total / Math.max(1, localPagination.pageSize))) + if (localPagination.current > maxPage) { + setLocalPagination((current) => ({ ...current, current: maxPage })) + } + }, [ + dataSource?.length, + localPagination.current, + localPagination.pageSize, + paginationConfig, + paginationIsExternallyControlled, + ]) + + const resolvedPagination: TablePaginationConfig | false | undefined = paginationConfig + ? paginationIsExternallyControlled + ? paginationConfig + : { + ...paginationConfig, + current: localPagination.current, + pageSize: localPagination.pageSize, + onChange: (nextPage, nextPageSize) => { + const normalizedPageSize = Math.max(1, Number(nextPageSize) || localPagination.pageSize) + setLocalPagination({ + current: normalizedPageSize === localPagination.pageSize ? nextPage : 1, + pageSize: normalizedPageSize, + }) + paginationConfig.onChange?.(nextPage, nextPageSize) + }, + } + : pagination const reservedPercent = (props.rowSelection ? 4 : 0) + (props.expandable && typeof props.expandable === 'object' ? 4 : 0) @@ -24,7 +74,9 @@ export default function PercentTable {...rest} + dataSource={dataSource} columns={normalizedColumns} + pagination={resolvedPagination} tableLayout="fixed" scroll={{ ...scrollConfig, x: '100%' }} /> diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index eff0fda5..02ac192d 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -29,7 +29,7 @@ import { message, } from 'antd' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' @@ -48,11 +48,7 @@ import { } from '@/services/admin' import { formatRedeemAmount } from '@/utils/redeem' import { formatDateTime } from '@/utils/date-time' -import { - ADMIN_DEFAULT_PAGE_SIZE, - buildAdminLocalTablePagination, - buildAdminTablePagination, -} from '@/utils/admin-pagination' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' const CHANNEL_OPTIONS = [ { value: 'work_order', label: '接单平台' }, @@ -251,12 +247,20 @@ function RulesPanel() { const queryClient = useQueryClient() const [open, setOpen] = useState(false) const [selectedRuleIds, setSelectedRuleIds] = useState([]) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [form] = Form.useForm() const query = useQuery({ - queryKey: ['fulfillment-rules'], - queryFn: () => fetchChannelRules(), + queryKey: ['fulfillment-rules', page, pageSize], + queryFn: () => fetchChannelRules({ page, pageSize }), }) + const ruleCount = query.data?.data?.total || 0 + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(ruleCount / pageSize)) + if (page > maxPage) setPage(maxPage) + }, [page, pageSize, ruleCount]) const saveMutation = useMutation({ mutationFn: (values: Record) => saveChannelRule(values as never), @@ -339,7 +343,7 @@ function RulesPanel() { return (