diff --git a/plans/039-restore-removed-features-pr322.md b/plans/039-restore-removed-features-pr322.md
new file mode 100644
index 0000000..ff336f1
--- /dev/null
+++ b/plans/039-restore-removed-features-pr322.md
@@ -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 修改,无需恢复
\ No newline at end of file
diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts
index 11d861f..2e6e5f8 100644
--- a/vite-frontend/src/api/error-message.ts
+++ b/vite-frontend/src/api/error-message.ts
@@ -1,3 +1,5 @@
+import type { BatchOperationFailure } from "@/api/types";
+
import axios from "axios";
interface ErrorPayload {
@@ -5,6 +7,20 @@ interface ErrorPayload {
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 => {
return axios.isAxiosError(error) && error.response?.status === 401;
};
@@ -25,3 +41,95 @@ export const extractApiErrorMessage = (
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}`;
+};
diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts
index cf968ea..3eeed4c 100644
--- a/vite-frontend/src/api/index.ts
+++ b/vite-frontend/src/api/index.ts
@@ -78,6 +78,8 @@ export const getNodeInstallCommand = (
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
+export const dismissNodeExpiryReminder = (id: number) =>
+ Network.post("/node/dismiss-expiry-reminder", { id });
export const checkNodeStatus = (nodeId?: number) => {
const params = nodeId ? { nodeId } : {};
diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts
index f8d1043..2021ae0 100644
--- a/vite-frontend/src/api/types.ts
+++ b/vite-frontend/src/api/types.ts
@@ -6,6 +6,7 @@ export interface NodeApiItem {
remark?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
+ expiryReminderDismissed?: number;
syncError?: string;
[key: string]: unknown;
}
@@ -210,6 +211,14 @@ export interface UserPackageInfoApiData {
export interface BatchOperationResult {
successCount: number;
failCount: number;
+ failures?: BatchOperationFailure[];
+ [key: string]: unknown;
+}
+
+export interface BatchOperationFailure {
+ id?: number;
+ name?: string;
+ reason?: string;
[key: string]: unknown;
}
diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx
index 617e27b..815c229 100644
--- a/vite-frontend/src/pages/config.tsx
+++ b/vite-frontend/src/pages/config.tsx
@@ -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 newConfigs = { ...configs, [key]: value };
@@ -923,6 +931,40 @@ export default function ConfigPage() {
+ 更新通道 +
++ 稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc + 的版本。 +
+