From f56b5d83195bcc4afe6cd6fc10e0a8b605e7732b Mon Sep 17 00:00:00 2001 From: bbbugg Date: Sun, 8 Feb 2026 20:57:51 +0800 Subject: [PATCH 01/26] feat: add batch upload result messages for file uploads in StatusPage --- ui/app/pages/StatusPage.vue | 107 ++++++++++++++++++++++++++++-------- ui/locales/en.json | 4 ++ ui/locales/zh.json | 4 ++ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index ce3c5014..34eaab56 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -543,6 +543,7 @@ type="file" style="display: none" accept=".json" + multiple @change="handleFileUpload" />
@@ -1828,43 +1829,101 @@ const triggerFileUpload = () => { }; const handleFileUpload = async event => { - const file = event.target.files[0]; - if (!file) return; + const files = Array.from(event.target.files); + if (!files.length) return; - // Reset input so same file can be selected again + // Reset input so same files can be selected again event.target.value = ""; - const reader = new FileReader(); - reader.onload = async e => { - try { - const content = e.target.result; - // Validate JSON - JSON.parse(content); + // Helper function to read a single file + const readFile = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve({ content: e.target.result, name: file.name }); + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsText(file); + }); + // Helper function to upload a single file + const uploadFile = async fileData => { + try { + const parsed = JSON.parse(fileData.content); const res = await fetch("/api/files", { - body: JSON.stringify({ - content: JSON.parse(content), // Send as object to let backend stringify formatted - }), - headers: { - "Content-Type": "application/json", - }, + body: JSON.stringify({ content: parsed }), + headers: { "Content-Type": "application/json" }, method: "POST", }); - if (res.ok) { const data = await res.json(); - ElMessage.success(t("fileUploadSuccess") + ` (${data.filename || ""})`); - // Immediately update status to reflect new account - updateContent(); - } else { - const data = await res.json(); - ElMessage.error(t("fileUploadFailed", { error: data.error || "Unknown error" })); + return { filename: data.filename || fileData.name, success: true }; } + const data = await res.json(); + return { error: data.error || "Unknown error", filename: fileData.name, success: false }; } catch (err) { - ElMessage.error(t("fileUploadFailed", { error: "Invalid JSON file" })); + return { error: "Invalid JSON", filename: fileData.name, success: false }; } }; - reader.readAsText(file); + + // Upload all files (single or batch, same logic) + const successFiles = []; + const failedFiles = []; + + for (const file of files) { + try { + const fileData = await readFile(file); + const result = await uploadFile(fileData); + if (result.success) { + successFiles.push({ local: file.name, saved: result.filename }); + } else { + failedFiles.push({ local: file.name, reason: result.error }); + } + } catch (err) { + failedFiles.push({ local: file.name, reason: err.message || "Read failed" }); + } + } + + // Build notification message with file details + let messageHtml = ""; + + if (successFiles.length > 0) { + messageHtml += `
${t("fileUploadBatchSuccess")} (${successFiles.length}):
`; + messageHtml += '"; + } + + if (failedFiles.length > 0) { + messageHtml += `
${t("fileUploadBatchFailed")} (${failedFiles.length}):
`; + messageHtml += '"; + } + + // Determine notification type + let notifyType = "success"; + if (failedFiles.length > 0 && successFiles.length === 0) { + notifyType = "error"; + } else if (failedFiles.length > 0) { + notifyType = "warning"; + } + + // Show notification in top-right, no auto close + // Use different title for single vs batch upload + const notifyTitle = files.length === 1 ? t("fileUploadComplete") : t("fileUploadBatchResult"); + ElNotification({ + dangerouslyUseHTMLString: true, + duration: 0, + message: messageHtml, + position: "top-right", + title: notifyTitle, + type: notifyType, + }); + + updateContent(); }; // Download account by index diff --git a/ui/locales/en.json b/ui/locales/en.json index ee8d9b25..009635b1 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -95,6 +95,10 @@ "expand": "Expand", "fake": "Fake", "false": "Disabled", + "fileUploadBatchFailed": "Failed files", + "fileUploadBatchResult": "Batch upload complete", + "fileUploadBatchSuccess": "Uploaded successfully", + "fileUploadComplete": "Upload complete", "fileUploadFailed": "Upload failed: {error}", "fileUploadSuccess": "Upload successful", "followSystem": "Follow System", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 57aea47a..72672c96 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -95,6 +95,10 @@ "expand": "展开", "fake": "假", "false": "已禁用", + "fileUploadBatchFailed": "失败文件", + "fileUploadBatchResult": "批量上传完成", + "fileUploadBatchSuccess": "成功上传", + "fileUploadComplete": "上传完成", "fileUploadFailed": "上传失败:{error}", "fileUploadSuccess": "上传成功", "followSystem": "跟随系统", From 4ae9529a7578911c33f2193565de088d178770d8 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 9 Feb 2026 11:27:15 +0800 Subject: [PATCH 02/26] feat: support batch upload of JSON files from ZIP archives in StatusPage --- package.json | 1 + ui/app/pages/StatusPage.vue | 104 +++++++++++++++++++++++++++++------- ui/locales/en.json | 4 +- ui/locales/zh.json | 4 +- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 324a1484..536cff94 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-vue": "^9.33.0", "husky": "^8.0.3", + "jszip": "^3.10.1", "less": "^4.4.2", "less-plugin-clean-css": "^1.6.0", "lint-staged": "^14.0.0", diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 34eaab56..229208cf 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -542,7 +542,7 @@ ref="fileInput" type="file" style="display: none" - accept=".json" + accept=".json,.zip" multiple @change="handleFileUpload" /> @@ -1279,6 +1279,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watchEffect } from "vue"; import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox, ElNotification } from "element-plus"; +import JSZip from "jszip"; import I18n from "../utils/i18n"; import { useTheme } from "../utils/useTheme"; @@ -1835,8 +1836,17 @@ const handleFileUpload = async event => { // Reset input so same files can be selected again event.target.value = ""; - // Helper function to read a single file - const readFile = file => + // Helper function to read file as ArrayBuffer (for zip) + const readFileAsArrayBuffer = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsArrayBuffer(file); + }); + + // Helper function to read file as text (for json) + const readFileAsText = file => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = e => resolve({ content: e.target.result, name: file.name }); @@ -1864,26 +1874,72 @@ const handleFileUpload = async event => { } }; - // Upload all files (single or batch, same logic) - const successFiles = []; - const failedFiles = []; + // Collect all JSON files to upload (including extracted from zip) + const jsonFilesToUpload = []; + const extractErrors = []; for (const file of files) { - try { - const fileData = await readFile(file); - const result = await uploadFile(fileData); - if (result.success) { - successFiles.push({ local: file.name, saved: result.filename }); - } else { - failedFiles.push({ local: file.name, reason: result.error }); + const lowerName = file.name.toLowerCase(); + + if (lowerName.endsWith(".zip")) { + // Extract JSON files from zip + try { + const arrayBuffer = await readFileAsArrayBuffer(file); + const zip = await JSZip.loadAsync(arrayBuffer); + const zipEntries = Object.keys(zip.files); + + let foundJsonInZip = false; + for (const entryName of zipEntries) { + const entry = zip.files[entryName]; + // Skip directories and non-json files + if (entry.dir || !entryName.toLowerCase().endsWith(".json")) continue; + + foundJsonInZip = true; + try { + const content = await entry.async("string"); + // Use format: zipName/entryName for display + const displayName = `${file.name}/${entryName}`; + jsonFilesToUpload.push({ content, name: displayName }); + } catch (err) { + extractErrors.push({ + local: `${file.name}/${entryName}`, + reason: err.message || "Extract failed", + }); + } + } + + if (!foundJsonInZip) { + extractErrors.push({ local: file.name, reason: t("zipNoJsonFiles") }); + } + } catch (err) { + extractErrors.push({ local: file.name, reason: t("zipExtractFailed") }); + } + } else if (lowerName.endsWith(".json")) { + // Regular JSON file + try { + const fileData = await readFileAsText(file); + jsonFilesToUpload.push(fileData); + } catch (err) { + extractErrors.push({ local: file.name, reason: err.message || "Read failed" }); } - } catch (err) { - failedFiles.push({ local: file.name, reason: err.message || "Read failed" }); } } - // Build notification message with file details - let messageHtml = ""; + // Upload all collected JSON files + const successFiles = []; + const failedFiles = [...extractErrors]; + + for (const fileData of jsonFilesToUpload) { + const result = await uploadFile(fileData); + if (result.success) { + successFiles.push({ local: fileData.name, saved: result.filename }); + } else { + failedFiles.push({ local: fileData.name, reason: result.error }); + } + } + + // Build notification message with file details (scrollable container) + let messageHtml = '
'; if (successFiles.length > 0) { messageHtml += `
${t("fileUploadBatchSuccess")} (${successFiles.length}):
`; @@ -1903,6 +1959,8 @@ const handleFileUpload = async event => { messageHtml += ""; } + messageHtml += "
"; + // Determine notification type let notifyType = "success"; if (failedFiles.length > 0 && successFiles.length === 0) { @@ -1911,9 +1969,15 @@ const handleFileUpload = async event => { notifyType = "warning"; } - // Show notification in top-right, no auto close - // Use different title for single vs batch upload - const notifyTitle = files.length === 1 ? t("fileUploadComplete") : t("fileUploadBatchResult"); + // Build title with counts + const totalProcessed = successFiles.length + failedFiles.length; + let notifyTitle; + if (totalProcessed === 1) { + notifyTitle = t("fileUploadComplete"); + } else { + notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; + } + ElNotification({ dangerouslyUseHTMLString: true, duration: 0, diff --git a/ui/locales/en.json b/ui/locales/en.json index 009635b1..5f410ca2 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -161,5 +161,7 @@ "usageCount": "Usage Count", "versionInfo": "Version Info", "warningDeleteCurrentAccount": "You are about to delete the currently active account. The browser connection will be closed and you will need to switch to another account. Continue?", - "warningTitle": "Warning" + "warningTitle": "Warning", + "zipExtractFailed": "Extract failed", + "zipNoJsonFiles": "No JSON files in archive" } diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 72672c96..44f47ea5 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -161,5 +161,7 @@ "usageCount": "使用次数", "versionInfo": "版本信息", "warningDeleteCurrentAccount": "您即将删除当前正在使用的账号。浏览器连接将被关闭,您需要切换到其他账号。是否继续?", - "warningTitle": "警告" + "warningTitle": "警告", + "zipExtractFailed": "解压失败", + "zipNoJsonFiles": "压缩包内无 JSON 文件" } From b3cbbe6703afe51187f1908134fddd7df80ab748 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 9 Feb 2026 13:39:29 +0800 Subject: [PATCH 03/26] feat: implement batch delete functionality for accounts in StatusPage --- src/routes/StatusRoutes.js | 70 ++++++++++++ ui/app/pages/StatusPage.vue | 216 +++++++++++++++++++++++++++++++++++- ui/locales/en.json | 7 ++ ui/locales/zh.json | 7 ++ 4 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 80f54510..6e913b64 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -242,6 +242,76 @@ class StatusRoutes { } }); + // Batch delete accounts - Must be defined before /api/accounts/:index to avoid index matching "batch" + app.delete("/api/accounts/batch", isAuthenticated, async (req, res) => { + const { indices, force } = req.body; + const currentAuthIndex = this.serverSystem.requestHandler.currentAuthIndex; + + // Validate parameters + if (!Array.isArray(indices) || indices.length === 0) { + return res.status(400).json({ message: "errorInvalidIndex" }); + } + + const { authSource } = this.serverSystem; + const validIndices = indices.filter( + idx => Number.isInteger(idx) && authSource.initialIndices.includes(idx) + ); + + if (validIndices.length === 0) { + return res.status(404).json({ message: "errorAccountNotFound" }); + } + + // Check if current active account is included + const includesCurrent = validIndices.includes(currentAuthIndex); + if (includesCurrent && !force) { + return res.status(409).json({ + includesCurrent: true, + message: "warningDeleteCurrentAccount", + requiresConfirmation: true, + }); + } + + const successIndices = []; + const failedIndices = []; + + for (const targetIndex of validIndices) { + try { + authSource.removeAuth(targetIndex); + successIndices.push(targetIndex); + this.logger.warn(`[WebUI] Account #${targetIndex} deleted via batch delete.`); + } catch (error) { + failedIndices.push({ error: error.message, index: targetIndex }); + this.logger.error(`[WebUI] Failed to delete account #${targetIndex}: ${error.message}`); + } + } + + // If current active account was deleted, close browser connection + if (includesCurrent && successIndices.includes(currentAuthIndex)) { + this.logger.warn( + `[WebUI] Current active account #${currentAuthIndex} was deleted. Closing browser connection...` + ); + this.serverSystem.browserManager.closeBrowser().catch(err => { + this.logger.error(`[WebUI] Error closing browser after batch deletion: ${err.message}`); + }); + this.serverSystem.browserManager.currentAuthIndex = -1; + } + + if (failedIndices.length > 0) { + return res.status(207).json({ + failedIndices, + message: "batchDeletePartial", + successCount: successIndices.length, + successIndices, + }); + } + + return res.status(200).json({ + message: "batchDeleteSuccess", + successCount: successIndices.length, + successIndices, + }); + }); + app.delete("/api/accounts/:index", isAuthenticated, (req, res) => { const rawIndex = req.params.index; const targetIndex = Number(rawIndex); diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 229208cf..ecd13d72 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -546,6 +546,46 @@ multiple @change="handleFileUpload" /> + +
+ + {{ t("selectAll") }} + + + {{ t("selectedCount", { count: selectedCount }) }} + + +
+
+
@@ -1531,6 +1554,54 @@ const batchDeleteAccounts = async () => { }); }; +// Batch download accounts as ZIP +const batchDownloadAccounts = async () => { + if (state.selectedAccounts.size === 0) { + ElMessage.warning(t("noAccountSelected")); + return; + } + + const indices = Array.from(state.selectedAccounts); + + try { + const res = await fetch("/api/accounts/batch/download", { + body: JSON.stringify({ indices }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + if (!res.ok) { + const data = await res.json(); + ElMessage.error(t(data.message || "batchDownloadFailed", data)); + return; + } + + // Get the blob and trigger download + const blob = await res.blob(); + const contentDisposition = res.headers.get("Content-Disposition"); + let filename = "auth_batch.zip"; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^"]+)"?/); + if (match) { + filename = match[1]; + } + } + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + ElMessage.success(t("batchDownloadSuccess", { count: indices.length })); + } catch (err) { + ElMessage.error(t("batchDownloadFailed", { error: err.message || err })); + } +}; + const currentAccountName = computed(() => { if (state.currentAuthIndex < 0) { return t("noActiveAccount"); @@ -2609,6 +2680,29 @@ watchEffect(() => { } } +.btn-batch-download { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid @primary-color; + border-radius: 6px; + background: transparent; + color: @primary-color; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: rgba(var(--color-primary-rgb), 0.1); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + .account-list { display: flex; flex-direction: column; diff --git a/ui/locales/en.json b/ui/locales/en.json index 5f70a393..2675d331 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -62,6 +62,9 @@ "batchDeleteFailed": "Batch delete failed: {error}", "batchDeletePartial": "Partial success: deleted {successCount}, failed {failedCount}", "batchDeleteSuccess": "Successfully deleted {count} accounts", + "batchDownload": "Batch Download", + "batchDownloadFailed": "Batch download failed: {error}", + "batchDownloadSuccess": "Successfully downloaded {count} auth files", "browserConnection": "Browser Connection", "btnAddUser": "Add User", "btnDeduplicateAuth": "Deduplicate Account", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 395a6055..4cf64da1 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -62,6 +62,9 @@ "batchDeleteFailed": "批量删除失败:{error}", "batchDeletePartial": "部分删除成功:已删除 {successCount} 个,失败 {failedCount} 个", "batchDeleteSuccess": "成功删除 {count} 个账号", + "batchDownload": "批量下载", + "batchDownloadFailed": "批量下载失败:{error}", + "batchDownloadSuccess": "成功下载 {count} 个 Auth 文件", "browserConnection": "浏览器连接", "btnAddUser": "添加账号", "btnDeduplicateAuth": "去重清理", From 45fed73da9429adcd3d57e8c934982603dcf2171 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 9 Feb 2026 17:05:09 +0800 Subject: [PATCH 05/26] chore: add escapeHtml utility to prevent XSS in StatusPage and AuthPage --- ui/app/pages/AuthPage.vue | 14 +------------- ui/app/pages/StatusPage.vue | 5 +++-- ui/app/utils/escapeHtml.js | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 ui/app/utils/escapeHtml.js diff --git a/ui/app/pages/AuthPage.vue b/ui/app/pages/AuthPage.vue index 06beb180..94181707 100644 --- a/ui/app/pages/AuthPage.vue +++ b/ui/app/pages/AuthPage.vue @@ -245,6 +245,7 @@