mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 03:06:37 +08:00
feat(batch): add failure details to batch operations with expandable result modal
- Backend: return per-item failure details (id, name, reason) for all batch operations - Frontend: add BatchActionResultModal component to display failures - Support delete/pause/resume/redeploy/change-tunnel for forwards and tunnels
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
|
||||
import type {
|
||||
BatchOperationFailure,
|
||||
ForwardApiItem,
|
||||
SpeedLimitApiItem,
|
||||
} from "@/api/types";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -24,6 +28,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -175,6 +180,20 @@ interface BatchProgressState {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface BatchResultModalState {
|
||||
failures: BatchOperationFailure[];
|
||||
open: boolean;
|
||||
summary: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||
failures: [],
|
||||
open: false,
|
||||
summary: "",
|
||||
title: "",
|
||||
};
|
||||
|
||||
type ForwardGroupOrderMap = Record<string, string[]>;
|
||||
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
||||
|
||||
@@ -672,6 +691,8 @@ export default function ForwardPage() {
|
||||
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [batchResultModal, setBatchResultModal] =
|
||||
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
@@ -2440,6 +2461,36 @@ export default function ForwardPage() {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const presentBatchOutcome = useCallback(
|
||||
(outcome: {
|
||||
failureDetails?: BatchOperationFailure[];
|
||||
resultSummary?: string;
|
||||
resultTitle?: string;
|
||||
toastMessage: string;
|
||||
toastVariant: "success" | "error";
|
||||
}) => {
|
||||
const failureDetails = outcome.failureDetails || [];
|
||||
|
||||
if (failureDetails.length > 0) {
|
||||
setBatchResultModal({
|
||||
failures: failureDetails,
|
||||
open: true,
|
||||
summary: outcome.resultSummary || outcome.toastMessage,
|
||||
title: outcome.resultTitle || "批量操作结果",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome.toastVariant === "success") {
|
||||
toast.success(outcome.toastMessage);
|
||||
} else {
|
||||
toast.error(outcome.toastMessage);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
@@ -2451,11 +2502,7 @@ export default function ForwardPage() {
|
||||
try {
|
||||
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
|
||||
|
||||
if (outcome.toastVariant === "success") {
|
||||
toast.success(outcome.toastMessage);
|
||||
} else {
|
||||
toast.error(outcome.toastMessage);
|
||||
}
|
||||
presentBatchOutcome(outcome);
|
||||
|
||||
if (outcome.shouldRefresh) {
|
||||
setBatchProgress({
|
||||
@@ -2490,11 +2537,7 @@ export default function ForwardPage() {
|
||||
enable,
|
||||
);
|
||||
|
||||
if (outcome.toastVariant === "success") {
|
||||
toast.success(outcome.toastMessage);
|
||||
} else {
|
||||
toast.error(outcome.toastMessage);
|
||||
}
|
||||
presentBatchOutcome(outcome);
|
||||
|
||||
if (outcome.shouldRefresh) {
|
||||
setBatchProgress({
|
||||
@@ -2525,11 +2568,7 @@ export default function ForwardPage() {
|
||||
Array.from(selectedIds),
|
||||
);
|
||||
|
||||
if (outcome.toastVariant === "success") {
|
||||
toast.success(outcome.toastMessage);
|
||||
} else {
|
||||
toast.error(outcome.toastMessage);
|
||||
}
|
||||
presentBatchOutcome(outcome);
|
||||
|
||||
if (outcome.shouldRefresh) {
|
||||
setBatchProgress({
|
||||
@@ -2561,11 +2600,7 @@ export default function ForwardPage() {
|
||||
batchTargetTunnelId,
|
||||
);
|
||||
|
||||
if (outcome.toastVariant === "success") {
|
||||
toast.success(outcome.toastMessage);
|
||||
} else {
|
||||
toast.error(outcome.toastMessage);
|
||||
}
|
||||
presentBatchOutcome(outcome);
|
||||
|
||||
if (outcome.shouldRefresh) {
|
||||
setBatchProgress({
|
||||
@@ -5848,6 +5883,22 @@ export default function ForwardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<BatchActionResultModal
|
||||
failures={batchResultModal.failures}
|
||||
isOpen={batchResultModal.open}
|
||||
summary={batchResultModal.summary}
|
||||
title={batchResultModal.title}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setBatchResultModal((prev) => ({ ...prev, open: true }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 筛选模态框 */}
|
||||
<Modal
|
||||
isOpen={isFilterModalOpen}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { BatchOperationResult } from "@/api/types";
|
||||
import type {
|
||||
BatchOperationFailure,
|
||||
BatchOperationResult,
|
||||
} from "@/api/types";
|
||||
|
||||
import {
|
||||
batchChangeTunnel,
|
||||
@@ -7,12 +10,19 @@ import {
|
||||
batchRedeployForwards,
|
||||
batchResumeForwards,
|
||||
} from "@/api";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
import {
|
||||
buildBatchFailureMessage,
|
||||
extractBatchFailures,
|
||||
extractApiErrorMessage,
|
||||
} from "@/api/error-message";
|
||||
|
||||
export interface ForwardBatchActionOutcome {
|
||||
toastVariant: "success" | "error";
|
||||
toastMessage: string;
|
||||
shouldRefresh: boolean;
|
||||
resultTitle?: string;
|
||||
resultSummary?: string;
|
||||
failureDetails?: BatchOperationFailure[];
|
||||
progressPercent?: number;
|
||||
progressLabel?: string;
|
||||
closeDeleteModal?: boolean;
|
||||
@@ -26,23 +36,37 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
|
||||
return {
|
||||
successCount: Number(raw.successCount ?? 0),
|
||||
failCount: Number(raw.failCount ?? 0),
|
||||
failures: extractBatchFailures(raw),
|
||||
};
|
||||
};
|
||||
|
||||
const buildBatchToast = (
|
||||
result: BatchOperationResult,
|
||||
successText: string,
|
||||
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
|
||||
resultTitle: string,
|
||||
): Pick<
|
||||
ForwardBatchActionOutcome,
|
||||
"toastVariant" | "toastMessage" | "resultTitle" | "resultSummary" | "failureDetails"
|
||||
> => {
|
||||
if (result.failCount === 0) {
|
||||
return {
|
||||
toastVariant: "success",
|
||||
toastMessage: successText,
|
||||
resultTitle,
|
||||
resultSummary: successText,
|
||||
failureDetails: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toastVariant: "error",
|
||||
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
toastMessage: buildBatchFailureMessage(
|
||||
result,
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
),
|
||||
resultTitle,
|
||||
resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
failureDetails: result.failures || [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,7 +87,11 @@ export const executeForwardBatchDelete = async (
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||
...buildBatchToast(
|
||||
summary,
|
||||
`成功删除 ${summary.successCount} 项`,
|
||||
"批量删除结果",
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||
@@ -105,6 +133,7 @@ export const executeForwardBatchToggleService = async (
|
||||
enable
|
||||
? `成功启用 ${summary.successCount} 项`
|
||||
: `成功停用 ${summary.successCount} 项`,
|
||||
enable ? "批量启用结果" : "批量停用结果",
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
@@ -136,7 +165,11 @@ export const executeForwardBatchRedeploy = async (
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||
...buildBatchToast(
|
||||
summary,
|
||||
`成功重新下发 ${summary.successCount} 项`,
|
||||
"批量下发结果",
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||
@@ -171,7 +204,11 @@ export const executeForwardBatchChangeTunnel = async (
|
||||
const summary = normalizeBatchResult(response.data);
|
||||
|
||||
return {
|
||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||
...buildBatchToast(
|
||||
summary,
|
||||
`成功换隧道 ${summary.successCount} 项`,
|
||||
"批量换隧道结果",
|
||||
),
|
||||
shouldRefresh: true,
|
||||
progressPercent: 100,
|
||||
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BatchOperationFailure } from "@/api/types";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -63,7 +66,11 @@ import {
|
||||
} from "@/pages/tunnel/form";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
import {
|
||||
buildBatchFailureMessage,
|
||||
extractBatchFailures,
|
||||
extractApiErrorMessage,
|
||||
} from "@/api/error-message";
|
||||
|
||||
interface ChainTunnel {
|
||||
nodeId: number;
|
||||
@@ -122,6 +129,20 @@ interface BatchProgressState {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface BatchResultModalState {
|
||||
failures: BatchOperationFailure[];
|
||||
open: boolean;
|
||||
summary: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||
failures: [],
|
||||
open: false,
|
||||
summary: "",
|
||||
title: "",
|
||||
};
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
@@ -226,6 +247,8 @@ export default function TunnelPage() {
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchResultModal, setBatchResultModal] =
|
||||
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
@@ -842,6 +865,18 @@ export default function TunnelPage() {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const openBatchResultModal = useCallback(
|
||||
(title: string, summary: string, failures: BatchOperationFailure[]) => {
|
||||
setBatchResultModal({
|
||||
failures,
|
||||
open: true,
|
||||
summary,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchLoading(true);
|
||||
@@ -874,9 +909,17 @@ export default function TunnelPage() {
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
);
|
||||
const failures = extractBatchFailures(result);
|
||||
|
||||
if (failures.length > 0) {
|
||||
openBatchResultModal(
|
||||
"批量删除结果",
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
failures,
|
||||
);
|
||||
} else {
|
||||
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
|
||||
}
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||
@@ -915,9 +958,22 @@ export default function TunnelPage() {
|
||||
if (result.failCount === 0) {
|
||||
toast.success(`成功重新下发 ${result.successCount} 项`);
|
||||
} else {
|
||||
toast.error(
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
);
|
||||
const failures = extractBatchFailures(result);
|
||||
|
||||
if (failures.length > 0) {
|
||||
openBatchResultModal(
|
||||
"批量下发结果",
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
failures,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
buildBatchFailureMessage(
|
||||
result,
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
@@ -3049,6 +3105,22 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<BatchActionResultModal
|
||||
failures={batchResultModal.failures}
|
||||
isOpen={batchResultModal.open}
|
||||
summary={batchResultModal.summary}
|
||||
title={batchResultModal.title}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setBatchResultModal((prev) => ({ ...prev, open: true }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||
}}
|
||||
/>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user