Generated
+33
@@ -1825,6 +1825,12 @@
|
|||||||
"vscode-uri": "^3.0.8"
|
"vscode-uri": "^3.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vscode/iconv-lite-umd": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-bRRFxLfg5dtAyl5XyiVWz/ZBPahpOpPrNYnnHpOpUZvam4tKH35wdhP4Kj6PbM0+KdliOsPzbGWpkxcdpNB/sg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.13",
|
"version": "3.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
|
||||||
@@ -8847,11 +8853,14 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"@vscode/iconv-lite-umd": "^0.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
@@ -9266,6 +9275,30 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/frontend/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/frontend/node_modules/esbuild": {
|
"packages/frontend/node_modules/esbuild": {
|
||||||
"version": "0.25.3",
|
"version": "0.25.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
|
||||||
|
|||||||
@@ -194,15 +194,15 @@ export class SftpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取文件内容 */
|
/** 读取文件内容 (支持指定编码) */
|
||||||
async readFile(sessionId: string, path: string, requestId: string): Promise<void> {
|
async readFile(sessionId: string, path: string, requestId: string, requestedEncoding?: string): Promise<void> {
|
||||||
const state = this.clientStates.get(sessionId);
|
const state = this.clientStates.get(sessionId);
|
||||||
if (!state || !state.sftp) {
|
if (!state || !state.sftp) {
|
||||||
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`);
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 readFile (ID: ${requestId})`);
|
||||||
state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
state?.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId})`);
|
console.debug(`[SFTP ${sessionId}] Received readFile request for ${path} (ID: ${requestId}, Requested Encoding: ${requestedEncoding ?? 'auto'})`);
|
||||||
try {
|
try {
|
||||||
const readStream = state.sftp.createReadStream(path);
|
const readStream = state.sftp.createReadStream(path);
|
||||||
let fileData = Buffer.alloc(0);
|
let fileData = Buffer.alloc(0);
|
||||||
@@ -215,74 +215,122 @@ export class SftpService {
|
|||||||
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件流错误: ${err.message}`, requestId: requestId }));
|
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `读取文件流错误: ${err.message}`, requestId: requestId }));
|
||||||
});
|
});
|
||||||
readStream.on('end', () => {
|
readStream.on('end', () => {
|
||||||
if (!errorOccurred) {
|
if (errorOccurred) return;
|
||||||
console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId}). Detecting encoding...`);
|
|
||||||
let contentUtf8: string;
|
console.log(`[SFTP ${sessionId}] readFile ${path} success, size: ${fileData.length} bytes (ID: ${requestId}). Processing content...`);
|
||||||
try {
|
let encodingUsed: string = 'utf-8'; // Default encoding
|
||||||
// 1. Detect encoding
|
let decodedContent: string = '';
|
||||||
|
let decodeError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (requestedEncoding) {
|
||||||
|
// 用户指定了编码
|
||||||
|
encodingUsed = requestedEncoding;
|
||||||
|
console.log(`[SFTP ${sessionId}] Using requested encoding: ${encodingUsed} (ID: ${requestId})`);
|
||||||
|
const normalizedEncoding = encodingUsed.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize more aggressively
|
||||||
|
if (iconv.encodingExists(normalizedEncoding)) {
|
||||||
|
decodedContent = iconv.decode(fileData, normalizedEncoding);
|
||||||
|
encodingUsed = normalizedEncoding; // Use the normalized name if valid
|
||||||
|
} else {
|
||||||
|
console.warn(`[SFTP ${sessionId}] Requested encoding "${requestedEncoding}" is not supported by iconv-lite. Falling back to UTF-8. (ID: ${requestId})`);
|
||||||
|
encodingUsed = 'utf-8'; // Fallback
|
||||||
|
decodedContent = iconv.decode(fileData, encodingUsed);
|
||||||
|
// Optionally add a warning?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自动检测编码
|
||||||
|
console.log(`[SFTP ${sessionId}] Detecting encoding for ${path} (ID: ${requestId})`);
|
||||||
const detection = jschardet.detect(fileData);
|
const detection = jschardet.detect(fileData);
|
||||||
const detectedEncoding = detection.encoding.toLowerCase();
|
const detectedEncodingRaw = detection.encoding ? detection.encoding.toLowerCase() : 'utf-8'; // Default to utf-8 if detection fails
|
||||||
const confidence = detection.confidence;
|
const confidence = detection.confidence || 0;
|
||||||
console.log(`[SFTP ${sessionId}] Detected encoding for ${path}: ${detectedEncoding} (confidence: ${confidence})`);
|
console.log(`[SFTP ${sessionId}] Detected encoding: ${detectedEncodingRaw} (confidence: ${confidence})`);
|
||||||
|
|
||||||
// 2. Decode to UTF-8 with improved logic for low confidence and Chinese encodings
|
const chineseEncodings = ['gbk', 'gb2312', 'gb18030', 'big5', 'euc-tw'];
|
||||||
const chineseEncodings = ['gbk', 'gb2312', 'gb18030', 'big5', 'euc-tw']; // Common Chinese/Taiwanese encodings
|
let normalizedDetected = detectedEncodingRaw.replace(/[^a-z0-9]/g, '');
|
||||||
|
if (normalizedDetected === 'windows1252') normalizedDetected = 'cp1252';
|
||||||
|
else if (normalizedDetected === 'gb2312') normalizedDetected = 'gbk'; // Prefer gbk
|
||||||
|
|
||||||
if (detectedEncoding === 'utf-8' || detectedEncoding === 'ascii') {
|
if (normalizedDetected === 'utf8' || normalizedDetected === 'ascii') {
|
||||||
contentUtf8 = fileData.toString('utf8');
|
encodingUsed = 'utf-8';
|
||||||
|
decodedContent = fileData.toString('utf8');
|
||||||
console.log(`[SFTP ${sessionId}] Decoded ${path} as UTF-8/ASCII.`);
|
console.log(`[SFTP ${sessionId}] Decoded ${path} as UTF-8/ASCII.`);
|
||||||
} else if (chineseEncodings.includes(detectedEncoding)) {
|
} else if (chineseEncodings.includes(normalizedDetected)) {
|
||||||
// If detected as a common Chinese encoding, trust it and use gb18030 for broader compatibility
|
// If detected as a common Chinese encoding, trust it and use gb18030 for broader compatibility
|
||||||
contentUtf8 = iconv.decode(fileData, 'gb18030');
|
encodingUsed = 'gb18030'; // Report gb18030 as used
|
||||||
console.log(`[SFTP ${sessionId}] Decoded ${path} from detected Chinese encoding (${detectedEncoding}) as gb18030.`);
|
decodedContent = iconv.decode(fileData, encodingUsed);
|
||||||
} else if (confidence < 0.90) { // Low confidence threshold (adjustable, e.g., 0.90 or 0.85)
|
console.log(`[SFTP ${sessionId}] Decoded ${path} from detected Chinese encoding (${normalizedDetected}) as ${encodingUsed}.`);
|
||||||
console.warn(`[SFTP ${sessionId}] Low confidence detection (${detectedEncoding}, ${confidence}) for ${path}. Attempting GB18030 decode first.`);
|
} else if (confidence < 0.90) { // Low confidence threshold
|
||||||
|
console.warn(`[SFTP ${sessionId}] Low confidence detection (${normalizedDetected}, ${confidence}) for ${path}. Attempting GB18030 decode first.`);
|
||||||
try {
|
try {
|
||||||
// Try decoding as GB18030 first for low confidence cases, common for Chinese Windows ANSI
|
// Try decoding as GB18030 first
|
||||||
contentUtf8 = iconv.decode(fileData, 'gb18030');
|
const tempContent = iconv.decode(fileData, 'gb18030');
|
||||||
// Basic check for Mojibake (presence of replacement char � U+FFFD)
|
// Basic check for Mojibake
|
||||||
if (contentUtf8.includes('\uFFFD')) {
|
if (tempContent.includes('\uFFFD')) {
|
||||||
console.warn(`[SFTP ${sessionId}] GB18030 decoding resulted in replacement characters. Falling back to original detection (${detectedEncoding}) or UTF-8.`);
|
console.warn(`[SFTP ${sessionId}] GB18030 decoding resulted in replacement characters. Falling back to original detection (${normalizedDetected}) or UTF-8.`);
|
||||||
// Fallback: Try the originally detected encoding if supported, otherwise UTF-8
|
// Fallback: Try the originally detected encoding if supported, otherwise UTF-8
|
||||||
if (iconv.encodingExists(detectedEncoding)) {
|
if (iconv.encodingExists(normalizedDetected)) {
|
||||||
contentUtf8 = iconv.decode(fileData, detectedEncoding);
|
encodingUsed = normalizedDetected;
|
||||||
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${detectedEncoding}.`);
|
decodedContent = iconv.decode(fileData, encodingUsed);
|
||||||
|
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${encodingUsed}.`);
|
||||||
} else {
|
} else {
|
||||||
contentUtf8 = fileData.toString('utf8');
|
encodingUsed = 'utf-8';
|
||||||
|
decodedContent = fileData.toString('utf8');
|
||||||
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`);
|
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`[SFTP ${sessionId}] Decoded ${path} as GB18030 due to low confidence detection.`);
|
encodingUsed = 'gb18030'; // Success with GB18030
|
||||||
|
decodedContent = tempContent;
|
||||||
|
console.log(`[SFTP ${sessionId}] Decoded ${path} as ${encodingUsed} due to low confidence detection.`);
|
||||||
}
|
}
|
||||||
} catch (gbkError) {
|
} catch (gbkError) {
|
||||||
console.warn(`[SFTP ${sessionId}] Error decoding as GB18030, falling back to original detection (${detectedEncoding}) or UTF-8:`, gbkError);
|
console.warn(`[SFTP ${sessionId}] Error decoding as GB18030, falling back to original detection (${normalizedDetected}) or UTF-8:`, gbkError);
|
||||||
// Fallback: Try the originally detected encoding if supported, otherwise UTF-8
|
// Fallback: Try the originally detected encoding if supported, otherwise UTF-8
|
||||||
if (iconv.encodingExists(detectedEncoding)) {
|
if (iconv.encodingExists(normalizedDetected)) {
|
||||||
contentUtf8 = iconv.decode(fileData, detectedEncoding);
|
encodingUsed = normalizedDetected;
|
||||||
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${detectedEncoding}.`);
|
decodedContent = iconv.decode(fileData, encodingUsed);
|
||||||
|
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as originally detected ${encodingUsed}.`);
|
||||||
} else {
|
} else {
|
||||||
contentUtf8 = fileData.toString('utf8');
|
encodingUsed = 'utf-8';
|
||||||
|
decodedContent = fileData.toString('utf8');
|
||||||
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`);
|
console.log(`[SFTP ${sessionId}] Falling back to decoding ${path} as UTF-8.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (iconv.encodingExists(detectedEncoding)) {
|
} else if (iconv.encodingExists(normalizedDetected)) {
|
||||||
// Higher confidence, non-Chinese, supported encoding
|
// Higher confidence, non-Chinese, supported encoding
|
||||||
contentUtf8 = iconv.decode(fileData, detectedEncoding);
|
encodingUsed = normalizedDetected;
|
||||||
console.log(`[SFTP ${sessionId}] Decoded ${path} from ${detectedEncoding} to UTF-8 using iconv-lite (high confidence).`);
|
decodedContent = iconv.decode(fileData, encodingUsed);
|
||||||
|
console.log(`[SFTP ${sessionId}] Decoded ${path} from ${encodingUsed} using iconv-lite (high confidence).`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[SFTP ${sessionId}] Unsupported or unknown encoding detected for ${path}: ${detectedEncoding}. Falling back to UTF-8.`);
|
console.warn(`[SFTP ${sessionId}] Unsupported or unknown encoding detected for ${path}: ${normalizedDetected}. Falling back to UTF-8.`);
|
||||||
contentUtf8 = fileData.toString('utf8'); // Final fallback
|
encodingUsed = 'utf-8'; // Final fallback
|
||||||
|
decodedContent = fileData.toString('utf8');
|
||||||
}
|
}
|
||||||
} catch (decodeError: any) {
|
|
||||||
console.error(`[SFTP ${sessionId}] Error detecting/decoding file ${path} (ID: ${requestId}):`, decodeError);
|
|
||||||
// Send error if decoding fails
|
|
||||||
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: `文件编码检测或转换失败: ${decodeError.message}`, requestId: requestId }));
|
|
||||||
return; // Stop further processing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send UTF-8 content to frontend
|
// Final check for replacement characters after deciding the encoding
|
||||||
state.ws.send(JSON.stringify({ type: 'sftp:readfile:success', path: path, payload: { content: contentUtf8 }, requestId: requestId })); // Send UTF-8 string directly
|
if (decodedContent.includes('\uFFFD')) {
|
||||||
|
console.warn(`[SFTP ${sessionId}] Final decoded content for ${path} (using ${encodingUsed}) contains replacement characters (U+FFFD). Decoding might be incorrect. (ID: ${requestId})`);
|
||||||
|
// decodeError = `解码内容可能不正确 (使用 ${encodingUsed}),检测到无效字符。`; // Optionally set error
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SFTP ${sessionId}] Error detecting/decoding file content for ${path} (ID: ${requestId}):`, err);
|
||||||
|
decodeError = `文件编码检测或转换失败: ${err.message}`;
|
||||||
|
state.ws.send(JSON.stringify({ type: 'sftp:readfile:error', path: path, payload: decodeError, requestId: requestId }));
|
||||||
|
return; // Stop processing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送 Base64 编码的原始数据和实际使用的编码
|
||||||
|
console.log(`[SFTP ${sessionId}] Sending raw content (Base64) and encoding used (${encodingUsed}) for ${path} (ID: ${requestId})`);
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
type: 'sftp:readfile:success',
|
||||||
|
path: path,
|
||||||
|
payload: {
|
||||||
|
rawContentBase64: fileData.toString('base64'), // 发送 Base64 字符串
|
||||||
|
encodingUsed: encodingUsed // 发送实际使用的编码
|
||||||
|
},
|
||||||
|
requestId: requestId
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SFTP ${sessionId}] readFile ${path} caught unexpected error (ID: ${requestId}):`, error);
|
console.error(`[SFTP ${sessionId}] readFile ${path} caught unexpected error (ID: ${requestId}):`, error);
|
||||||
@@ -290,17 +338,30 @@ export class SftpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写入文件内容 */
|
/** 写入文件内容 (支持指定编码) */
|
||||||
async writefile(sessionId: string, path: string, data: string, requestId: string): Promise<void> {
|
// --- 修改:添加 encoding 参数 ---
|
||||||
|
async writefile(sessionId: string, path: string, data: string, requestId: string, encoding?: string): Promise<void> {
|
||||||
const state = this.clientStates.get(sessionId);
|
const state = this.clientStates.get(sessionId);
|
||||||
if (!state || !state.sftp) {
|
if (!state || !state.sftp) {
|
||||||
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 writefile (ID: ${requestId})`);
|
console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 writefile (ID: ${requestId})`);
|
||||||
state?.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
state?.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId})`);
|
// --- 修改:使用传入的 encoding 或默认 utf-8 ---
|
||||||
|
const targetEncoding = encoding || 'utf-8';
|
||||||
|
console.debug(`[SFTP ${sessionId}] Received writefile request for ${path} (ID: ${requestId}, Encoding: ${targetEncoding})`);
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(data, 'utf8');
|
// --- 修改:使用 iconv-lite 根据指定编码创建 Buffer ---
|
||||||
|
let buffer: Buffer;
|
||||||
|
try {
|
||||||
|
buffer = iconv.encode(data, targetEncoding);
|
||||||
|
console.log(`[SFTP ${sessionId}] Encoded content for ${path} using ${targetEncoding} (Buffer size: ${buffer.length})`);
|
||||||
|
} catch (encodeError: any) {
|
||||||
|
console.error(`[SFTP ${sessionId}] Failed to encode content for ${path} with encoding ${targetEncoding} (ID: ${requestId}):`, encodeError);
|
||||||
|
state.ws.send(JSON.stringify({ type: 'sftp:writefile:error', path: path, payload: `无效的编码或编码失败: ${targetEncoding}`, requestId: requestId }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.debug(`[SFTP ${sessionId}] Creating write stream for ${path} (ID: ${requestId})`);
|
console.debug(`[SFTP ${sessionId}] Creating write stream for ${path} (ID: ${requestId})`);
|
||||||
const writeStream = state.sftp.createWriteStream(path);
|
const writeStream = state.sftp.createWriteStream(path);
|
||||||
let errorOccurred = false;
|
let errorOccurred = false;
|
||||||
|
|||||||
@@ -1080,16 +1080,26 @@ connectionName: connInfo?.name || 'Unknown', // 添加连接名称 (使用可选
|
|||||||
else throw new Error("Missing 'path' in payload for stat");
|
else throw new Error("Missing 'path' in payload for stat");
|
||||||
break;
|
break;
|
||||||
case 'sftp:readfile':
|
case 'sftp:readfile':
|
||||||
if (payload?.path) sftpService.readFile(sessionId, payload.path, requestId);
|
// --- 修改:提取并传递可选的 encoding 参数 ---
|
||||||
else throw new Error("Missing 'path' in payload for readfile");
|
if (payload?.path) {
|
||||||
|
const requestedEncoding = payload?.encoding; // 获取可选的 encoding
|
||||||
|
sftpService.readFile(sessionId, payload.path, requestId, requestedEncoding); // 传递给 service 方法
|
||||||
|
} else {
|
||||||
|
throw new Error("Missing 'path' in payload for readfile");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
// --- 结束修改 ---
|
||||||
case 'sftp:writefile':
|
case 'sftp:writefile':
|
||||||
const fileContent = payload?.content ?? payload?.data ?? '';
|
const fileContent = payload?.content ?? payload?.data ?? '';
|
||||||
|
// --- 修改:提取可选的 encoding 参数 ---
|
||||||
|
const encoding = payload?.encoding; // 获取可选的 encoding
|
||||||
if (payload?.path) {
|
if (payload?.path) {
|
||||||
const dataToSend = (typeof fileContent === 'string') ? fileContent : '';
|
const dataToSend = (typeof fileContent === 'string') ? fileContent : '';
|
||||||
sftpService.writefile(sessionId, payload.path, dataToSend, requestId);
|
// --- 修改:将 encoding 传递给 service 方法 ---
|
||||||
|
sftpService.writefile(sessionId, payload.path, dataToSend, requestId, encoding);
|
||||||
} else throw new Error("Missing 'path' in payload for writefile");
|
} else throw new Error("Missing 'path' in payload for writefile");
|
||||||
break;
|
break;
|
||||||
|
// --- 结束修改 ---
|
||||||
case 'sftp:mkdir':
|
case 'sftp:mkdir':
|
||||||
if (payload?.path) sftpService.mkdir(sessionId, payload.path, requestId);
|
if (payload?.path) sftpService.mkdir(sessionId, payload.path, requestId);
|
||||||
else throw new Error("Missing 'path' in payload for mkdir");
|
else throw new Error("Missing 'path' in payload for mkdir");
|
||||||
|
|||||||
@@ -12,10 +12,14 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"@vscode/iconv-lite-umd": "^0.7.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"pinia-plugin-persistedstate": "^4.2.0",
|
"pinia-plugin-persistedstate": "^4.2.0",
|
||||||
@@ -28,7 +32,6 @@
|
|||||||
"vue3-recaptcha2": "^1.8.0",
|
"vue3-recaptcha2": "^1.8.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -40,4 +43,3 @@
|
|||||||
"vue-tsc": "^2.2.8"
|
"vue-tsc": "^2.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'close-tab', tabId: string): void;
|
(e: 'close-tab', tabId: string): void;
|
||||||
(e: 'request-save', tabId: string): void; // 发送保存请求,携带 tabId
|
(e: 'request-save', tabId: string): void; // 发送保存请求,携带 tabId
|
||||||
(e: 'update:content', payload: { tabId: string; content: string }): void; // 用于 v-model 同步
|
(e: 'update:content', payload: { tabId: string; content: string }): void; // 用于 v-model 同步
|
||||||
|
(e: 'change-encoding', payload: { tabId: string; encoding: string }): void; // +++ 新增:编码更改事件 +++
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
||||||
@@ -51,14 +52,7 @@ watch(activeTab, (newTab) => {
|
|||||||
localEditorContent.value = newTab?.content ?? '';
|
localEditorContent.value = newTab?.content ?? '';
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// 监听 activeTab 内容的变化 (处理异步加载完成的情况)
|
// 移除用于调试的 watch 函数
|
||||||
watch(() => activeTab.value?.content, (newContent) => {
|
|
||||||
// console.log('[EditorContainer] Active tab content changed, updating local content.');
|
|
||||||
if (localEditorContent.value !== newContent) {
|
|
||||||
localEditorContent.value = newContent ?? '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当本地编辑器内容变化时,通知父组件 (WorkspaceView)
|
// 当本地编辑器内容变化时,通知父组件 (WorkspaceView)
|
||||||
watch(localEditorContent, (newContent) => {
|
watch(localEditorContent, (newContent) => {
|
||||||
// console.log('[EditorContainer] Local content changed, checking if emit needed.');
|
// console.log('[EditorContainer] Local content changed, checking if emit needed.');
|
||||||
@@ -82,6 +76,61 @@ const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
|
|||||||
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
|
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
|
||||||
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
|
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
|
||||||
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态
|
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态
|
||||||
|
// +++ 新增:计算当前选择的编码 +++
|
||||||
|
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
|
||||||
|
|
||||||
|
// +++ 新增:编码选项 +++
|
||||||
|
// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用)
|
||||||
|
// 扩展编码列表以包含更多常用选项
|
||||||
|
const encodingOptions = ref([
|
||||||
|
// Unicode
|
||||||
|
{ value: 'utf-8', text: 'UTF-8' },
|
||||||
|
{ value: 'utf-16le', text: 'UTF-16 LE' },
|
||||||
|
{ value: 'utf-16be', text: 'UTF-16 BE' },
|
||||||
|
// Chinese
|
||||||
|
{ value: 'gbk', text: 'GBK' },
|
||||||
|
{ value: 'gb18030', text: 'GB18030' },
|
||||||
|
{ value: 'big5', text: 'Big5 (Traditional Chinese)' },
|
||||||
|
// Japanese
|
||||||
|
{ value: 'shift_jis', text: 'Shift-JIS' },
|
||||||
|
{ value: 'euc-jp', text: 'EUC-JP' },
|
||||||
|
// Korean
|
||||||
|
{ value: 'euc-kr', text: 'EUC-KR' },
|
||||||
|
// Western European
|
||||||
|
{ value: 'iso-8859-1', text: 'ISO-8859-1 (Latin-1)' },
|
||||||
|
{ value: 'iso-8859-15', text: 'ISO-8859-15 (Latin-9)' },
|
||||||
|
{ value: 'cp1252', text: 'Windows-1252' }, // Western European
|
||||||
|
// Central European
|
||||||
|
{ value: 'iso-8859-2', text: 'ISO-8859-2 (Latin-2)' },
|
||||||
|
{ value: 'cp1250', text: 'Windows-1250' }, // Central European
|
||||||
|
// Cyrillic
|
||||||
|
{ value: 'iso-8859-5', text: 'ISO-8859-5 (Cyrillic)' },
|
||||||
|
{ value: 'cp1251', text: 'Windows-1251 (Cyrillic)' },
|
||||||
|
{ value: 'koi8-r', text: 'KOI8-R' },
|
||||||
|
{ value: 'koi8-u', text: 'KOI8-U' },
|
||||||
|
// Greek
|
||||||
|
{ value: 'iso-8859-7', text: 'ISO-8859-7 (Greek)' },
|
||||||
|
{ value: 'cp1253', text: 'Windows-1253 (Greek)' },
|
||||||
|
// Turkish
|
||||||
|
{ value: 'iso-8859-9', text: 'ISO-8859-9 (Turkish)' },
|
||||||
|
{ value: 'cp1254', text: 'Windows-1254 (Turkish)' },
|
||||||
|
// Hebrew
|
||||||
|
{ value: 'iso-8859-8', text: 'ISO-8859-8 (Hebrew)' },
|
||||||
|
{ value: 'cp1255', text: 'Windows-1255 (Hebrew)' },
|
||||||
|
// Arabic
|
||||||
|
{ value: 'iso-8859-6', text: 'ISO-8859-6 (Arabic)' },
|
||||||
|
{ value: 'cp1256', text: 'Windows-1256 (Arabic)' },
|
||||||
|
// Baltic
|
||||||
|
{ value: 'iso-8859-4', text: 'ISO-8859-4 (Baltic)' }, // Latin-4
|
||||||
|
{ value: 'iso-8859-13', text: 'ISO-8859-13 (Baltic)' }, // Latin-7
|
||||||
|
{ value: 'cp1257', text: 'Windows-1257 (Baltic)' },
|
||||||
|
// Vietnamese
|
||||||
|
{ value: 'cp1258', text: 'Windows-1258 (Vietnamese)' },
|
||||||
|
// Thai
|
||||||
|
{ value: 'tis-620', text: 'TIS-620 (Thai)' }, // Often cp874
|
||||||
|
{ value: 'cp874', text: 'Windows-874 (Thai)' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
// --- 事件处理 ---
|
// --- 事件处理 ---
|
||||||
const handleSaveRequest = () => {
|
const handleSaveRequest = () => {
|
||||||
@@ -90,6 +139,17 @@ const handleSaveRequest = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// +++ 新增:处理编码更改事件 +++
|
||||||
|
const handleEncodingChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const newEncoding = target.value;
|
||||||
|
if (activeTab.value && newEncoding && newEncoding !== currentSelectedEncoding.value) {
|
||||||
|
console.log(`[EditorContainer] Encoding changed to ${newEncoding} for tab ${activeTab.value.id}`);
|
||||||
|
emit('change-encoding', { tabId: activeTab.value.id, encoding: newEncoding });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 注意:关闭/最小化按钮现在应该在 WorkspaceView 控制 Pane,而不是这里
|
// 注意:关闭/最小化按钮现在应该在 WorkspaceView 控制 Pane,而不是这里
|
||||||
// const handleCloseContainer = () => { ... };
|
// const handleCloseContainer = () => { ... };
|
||||||
// const handleMinimizeContainer = () => { ... };
|
// const handleMinimizeContainer = () => { ... };
|
||||||
@@ -180,6 +240,21 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
|||||||
<span v-if="currentTabIsModified" class="modified-indicator">*</span>
|
<span v-if="currentTabIsModified" class="modified-indicator">*</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
|
<!-- +++ 新增:编码选择下拉菜单 +++ -->
|
||||||
|
<select
|
||||||
|
v-if="activeTab && !currentTabIsLoading"
|
||||||
|
:value="currentSelectedEncoding"
|
||||||
|
@change="handleEncodingChange"
|
||||||
|
class="encoding-select"
|
||||||
|
:title="t('fileManager.changeEncodingTooltip', '更改文件编码')"
|
||||||
|
>
|
||||||
|
<option v-for="option in encodingOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.text }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else-if="activeTab" class="encoding-select-placeholder">{{ t('fileManager.loadingEncoding', '加载中...') }}</span>
|
||||||
|
<!-- +++ 结束新增 +++ -->
|
||||||
|
|
||||||
<span v-if="currentTabSaveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span>
|
<span v-if="currentTabSaveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span>
|
||||||
<span v-if="currentTabSaveStatus === 'success'" class="save-status success">✅ {{ t('fileManager.saveSuccess') }}</span>
|
<span v-if="currentTabSaveStatus === 'success'" class="save-status success">✅ {{ t('fileManager.saveSuccess') }}</span>
|
||||||
<span v-if="currentTabSaveStatus === 'error'" class="save-status error">❌ {{ t('fileManager.saveError') }}: {{ currentTabSaveError }}</span>
|
<span v-if="currentTabSaveStatus === 'error'" class="save-status error">❌ {{ t('fileManager.saveError') }}: {{ currentTabSaveError }}</span>
|
||||||
@@ -276,7 +351,7 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
|||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.8rem; /* 稍微减小间距以容纳下拉菜单 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
@@ -306,3 +381,34 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style scoped> /* Add new styles below existing scoped styles */
|
||||||
|
.encoding-select {
|
||||||
|
background-color: #444;
|
||||||
|
color: #f0f0f0;
|
||||||
|
border: 1px solid #666;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-select:hover {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-select:focus {
|
||||||
|
border-color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encoding-select-placeholder {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 80px; /* 与 select 大致对齐 */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const emit = defineEmits({
|
|||||||
'find-previous': null, // ()
|
'find-previous': null, // ()
|
||||||
'close-search': null, // ()
|
'close-search': null, // ()
|
||||||
'clear-terminal': null, // () +++ 添加 clear-terminal 事件 +++
|
'clear-terminal': null, // () +++ 添加 clear-terminal 事件 +++
|
||||||
|
'change-encoding': null, // +++ 添加 change-encoding 事件 +++
|
||||||
// --- 移除 RDP 事件 ---
|
// --- 移除 RDP 事件 ---
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,6 +204,8 @@ const componentProps = computed(() => {
|
|||||||
onActivateTab: (tabId: string) => emit('activateEditorTab', tabId),
|
onActivateTab: (tabId: string) => emit('activateEditorTab', tabId),
|
||||||
'onUpdate:content': (payload: { tabId: string; content: string }) => emit('updateEditorContent', payload), // 注意事件名
|
'onUpdate:content': (payload: { tabId: string; content: string }) => emit('updateEditorContent', payload), // 注意事件名
|
||||||
onRequestSave: (tabId: string) => emit('saveEditorTab', tabId),
|
onRequestSave: (tabId: string) => emit('saveEditorTab', tabId),
|
||||||
|
// +++ 添加:转发 change-encoding 事件 +++
|
||||||
|
onChangeEncoding: (payload: { tabId: string; encoding: string }) => emit('change-encoding', payload),
|
||||||
};
|
};
|
||||||
case 'commandBar':
|
case 'commandBar':
|
||||||
// CommandInputBar 需要转发 send-command 事件
|
// CommandInputBar 需要转发 send-command 事件
|
||||||
@@ -510,6 +513,7 @@ onMounted(() => {
|
|||||||
@find-previous="emit('find-previous')"
|
@find-previous="emit('find-previous')"
|
||||||
@close-search="emit('close-search')"
|
@close-search="emit('close-search')"
|
||||||
@clear-terminal="() => emit('clear-terminal')"
|
@clear-terminal="() => emit('clear-terminal')"
|
||||||
|
@change-encoding="emit('change-encoding', $event)"
|
||||||
class="flex-grow overflow-auto"
|
class="flex-grow overflow-auto"
|
||||||
/>
|
/>
|
||||||
</pane>
|
</pane>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, readonly, reactive, computed, type Ref, type ComputedRef } from 'vue'; // 引入 reactive 和 computed
|
import { ref, readonly, reactive, computed, type Ref, type ComputedRef } from 'vue'; // 引入 reactive 和 computed
|
||||||
import type { FileListItem, FileAttributes, EditorFileContent } from '../types/sftp.types'; // 修正导入为 FileAttributes
|
import type { FileListItem, FileAttributes, EditorFileContent, SftpReadFileSuccessPayload, SftpReadFileRequestPayload } from '../types/sftp.types'; // +++ 添加 SftpReadFileRequestPayload 导入 +++
|
||||||
import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types';
|
import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types';
|
||||||
// 导入 UI 通知 store
|
// 导入 UI 通知 store
|
||||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // 更正导入
|
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // 更正导入
|
||||||
@@ -307,11 +307,12 @@ export function createSftpActionsManager(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销
|
// readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销
|
||||||
const readFile = (path: string): Promise<EditorFileContent> => {
|
// --- 修改:接受可选 encoding 参数,返回包含 content 和 encodingUsed 的 Payload ---
|
||||||
|
const readFile = (path: string, encoding?: string): Promise<SftpReadFileSuccessPayload> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!isSftpReady.value) {
|
if (!isSftpReady.value) {
|
||||||
const errMsg = t('fileManager.errors.sftpNotReady');
|
const errMsg = t('fileManager.errors.sftpNotReady');
|
||||||
console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`); // 日志改为中文
|
console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`);
|
||||||
uiNotificationsStore.showError(errMsg);
|
uiNotificationsStore.showError(errMsg);
|
||||||
return reject(new Error(errMsg));
|
return reject(new Error(errMsg));
|
||||||
}
|
}
|
||||||
@@ -328,34 +329,40 @@ export function createSftpActionsManager(
|
|||||||
}, 20000); // 20 秒超时
|
}, 20000); // 20 秒超时
|
||||||
|
|
||||||
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
|
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||||
// 确保 payload 是期望的类型
|
// +++ 修改:处理包含 rawContentBase64 和 encodingUsed 的新 payload +++
|
||||||
const successPayload = payload as { content: string; encoding: 'utf8' | 'base64' };
|
const successPayload = payload as SftpReadFileSuccessPayload; // Type assertion remains valid
|
||||||
if (message.requestId === requestId && message.path === path) {
|
if (message.requestId === requestId && message.path === path) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
unregisterSuccess?.();
|
unregisterSuccess?.();
|
||||||
unregisterError?.();
|
unregisterError?.();
|
||||||
resolve({ content: successPayload.content, encoding: successPayload.encoding });
|
// Resolve with the new payload structure
|
||||||
|
resolve({ rawContentBase64: successPayload.rawContentBase64, encodingUsed: successPayload.encodingUsed });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => {
|
unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => {
|
||||||
// 确保 payload 是期望的类型 (string)
|
|
||||||
const errorPayload = payload as string;
|
const errorPayload = payload as string;
|
||||||
if (message.requestId === requestId && message.path === path) {
|
if (message.requestId === requestId && message.path === path) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
unregisterSuccess?.();
|
unregisterSuccess?.();
|
||||||
unregisterError?.();
|
unregisterError?.();
|
||||||
const errorMsg = errorPayload || t('fileManager.errors.readFileFailed'); // 使用 i18n
|
const errorMsg = errorPayload || t('fileManager.errors.readFileFailed');
|
||||||
uiNotificationsStore.showError(`${t('fileManager.errors.readFileError')}: ${errorMsg}`);
|
uiNotificationsStore.showError(`${t('fileManager.errors.readFileError')}: ${errorMsg}`);
|
||||||
reject(new Error(errorMsg));
|
reject(new Error(errorMsg));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } });
|
// --- 修改:在 payload 中包含可选的 encoding ---
|
||||||
|
const requestPayload: SftpReadFileRequestPayload = { path };
|
||||||
|
if (encoding) {
|
||||||
|
requestPayload.encoding = encoding;
|
||||||
|
}
|
||||||
|
sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: requestPayload });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeFile = (path: string, content: string): Promise<void> => {
|
// --- 修改:接受可选 encoding 参数 ---
|
||||||
|
const writeFile = (path: string, content: string, encoding?: string): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!isSftpReady.value) {
|
if (!isSftpReady.value) {
|
||||||
const errMsg = t('fileManager.errors.sftpNotReady');
|
const errMsg = t('fileManager.errors.sftpNotReady');
|
||||||
@@ -364,7 +371,8 @@ export function createSftpActionsManager(
|
|||||||
return reject(new Error(errMsg));
|
return reject(new Error(errMsg));
|
||||||
}
|
}
|
||||||
const requestId = generateRequestId();
|
const requestId = generateRequestId();
|
||||||
const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
|
// --- 修改:使用传入的 encoding,默认为 utf8 ---
|
||||||
|
const finalEncoding = encoding || 'utf8';
|
||||||
let unregisterSuccess: (() => void) | null = null;
|
let unregisterSuccess: (() => void) | null = null;
|
||||||
let unregisterError: (() => void) | null = null;
|
let unregisterError: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -401,7 +409,8 @@ export function createSftpActionsManager(
|
|||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'sftp:writefile',
|
type: 'sftp:writefile',
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
payload: { path, content, encoding }
|
// --- 修改:在 payload 中包含最终的编码 ---
|
||||||
|
payload: { path, content, encoding: finalEncoding }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -333,7 +333,9 @@
|
|||||||
"noOpenFile": "No file open",
|
"noOpenFile": "No file open",
|
||||||
"selectFileToEdit": "Select a file from the file manager to start editing.",
|
"selectFileToEdit": "Select a file from the file manager to start editing.",
|
||||||
"searchPlaceholder": "Search files...",
|
"searchPlaceholder": "Search files...",
|
||||||
"dropFilesHere": "Drop files here to upload"
|
"dropFilesHere": "Drop files here to upload",
|
||||||
|
"changeEncodingTooltip": "Change file encoding",
|
||||||
|
"loadingEncoding": "Loading..."
|
||||||
},
|
},
|
||||||
"statusMonitor": {
|
"statusMonitor": {
|
||||||
"title": "Server Status",
|
"title": "Server Status",
|
||||||
|
|||||||
@@ -327,7 +327,9 @@
|
|||||||
"uploadTasks": "アップロードタスク",
|
"uploadTasks": "アップロードタスク",
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"moveSameDirectory": "同じディレクトリ内で切り取りと貼り付けはできません。"
|
"moveSameDirectory": "同じディレクトリ内で切り取りと貼り付けはできません。"
|
||||||
}
|
},
|
||||||
|
"changeEncodingTooltip": "ファイルエンコーディングを変更",
|
||||||
|
"loadingEncoding": "読み込み中..."
|
||||||
},
|
},
|
||||||
"focusSwitcher": {
|
"focusSwitcher": {
|
||||||
"allInputsConfigured": "すべての利用可能な入力ソースが設定されました",
|
"allInputsConfigured": "すべての利用可能な入力ソースが設定されました",
|
||||||
|
|||||||
@@ -333,7 +333,9 @@
|
|||||||
"noOpenFile": "未打开文件",
|
"noOpenFile": "未打开文件",
|
||||||
"selectFileToEdit": "请从文件管理器中选择文件以开始编辑。",
|
"selectFileToEdit": "请从文件管理器中选择文件以开始编辑。",
|
||||||
"searchPlaceholder": "搜索文件...",
|
"searchPlaceholder": "搜索文件...",
|
||||||
"dropFilesHere": "将文件拖拽到此处上传"
|
"dropFilesHere": "将文件拖拽到此处上传",
|
||||||
|
"changeEncodingTooltip": "更改文件编码",
|
||||||
|
"loadingEncoding": "加载中..."
|
||||||
},
|
},
|
||||||
"statusMonitor": {
|
"statusMonitor": {
|
||||||
"title": "服务器状态",
|
"title": "服务器状态",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { ref, computed, readonly, watch, nextTick } from 'vue'; // Import nextTi
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSessionStore } from './session.store'; // 导入会话 Store
|
import { useSessionStore } from './session.store'; // 导入会话 Store
|
||||||
import type { EditorFileContent, SaveStatus } from '../types/sftp.types'; // 保持导入 SaveStatus
|
import type { SaveStatus, SftpReadFileSuccessPayload } from '../types/sftp.types'; // 移除 SftpReadFileRequestPayload, 因为 readFile 不再需要它
|
||||||
|
import * as iconv from '@vscode/iconv-lite-umd'; // +++ 导入 iconv-lite +++
|
||||||
|
import { Buffer } from 'buffer/'; // +++ 导入 Buffer (需要安装 buffer 依赖) +++
|
||||||
|
|
||||||
// --- 类型定义 ---
|
// --- 类型定义 ---
|
||||||
// 文件信息,用于打开文件操作
|
// 文件信息,用于打开文件操作
|
||||||
@@ -12,24 +14,23 @@ export interface FileInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编辑器标签页状态
|
// 编辑器标签页状态
|
||||||
|
// 编辑器标签页状态 (简化)
|
||||||
export interface FileTab {
|
export interface FileTab {
|
||||||
id: string; // 唯一标识符,例如 `${sessionId}:${filePath}`
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
filename: string; // 文件名,用于标签显示
|
filename: string;
|
||||||
content: string; // 当前编辑器内容
|
content: string; // 当前解码后的内容 (前端解码)
|
||||||
originalContent: string; // 加载或上次保存时的内容
|
originalContent: string; // 初始加载或上次保存时解码后的内容 (前端解码)
|
||||||
|
rawContentBase64: string | null; // +++ 新增:存储原始 Base64 数据 +++
|
||||||
language: string;
|
language: string;
|
||||||
encoding: 'utf8' | 'base64'; // 原始编码
|
selectedEncoding: string; // 当前选择或自动检测到的编码
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
loadingError: string | null;
|
loadingError: string | null;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
saveStatus: SaveStatus;
|
saveStatus: SaveStatus;
|
||||||
saveError: string | null;
|
saveError: string | null;
|
||||||
isModified: boolean; // 内容是否已修改
|
isModified: boolean;
|
||||||
// 添加 sessionId 以便在共享模式下区分来源 (虽然此 store 主要用于共享模式)
|
|
||||||
// 或者在独立模式下,此 store 可能不被使用或以不同方式使用
|
|
||||||
// sessionId: string; // 暂时不加,因为 session.store 已处理独立模式
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 辅助函数 (移到外部并导出) ---
|
// --- 辅助函数 (移到外部并导出) ---
|
||||||
@@ -68,6 +69,33 @@ export const getLanguageFromFilename = (filename: string): string => {
|
|||||||
export const getFilenameFromPath = (filePath: string): string => {
|
export const getFilenameFromPath = (filePath: string): string => {
|
||||||
return filePath.split('/').pop() || filePath;
|
return filePath.split('/').pop() || filePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// +++ 新增:前端解码辅助函数 +++
|
||||||
|
const decodeRawContent = (rawContentBase64: string, encoding: string): string => {
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(rawContentBase64, 'base64');
|
||||||
|
const normalizedEncoding = encoding.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize encoding name
|
||||||
|
|
||||||
|
// 优先使用 TextDecoder 处理标准编码
|
||||||
|
if (['utf8', 'utf16le', 'utf16be'].includes(normalizedEncoding)) {
|
||||||
|
const decoder = new TextDecoder(encoding); // Use original encoding name for TextDecoder
|
||||||
|
return decoder.decode(buffer);
|
||||||
|
}
|
||||||
|
// 使用 iconv-lite 处理其他编码
|
||||||
|
else if (iconv.encodingExists(normalizedEncoding)) {
|
||||||
|
return iconv.decode(buffer, normalizedEncoding);
|
||||||
|
}
|
||||||
|
// 如果 iconv-lite 也不支持,回退到 UTF-8 并警告
|
||||||
|
else {
|
||||||
|
console.warn(`[decodeRawContent] Unsupported encoding "${encoding}" requested. Falling back to UTF-8.`);
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
return decoder.decode(buffer);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[decodeRawContent] Error decoding content with encoding "${encoding}":`, error);
|
||||||
|
return `// Error decoding content: ${error.message}`; // 返回错误信息
|
||||||
|
}
|
||||||
|
};
|
||||||
// --- End Helper Functions ---
|
// --- End Helper Functions ---
|
||||||
|
|
||||||
export const useFileEditorStore = defineStore('fileEditor', () => {
|
export const useFileEditorStore = defineStore('fileEditor', () => {
|
||||||
@@ -98,6 +126,9 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 移除 decodeBase64Content 辅助方法 ---
|
||||||
|
|
||||||
|
|
||||||
// --- 核心方法 ---
|
// --- 核心方法 ---
|
||||||
|
|
||||||
// 修改:triggerPopup 接收文件信息并存储
|
// 修改:triggerPopup 接收文件信息并存储
|
||||||
@@ -135,23 +166,23 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新标签页
|
// 创建新标签页 (使用简化后的 FileTab)
|
||||||
const newTab: FileTab = {
|
const newTab: FileTab = {
|
||||||
id: tabId,
|
id: tabId,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
filePath: targetFilePath,
|
filePath: targetFilePath,
|
||||||
filename: getFilenameFromPath(targetFilePath),
|
filename: getFilenameFromPath(targetFilePath),
|
||||||
content: '', // 初始为空
|
content: '', // 将在加载后由前端解码填充
|
||||||
originalContent: '', // 初始为空
|
originalContent: '', // 将在加载后由前端解码填充
|
||||||
|
rawContentBase64: null, // +++ 初始化为 null +++
|
||||||
language: getLanguageFromFilename(targetFilePath),
|
language: getLanguageFromFilename(targetFilePath),
|
||||||
encoding: 'utf8', // 默认为 utf8
|
selectedEncoding: 'utf-8', // 初始默认,将由后端更新
|
||||||
isLoading: true, // 开始加载
|
isLoading: true,
|
||||||
loadingError: null,
|
loadingError: null,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
saveStatus: 'idle',
|
saveStatus: 'idle',
|
||||||
saveError: null,
|
saveError: null,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
// sessionId: sessionId, // 记录来源会话
|
|
||||||
};
|
};
|
||||||
tabs.value.set(tabId, newTab);
|
tabs.value.set(tabId, newTab);
|
||||||
// setActiveTab(tabId); // 移除同步激活
|
// setActiveTab(tabId); // 移除同步激活
|
||||||
@@ -179,51 +210,33 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
try {
|
try {
|
||||||
const fileData = await sftpManager.readFile(targetFilePath);
|
// 调用 sftpManager.readFile 获取原始数据和编码
|
||||||
console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 读取成功。编码: ${fileData.encoding}`);
|
const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(targetFilePath);
|
||||||
|
console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 原始数据读取成功。后端使用编码: ${fileData.encodingUsed}`);
|
||||||
|
|
||||||
let decodedContent = '';
|
const tabToUpdate = tabs.value.get(tabId);
|
||||||
let finalEncoding: 'utf8' | 'base64' = 'utf8';
|
if (!tabToUpdate) {
|
||||||
|
console.error(`[文件编辑器 Store] 无法更新标签页 ${tabId},因为它在加载完成前被关闭了。`);
|
||||||
if (fileData.encoding === 'base64') {
|
return;
|
||||||
finalEncoding = 'base64';
|
|
||||||
try {
|
|
||||||
const binaryString = atob(fileData.content);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const decoder = new TextDecoder('utf-8'); // 显式使用 UTF-8
|
|
||||||
decodedContent = decoder.decode(bytes);
|
|
||||||
console.log(`[文件编辑器 Store] Base64 文件 ${targetFilePath} 已解码为 UTF-8。`);
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.error(`[文件编辑器 Store] Base64 或 UTF-8 解码错误 for ${targetFilePath}:`, decodeError);
|
|
||||||
const errorMsg = t('fileManager.errors.fileDecodeError');
|
|
||||||
decodedContent = `// ${errorMsg}\n// Original Base64 content:\n${fileData.content}`;
|
|
||||||
// 更新标签页状态以反映错误
|
|
||||||
const tabToUpdate = tabs.value.get(tabId);
|
|
||||||
if (tabToUpdate) {
|
|
||||||
tabToUpdate.loadingError = errorMsg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalEncoding = 'utf8';
|
|
||||||
decodedContent = fileData.content;
|
|
||||||
console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 已按 ${finalEncoding} 处理。`);
|
|
||||||
if (decodedContent.includes('\uFFFD')) {
|
|
||||||
console.warn(`[文件编辑器 Store] 文件 ${targetFilePath} 内容可能包含无效字符,原始编码可能不是 UTF-8。`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// +++ 前端解码 +++
|
||||||
|
const initialContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed);
|
||||||
|
|
||||||
// 更新标签页状态
|
// 更新标签页状态
|
||||||
const tabToUpdate = tabs.value.get(tabId);
|
const updatedTab: FileTab = {
|
||||||
if (tabToUpdate) {
|
...tabToUpdate,
|
||||||
tabToUpdate.content = decodedContent;
|
rawContentBase64: fileData.rawContentBase64, // 存储原始数据
|
||||||
tabToUpdate.originalContent = decodedContent; // 设置初始内容
|
content: initialContent,
|
||||||
tabToUpdate.encoding = finalEncoding;
|
originalContent: initialContent, // 初始原始内容
|
||||||
tabToUpdate.isLoading = false;
|
selectedEncoding: fileData.encodingUsed, // 存储后端实际使用的编码
|
||||||
tabToUpdate.isModified = false; // 初始未修改
|
isLoading: false,
|
||||||
}
|
isModified: false,
|
||||||
|
loadingError: null,
|
||||||
|
};
|
||||||
|
tabs.value.set(tabId, updatedTab); // 替换以确保响应性
|
||||||
|
|
||||||
|
console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 内容已解码 (${fileData.encodingUsed}) 并设置到标签页 ${tabId}。`);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[文件编辑器 Store] 读取文件 ${targetFilePath} 失败:`, err);
|
console.error(`[文件编辑器 Store] 读取文件 ${targetFilePath} 失败:`, err);
|
||||||
@@ -323,10 +336,12 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
tab.saveError = null;
|
tab.saveError = null;
|
||||||
|
|
||||||
const contentToSave = tab.content;
|
const contentToSave = tab.content;
|
||||||
|
const encodingToUse = tab.selectedEncoding; // 获取选定的编码
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sftpManager.writeFile(tab.filePath, contentToSave);
|
// --- 修改:传递 selectedEncoding 给 writeFile ---
|
||||||
console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 保存成功。`);
|
await sftpManager.writeFile(tab.filePath, contentToSave, encodingToUse);
|
||||||
|
console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 使用编码 ${encodingToUse} 保存成功。`);
|
||||||
tab.isSaving = false;
|
tab.isSaving = false;
|
||||||
tab.saveStatus = 'success';
|
tab.saveStatus = 'success';
|
||||||
tab.saveError = null;
|
tab.saveError = null;
|
||||||
@@ -427,6 +442,63 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// +++ 修改:更改文件编码(通过请求后端重新读取) +++
|
||||||
|
// +++ 修改:changeEncoding 现在在前端解码 +++
|
||||||
|
const changeEncoding = (tabId: string, newEncoding: string) => {
|
||||||
|
const tab = tabs.value.get(tabId);
|
||||||
|
if (!tab) {
|
||||||
|
console.warn(`[文件编辑器 Store] 尝试更改不存在的标签页 ${tabId} 的编码。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tab.rawContentBase64) {
|
||||||
|
console.error(`[文件编辑器 Store] 无法更改编码:标签页 ${tabId} 没有原始文件数据。`);
|
||||||
|
// 可以设置错误状态
|
||||||
|
tab.loadingError = '缺少原始文件数据,无法更改编码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tab.selectedEncoding === newEncoding) {
|
||||||
|
console.log(`[文件编辑器 Store] 编码已经是 ${newEncoding},无需更改。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[文件编辑器 Store] 使用新编码 "${newEncoding}" 在前端重新解码文件: ${tab.filePath} (Tab ID: ${tabId})`);
|
||||||
|
|
||||||
|
// 设置加载状态(可选,解码通常很快,但可以防止 UI 闪烁)
|
||||||
|
// tab.isLoading = true;
|
||||||
|
// tab.loadingError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用新编码解码存储的原始数据
|
||||||
|
const newContent = decodeRawContent(tab.rawContentBase64, newEncoding);
|
||||||
|
|
||||||
|
// 更新标签页状态
|
||||||
|
const updatedTab: FileTab = {
|
||||||
|
...tab,
|
||||||
|
content: newContent,
|
||||||
|
selectedEncoding: newEncoding, // 更新选择的编码
|
||||||
|
isLoading: false, // 解码完成
|
||||||
|
loadingError: null,
|
||||||
|
// isModified 状态保持不变
|
||||||
|
};
|
||||||
|
tabs.value.set(tabId, updatedTab);
|
||||||
|
console.log(`[文件编辑器 Store] 文件 ${tab.filePath} 使用新编码 "${newEncoding}" 解码完成。`);
|
||||||
|
|
||||||
|
} catch (err: any) { // catch 应该在 decodeRawContent 内部处理了,但以防万一
|
||||||
|
console.error(`[文件编辑器 Store] 使用编码 "${newEncoding}" 在前端解码文件 ${tab.filePath} 失败:`, err);
|
||||||
|
const errorMsg = `前端解码失败 (编码: ${newEncoding}): ${err.message || err}`;
|
||||||
|
// 更新错误状态
|
||||||
|
const errorTab: FileTab = {
|
||||||
|
...tab,
|
||||||
|
isLoading: false,
|
||||||
|
loadingError: errorMsg,
|
||||||
|
};
|
||||||
|
tabs.value.set(tabId, errorTab);
|
||||||
|
}
|
||||||
|
// finally {
|
||||||
|
// if (tab) tab.isLoading = false; // 确保加载状态被重置
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
// 移除旧的 updateContent,因为它只更新活动标签页
|
// 移除旧的 updateContent,因为它只更新活动标签页
|
||||||
// const updateContent = (newContent: string) => { ... };
|
// const updateContent = (newContent: string) => { ... };
|
||||||
|
|
||||||
@@ -487,6 +559,7 @@ export const useFileEditorStore = defineStore('fileEditor', () => {
|
|||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateFileContent, // 暴露新的更新方法
|
updateFileContent, // 暴露新的更新方法
|
||||||
|
changeEncoding, // +++ 暴露更改编码的方法 +++
|
||||||
triggerPopup, // 暴露新的触发方法
|
triggerPopup, // 暴露新的触发方法
|
||||||
// setEditorVisibility, // 移除
|
// setEditorVisibility, // 移除
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRouter } from 'vue-router'; // +++ 导入 useRouter +++
|
import { useRouter } from 'vue-router'; // +++ 导入 useRouter +++
|
||||||
import { useConnectionsStore, type ConnectionInfo } from './connections.store';
|
import { useConnectionsStore, type ConnectionInfo } from './connections.store';
|
||||||
// 导入文件编辑器相关的类型
|
// 导入文件编辑器相关的类型
|
||||||
import type { FileTab, FileInfo } from './fileEditor.store'; // 导入 FileTab 和 FileInfo
|
import type { FileTab, FileInfo } from './fileEditor.store'; // 导入 FileTab 和 FileInfo (确保 FileTab 已在 fileEditor.store.ts 中简化)
|
||||||
|
import type { SftpReadFileSuccessPayload } from '../types/sftp.types'; // 导入更新后的类型
|
||||||
|
// --- 修复:添加 iconv-lite 和 buffer 导入 ---
|
||||||
|
import * as iconv from '@vscode/iconv-lite-umd';
|
||||||
|
import { Buffer } from 'buffer/';
|
||||||
|
|
||||||
// 导入管理器工厂函数 (用于创建实例)
|
// 导入管理器工厂函数 (用于创建实例)
|
||||||
// 导入 WsConnectionStatus 类型
|
// 导入 WsConnectionStatus 类型
|
||||||
@@ -51,6 +55,33 @@ const getLanguageFromFilename = (filename: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 修复:添加 decodeRawContent 辅助函数 ---
|
||||||
|
const decodeRawContent = (rawContentBase64: string, encoding: string): string => {
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(rawContentBase64, 'base64');
|
||||||
|
const normalizedEncoding = encoding.toLowerCase().replace(/[^a-z0-9]/g, ''); // Normalize encoding name
|
||||||
|
|
||||||
|
// 优先使用 TextDecoder 处理标准编码
|
||||||
|
if (['utf8', 'utf16le', 'utf16be'].includes(normalizedEncoding)) {
|
||||||
|
const decoder = new TextDecoder(encoding); // Use original encoding name for TextDecoder
|
||||||
|
return decoder.decode(buffer);
|
||||||
|
}
|
||||||
|
// 使用 iconv-lite 处理其他编码
|
||||||
|
else if (iconv.encodingExists(normalizedEncoding)) {
|
||||||
|
return iconv.decode(buffer, normalizedEncoding);
|
||||||
|
}
|
||||||
|
// 如果 iconv-lite 也不支持,回退到 UTF-8 并警告
|
||||||
|
else {
|
||||||
|
console.warn(`[SessionStore decodeRawContent] Unsupported encoding "${encoding}" requested. Falling back to UTF-8.`);
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
return decoder.decode(buffer);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SessionStore decodeRawContent] Error decoding content with encoding "${encoding}":`, error);
|
||||||
|
return `// Error decoding content: ${error.message}`; // 返回错误信息
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// --- 结束修复 ---
|
||||||
|
|
||||||
// --- 类型定义 (导出以便其他模块使用) ---
|
// --- 类型定义 (导出以便其他模块使用) ---
|
||||||
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
||||||
@@ -91,6 +122,9 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
const connectionsStore = useConnectionsStore();
|
const connectionsStore = useConnectionsStore();
|
||||||
const router = useRouter(); // +++ 获取 router 实例 +++
|
const router = useRouter(); // +++ 获取 router 实例 +++
|
||||||
|
|
||||||
|
// --- 移除 decodeBase64Content 辅助方法 ---
|
||||||
|
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
// 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性
|
// 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性
|
||||||
const sessions = shallowRef<Map<string, SessionState>>(new Map());
|
const sessions = shallowRef<Map<string, SessionState>>(new Map());
|
||||||
@@ -299,10 +333,12 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
tab.saveError = null;
|
tab.saveError = null;
|
||||||
|
|
||||||
const contentToSave = tab.content;
|
const contentToSave = tab.content;
|
||||||
|
const encodingToUse = tab.selectedEncoding; // 获取选定的编码
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sftpManager.writeFile(tab.filePath, contentToSave);
|
// --- 修改:传递 selectedEncoding 给 writeFile ---
|
||||||
console.log(`[SessionStore] 文件 ${tab.filePath} (会话 ${sessionId}) 保存成功。`);
|
await sftpManager.writeFile(tab.filePath, contentToSave, encodingToUse);
|
||||||
|
console.log(`[SessionStore] 文件 ${tab.filePath} (会话 ${sessionId}) 使用编码 ${encodingToUse} 保存成功。`);
|
||||||
tab.isSaving = false;
|
tab.isSaving = false;
|
||||||
tab.saveStatus = 'success';
|
tab.saveStatus = 'success';
|
||||||
tab.saveError = null;
|
tab.saveError = null;
|
||||||
@@ -456,24 +492,24 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
console.log(`[SessionStore] 会话 ${sessionId} 中已存在文件 ${fileInfo.fullPath} 的标签页,已激活: ${existingTab.id}`);
|
console.log(`[SessionStore] 会话 ${sessionId} 中已存在文件 ${fileInfo.fullPath} 的标签页,已激活: ${existingTab.id}`);
|
||||||
} else {
|
} else {
|
||||||
// 创建新标签页
|
// 创建新标签页
|
||||||
|
// 创建新标签页 (使用简化后的 FileTab 接口)
|
||||||
|
// --- 修复:初始化 rawContentBase64 ---
|
||||||
const newTab: FileTab = {
|
const newTab: FileTab = {
|
||||||
id: generateSessionId(), // 复用会话 ID 生成逻辑创建唯一标签页 ID
|
id: generateSessionId(), // 使用独立 ID
|
||||||
filename: fileInfo.name, // 使用 filename 匹配 FileTab 接口
|
sessionId: sessionId,
|
||||||
filePath: fileInfo.fullPath, // 使用 filePath 匹配 FileTab 接口
|
filePath: fileInfo.fullPath,
|
||||||
// content, originalContent, language, encoding 将在 FileEditorContainer 或 fileEditor.store 中处理
|
filename: fileInfo.name,
|
||||||
content: '', // 初始内容为空
|
content: '', // 将由后端填充
|
||||||
originalContent: '', // 初始原始内容为空
|
originalContent: '', // 将由后端填充
|
||||||
language: 'plaintext', // 初始语言,稍后会根据文件名更新
|
rawContentBase64: null, // 初始化为 null
|
||||||
encoding: 'utf8', // 默认编码
|
language: getLanguageFromFilename(fileInfo.name),
|
||||||
isModified: false, // 使用 isModified 匹配 FileTab 接口
|
selectedEncoding: 'utf-8', // 初始默认,将由后端更新
|
||||||
isLoading: false, // 初始化为 boolean
|
isLoading: true,
|
||||||
loadingError: null, // 使用 loadingError 匹配 FileTab 接口
|
loadingError: null,
|
||||||
// --- 编辑器状态相关 ---
|
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
saveStatus: 'idle',
|
saveStatus: 'idle',
|
||||||
saveError: null,
|
saveError: null,
|
||||||
// --- 关联会话 ID ---
|
isModified: false,
|
||||||
sessionId: sessionId, // 记录此标签页属于哪个会话
|
|
||||||
};
|
};
|
||||||
// session.editorTabs.value.push(newTab); // 移除重复的 push
|
// session.editorTabs.value.push(newTab); // 移除重复的 push
|
||||||
session.editorTabs.value.push(newTab);
|
session.editorTabs.value.push(newTab);
|
||||||
@@ -481,64 +517,67 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
console.log(`[SessionStore] 已在会话 ${sessionId} 中为文件 ${fileInfo.fullPath} 创建新标签页: ${newTab.id}`);
|
console.log(`[SessionStore] 已在会话 ${sessionId} 中为文件 ${fileInfo.fullPath} 创建新标签页: ${newTab.id}`);
|
||||||
|
|
||||||
// --- 新增:异步加载文件内容 ---
|
// --- 新增:异步加载文件内容 ---
|
||||||
|
// --- 修改:异步加载文件内容 (处理后端解码后的内容) ---
|
||||||
const loadContent = async () => {
|
const loadContent = async () => {
|
||||||
const tabToLoad = session.editorTabs.value.find(t => t.id === newTab.id);
|
const tabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id);
|
||||||
if (!tabToLoad) return; // Tab might have been closed quickly
|
if (tabIndex === -1) return; // Tab might have been closed quickly
|
||||||
|
|
||||||
tabToLoad.isLoading = true;
|
// 更新加载状态 (使用 splice 保证响应性)
|
||||||
tabToLoad.loadingError = null;
|
const loadingTab = { ...session.editorTabs.value[tabIndex], isLoading: true, loadingError: null };
|
||||||
|
session.editorTabs.value.splice(tabIndex, 1, loadingTab);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取默认的 sftpManager 实例来执行读取操作
|
// 获取默认的 sftpManager 实例来执行读取操作
|
||||||
const sftpManager = getOrCreateSftpManager(sessionId, 'primary');
|
const sftpManager = getOrCreateSftpManager(sessionId, 'primary');
|
||||||
if (!sftpManager) {
|
if (!sftpManager) {
|
||||||
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
|
throw new Error(t('fileManager.errors.sftpManagerNotFound'));
|
||||||
}
|
}
|
||||||
console.log(`[SessionStore ${sessionId}] 使用 primary sftpManager 读取文件 ${fileInfo.fullPath}`);
|
console.log(`[SessionStore ${sessionId}] 使用 primary sftpManager 读取文件 ${fileInfo.fullPath}`);
|
||||||
const fileData = await sftpManager.readFile(fileInfo.fullPath);
|
|
||||||
console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 读取成功。编码: ${fileData.encoding}`);
|
|
||||||
|
|
||||||
let decodedContent = '';
|
// 调用 sftpManager.readFile (不带编码参数,让后端自动检测)
|
||||||
let finalEncoding: 'utf8' | 'base64' = 'utf8';
|
// 后端现在返回 { content: string, encodingUsed: string }
|
||||||
|
const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(fileInfo.fullPath);
|
||||||
|
console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 读取成功。后端使用编码: ${fileData.encodingUsed}`);
|
||||||
|
|
||||||
if (fileData.encoding === 'base64') {
|
// 更新标签页状态 (使用 splice 保证响应性)
|
||||||
finalEncoding = 'base64';
|
const currentTabState = session.editorTabs.value.find(t => t.id === newTab.id); // 获取最新状态
|
||||||
try {
|
if (!currentTabState) return; // 可能在请求期间关闭了
|
||||||
const binaryString = atob(fileData.content);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
// --- 修复:使用 decodeRawContent 解码并存储原始数据 ---
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
const initialContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed);
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
const updatedTab: FileTab = {
|
||||||
}
|
...currentTabState,
|
||||||
const decoder = new TextDecoder('utf-8');
|
content: initialContent,
|
||||||
decodedContent = decoder.decode(bytes);
|
originalContent: initialContent, // 初始原始内容
|
||||||
} catch (decodeError) {
|
rawContentBase64: fileData.rawContentBase64, // 存储原始 Base64 数据
|
||||||
console.error(`[SessionStore ${sessionId}] Base64 解码错误 for ${fileInfo.fullPath}:`, decodeError);
|
selectedEncoding: fileData.encodingUsed, // 存储后端实际使用的编码
|
||||||
tabToLoad.loadingError = t('fileManager.errors.fileDecodeError');
|
isLoading: false,
|
||||||
decodedContent = `// ${tabToLoad.loadingError}\n// Original Base64 content:\n${fileData.content}`;
|
isModified: false,
|
||||||
}
|
loadingError: null,
|
||||||
} else {
|
};
|
||||||
finalEncoding = 'utf8';
|
const finalTabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id); // 重新获取索引
|
||||||
decodedContent = fileData.content;
|
if (finalTabIndex !== -1) {
|
||||||
if (decodedContent.includes('\uFFFD')) {
|
session.editorTabs.value.splice(finalTabIndex, 1, updatedTab);
|
||||||
console.warn(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 内容可能包含无效字符。`);
|
console.log(`[SessionStore ${sessionId}] 文件 ${fileInfo.fullPath} 内容已加载并设置到标签页 ${newTab.id}。`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[SessionStore ${sessionId}] 尝试更新标签页 ${newTab.id} 时未找到索引。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SessionStore ${sessionId}] 读取文件 ${fileInfo.fullPath} 失败:`, err);
|
||||||
|
// 更新错误状态 (使用 splice 保证响应性)
|
||||||
|
const errorTabIndex = session.editorTabs.value.findIndex(t => t.id === newTab.id);
|
||||||
|
if (errorTabIndex !== -1) {
|
||||||
|
const errorTab = {
|
||||||
|
...session.editorTabs.value[errorTabIndex],
|
||||||
|
isLoading: false,
|
||||||
|
loadingError: `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`,
|
||||||
|
content: `// 加载错误: ${err.message || err}`
|
||||||
|
};
|
||||||
|
session.editorTabs.value.splice(errorTabIndex, 1, errorTab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// 更新标签页状态
|
|
||||||
tabToLoad.content = decodedContent;
|
|
||||||
tabToLoad.originalContent = decodedContent;
|
|
||||||
tabToLoad.encoding = finalEncoding;
|
|
||||||
tabToLoad.language = getLanguageFromFilename(fileInfo.name); // 根据文件名设置语言
|
|
||||||
tabToLoad.isModified = false;
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`[SessionStore ${sessionId}] 读取文件 ${fileInfo.fullPath} 失败:`, err);
|
|
||||||
tabToLoad.loadingError = `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`;
|
|
||||||
tabToLoad.content = `// ${tabToLoad.loadingError}`;
|
|
||||||
} finally {
|
|
||||||
tabToLoad.isLoading = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadContent(); // 启动内容加载
|
loadContent(); // 启动内容加载
|
||||||
}
|
}
|
||||||
@@ -597,6 +636,84 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定会话中更改文件编码并重新解码
|
||||||
|
*/
|
||||||
|
// --- 修改:更改文件编码(通过请求后端重新读取) ---
|
||||||
|
const changeEncodingInSession = async (sessionId: string, tabId: string, newEncoding: string) => {
|
||||||
|
const session = sessions.value.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
console.warn(`[SessionStore] 尝试更改不存在的会话 ${sessionId} 中标签页 ${tabId} 的编码。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabIndex = session.editorTabs.value.findIndex(t => t.id === tabId);
|
||||||
|
if (tabIndex === -1) {
|
||||||
|
console.warn(`[SessionStore] 尝试更改会话 ${sessionId} 中不存在的标签页 ${tabId} 的编码。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取默认的 sftpManager 实例
|
||||||
|
const sftpManager = getOrCreateSftpManager(sessionId, 'primary');
|
||||||
|
if (!sftpManager) {
|
||||||
|
console.error(`[SessionStore] 无法获取会话 ${sessionId} 的 primary sftpManager 来更改编码。`);
|
||||||
|
// 更新错误状态
|
||||||
|
const errorTab = { ...session.editorTabs.value[tabIndex], isLoading: false, loadingError: '无法获取 SFTP 实例' };
|
||||||
|
session.editorTabs.value.splice(tabIndex, 1, errorTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = session.editorTabs.value[tabIndex];
|
||||||
|
console.log(`[SessionStore] 请求使用新编码 "${newEncoding}" 重新读取文件: ${tab.filePath} (会话 ${sessionId}, Tab ID: ${tabId})`);
|
||||||
|
|
||||||
|
// 设置加载状态 (使用 splice)
|
||||||
|
const loadingTab = { ...tab, isLoading: true, loadingError: null };
|
||||||
|
session.editorTabs.value.splice(tabIndex, 1, loadingTab);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 向后端发送 readFile 请求,并指定编码
|
||||||
|
const fileData: SftpReadFileSuccessPayload = await sftpManager.readFile(tab.filePath, newEncoding);
|
||||||
|
console.log(`[SessionStore ${sessionId}] 文件 ${tab.filePath} 使用编码 "${newEncoding}" 重新读取成功。后端实际使用编码: ${fileData.encodingUsed}`);
|
||||||
|
|
||||||
|
// 更新标签页状态 (使用 splice)
|
||||||
|
const currentTabState = session.editorTabs.value.find(t => t.id === tabId); // 获取最新状态
|
||||||
|
if (!currentTabState) return; // 可能在请求期间关闭了
|
||||||
|
|
||||||
|
// --- 修复:使用 decodeRawContent 解码并更新原始数据 ---
|
||||||
|
const newDecodedContent = decodeRawContent(fileData.rawContentBase64, fileData.encodingUsed);
|
||||||
|
const updatedTab: FileTab = {
|
||||||
|
...currentTabState,
|
||||||
|
content: newDecodedContent,
|
||||||
|
rawContentBase64: fileData.rawContentBase64, // 更新原始 Base64 数据
|
||||||
|
// originalContent 保持不变
|
||||||
|
selectedEncoding: fileData.encodingUsed, // 使用后端确认的编码
|
||||||
|
isLoading: false,
|
||||||
|
loadingError: null,
|
||||||
|
// isModified 状态保持不变
|
||||||
|
};
|
||||||
|
const finalTabIndex = session.editorTabs.value.findIndex(t => t.id === tabId); // 重新获取索引
|
||||||
|
if (finalTabIndex !== -1) {
|
||||||
|
session.editorTabs.value.splice(finalTabIndex, 1, updatedTab);
|
||||||
|
} else {
|
||||||
|
console.warn(`[SessionStore ${sessionId}] 尝试更新标签页 ${tabId} 时未找到索引 (changeEncoding)。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SessionStore ${sessionId}] 使用编码 "${newEncoding}" 重新读取文件 ${tab.filePath} 失败:`, err);
|
||||||
|
const errorMsg = `${t('fileManager.errors.readFileFailed')} (编码: ${newEncoding}): ${err.message || err}`;
|
||||||
|
// 更新错误状态 (使用 splice)
|
||||||
|
const errorTabIndex = session.editorTabs.value.findIndex(t => t.id === tabId);
|
||||||
|
if (errorTabIndex !== -1) {
|
||||||
|
const errorTab = {
|
||||||
|
...session.editorTabs.value[errorTabIndex],
|
||||||
|
isLoading: false,
|
||||||
|
loadingError: errorMsg,
|
||||||
|
};
|
||||||
|
session.editorTabs.value.splice(errorTabIndex, 1, errorTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建指定会话和实例 ID 的 SFTP 管理器。
|
* 获取或创建指定会话和实例 ID 的 SFTP 管理器。
|
||||||
* @param sessionId 会话 ID
|
* @param sessionId 会话 ID
|
||||||
@@ -694,6 +811,7 @@ export const useSessionStore = defineStore('session', () => {
|
|||||||
setActiveEditorTabInSession,
|
setActiveEditorTabInSession,
|
||||||
updateFileContentInSession, // 导出更新内容 Action
|
updateFileContentInSession, // 导出更新内容 Action
|
||||||
saveFileInSession, // 导出保存文件 Action
|
saveFileInSession, // 导出保存文件 Action
|
||||||
|
changeEncodingInSession, // 导出更改编码 Action
|
||||||
// --- RDP Modal Actions ---
|
// --- RDP Modal Actions ---
|
||||||
openRdpModal, // 导出打开 RDP 模态框 Action
|
openRdpModal, // 导出打开 RDP 模态框 Action
|
||||||
closeRdpModal, // 导出关闭 RDP 模态框 Action
|
closeRdpModal, // 导出关闭 RDP 模态框 Action
|
||||||
|
|||||||
@@ -26,3 +26,15 @@ export interface EditorFileContent {
|
|||||||
|
|
||||||
// 类型定义:编辑器保存状态 (从 useFileEditor 迁移)
|
// 类型定义:编辑器保存状态 (从 useFileEditor 迁移)
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
|
export type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||||
|
|
||||||
|
// 类型定义:后端 readFile 请求的 payload 结构
|
||||||
|
export interface SftpReadFileRequestPayload {
|
||||||
|
path: string;
|
||||||
|
encoding?: string; // 可选:请求使用的特定编码
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型定义:后端 readFile 成功时返回的 payload 结构 (更新)
|
||||||
|
export interface SftpReadFileSuccessPayload {
|
||||||
|
rawContentBase64: string; // Base64 编码的原始文件内容
|
||||||
|
encodingUsed: string; // 后端自动检测或用户请求时实际使用的编码
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,6 +372,23 @@ const handleCloseEditorTab = (tabId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// +++ 新增:处理编辑器编码更改事件 +++
|
||||||
|
const handleChangeEncoding = (payload: { tabId: string; encoding: string }) => {
|
||||||
|
const isShared = shareFileEditorTabsBoolean.value;
|
||||||
|
console.log(`[WorkspaceView] handleChangeEncoding for tab ${payload.tabId} to ${payload.encoding}, Shared mode: ${isShared}`);
|
||||||
|
if (isShared) {
|
||||||
|
fileEditorStore.changeEncoding(payload.tabId, payload.encoding);
|
||||||
|
} else {
|
||||||
|
const currentActiveSessionId = activeSessionId.value;
|
||||||
|
if (currentActiveSessionId) {
|
||||||
|
// 假设 sessionStore 有一个 changeEncodingInSession 方法
|
||||||
|
sessionStore.changeEncodingInSession(currentActiveSessionId, payload.tabId, payload.encoding);
|
||||||
|
} else {
|
||||||
|
console.warn('[WorkspaceView] Cannot change editor encoding: No active session in independent mode.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- 连接列表操作处理 (用于 WorkspaceConnectionList) ---
|
// --- 连接列表操作处理 (用于 WorkspaceConnectionList) ---
|
||||||
const handleConnectRequest = (id: number) => {
|
const handleConnectRequest = (id: number) => {
|
||||||
console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`);
|
console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`);
|
||||||
@@ -433,6 +450,7 @@ const handleCloseEditorTab = (tabId: string) => {
|
|||||||
@find-previous="handleFindPrevious"
|
@find-previous="handleFindPrevious"
|
||||||
@close-search="handleCloseSearch"
|
@close-search="handleCloseSearch"
|
||||||
@clear-terminal="handleClearTerminal"
|
@clear-terminal="handleClearTerminal"
|
||||||
|
@change-encoding="handleChangeEncoding"
|
||||||
></LayoutRenderer> <!-- 修正:使用单独的结束标签 -->
|
></LayoutRenderer> <!-- 修正:使用单独的结束标签 -->
|
||||||
<div v-else class="pane-placeholder"> <!-- 确保 v-else 紧随 v-if -->
|
<div v-else class="pane-placeholder"> <!-- 确保 v-else 紧随 v-if -->
|
||||||
{{ t('layout.loading', '加载布局中...') }}
|
{{ t('layout.loading', '加载布局中...') }}
|
||||||
|
|||||||
Reference in New Issue
Block a user