feat: 添加导出连接功能

This commit is contained in:
Baobhan Sith
2025-05-10 16:35:20 +08:00
parent 4b9d086ae6
commit b702eb3b88
11 changed files with 379 additions and 29 deletions
+63
View File
@@ -13,6 +13,7 @@
"packages/*"
],
"dependencies": {
"archiver-zip-encrypted": "^2.0.0",
"axios": "^1.8.4",
"fs-extra": "^11.3.0",
"pinia-plugin-persistedstate": "^4.2.0",
@@ -1584,6 +1585,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
"integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/archiver": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz",
@@ -2270,6 +2281,21 @@
"node": ">=0.4.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/aes-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -2495,6 +2521,24 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/archiver-zip-encrypted": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/archiver-zip-encrypted/-/archiver-zip-encrypted-2.0.0.tgz",
"integrity": "sha512-QJPkMPb3fHwUnXpZlzbwvvzgzr4bKK84Kc+O8+oRtsEzLqK+iwJXygJ+mHouE0LWEtR0BNs6Oys/48hyRB5xOw==",
"license": "MIT",
"dependencies": {
"aes-js": "^3.1.2",
"archiver": "^7.0.0",
"archiver-utils": "^5.0.1",
"buffer-crc32": "^1.0.0",
"compress-commons": "^6.0.0",
"crc32-stream": "^6.0.0",
"zip-stream": "^6.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/archiver/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -2751,6 +2795,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
@@ -3804,6 +3854,16 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/easyzip": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/easyzip/-/easyzip-1.0.3.tgz",
"integrity": "sha512-tNbweMZjm+D3k0sfAjN/095pS5LQFP4uVkrdiu0kQGaJ0vzk2oScr0SI45uN/nVovIIr9E8w4uxRDG9Vy2kqAw==",
"license": "ISC",
"dependencies": {
"bluebird": "^3.4.7",
"moment": "^2.17.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8983,12 +9043,14 @@
"@types/multer": "^1.4.12",
"@types/session-file-store": "^1.2.5",
"@types/uuid": "^10.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.9.0",
"bcrypt": "^5.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dotenv": "^16.5.0",
"easyzip": "^1.0.3",
"express": "^5.1.0",
"express-session": "^1.18.1",
"i18next": "^25.0.0",
@@ -9009,6 +9071,7 @@
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.1",
"@types/express-session": "^1.18.1",
+1
View File
@@ -26,6 +26,7 @@
"homepage": "https://github.com/Heavrnl/nexus-terminal#readme",
"description": "",
"dependencies": {
"archiver-zip-encrypted": "^2.0.0",
"axios": "^1.8.4",
"fs-extra": "^11.3.0",
"pinia-plugin-persistedstate": "^4.2.0",
+3
View File
@@ -14,12 +14,14 @@
"@types/multer": "^1.4.12",
"@types/session-file-store": "^1.2.5",
"@types/uuid": "^10.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.9.0",
"bcrypt": "^5.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dotenv": "^16.5.0",
"easyzip": "^1.0.3",
"express": "^5.1.0",
"express-session": "^1.18.1",
"i18next": "^25.0.0",
@@ -40,6 +42,7 @@
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.1",
"@types/express-session": "^1.18.1",
@@ -2,6 +2,14 @@
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
import { decrypt, getEncryptionKeyBuffer as getCryptoKeyBuffer } from '../utils/crypto'; // For decrypting connection details
import archiver from 'archiver';
archiver.registerFormat('zip-encrypted', require("archiver-zip-encrypted"));
// We might still need fs, path, os if easyzip requires writing to a temp file for password protection,
// but let's try to do it in memory first.
// import fs from 'fs';
// import path from 'path';
// import os from 'os';
@@ -11,7 +19,12 @@ interface ImportedConnectionData {
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
auth_method: 'password' | 'key'; // For SSH
// Plaintext fields for export
password?: string | null;
private_key?: string | null;
passphrase?: string | null;
// Encrypted fields might still be part of the base ImportedConnectionData if it's used elsewhere
encrypted_password?: string | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
@@ -22,13 +35,44 @@ interface ImportedConnectionData {
host: string;
port: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key';
auth_method?: 'none' | 'password' | 'key'; // For proxy
// Plaintext fields for proxy export
password?: string | null;
private_key?: string | null; // If proxy uses key auth
passphrase?: string | null; // If proxy key has passphrase
// Encrypted fields for proxy
encrypted_password?: string | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
} | null;
}
interface ExportedConnectionData extends Omit<ImportedConnectionData, 'id'> {}
// This will represent the structure of the data *before* it's put into the JSON for export,
// containing plaintext sensitive info.
interface PlaintextExportConnectionData {
name: string;
type: 'SSH' | 'RDP' | 'VNC';
host: string;
port: number;
username: string;
auth_method: 'password' | 'key'; // SSH auth method
password?: string | null; // Plaintext password
private_key?: string | null; // Plaintext private key
passphrase?: string | null; // Plaintext passphrase for key
tag_ids?: number[];
proxy?: {
name: string;
type: 'SOCKS5' | 'HTTP';
host: string;
port: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key'; // Proxy auth method
password?: string | null; // Plaintext proxy password
private_key?: string | null; // Plaintext proxy private key
passphrase?: string | null; // Plaintext proxy key passphrase
} | null;
}
export interface ImportResult {
successCount: number;
failureCount: number;
@@ -37,9 +81,10 @@ export interface ImportResult {
/**
* 导出所有连接配置
* 获取所有连接的明文数据以供导出。
* 敏感信息将被解密。
*/
export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
const getPlaintextConnectionsData = async (): Promise<PlaintextExportConnectionData[]> => {
try {
const db = await getDbInstance();
@@ -85,32 +130,66 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
});
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
const connection: ExportedConnectionData = {
const formattedData: PlaintextExportConnectionData[] = connectionsWithProxies.map(row => {
// Decrypt main connection sensitive data
let plainPassword = null;
if (row.encrypted_password) {
try { plainPassword = decrypt(row.encrypted_password); }
catch (e) { console.warn(`解密连接 [${row.name}] 密码失败: ${(e as Error).message}`); }
}
let plainPrivateKey = null;
if (row.encrypted_private_key) {
try { plainPrivateKey = decrypt(row.encrypted_private_key); }
catch (e) { console.warn(`解密连接 [${row.name}] 私钥失败: ${(e as Error).message}`); }
}
let plainPassphrase = null;
if (row.encrypted_passphrase) {
try { plainPassphrase = decrypt(row.encrypted_passphrase); }
catch (e) { console.warn(`解密连接 [${row.name}] 私钥密码失败: ${(e as Error).message}`); }
}
const connection: PlaintextExportConnectionData = {
name: row.name ?? 'Unnamed',
type: row.type, // Add type field
type: row.type,
host: row.host,
port: row.port,
username: row.username,
auth_method: row.auth_method,
encrypted_password: row.encrypted_password,
encrypted_private_key: row.encrypted_private_key,
encrypted_passphrase: row.encrypted_passphrase,
auth_method: row.auth_method, // Keep auth_method as is
password: plainPassword,
private_key: plainPrivateKey,
passphrase: plainPassphrase,
tag_ids: tagsMap[row.id] || [],
proxy: null
};
if (row.proxy_db_id) {
if (row.proxy_db_id && row.proxy_name && row.proxy_type && row.proxy_host && row.proxy_port !== null) {
// Decrypt proxy sensitive data
let proxyPlainPassword = null;
if (row.proxy_encrypted_password) {
try { proxyPlainPassword = decrypt(row.proxy_encrypted_password); }
catch (e) { console.warn(`解密代理 [${row.proxy_name}] 密码失败: ${(e as Error).message}`); }
}
let proxyPlainPrivateKey = null;
if (row.proxy_encrypted_private_key) {
try { proxyPlainPrivateKey = decrypt(row.proxy_encrypted_private_key); }
catch (e) { console.warn(`解密代理 [${row.proxy_name}] 私钥失败: ${(e as Error).message}`); }
}
let proxyPlainPassphrase = null;
if (row.proxy_encrypted_passphrase) {
try { proxyPlainPassphrase = decrypt(row.proxy_encrypted_passphrase); }
catch (e) { console.warn(`解密代理 [${row.proxy_name}] 私钥密码失败: ${(e as Error).message}`); }
}
connection.proxy = {
name: row.proxy_name ?? 'Unnamed Proxy',
type: row.proxy_type ?? 'SOCKS5',
host: row.proxy_host ?? '',
port: row.proxy_port ?? 0,
name: row.proxy_name,
type: row.proxy_type,
host: row.proxy_host,
port: row.proxy_port,
username: row.proxy_username,
auth_method: row.proxy_auth_method ?? 'none',
encrypted_password: row.proxy_encrypted_password,
encrypted_private_key: row.proxy_encrypted_private_key,
encrypted_passphrase: row.proxy_encrypted_passphrase,
password: proxyPlainPassword,
private_key: proxyPlainPrivateKey,
passphrase: proxyPlainPassphrase,
};
}
return connection;
@@ -119,8 +198,59 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
return formattedData;
} catch (err: any) {
console.error('Service: 导出连接时出错:', err.message);
throw new Error(`导出连接失败: ${err.message}`);
console.error('Service: 获取明文连接数据时出错:', err.message);
throw new Error(`获取明文连接数据失败: ${err.message}`);
}
};
/**
* 导出所有连接配置为一个加密的 ZIP 文件。
* @returns Buffer 包含加密的 ZIP 文件内容 (IV + Ciphertext + AuthTag)。
*/
export const exportConnectionsAsEncryptedZip = async (): Promise<Buffer> => {
try {
const connections = await getPlaintextConnectionsData();
const jsonContent = JSON.stringify(connections, null, 2);
// 注意:当前版本的 adm-zip 不支持在内存中设置密码
// const zipPassword = process.env.ENCRYPTION_KEY;
// if (!zipPassword || zipPassword.trim() === '') {
// console.error('错误:ENCRYPTION_KEY 环境变量未设置或为空!无法为ZIP文件设置密码。');
// throw new Error('ENCRYPTION_KEY is not set or is empty, cannot password-protect the ZIP file.');
// }
const zipPassword = process.env.ENCRYPTION_KEY;
if (!zipPassword || zipPassword.trim() === '') {
console.error('错误:ENCRYPTION_KEY 环境变量未设置或为空!无法为ZIP文件设置密码。');
throw new Error('ENCRYPTION_KEY is not set or is empty, cannot password-protect the ZIP file.');
}
const archive = archiver.create('zip-encrypted', {
zlib: { level: 9 }, // 设置压缩级别
encryptionMethod: 'aes256', // 使用 AES-256 加密
password: zipPassword // 设置密码
});
const buffer: Buffer[] = [];
archive.on('data', (data) => {
buffer.push(data);
});
archive.on('error', (err) => {
console.error('Service: 使用 archiver 创建加密 ZIP buffer 时出错:', err);
throw new Error(`使用 archiver 创建加密 ZIP buffer 失败: ${err.message}`);
});
archive.append(jsonContent, { name: 'connections.json' });
await archive.finalize();
return Buffer.concat(buffer);
} catch (error: any) {
// This catch block might not be reached if errors are only within the Promise.
// The promise's reject will handle errors during zip.writeToBuffer.
console.error('Service: 导出连接 ZIP (archiver) 时发生意外错误:', error);
throw new Error(`导出连接 ZIP (archiver) 失败: ${error.message}`);
}
};
@@ -3,6 +3,7 @@ import { settingsService } from '../services/settings.service';
import { AuditLogService } from '../services/audit.service';
import { NotificationService } from '../services/notification.service'; // 添加导入
import { ipBlacklistService } from '../services/ip-blacklist.service';
import { exportConnectionsAsEncryptedZip } from '../services/import-export.service'; // Import the new export service
import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types
import i18next from '../i18n'; // +++ Import i18next +++
@@ -502,6 +503,32 @@ async setCaptchaConfig(req: Request, res: Response): Promise<void> {
console.error('[控制器] 设置“显示快捷指令标签”时出错:', error);
res.status(500).json({ message: '设置“显示快捷指令标签”失败', error: error.message });
}
} // <-- No comma after the last method
}, // <-- Add comma here for the new method
/**
* 导出所有连接配置为加密的 ZIP 文件
*/
async exportAllConnections(req: Request, res: Response): Promise<void> {
try {
console.log('[控制器] 收到导出所有连接的请求。');
const encryptedZipBuffer = await exportConnectionsAsEncryptedZip();
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="nexus_connections_export.zip"');
res.send(encryptedZipBuffer);
// auditLogService.logAction('CONNECTIONS_EXPORTED', { userId: (req.user as any)?.id || 'unknown' }); // 移除审计日志
console.log('[控制器] 成功发送加密的连接导出文件。');
} catch (error: any) {
console.error('[控制器] 导出所有连接时出错:', error);
// 检查是否是因为 ENCRYPTION_KEY 未设置导致的错误
if (error.message && (error.message.includes('ENCRYPTION_KEY is not set') || error.message.includes('Failed to decode ENCRYPTION_KEY') || error.message.includes('Invalid ENCRYPTION_KEY length'))) {
res.status(500).json({ message: i18next.t('error.exportFailedEncryptionKey'), error: error.message });
} else {
res.status(500).json({ message: i18next.t('error.exportFailedGeneric'), error: error.message });
}
}
} // <-- No comma after the last method if it's truly the last one
};
@@ -65,9 +65,17 @@ router.get('/show-quick-command-tags', settingsController.getShowQuickCommandTag
// PUT /api/v1/settings/show-quick-command-tags - 更新设置
router.put('/show-quick-command-tags', settingsController.setShowQuickCommandTags);
// +++ 新增:导出所有连接路由 +++
// GET /api/v1/settings/export-connections - 导出所有连接为加密的 ZIP 文件
router.get('/export-connections', settingsController.exportAllConnections);
export default router;
// +++ 新增:CAPTCHA 配置路由 (需要认证更新) +++
// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置
// 注意:这个路由定义在 `export default router` 之后,这是不正确的。
// 我会将它移到 `export default router` 之前,并确保它也在 `isAuthenticated` 中间件的作用域内。
// 然而,既然它已经存在,并且在 `isAuthenticated` 之后(通过 router.use(isAuthenticated)),
// 我们只需要确保导出路由也在正确的位置。
router.put('/captcha', settingsController.setCaptchaConfig);
+17
View File
@@ -0,0 +1,17 @@
declare module 'archiver' {
interface ArchiverOptions {
encryptionMethod?: 'aes256' | 'zip20';
password?: string;
zlib?: { level: number };
}
function registerFormat(format: string, module: any): void;
function create(format: string, options: ArchiverOptions): Archiver;
interface Archiver extends NodeJS.EventEmitter {
on(event: 'data', listener: (data: Buffer) => void): this;
on(event: 'error', listener: (err: Error) => void): this;
append(data: any, options: { name: string }): void;
finalize(): Promise<void>;
}
}
+7
View File
@@ -0,0 +1,7 @@
declare module 'easyzip' {
export class EasyZip {
constructor();
file(name: string, content: string): void;
writeToBuffer(options: { password: string, compress: boolean }, callback: (err: Error | null, buffer: Buffer) => void): void;
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ const tagLength = 16; // GCM 认证标签长度
/**
* Internal helper to get and validate the encryption key buffer on demand.
*/
const getEncryptionKeyBuffer = (): Buffer => {
export const getEncryptionKeyBuffer = (): Buffer => {
const keyEnv = process.env.ENCRYPTION_KEY;
if (!keyEnv) {
// This should ideally not happen due to initializeEnvironment in index.ts
+1 -1
View File
@@ -51,7 +51,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
});
console.log('WebSocket 服务器初始化完成 (重构版)。');
console.log('WebSocket 服务器初始化完成。');
return wss;
};
@@ -684,6 +684,38 @@
</div>
</div>
</div>
<!-- Data Management Section (including Export) -->
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">
{{ t('settings.category.dataManagement', '数据管理') }}
</h2>
<div class="p-6 space-y-6">
<!-- Export Connections Section -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ t('settings.exportConnections.title', '导出连接数据') }}</h3>
<p class="text-sm text-text-secondary mb-2">
{{ t('settings.exportConnections.description', '将所有连接配置(包括密码和密钥等敏感信息)导出为一个加密的 ZIP 文件。') }}
</p>
<p class="text-sm text-text-secondary mb-4">
<span class="font-semibold text-warning">{{ t('settings.exportConnections.decryptKeyInfo', '解压密码为您的 data/.env 文件中的 ENCRYPTION_KEY。请妥善保管此文件。') }}</span>
</p>
<form @submit.prevent="handleExportConnections" class="space-y-4">
<div class="flex items-center justify-between">
<button type="submit" :disabled="exportConnectionsLoading"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium inline-flex items-center">
<svg v-if="exportConnectionsLoading" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ exportConnectionsLoading ? t('common.loading') : t('settings.exportConnections.buttonText', '开始导出') }}
</button>
<p v-if="exportConnectionsMessage" :class="['text-sm', exportConnectionsSuccess ? 'text-success' : 'text-error']">{{ exportConnectionsMessage }}</p>
</div>
</form>
</div>
</div>
</div> <!-- End Data Management Section -->
<!-- Appearance Section: Only show if settings data is loaded -->
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.category.appearance') }}</h2>
@@ -850,6 +882,7 @@ const terminalScrollbackLimitSuccess = ref(false); // NEW
const fileManagerShowDeleteConfirmationLoading = ref(false); // NEW
const fileManagerShowDeleteConfirmationMessage = ref(''); // NEW
const fileManagerShowDeleteConfirmationSuccess = ref(false); // NEW
// CAPTCHA Form State
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
enabled: false,
@@ -863,6 +896,11 @@ const captchaLoading = ref(false);
const captchaMessage = ref('');
const captchaSuccess = ref(false);
// --- Export Connections State ---
const exportConnectionsLoading = ref(false);
const exportConnectionsMessage = ref('');
const exportConnectionsSuccess = ref(false);
// --- Passkey State ---
const passkeyLoading = ref(false); // For registering new passkey
const passkeyMessage = ref(''); // General messages for passkey operations (register, delete, edit name)
@@ -1194,6 +1232,62 @@ const handleUpdateTerminalScrollbackLimit = async () => {
}
};
// --- Export Connections Method ---
const handleExportConnections = async () => {
exportConnectionsLoading.value = true;
exportConnectionsMessage.value = '';
exportConnectionsSuccess.value = false;
try {
const response = await apiClient.get('/settings/export-connections', { // Corrected API path
responseType: 'blob',
});
let filename = 'nexus_connections_export.zip';
const disposition = response.headers['content-disposition'];
if (disposition && disposition.includes('attachment')) {
const filenameRegex = /filename[^;=\n]*=(?:(['"])(.*?)\1|([^;\n]*))/;
const matches = filenameRegex.exec(disposition);
if (matches != null && (matches[2] || matches[3])) {
filename = matches[2] || matches[3]; // Use captured group 2 (quoted) or 3 (unquoted)
}
}
const blob = new Blob([response.data], { type: response.headers['content-type'] || 'application/zip' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
exportConnectionsMessage.value = t('settings.exportConnections.success', '导出成功。文件已开始下载。');
exportConnectionsSuccess.value = true;
} catch (error: any) {
console.error('导出连接失败:', error);
let message = t('settings.exportConnections.error', '导出连接时发生错误。');
if (isAxiosError(error) && error.response && error.response.data) {
if (error.response.data instanceof Blob && error.response.data.type === 'application/json') {
try {
const errorJson = JSON.parse(await error.response.data.text());
message = errorJson.message || message;
} catch (e) { /* Blob not valid JSON */ }
} else if (typeof error.response.data === 'string') {
message = error.response.data;
} else if (error.response.data && typeof error.response.data.message === 'string') {
message = error.response.data.message;
}
} else if (error.message) {
message = error.message;
}
exportConnectionsMessage.value = message;
exportConnectionsSuccess.value = false;
} finally {
exportConnectionsLoading.value = false;
}
};
// --- ---
const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true);