mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 10:46:37 +08:00
Restore removed features from PR #322
- Add back BatchOperationFailure type and batch failure handling functions - Add back dismissNodeExpiryReminder API endpoint - Add back update channel selection UI in config page - Keep simplified version display in version-footer.tsx
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# 恢复 PR #322 移除的功能
|
||||||
|
|
||||||
|
**目标**: 修改 PR #322,保留被移除的功能,同时保留版本显示简化改动。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能,用户要求**加回**这些被移除的功能:
|
||||||
|
1. 批量操作失败详情弹窗(`BatchOperationFailure` 类型及相关处理)
|
||||||
|
2. 节点到期提醒关闭功能(`dismissNodeExpiryReminder` API)
|
||||||
|
3. 更新通道选择功能(稳定版/开发版切换)
|
||||||
|
|
||||||
|
用户要求**保留**的改动:
|
||||||
|
- 版本显示简化(移除 "v" 前缀和更新可用徽章)
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [x] 检出 PR #322 到本地分支 `pr-322`
|
||||||
|
- [x] 恢复 `api/types.ts` 中的 `expiryReminderDismissed` 字段
|
||||||
|
- [x] 恢复 `api/types.ts` 中的 `BatchOperationFailure` 类型和 `failures` 字段
|
||||||
|
- [x] 恢复 `api/error-message.ts` 中的批量操作失败处理函数
|
||||||
|
- [x] 恢复 `api/index.ts` 中的 `dismissNodeExpiryReminder` API
|
||||||
|
- [x] 恢复 `config.tsx` 中的更新通道选择功能
|
||||||
|
- [x] 运行 lint 验证
|
||||||
|
- [ ] 提交并推送修改
|
||||||
|
|
||||||
|
## 修改的文件
|
||||||
|
|
||||||
|
- `vite-frontend/src/api/types.ts` - 添加 `expiryReminderDismissed` 和 `BatchOperationFailure`
|
||||||
|
- `vite-frontend/src/api/error-message.ts` - 添加批量操作失败处理函数
|
||||||
|
- `vite-frontend/src/api/index.ts` - 添加 `dismissNodeExpiryReminder` API
|
||||||
|
- `vite-frontend/src/pages/config.tsx` - 添加更新通道选择功能
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `version-footer.tsx` 保持简化版本显示(不恢复)
|
||||||
|
- `batch-action-result-modal.tsx` 未被 PR 修改,无需恢复
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BatchOperationFailure } from "@/api/types";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
interface ErrorPayload {
|
interface ErrorPayload {
|
||||||
@@ -5,6 +7,20 @@ interface ErrorPayload {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchFailurePayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
reason?: string;
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchResultPayload {
|
||||||
|
failures?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BATCH_FAILURES_IN_TOAST = 3;
|
||||||
|
|
||||||
export const isUnauthorizedError = (error: unknown): boolean => {
|
export const isUnauthorizedError = (error: unknown): boolean => {
|
||||||
return axios.isAxiosError(error) && error.response?.status === 401;
|
return axios.isAxiosError(error) && error.response?.status === 401;
|
||||||
};
|
};
|
||||||
@@ -25,3 +41,95 @@ export const extractApiErrorMessage = (
|
|||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeBatchFailure = (
|
||||||
|
failure: unknown,
|
||||||
|
): BatchOperationFailure | null => {
|
||||||
|
if (typeof failure === "string") {
|
||||||
|
const reason = failure.trim();
|
||||||
|
|
||||||
|
return reason ? { reason } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (failure ?? {}) as BatchFailurePayload;
|
||||||
|
const id =
|
||||||
|
typeof payload.id === "number" && Number.isFinite(payload.id)
|
||||||
|
? payload.id
|
||||||
|
: undefined;
|
||||||
|
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
||||||
|
const reasonSource = [payload.reason, payload.msg, payload.message].find(
|
||||||
|
(item) => typeof item === "string" && item.trim() !== "",
|
||||||
|
);
|
||||||
|
const reason = typeof reasonSource === "string" ? reasonSource.trim() : "";
|
||||||
|
|
||||||
|
if (!name && !reason && id === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(id !== undefined ? { id } : {}),
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBatchFailureReason = (
|
||||||
|
failure: BatchOperationFailure,
|
||||||
|
): string => {
|
||||||
|
const name = typeof failure.name === "string" ? failure.name.trim() : "";
|
||||||
|
const reason =
|
||||||
|
typeof failure.reason === "string" ? failure.reason.trim() : "";
|
||||||
|
|
||||||
|
if (name && reason) {
|
||||||
|
return `${name}: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
|
||||||
|
return `ID ${failure.id}: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
|
||||||
|
return `ID ${failure.id} 下发失败`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractBatchFailures = (
|
||||||
|
result: unknown,
|
||||||
|
): BatchOperationFailure[] => {
|
||||||
|
const payload = (result ?? {}) as BatchResultPayload;
|
||||||
|
|
||||||
|
return Array.isArray(payload.failures)
|
||||||
|
? payload.failures
|
||||||
|
.map((item) => normalizeBatchFailure(item))
|
||||||
|
.filter((item): item is BatchOperationFailure => item !== null)
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildBatchFailureMessage = (
|
||||||
|
result: unknown,
|
||||||
|
fallbackSummary: string,
|
||||||
|
): string => {
|
||||||
|
const failures = extractBatchFailures(result)
|
||||||
|
.map((item) => normalizeBatchFailureReason(item))
|
||||||
|
.filter((item) => item !== "");
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
return fallbackSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST);
|
||||||
|
const hiddenCount = failures.length - visibleFailures.length;
|
||||||
|
const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : "";
|
||||||
|
|
||||||
|
return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const getNodeInstallCommand = (
|
|||||||
export const updateNodeOrder = (data: {
|
export const updateNodeOrder = (data: {
|
||||||
nodes: Array<{ id: number; inx: number }>;
|
nodes: Array<{ id: number; inx: number }>;
|
||||||
}) => Network.post("/node/update-order", data);
|
}) => Network.post("/node/update-order", data);
|
||||||
|
export const dismissNodeExpiryReminder = (id: number) =>
|
||||||
|
Network.post("/node/dismiss-expiry-reminder", { id });
|
||||||
export const checkNodeStatus = (nodeId?: number) => {
|
export const checkNodeStatus = (nodeId?: number) => {
|
||||||
const params = nodeId ? { nodeId } : {};
|
const params = nodeId ? { nodeId } : {};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface NodeApiItem {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
renewalCycle?: "month" | "quarter" | "year" | "";
|
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||||
|
expiryReminderDismissed?: number;
|
||||||
syncError?: string;
|
syncError?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -210,6 +211,14 @@ export interface UserPackageInfoApiData {
|
|||||||
export interface BatchOperationResult {
|
export interface BatchOperationResult {
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
|
failures?: BatchOperationFailure[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOperationFailure {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
reason?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,14 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||||
|
setUpdateChannel(channel);
|
||||||
|
setUpdateReleaseChannel(channel);
|
||||||
|
toast.success(
|
||||||
|
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfigChange = (key: string, value: string) => {
|
const handleConfigChange = (key: string, value: string) => {
|
||||||
const newConfigs = { ...configs, [key]: value };
|
const newConfigs = { ...configs, [key]: value };
|
||||||
|
|
||||||
@@ -923,6 +931,40 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
<Divider className="my-2" />
|
<Divider className="my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
更新通道
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
|
||||||
|
的版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
selectedKeys={[updateChannel]}
|
||||||
|
size="md"
|
||||||
|
variant="bordered"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected =
|
||||||
|
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||||
|
|
||||||
|
handleUpdateChannelChange(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
|
||||||
|
稳定版
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
key="dev"
|
||||||
|
description="仅 alpha / beta / rc 关键字版本"
|
||||||
|
>
|
||||||
|
开发版
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
|
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -1093,7 +1135,14 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
|
}}
|
||||||
|
isOpen={exportSelectorOpen}
|
||||||
|
onOpenChange={setExportSelectorOpen}
|
||||||
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
@@ -1118,7 +1167,14 @@ export default function ConfigPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }} isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
|
}}
|
||||||
|
isOpen={importSelectorOpen}
|
||||||
|
onOpenChange={setImportSelectorOpen}
|
||||||
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -226,8 +226,10 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
|||||||
typeof item.expiryTime === "number" && item.expiryTime > 0
|
typeof item.expiryTime === "number" && item.expiryTime > 0
|
||||||
? item.expiryTime
|
? item.expiryTime
|
||||||
: undefined,
|
: undefined,
|
||||||
|
expiryReminderDismissed: item.expiryReminderDismissed,
|
||||||
}))
|
}))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
|
if (item.expiryReminderDismissed) return false;
|
||||||
if (!item.expiryTime || !item.renewalCycle) return false;
|
if (!item.expiryTime || !item.renewalCycle) return false;
|
||||||
const snapshot = getNodeRenewalSnapshot(
|
const snapshot = getNodeRenewalSnapshot(
|
||||||
item.expiryTime,
|
item.expiryTime,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BatchOperationResult } from "@/api/types";
|
import type { BatchOperationFailure, BatchOperationResult } from "@/api/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
batchChangeTunnel,
|
batchChangeTunnel,
|
||||||
@@ -7,12 +7,19 @@ import {
|
|||||||
batchRedeployForwards,
|
batchRedeployForwards,
|
||||||
batchResumeForwards,
|
batchResumeForwards,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { extractApiErrorMessage } from "@/api/error-message";
|
import {
|
||||||
|
buildBatchFailureMessage,
|
||||||
|
extractBatchFailures,
|
||||||
|
extractApiErrorMessage,
|
||||||
|
} from "@/api/error-message";
|
||||||
|
|
||||||
export interface ForwardBatchActionOutcome {
|
export interface ForwardBatchActionOutcome {
|
||||||
toastVariant: "success" | "error";
|
toastVariant: "success" | "error";
|
||||||
toastMessage: string;
|
toastMessage: string;
|
||||||
shouldRefresh: boolean;
|
shouldRefresh: boolean;
|
||||||
|
resultTitle?: string;
|
||||||
|
resultSummary?: string;
|
||||||
|
failureDetails?: BatchOperationFailure[];
|
||||||
progressPercent?: number;
|
progressPercent?: number;
|
||||||
progressLabel?: string;
|
progressLabel?: string;
|
||||||
closeDeleteModal?: boolean;
|
closeDeleteModal?: boolean;
|
||||||
@@ -26,23 +33,41 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
|
|||||||
return {
|
return {
|
||||||
successCount: Number(raw.successCount ?? 0),
|
successCount: Number(raw.successCount ?? 0),
|
||||||
failCount: Number(raw.failCount ?? 0),
|
failCount: Number(raw.failCount ?? 0),
|
||||||
|
failures: extractBatchFailures(raw),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBatchToast = (
|
const buildBatchToast = (
|
||||||
result: BatchOperationResult,
|
result: BatchOperationResult,
|
||||||
successText: string,
|
successText: string,
|
||||||
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
|
resultTitle: string,
|
||||||
|
): Pick<
|
||||||
|
ForwardBatchActionOutcome,
|
||||||
|
| "toastVariant"
|
||||||
|
| "toastMessage"
|
||||||
|
| "resultTitle"
|
||||||
|
| "resultSummary"
|
||||||
|
| "failureDetails"
|
||||||
|
> => {
|
||||||
if (result.failCount === 0) {
|
if (result.failCount === 0) {
|
||||||
return {
|
return {
|
||||||
toastVariant: "success",
|
toastVariant: "success",
|
||||||
toastMessage: successText,
|
toastMessage: successText,
|
||||||
|
resultTitle,
|
||||||
|
resultSummary: successText,
|
||||||
|
failureDetails: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toastVariant: "error",
|
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 +88,11 @@ export const executeForwardBatchDelete = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功删除 ${summary.successCount} 项`,
|
||||||
|
"批量删除结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||||
@@ -105,6 +134,7 @@ export const executeForwardBatchToggleService = async (
|
|||||||
enable
|
enable
|
||||||
? `成功启用 ${summary.successCount} 项`
|
? `成功启用 ${summary.successCount} 项`
|
||||||
: `成功停用 ${summary.successCount} 项`,
|
: `成功停用 ${summary.successCount} 项`,
|
||||||
|
enable ? "批量启用结果" : "批量停用结果",
|
||||||
),
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
@@ -136,7 +166,11 @@ export const executeForwardBatchRedeploy = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功重新下发 ${summary.successCount} 项`,
|
||||||
|
"批量下发结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||||
@@ -171,7 +205,11 @@ export const executeForwardBatchChangeTunnel = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功换隧道 ${summary.successCount} 项`,
|
||||||
|
"批量换隧道结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||||
@@ -185,4 +223,4 @@ export const executeForwardBatchChangeTunnel = async (
|
|||||||
shouldRefresh: false,
|
shouldRefresh: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user