Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8258ee00b9 |
@@ -149,9 +149,6 @@ function PointScorePopover({ row }: { row: CouponDataRow }) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
}
|
||||
const score = `${row.point_success_count}/${row.point_total_count}`;
|
||||
const scoreWithRate = `${score}(${(
|
||||
row.point_success_count / row.point_total_count * 100
|
||||
).toFixed(1)}%)`;
|
||||
const scoreColor =
|
||||
row.point_success_count === row.point_total_count
|
||||
? STATUS_TAG.completed.color
|
||||
@@ -223,7 +220,7 @@ function PointScorePopover({ row }: { row: CouponDataRow }) {
|
||||
)}
|
||||
>
|
||||
<Button type="link" size="small" style={{ height: 'auto', padding: 0, color: scoreColor }}>
|
||||
{scoreWithRate}
|
||||
{score}
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
@@ -605,9 +602,9 @@ export default function CouponDataPage() {
|
||||
render: (v: number | null) => fmtSec(v),
|
||||
},
|
||||
{
|
||||
title: '单券成功率',
|
||||
title: '百分比',
|
||||
key: 'point_success_rate',
|
||||
width: 150,
|
||||
width: 110,
|
||||
render: (_: unknown, r: CouponDataRow) => <PointScorePopover row={r} />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1065,14 +1065,14 @@ export default function DashboardPage() {
|
||||
value={fmtInt(periodData?.coins.reward_video_coin_total)}
|
||||
delta={rewardVideoCoinRatio.value}
|
||||
deltaTone={rewardVideoCoinRatio.tone}
|
||||
hint="普通看视频与历史签到膨胀的实发金币之和,不再重复计入领券奖励或常规任务。"
|
||||
hint="独立统计看视频发放,不再重复计入领券奖励。"
|
||||
/>
|
||||
<StatCard
|
||||
title="常规任务金币"
|
||||
value={fmtInt(regularTaskCoinTotal)}
|
||||
delta={regularTaskCoinRatio.value}
|
||||
deltaTone={regularTaskCoinRatio.tone}
|
||||
hint="每日签到、通知/其他任务、上报更低价和反馈采纳的实发金币之和。"
|
||||
hint="每日签到、历史签到膨胀、通知/其他任务、上报更低价和反馈采纳的实发金币之和。"
|
||||
/>
|
||||
<StatCard
|
||||
title="本期提现金额"
|
||||
|
||||
@@ -21,12 +21,11 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { api } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatWallTime } from '@/lib/format';
|
||||
import { refreshReviewBadge } from '@/lib/reviewBadge';
|
||||
import { BULK_REVIEW_MAX, bulkFailureReasonText, failedReviewIds } from '@/lib/bulkAction';
|
||||
import type { BulkReviewResult } from '@/lib/bulkAction';
|
||||
import { runBulkAction } from '@/lib/bulkAction';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { Feedback, FeedbackSummary } from '@/lib/types';
|
||||
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
||||
@@ -260,10 +259,6 @@ export default function FeedbacksPage() {
|
||||
|
||||
const openBulkModal = (action: 'approve' | 'reject') => {
|
||||
if (!selectedFeedbacks.length) return;
|
||||
if (selectedFeedbacks.length > BULK_REVIEW_MAX) {
|
||||
message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条反馈`);
|
||||
return;
|
||||
}
|
||||
bulkForm.resetFields();
|
||||
setBulkAction(action);
|
||||
};
|
||||
@@ -274,40 +269,34 @@ export default function FeedbacksPage() {
|
||||
const targets = selectedFeedbacks;
|
||||
setBulkSubmitting(true);
|
||||
try {
|
||||
const { data } = await api.post<BulkReviewResult>(
|
||||
`/admin/api/feedbacks/bulk/${bulkAction}`,
|
||||
const result = await runBulkAction(targets, (item) =>
|
||||
bulkAction === 'approve'
|
||||
? {
|
||||
ids: targets.map((item) => item.id),
|
||||
? api.post(`/admin/api/feedbacks/${item.id}/approve`, {
|
||||
reward_coins: values.reward_coins,
|
||||
note: values.note?.trim() || null,
|
||||
reply: values.reply?.trim() || null,
|
||||
}
|
||||
: {
|
||||
ids: targets.map((item) => item.id),
|
||||
})
|
||||
: api.post(`/admin/api/feedbacks/${item.id}/reject`, {
|
||||
reason: values.reason.trim(),
|
||||
note: values.note?.trim() || null,
|
||||
reply: values.reply?.trim() || null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const failedIds = failedReviewIds(data);
|
||||
setSelectedRowKeys(failedIds);
|
||||
setSelectedRowKeys(result.failedIds);
|
||||
setBulkAction(null);
|
||||
bulkForm.resetFields();
|
||||
if (failedIds.length) reload();
|
||||
if (result.failedIds.length) reload();
|
||||
else onPageChange(1, pageSize);
|
||||
loadSummary();
|
||||
refreshReviewBadge('/feedbacks');
|
||||
const label = bulkAction === 'approve' ? '批量采纳' : '批量拒绝';
|
||||
if (failedIds.length) {
|
||||
if (result.failedIds.length) {
|
||||
message.warning(
|
||||
`${label}完成:成功 ${data.success} 条,失败 ${failedIds.length} 条;失败项已保留勾选${bulkFailureReasonText(data)}`,
|
||||
`${label}完成:成功 ${result.succeededIds.length} 条,失败 ${result.failedIds.length} 条;失败项已保留勾选`,
|
||||
);
|
||||
} else {
|
||||
message.success(`${label}完成:成功 ${data.success} 条`);
|
||||
message.success(`${label}完成:成功 ${result.succeededIds.length} 条`);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setBulkSubmitting(false);
|
||||
}
|
||||
@@ -493,14 +482,14 @@ export default function FeedbacksPage() {
|
||||
<Text>已选 <Text strong>{selectedFeedbacks.length}</Text> 条待审核反馈</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedFeedbacks.length || selectedFeedbacks.length > BULK_REVIEW_MAX}
|
||||
disabled={!selectedFeedbacks.length}
|
||||
onClick={() => openBulkModal('approve')}
|
||||
>
|
||||
批量采纳
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedFeedbacks.length || selectedFeedbacks.length > BULK_REVIEW_MAX}
|
||||
disabled={!selectedFeedbacks.length}
|
||||
onClick={() => openBulkModal('reject')}
|
||||
>
|
||||
批量拒绝
|
||||
@@ -519,13 +508,7 @@ export default function FeedbacksPage() {
|
||||
canReview
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
const ids = keys as number[];
|
||||
if (ids.length > BULK_REVIEW_MAX) {
|
||||
message.warning(`单次最多选择 ${BULK_REVIEW_MAX} 条待审核反馈`);
|
||||
}
|
||||
setSelectedRowKeys(ids.slice(0, BULK_REVIEW_MAX));
|
||||
},
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !isPending(record.status),
|
||||
name: `选择反馈 #${record.id}`,
|
||||
@@ -539,7 +522,6 @@ export default function FeedbacksPage() {
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (t) => `共 ${t} 条反馈`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
|
||||
@@ -23,8 +23,7 @@ import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { mediaUrl } from '@/lib/media';
|
||||
import { refreshReviewBadge } from '@/lib/reviewBadge';
|
||||
import { BULK_REVIEW_MAX, bulkFailureReasonText, failedReviewIds } from '@/lib/bulkAction';
|
||||
import type { BulkReviewResult } from '@/lib/bulkAction';
|
||||
import { runBulkAction } from '@/lib/bulkAction';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
||||
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
|
||||
@@ -198,45 +197,32 @@ export default function PriceReportsPage() {
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const reportBulkResult = (label: string, result: BulkReviewResult) => {
|
||||
const failedIds = failedReviewIds(result);
|
||||
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}完成:成功 ${result.success} 条,失败 ${failedIds.length} 条;失败项已保留勾选${bulkFailureReasonText(result)}`,
|
||||
);
|
||||
message.warning(`${label}完成:成功 ${succeeded} 条,失败 ${failedIds.length} 条;失败项已保留勾选`);
|
||||
} else {
|
||||
message.success(`${label}完成:成功 ${result.success} 条`);
|
||||
message.success(`${label}完成:成功 ${succeeded} 条`);
|
||||
}
|
||||
};
|
||||
|
||||
const bulkApprove = () => {
|
||||
const targets = selectedReports;
|
||||
if (!targets.length) return;
|
||||
if (targets.length > BULK_REVIEW_MAX) {
|
||||
message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条记录`);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `确认批量通过 ${targets.length} 条低价上报?`,
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
content: `通过后将向每位用户发放 1000 金币,共发放 ${targets.length * 1000} 金币。请确认已逐条核实截图。`,
|
||||
okText: '批量通过并发金币',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { data } = await api.post<BulkReviewResult>(
|
||||
'/admin/api/price-reports/bulk/approve',
|
||||
{ ids: targets.map((item) => item.id) },
|
||||
);
|
||||
reportBulkResult('批量通过', data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
throw e;
|
||||
}
|
||||
const result = await runBulkAction(targets, (item) =>
|
||||
api.post(`/admin/api/price-reports/${item.id}/approve`),
|
||||
);
|
||||
reportBulkResult('批量通过', result.succeededIds.length, result.failedIds);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -274,10 +260,6 @@ export default function PriceReportsPage() {
|
||||
const confirmReject = async () => {
|
||||
const targets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : [];
|
||||
if (!targets.length) return;
|
||||
if (targets.length > BULK_REVIEW_MAX) {
|
||||
message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条记录`);
|
||||
return;
|
||||
}
|
||||
const reason = rejectReason.trim();
|
||||
if (!reason) {
|
||||
message.warning('请填写拒绝理由');
|
||||
@@ -286,11 +268,10 @@ export default function PriceReportsPage() {
|
||||
setRejectSubmitting(true);
|
||||
try {
|
||||
if (bulkRejecting.length) {
|
||||
const { data } = await api.post<BulkReviewResult>(
|
||||
'/admin/api/price-reports/bulk/reject',
|
||||
{ ids: targets.map((item) => item.id), reason },
|
||||
const result = await runBulkAction(targets, (item) =>
|
||||
api.post(`/admin/api/price-reports/${item.id}/reject`, { reason }),
|
||||
);
|
||||
reportBulkResult('批量拒绝', data);
|
||||
reportBulkResult('批量拒绝', result.succeededIds.length, result.failedIds);
|
||||
} else {
|
||||
await api.post(`/admin/api/price-reports/${targets[0].id}/reject`, { reason });
|
||||
refreshReviewBadge('/price-reports');
|
||||
@@ -474,7 +455,7 @@ export default function PriceReportsPage() {
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={!selectedReports.length || selectedReports.length > BULK_REVIEW_MAX}
|
||||
disabled={!selectedReports.length}
|
||||
onClick={bulkApprove}
|
||||
>
|
||||
批量通过
|
||||
@@ -482,7 +463,7 @@ export default function PriceReportsPage() {
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={!selectedReports.length || selectedReports.length > BULK_REVIEW_MAX}
|
||||
disabled={!selectedReports.length}
|
||||
onClick={() => {
|
||||
setRejecting(null);
|
||||
setBulkRejecting(selectedReports);
|
||||
@@ -504,13 +485,7 @@ export default function PriceReportsPage() {
|
||||
canReview
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
const ids = keys as number[];
|
||||
if (ids.length > BULK_REVIEW_MAX) {
|
||||
message.warning(`单次最多选择 ${BULK_REVIEW_MAX} 条待审核记录`);
|
||||
}
|
||||
setSelectedRowKeys(ids.slice(0, BULK_REVIEW_MAX));
|
||||
},
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: record.status !== 'pending',
|
||||
name: `选择低价上报 #${record.id}`,
|
||||
@@ -525,7 +500,6 @@ export default function PriceReportsPage() {
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
|
||||
+26
-28
@@ -1,33 +1,31 @@
|
||||
export interface BulkReviewItemResult {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
status: string | null;
|
||||
error: string | null;
|
||||
export interface BulkActionResult {
|
||||
succeededIds: number[];
|
||||
failedIds: number[];
|
||||
}
|
||||
|
||||
export interface BulkReviewResult {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
items: BulkReviewItemResult[];
|
||||
}
|
||||
/**
|
||||
* Run per-record write endpoints with bounded concurrency.
|
||||
*
|
||||
* The review endpoints already own transaction, audit and idempotency semantics,
|
||||
* so the admin UI deliberately keeps using them instead of duplicating that logic.
|
||||
*/
|
||||
export async function runBulkAction<T extends { id: number }>(
|
||||
targets: T[],
|
||||
action: (target: T) => Promise<unknown>,
|
||||
concurrency = 5,
|
||||
): Promise<BulkActionResult> {
|
||||
const succeededIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
|
||||
export const BULK_REVIEW_MAX = 50;
|
||||
for (let index = 0; index < targets.length; index += concurrency) {
|
||||
const chunk = targets.slice(index, index + concurrency);
|
||||
const settled = await Promise.allSettled(chunk.map(action));
|
||||
settled.forEach((result, resultIndex) => {
|
||||
const id = chunk[resultIndex].id;
|
||||
if (result.status === 'fulfilled') succeededIds.push(id);
|
||||
else failedIds.push(id);
|
||||
});
|
||||
}
|
||||
|
||||
export function failedReviewIds(result: BulkReviewResult): number[] {
|
||||
return result.items.filter((item) => !item.ok).map((item) => item.id);
|
||||
}
|
||||
|
||||
export function bulkFailureReasonText(result: BulkReviewResult): string {
|
||||
const reasons = Array.from(
|
||||
new Set(
|
||||
result.items
|
||||
.filter((item) => !item.ok)
|
||||
.map((item) => item.error?.trim())
|
||||
.filter((reason): reason is string => Boolean(reason)),
|
||||
),
|
||||
);
|
||||
if (!reasons.length) return '';
|
||||
const visible = reasons.slice(0, 3);
|
||||
return `;原因:${visible.join(';')}${reasons.length > visible.length ? ';…' : ''}`;
|
||||
return { succeededIds, failedIds };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user