From aff250f9fd9d9daa502065ff2790f05f41c7447c Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Thu, 23 Jul 2026 17:55:47 +0800 Subject: [PATCH] feat(admin): support backend bulk reward reviews --- src/app/(main)/feedbacks/page.tsx | 188 ++++++++++++++++++++++++++ src/app/(main)/price-reports/page.tsx | 125 +++++++++++++++-- src/lib/bulkAction.ts | 17 +++ 3 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 src/lib/bulkAction.ts diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index f94ba01..dd0ba6e 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -4,11 +4,15 @@ import { useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import type { SorterResult } from 'antd/es/table/interface'; import { + App, Button, Card, DatePicker, + Form, Image, Input, + InputNumber, + Modal, Select, Space, Statistic, @@ -21,6 +25,8 @@ import { api } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatWallTime } from '@/lib/format'; import { refreshReviewBadge } from '@/lib/reviewBadge'; +import { failedReviewIds } from '@/lib/bulkAction'; +import type { BulkReviewResult } from '@/lib/bulkAction'; import { usePagedList } from '@/lib/usePagedList'; import type { Feedback, FeedbackSummary } from '@/lib/types'; import FeedbackHandleDrawer from './FeedbackHandleDrawer'; @@ -28,6 +34,7 @@ import UserRecordsDrawer from '@/components/UserRecordsDrawer'; const { Text, Paragraph } = Typography; const { RangePicker } = DatePicker; +const REWARD_MAX = 10000; // 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || ''; @@ -155,6 +162,8 @@ const RECORD_COLUMNS: ColumnsType = [ ]; export default function FeedbacksPage() { + const { message } = App.useApp(); + const [bulkForm] = Form.useForm(); // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); const [source, setSource] = useState(); @@ -171,6 +180,19 @@ export default function FeedbacksPage() { usePagedList('/admin/api/feedbacks', filters); const canReview = canDo(['operator']); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [bulkAction, setBulkAction] = useState<'approve' | 'reject' | null>(null); + const [bulkSubmitting, setBulkSubmitting] = useState(false); + const selectedFeedbacks = items.filter( + (item) => selectedRowKeys.includes(item.id) && isPending(item.status), + ); + + useEffect(() => { + const visiblePendingIds = new Set( + items.filter((item) => isPending(item.status)).map((item) => item.id), + ); + setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id))); + }, [items]); const [summary, setSummary] = useState(null); const loadSummary = async () => { @@ -236,6 +258,55 @@ export default function FeedbacksPage() { const sortOrderOf = (field: SortField) => sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + const openBulkModal = (action: 'approve' | 'reject') => { + if (!selectedFeedbacks.length) return; + bulkForm.resetFields(); + setBulkAction(action); + }; + + const submitBulkReview = async () => { + if (!bulkAction || !selectedFeedbacks.length) return; + const values = await bulkForm.validateFields(); + const targets = selectedFeedbacks; + setBulkSubmitting(true); + try { + const { data } = await api.post( + `/admin/api/feedbacks/bulk/${bulkAction}`, + bulkAction === 'approve' + ? { + ids: targets.map((item) => item.id), + reward_coins: values.reward_coins, + note: values.note?.trim() || null, + reply: values.reply?.trim() || null, + } + : { + ids: targets.map((item) => item.id), + reason: values.reason.trim(), + note: values.note?.trim() || null, + reply: values.reply?.trim() || null, + }, + ); + const failedIds = failedReviewIds(data); + setSelectedRowKeys(failedIds); + setBulkAction(null); + bulkForm.resetFields(); + if (failedIds.length) reload(); + else onPageChange(1, pageSize); + loadSummary(); + refreshReviewBadge('/feedbacks'); + const label = bulkAction === 'approve' ? '批量采纳' : '批量拒绝'; + if (failedIds.length) { + message.warning( + `${label}完成:成功 ${data.success} 条,失败 ${failedIds.length} 条;失败项已保留勾选`, + ); + } else { + message.success(`${label}完成:成功 ${data.success} 条`); + } + } finally { + setBulkSubmitting(false); + } + }; + const columns: ColumnsType = [ { title: '用户ID', @@ -411,10 +482,45 @@ export default function FeedbacksPage() { + {canReview && ( + + 已选 {selectedFeedbacks.length} 条待审核反馈 + + + {!!selectedFeedbacks.length && ( + + )} + + )} + setSelectedRowKeys(keys as number[]), + getCheckboxProps: (record) => ({ + disabled: !isPending(record.status), + name: `选择反馈 #${record.id}`, + }), + } + : undefined + } loading={loading} pagination={{ current: page, @@ -428,6 +534,88 @@ export default function FeedbacksPage() { scroll={{ x: 1720 }} /> + { + if (bulkSubmitting) return; + setBulkAction(null); + bulkForm.resetFields(); + }} + destroyOnHidden + > + + 下列设置会统一应用到已选的 {selectedFeedbacks.length} + 条反馈。批量操作前请确认每条内容适用相同处理结果。 + +
+ {bulkAction === 'approve' ? ( + + + + ) : ( + + + + )} + + + + + + + +
+ (null); const [rejecting, setRejecting] = useState(null); + const [bulkRejecting, setBulkRejecting] = useState([]); const [rejectReason, setRejectReason] = useState(''); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 提交时间列服务端排序(默认按提交时间倒序,最新在前) const [sortBy, setSortBy] = useState('created_at'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); @@ -165,6 +170,16 @@ export default function PriceReportsPage() { }; const canReview = canDo(['operator']); + const selectedReports = items.filter( + (item) => selectedRowKeys.includes(item.id) && item.status === 'pending', + ); + + useEffect(() => { + const visiblePendingIds = new Set( + items.filter((item) => item.status === 'pending').map((item) => item.id), + ); + setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id))); + }, [items]); const loadSummary = async () => { try { @@ -183,6 +198,37 @@ export default function PriceReportsPage() { loadSummary(); }; + const reportBulkResult = (label: string, succeeded: number, failedIds: number[]) => { + setSelectedRowKeys(failedIds); + refreshReviewBadge('/price-reports'); + loadSummary(); + if (failedIds.length) reload(); + else onPageChange(1, pageSize); + if (failedIds.length) { + message.warning(`${label}完成:成功 ${succeeded} 条,失败 ${failedIds.length} 条;失败项已保留勾选`); + } else { + message.success(`${label}完成:成功 ${succeeded} 条`); + } + }; + + const bulkApprove = () => { + const targets = selectedReports; + if (!targets.length) return; + modal.confirm({ + title: `确认批量通过 ${targets.length} 条低价上报?`, + icon: , + content: `通过后将向每位用户发放 1000 金币,共发放 ${targets.length * 1000} 金币。请确认已逐条核实截图。`, + okText: '批量通过并发金币', + onOk: async () => { + const { data } = await api.post( + '/admin/api/price-reports/bulk/approve', + { ids: targets.map((item) => item.id) }, + ); + reportBulkResult('批量通过', data.success, failedReviewIds(data)); + }, + }); + }; + const approve = (r: PriceReport) => { modal.confirm({ title: '确认通过该上报?', @@ -214,21 +260,34 @@ export default function PriceReportsPage() { }; const confirmReject = async () => { - if (!rejecting) return; + const targets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : []; + if (!targets.length) return; const reason = rejectReason.trim(); if (!reason) { message.warning('请填写拒绝理由'); return; } + setRejectSubmitting(true); try { - await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason }); - refreshReviewBadge('/price-reports'); - message.success('已拒绝'); + if (bulkRejecting.length) { + const { data } = await api.post( + '/admin/api/price-reports/bulk/reject', + { ids: targets.map((item) => item.id), reason }, + ); + reportBulkResult('批量拒绝', data.success, failedReviewIds(data)); + } else { + await api.post(`/admin/api/price-reports/${targets[0].id}/reject`, { reason }); + refreshReviewBadge('/price-reports'); + message.success('已拒绝'); + refreshAfterChange(); + } setRejecting(null); + setBulkRejecting([]); setRejectReason(''); - refreshAfterChange(); } catch (e) { message.error(errMsg(e)); + } finally { + setRejectSubmitting(false); } }; @@ -393,10 +452,50 @@ export default function PriceReportsPage() { onChange={setActiveStatus} items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))} /> + {canReview && ( + + 已选 {selectedReports.length} 条待审核记录 + + + {!!selectedReports.length && ( + + )} + + )}
setSelectedRowKeys(keys as number[]), + getCheckboxProps: (record) => ({ + disabled: record.status !== 'pending', + name: `选择低价上报 #${record.id}`, + }), + } + : undefined + } loading={loading} onChange={onTableChange} pagination={{ @@ -412,21 +511,25 @@ export default function PriceReportsPage() { }} onOk={confirmReject} onCancel={() => { setRejecting(null); + setBulkRejecting([]); setRejectReason(''); }} > - {rejecting && ( + {(rejecting || bulkRejecting.length > 0) && ( - - 用户 #{rejecting.user_id} · {rejecting.store_name || '-'} - + {bulkRejecting.length ? ( + 以下原因会统一应用到已选的 {bulkRejecting.length} 条记录。 + ) : rejecting ? ( + 用户 #{rejecting.user_id} · {rejecting.store_name || '-'} + ) : null} {REJECT_TEMPLATES.map((tpl) => (