diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a49f1c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/data/ +/logs/ +/node_modules/ +/out/ +/package-lock.json +/public/dist/ +/public/electron/ +/run/ diff --git a/electron/class/device_probe.js b/electron/class/device_probe.js new file mode 100644 index 0000000..471b13a --- /dev/null +++ b/electron/class/device_probe.js @@ -0,0 +1,224 @@ +'use strict'; + +const net = require('net'); +const http = require('http'); +const https = require('https'); +const { SocksClient } = require('socks'); +const { pub } = require('./public.js'); + +const ONLINE_CACHE_TIME = 30 * 1000; +const UNKNOWN_CACHE_TIME = 60 * 1000; +const MAX_OFFLINE_CACHE_TIME = 5 * 60 * 1000; + +/** + * 低频设备可达性探测。面板 API 正常时不发起 TCP 请求,仅在网络级失败时补充探测。 + */ +class DeviceProbe { + constructor(options = {}) { + this.timeout = options.timeout || 2500; + this.maxConcurrent = options.maxConcurrent || 10; + this.activeCount = 0; + this.queue = []; + this.states = new Map(); + } + + getKey(panel) { + return String(panel.panel_id || panel.url); + } + + getState(panel) { + const key = this.getKey(panel); + if (!this.states.has(key)) { + this.states.set(key, { + status: 'unknown', + failures: 0, + nextProbeAt: 0, + inFlight: false, + callbacks: [], + version: 0 + }); + } + return this.states.get(key); + } + + markReachable(panel) { + const state = this.getState(panel); + state.version++; + state.status = 'online'; + state.failures = 0; + state.nextProbeAt = Date.now() + ONLINE_CACHE_TIME; + // 新的 API 成功结果已经替代旧失败请求,不再回放旧探测回调。 + state.callbacks = state.callbacks.filter((item) => item.version >= state.version); + } + + probe(panel, callback) { + const state = this.getState(panel); + const now = Date.now(); + + if (!state.inFlight && state.nextProbeAt > now) { + return setImmediate(() => callback(state.status)); + } + + state.callbacks.push({ callback: callback, version: state.version }); + if (state.inFlight) return; + + state.inFlight = true; + this.queue.push({ panel: Object.assign({}, panel), state: state, version: state.version }); + this.drain(); + } + + drain() { + while (this.activeCount < this.maxConcurrent && this.queue.length > 0) { + const task = this.queue.shift(); + if (task.version !== task.state.version && task.state.status === 'online') { + this.finish(task, 'online'); + continue; + } + this.activeCount++; + this.connect(task.panel, (status) => { + this.finish(task, status); + this.activeCount--; + this.drain(); + }); + } + } + + finish(task, status) { + const state = task.state; + if (task.version !== state.version) { + state.inFlight = false; + state.callbacks = state.callbacks.filter((item) => item.version > task.version); + if (state.callbacks.some((item) => item.version === state.version)) { + state.inFlight = true; + this.queue.push({ panel: task.panel, state: state, version: state.version }); + } + return; + } + + state.status = status; + state.inFlight = false; + if (status === 'online') { + state.failures = 0; + state.nextProbeAt = Date.now() + ONLINE_CACHE_TIME; + } else if (status === 'offline') { + state.failures++; + const retryDelay = Math.min( + ONLINE_CACHE_TIME * Math.pow(2, Math.max(0, state.failures - 1)), + MAX_OFFLINE_CACHE_TIME + ); + state.nextProbeAt = Date.now() + retryDelay; + } else { + state.nextProbeAt = Date.now() + UNKNOWN_CACHE_TIME; + } + + const callbacks = state.callbacks.filter((item) => item.version === task.version); + state.callbacks = state.callbacks.filter((item) => item.version !== task.version); + callbacks.forEach((item) => item.callback(state.status)); + } + + connect(panel, callback) { + let target; + try { + const panelUrl = new URL(panel.url); + target = { + host: panelUrl.hostname.replace(/^\[|\]$/g, ''), + port: Number(panelUrl.port || (panelUrl.protocol === 'https:' ? 443 : 80)) + }; + } catch (error) { + return callback('unknown'); + } + + let proxy = null; + try { + proxy = panel.proxy_id + ? pub.M('proxy_info').where('proxy_id=?', panel.proxy_id).find() + : null; + } catch (error) { + return callback('unknown'); + } + + if (!proxy) return this.connectDirect(target, callback); + if (Number(proxy.proxy_type) === 2) return this.connectSocks(target, proxy, callback); + if (Number(proxy.proxy_type) === 0 || Number(proxy.proxy_type) === 1) { + return this.connectHttpProxy(target, proxy, callback); + } + return callback('unknown'); + } + + connectDirect(target, callback) { + let settled = false; + const socket = net.createConnection(target); + const finish = (status) => { + if (settled) return; + settled = true; + socket.destroy(); + callback(status); + }; + socket.setTimeout(this.timeout); + socket.once('connect', () => finish('online')); + socket.once('timeout', () => finish('offline')); + socket.once('error', (error) => { + const status = error && ['ENOTFOUND', 'EAI_AGAIN'].includes(error.code) + ? 'unknown' + : 'offline'; + finish(status); + }); + } + + connectSocks(target, proxy, callback) { + const options = { + proxy: { + host: proxy.proxy_ip, + port: Number(proxy.proxy_port), + type: 5, + userId: proxy.proxy_username || undefined, + password: proxy.proxy_password || undefined + }, + command: 'connect', + destination: target, + timeout: this.timeout + }; + + SocksClient.createConnection(options, (error, info) => { + if (error || !info || !info.socket) return callback('unknown'); + info.socket.destroy(); + callback('online'); + }); + } + + connectHttpProxy(target, proxy, callback) { + const headers = {}; + if (proxy.proxy_username && proxy.proxy_password) { + const credentials = Buffer.from(`${proxy.proxy_username}:${proxy.proxy_password}`).toString('base64'); + headers['Proxy-Authorization'] = `Basic ${credentials}`; + } + + const transport = Number(proxy.proxy_type) === 1 ? https : http; + const request = transport.request({ + host: proxy.proxy_ip, + port: Number(proxy.proxy_port), + method: 'CONNECT', + path: `${target.host}:${target.port}`, + headers: headers, + timeout: this.timeout, + rejectUnauthorized: false + }); + + let settled = false; + const finish = (status) => { + if (settled) return; + settled = true; + request.destroy(); + callback(status); + }; + request.once('connect', (response, socket) => { + socket.destroy(); + finish(response.statusCode === 200 ? 'online' : 'unknown'); + }); + request.once('timeout', () => finish('unknown')); + request.once('error', () => finish('unknown')); + request.end(); + } +} + +module.exports = { DeviceProbe }; diff --git a/electron/class/panel_api.js b/electron/class/panel_api.js index d860920..50b4e1a 100644 --- a/electron/class/panel_api.js +++ b/electron/class/panel_api.js @@ -71,6 +71,10 @@ PanelApi.prototype.request = function(uri,data,callback){ this.request_to_panel(uri,data,function(response,error){ if(error){ return callback(null,error); + }else if(response.statusCode >= 300 && response.statusCode < 400){ + let redirect_error = new Error(response.statusMessage || `HTTP ${response.statusCode}`); + redirect_error.code = 'PANEL_HTTP_REDIRECT'; + return callback(response.body,redirect_error); }else if(response.statusCode != 200){ return callback(response.body,response.statusMessage); }else{ @@ -144,4 +148,4 @@ module.exports = { PanelApi }; * console.log(res); * * - */ \ No newline at end of file + */ diff --git a/electron/class/panel_app.js b/electron/class/panel_app.js index b76ae52..3a6f1ba 100644 --- a/electron/class/panel_app.js +++ b/electron/class/panel_app.js @@ -79,6 +79,12 @@ class PanelApp { return callback(null, err); } + if (res.statusCode >= 300 && res.statusCode < 400) { + let redirect_error = new Error(res.statusMessage || `HTTP ${res.statusCode}`); + redirect_error.code = 'PANEL_HTTP_REDIRECT'; + return callback(null, redirect_error); + } + if (res.body[0] == '{') { let res_body = JSON.parse(res.body); // pub.debug(res_body); @@ -86,13 +92,13 @@ class PanelApp { return callback(null, err); } - let de_crypt_data = ''; + let data; try { - de_crypt_data = pub.aes_decrypt_ecb(res.body, that.KEY); + let de_crypt_data = pub.aes_decrypt_ecb(res.body, that.KEY); + data = JSON.parse(de_crypt_data); } catch (e) { return callback(null, e); } - let data = JSON.parse(de_crypt_data); if (data.status && data.data) data = data.data; callback(data, err); }, 6000); @@ -158,4 +164,4 @@ class PanelApp { } } -module.exports = { PanelApp }; \ No newline at end of file +module.exports = { PanelApp }; diff --git a/electron/class/sqlite.js b/electron/class/sqlite.js index b1cfac6..1414f89 100644 --- a/electron/class/sqlite.js +++ b/electron/class/sqlite.js @@ -55,6 +55,7 @@ class Sqlite { this.checkField('ssh_info', 'os_name', 'TEXT', '"Linux"') this.checkField('ssh_info', 'mstsc_options', 'TEXT', '"{}"') this.checkField('ssh_info', 'sort', 'INTEGER', '0') + this.checkField('panel_info', 'sort', 'INTEGER', '0') this.checkField('panel_info', 'ov', 'INTEGER', '-1') this.checkField('panel_info', 'server_id', 'TEXT', '""') this.checkField('panel_info', 'current_disk', 'TEXT', '""') @@ -253,6 +254,7 @@ class Sqlite { \`server_id\` TEXT DEFAULT "", -- server_id \`proxy_id\` INTEGER DEFAULT 0, -- 代理ID \`common_use\` INTEGER DEFAULT 0, -- 常用显示状态 1=显示 0=隐藏 + \`sort\` INTEGER DEFAULT 0, -- 排序值 \`area\` TEXT DEFAULT "" -- 服务器归属区域 )`; @@ -1210,4 +1212,4 @@ class Sqlite { } } -module.exports = { Sqlite } \ No newline at end of file +module.exports = { Sqlite } diff --git a/electron/controller/panel.js b/electron/controller/panel.js index 6e67da7..1ee7b14 100644 --- a/electron/controller/panel.js +++ b/electron/controller/panel.js @@ -4,6 +4,7 @@ const { Controller } = require('ee-core'); const { pub } = require("../class/public.js"); const { PanelApi } = require("../class/panel_api.js"); const { PanelApp } = require("../class/panel_app.js"); +const { DeviceProbe } = require("../class/device_probe.js"); const { dialog } = require('electron'); const Electron = require('ee-core/electron'); const Services = require('ee-core/services'); @@ -11,6 +12,7 @@ const os = require("os"); const { glob } = require('fs'); global.PanelLoadStatus = { status: false, last_time: 0 }; // 面板负载信息获取状态 global.PanelActionTime = 0; // 面板操作时间 +const PANEL_PROTOCOL_PROBE_COOLDOWN = 60 * 1000; // 协议探测失败后的冷却时间 /** * example @@ -20,6 +22,8 @@ class PanelController extends Controller { constructor(ctx) { super(ctx); this.TABLE = 'panel_info'; + this.protocolProbeTimes = new Map(); + this.deviceProbe = new DeviceProbe({ timeout: 2500, maxConcurrent: 10 }); global.PanelActionTime = pub.time(); } @@ -219,6 +223,20 @@ class PanelController extends Controller { result.groups = result.groups.concat(groups); } + // 统计各分组面板数量,全部分组展示所有面板总数 + const panel_counts = pub.M(this.TABLE).field('group_id, COUNT(*) AS panel_count').group('group_id').select(); + const group_count_map = {}; + let panel_total = 0; + panel_counts.forEach(item => { + const count = Number(item.panel_count) || 0; + group_count_map[item.group_id] = count; + panel_total += count; + }); + result.groups = result.groups.map(group => ({ + ...group, + panel_count: group.group_id === -1 ? panel_total : (group_count_map[group.group_id] || 0) + })); + // 检查分组是否存在 if (group_id !== undefined && group_id != -1){ let is_group_exists = false; @@ -243,7 +261,7 @@ class PanelController extends Controller { } // 获取面板列表 - result.data = pub.M(this.TABLE).where(where, params).order('panel_id DESC').select(); + result.data = pub.M(this.TABLE).where(where, params).order('sort DESC, panel_id DESC').select(); global.PanelList = result.data; for (let i = 0; i < result.data.length; i++) { @@ -278,6 +296,29 @@ class PanelController extends Controller { pub.M(this.TABLE).where('panel_id=?', panel_id).update({current_disk: disk_path}); } + /** + * @name 保存面板默认排序 + * @param {object} args { + * panel_ids: number[] - 按展示顺序排列的面板ID + * } + */ + async set_sort(args, event) { + const panel_ids = Array.isArray(args.data.panel_ids) ? args.data.panel_ids : []; + if (panel_ids.length === 0) return; + + const panel_id_set = new Set(panel_ids); + const current_list = pub.M(this.TABLE).order('sort DESC, panel_id DESC').select(); + const ordered_ids = [...panel_ids]; + const merged_ids = current_list.map(panel => { + return panel_id_set.has(panel.panel_id) ? ordered_ids.shift() : panel.panel_id; + }); + const total = merged_ids.length; + + merged_ids.forEach((panel_id, index) => { + pub.M(this.TABLE).where('panel_id=?', panel_id).update({ sort: total - index }); + }); + } + /** * @name 添加面板分组 * @param {object} args { @@ -688,6 +729,8 @@ class PanelController extends Controller { // 是否有修改api_token if (find['api_token'] == pdata.api_token) { + // APP 密钥中的 URL 可能是协议自动探测前的旧地址,密钥未变时保留已校正的 URL。 + pdata.url = find.url; if (pub.M(this.TABLE).where('panel_id=?', panel_id).update(pdata)) { global.socks.syncProxy(); // 同步代理池 Services.get('user').syncPanelToCloud(); @@ -1040,6 +1083,122 @@ class PanelController extends Controller { return res; } + /** + * @name 获取相反协议的面板地址 + * @param {string} url - 面板地址 + * @returns {string} + */ + get_alternate_protocol_url(url) { + if (typeof url !== 'string') return ''; + if (/^https:\/\//i.test(url)) return url.replace(/^https:\/\//i, 'http://'); + if (/^http:\/\//i.test(url)) return url.replace(/^http:\/\//i, 'https://'); + return ''; + } + + /** + * @name 判断是否为网络或协议级错误 + * @param {*} res - 面板响应 + * @param {*} err - 请求错误 + * @returns {boolean} + */ + is_panel_network_error(res, err) { + let error_text = ''; + if (err) { + error_text = typeof err === 'string' + ? err + : `${err.code || ''} ${err.message || ''}`; + } + if (typeof res === 'string') error_text += ` ${res}`; + + const network_error = /(EPROTO|ERR_SSL|SSL routines|TLS|wrong version number|unknown protocol|socket hang up|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN|ERR_NAME_NOT_RESOLVED|PANEL_HTTP_REDIRECT|plain HTTP request.*HTTPS|HTTP request.*HTTPS server)/i; + return network_error.test(error_text); + } + + /** + * @name 判断是否需要尝试另一种访问协议 + * @param {object} panel - 面板信息 + * @param {*} res - 面板响应 + * @param {*} err - 请求错误 + * @returns {boolean} + */ + should_probe_panel_protocol(panel, res, err) { + if (!panel || !this.get_alternate_protocol_url(panel.url)) return false; + // 只处理连接层、TLS 和协议重定向错误,密钥、白名单等业务错误不触发切换。 + if (!this.is_panel_network_error(res, err)) return false; + const error_text = typeof err === 'string' + ? err + : `${err && err.code ? err.code : ''} ${err && err.message ? err.message : ''}`; + if (/(ENOTFOUND|EAI_AGAIN|ERR_NAME_NOT_RESOLVED)/i.test(error_text)) return false; + + const probe_key = String(panel.panel_id || panel.url); + const now = Date.now(); + const last_probe_time = this.protocolProbeTimes.get(probe_key) || 0; + if (now - last_probe_time < PANEL_PROTOCOL_PROBE_COOLDOWN) return false; + + this.protocolProbeTimes.set(probe_key, now); + return true; + } + + /** + * @name 验证面板负载接口响应 + * @param {*} res - 面板响应 + * @returns {boolean} + */ + is_valid_panel_load_response(res) { + if (!res || typeof res !== 'object' || Array.isArray(res)) return false; + if (!res.load || typeof res.load !== 'object') return false; + if (!['one', 'five', 'fifteen'].every((key) => Number.isFinite(res.load[key]))) return false; + if (!Array.isArray(res.cpu) || res.cpu.length < 2) return false; + if (!res.mem || typeof res.mem !== 'object') return false; + if (!Number.isFinite(Number(res.mem.memRealUsed)) || !Number.isFinite(Number(res.mem.memTotal))) return false; + if (!Array.isArray(res.disk) || res.disk.length === 0) return false; + return res.disk.every((disk) => disk + && typeof disk.path === 'string' + && Array.isArray(disk.size) + && disk.size.length >= 4); + } + + /** + * @name 保存探测成功的面板协议 + * @param {object} panel - 面板信息 + * @param {string} new_url - 新面板地址 + * @returns {object|null} + */ + save_detected_panel_protocol(panel, new_url) { + const old_url = panel.url; + if (!new_url || new_url === old_url) return null; + + const duplicate_count = pub.M(this.TABLE) + .where('url=? and panel_id<>?', [new_url, panel.panel_id]) + .count(); + if (duplicate_count > 0) { + pub.write_log(0, pub.lang('面板[{}]协议探测成功,但地址[{}]已被其他面板使用', panel.title || old_url, new_url), 1); + return null; + } + + const update = pub.M(this.TABLE).where('panel_id=?', panel.panel_id).update({ + url: new_url, + status: 0 + }); + if (!update) return null; + + panel.url = new_url; + panel.status = 0; + this.protocolProbeTimes.delete(String(panel.panel_id || old_url)); + + const old_protocol = old_url.split(':')[0].toUpperCase(); + const new_protocol = new_url.split(':')[0].toUpperCase(); + const panel_name = panel.title || old_url; + const msg = new_protocol === 'HTTP' + ? pub.lang('面板[{}]已自动从 {} 切换为 {},当前连接未加密', panel_name, old_protocol, new_protocol) + : pub.lang('面板[{}]已自动从 {} 切换为 {}', panel_name, old_protocol, new_protocol); + + pub.write_log(0, msg); + Services.get('user').removePanelToCloud(old_url); + Services.get('user').syncPanelToCloud(); + return { url: new_url, protocol: new_protocol.toLowerCase(), msg: msg }; + } + /** * @name 获取并同步面板负载信息到前端 * @param {*} channel @@ -1058,13 +1217,27 @@ class PanelController extends Controller { p = new PanelApi(panel.url, panel.api_token, panel); } - p.get_network(function (res, err) { + const handle_result = function (client, res, err, protocol_changed, connection_state) { res = that.parseResult(res, err, panel.auth_type); + const device_status = connection_state ? connection_state.device_status : 'online'; + const panel_status = connection_state ? connection_state.panel_status : 'online'; if (res && res.status === false){ // -2表示获取不到授权状态,如果获取不到服务器状态和授权状态,直接将其设置为免费版 if(panel.ov === -1) pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ ov: 0 }) - if(panel.status === 0) pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ status: 1 }) - return Electron.mainWindow.webContents.send(channel, { panel_id: panel.panel_id,ov:panel.ov,is_open:true,status:1,data: res }); + if(panel.status === 0) { + pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ status: 1 }) + panel.status = 1; + that.update_global_panel_param(panel.panel_id, { status: 1 }); + } + return Electron.mainWindow.webContents.send(channel, { + panel_id: panel.panel_id, + ov: panel.ov, + is_open: true, + status: 1, + device_status: device_status, + panel_status: panel_status, + data: res + }); } if (callback) return callback(res, err); // 重新获取面板信息 @@ -1072,19 +1245,26 @@ class PanelController extends Controller { panel.ov = pub.M(that.TABLE).where('panel_id=?', panel.panel_id).getField('ov'); } if (err) { - return Electron.mainWindow.webContents.send(channel, { panel_id: panel.panel_id,ov:panel.ov, data: { status: false, msg: pub.lang('连接失败: ') + err.message } }); + return Electron.mainWindow.webContents.send(channel, { + panel_id: panel.panel_id, + ov: panel.ov, + device_status: device_status, + panel_status: panel_status, + data: { status: false, msg: pub.lang('连接失败: ') + err.message } + }); } if (res) { // 更新面板标题 - if (res.title && panel.title == panel.url.match(/\/\/(.*?):/)[1]) pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ title: res.title }); + const panel_host = panel.url.match(/^https?:\/\/([^/:]+)/i); + if (res.title && panel_host && panel.title == panel_host[1]) pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ title: res.title }); // 更新面板状态 if (panel.status === 1) { pub.M(that.TABLE).where('panel_id=?', panel.panel_id).update({ status: 0 }); that.update_global_panel_param(panel.panel_id, { status: 0 }); // 更新全局面板状态 - } + } if(!panel.server_id || panel.ov == -1 || !panel.get_ov){ // 获取server_id - p.get_server_id(function(server_id,version,is_aaPanel){ + client.get_server_id(function(server_id,version,is_aaPanel){ if(server_id){ panel.server_id = server_id; let pdata = { server_id: server_id }; @@ -1109,11 +1289,69 @@ class PanelController extends Controller { // 发送负载信息到前端 try { - if (channel && Electron.mainWindow.isFocused()) return Electron.mainWindow.webContents.send(channel, { panel_id: panel.panel_id,ov:panel.ov,is_open:panel.is_open,status:0, data: res }); + if (channel && Electron.mainWindow.isFocused()) { + let result = { + panel_id: panel.panel_id, + ov: panel.ov, + is_open: panel.is_open, + status: 0, + device_status: 'online', + panel_status: 'online', + data: res + }; + if (protocol_changed) result.protocol_changed = protocol_changed; + return Electron.mainWindow.webContents.send(channel, result); + } } catch (err) { pub.log('sync load error:', err.message); } } + }; + + const handle_failed_request = function (client, res, err) { + if (!that.is_panel_network_error(res, err)) { + that.deviceProbe.markReachable(panel); + return handle_result(client, res, err, null, { + device_status: 'online', + panel_status: 'error' + }); + } + + that.deviceProbe.probe(panel, function (device_status) { + return handle_result(client, res, err, null, { + device_status: device_status, + panel_status: device_status === 'online' ? 'error' : 'unknown' + }); + }); + }; + + p.get_network(function (res, err) { + if (!that.should_probe_panel_protocol(panel, res, err)) { + if (that.is_valid_panel_load_response(that.parseResult(res, err, panel.auth_type))) { + that.deviceProbe.markReachable(panel); + return handle_result(p, res, err); + } + return handle_failed_request(p, res, err); + } + + const alternate_url = that.get_alternate_protocol_url(panel.url); + const alternate_panel = Object.assign({}, panel, { url: alternate_url }); + const alternate_client = panel.auth_type == 3 + ? new PanelApp(alternate_url, panel.api_token, alternate_panel) + : new PanelApi(alternate_url, panel.api_token, alternate_panel); + + alternate_client.get_network(function (alternate_res, alternate_err) { + const parsed_res = that.parseResult(alternate_res, alternate_err, panel.auth_type); + const probe_succeeded = !alternate_err + && that.is_valid_panel_load_response(parsed_res); + + if (!probe_succeeded) return handle_failed_request(p, res, err); + + const protocol_changed = that.save_detected_panel_protocol(panel, alternate_url); + if (!protocol_changed) return handle_failed_request(p, res, err); + that.deviceProbe.markReachable(panel); + return handle_result(alternate_client, parsed_res, null, protocol_changed); + }); }); } /** @@ -1122,7 +1360,7 @@ class PanelController extends Controller { * @param {object} param - 参数数组 {键:值} 支持同时修改多个参数 */ update_global_panel_param(panel_id,param) { - if(!panel_id || !param || !Array.isArray(param) || param.length == 0) return; + if(!panel_id || !param || typeof param !== 'object' || Array.isArray(param) || Object.keys(param).length == 0) return; // 遍历全局面板列表,修改参数 for (let i = 0; i < global.PanelList.length; i++) { if (global.PanelList[i].panel_id == panel_id) { @@ -1246,4 +1484,4 @@ class PanelController extends Controller { } PanelController.toString = () => '[class PanelController]'; -module.exports = PanelController; \ No newline at end of file +module.exports = PanelController; diff --git a/electron/controller/window.js b/electron/controller/window.js index c141b0e..8577c6f 100644 --- a/electron/controller/window.js +++ b/electron/controller/window.js @@ -21,7 +21,7 @@ class WindowController extends Controller { */ async load(options) { LoadView = new BrowserView(options); - LoadView.webContents.loadURL('file://' + pub.get_public_path() + '/html/loading.html'); + await LoadView.webContents.loadURL('file://' + pub.get_public_path() + '/html/loading.html'); } /** @@ -65,142 +65,135 @@ class WindowController extends Controller { * @returns {object} */ async create(args, event) { - let channel = args.channel; - let url = args.data.url; - let bounds = args.data.bounds; - let auto_resize = args.data.auto_resize; - let view_key = args.data.view_key; + const channel = args.channel; + const url = args.data.url; + const bounds = args.data.bounds; + const auto_resize = args.data.auto_resize; + const view_key = args.data.view_key; let options = args.data.options; - let proxy_id = args.data.proxy_id || 0; + const proxy_id = args.data.proxy_id || 0; - // 获取主窗口 - let mainWindow = Electron.mainWindow; - if (!mainWindow) { - return pub.send_error(event, channel, pub.lang('主窗口不存在')); - } + const mainWindow = Electron.mainWindow; + if (!mainWindow) return pub.send_error(event, channel, pub.lang('主窗口不存在')); - // 判断视图是否存在 if (global.PanelViews[view_key]) { - // 如果视图已经存在,则直接显示 + if (!global.PanelViews[view_key].loaded) { + return pub.send_error(event, channel, pub.lang('视图正在创建,请稍候')); + } mainWindow.setBrowserView(global.PanelViews[view_key]); - return pub.send_success_msg(event, channel, pub.lang('视图已经存在,直接显示')); + return pub.send_success(event, channel, { view_key: view_key, existing: true }); } if (!options) options = {}; if (!options.webPreferences) options.webPreferences = {}; - // 开发者模式 - // options.webPreferences.devTools = true; - - - // 创建视图 - global.PanelViews[view_key] = new BrowserView(options); - - // 打开开发者工具 - // global.PanelViews[view_key].webContents.openDevTools(); - - - // 设置主窗口视图 - mainWindow.setBrowserView(global.PanelViews[view_key]); - global.PanelViews[view_key].is_show = true; - global.PanelViews[view_key].view_key = view_key; + const panelView = new BrowserView(options); + global.PanelViews[view_key] = panelView; + panelView.is_show = true; + panelView.loaded = false; + panelView.view_key = view_key; ShowKey = view_key; - // 设置视图位置和大小 - if (bounds) { - if (bounds.x !== undefined) { - bounds.x = parseInt(bounds.x); - bounds.y = parseInt(bounds.y); - bounds.width = parseInt(bounds.width); - bounds.height = parseInt(bounds.height) + 1; - global.PanelViews[view_key].setBounds(bounds); + mainWindow.setBrowserView(panelView); + if (bounds && bounds.x !== undefined) { + bounds.x = parseInt(bounds.x); + bounds.y = parseInt(bounds.y); + bounds.width = parseInt(bounds.width); + bounds.height = parseInt(bounds.height) + 1; + panelView.setBounds(bounds); + } + if (auto_resize) panelView.setAutoResize(auto_resize); + Services.get('window').setEvent(panelView, proxy_id); + + if (!LoadView) { + try { + await this.load(options); + } catch (error) { + LoadView = null; } } - - // 设置视图自动调整大小 - if (auto_resize) global.PanelViews[view_key].setAutoResize(auto_resize); - - - // 设置事件 - Services.get('window').setEvent(global.PanelViews[view_key], proxy_id); - - // 加载URL - global.PanelViews[view_key].webContents.loadURL(url); - - - // 将加载视图置顶 - if (!LoadView) this.load(options); // 创建加载视图 if (LoadView) { mainWindow.setBrowserView(LoadView); LoadView.is_show = true; - bounds.x = parseInt(bounds.x); - bounds.y = parseInt(bounds.y); - bounds.width = parseInt(bounds.width); - bounds.height = parseInt(bounds.height); - LoadView.setBounds(bounds); - LoadView.setAutoResize(auto_resize); - - // 隐藏子视图 - mainWindow.removeBrowserView(global.PanelViews[view_key]); + if (bounds) { + LoadView.setBounds({ + x: parseInt(bounds.x), + y: parseInt(bounds.y), + width: parseInt(bounds.width), + height: parseInt(bounds.height) + }); + } + if (auto_resize) LoadView.setAutoResize(auto_resize); + mainWindow.removeBrowserView(panelView); } - - // 页面加载失败 - global.PanelViews[view_key].webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { - let message = pub.lang('请检查网络连接'); - switch (errorDescription) { - case 'ERR_TIMED_OUT': - message = pub.lang('连接超时'); - break; - case 'ERR_CONNECTION_REFUSED': - message = pub.lang('无法连接到网络'); - // 重新加载 - setTimeout(() => { - if (!global.PanelViews[view_key].reload_num) global.PanelViews[view_key].reload_num = 0; - global.PanelViews[view_key].reload_num++; - global.PanelViews[view_key].webContents.reload(); - }, 500); - break; - case 'ERR_CONNECTION_RESET': - message = pub.lang('网络连接被重置'); - break; - case 'ERR_NAME_NOT_RESOLVED': - message = pub.lang('无法解析域名'); - break; - case 'ERR_ABORTED': - message = pub.lang('连接被中止'); - break; + let settled = false; + let loadTimer = null; + const hideCurrentLoadView = () => { + if (LoadView && LoadView.is_show && ShowKey === view_key) this.hide_load(); + }; + const getLoadErrorMessage = (errorDescription) => { + const messages = { + ERR_TIMED_OUT: pub.lang('连接超时'), + ERR_CONNECTION_REFUSED: pub.lang('无法连接到网络'), + ERR_CONNECTION_RESET: pub.lang('网络连接被重置'), + ERR_NAME_NOT_RESOLVED: pub.lang('无法解析域名') + }; + return messages[errorDescription] || pub.lang('请检查网络连接'); + }; + + const destroyPendingView = () => { + try { + mainWindow.removeBrowserView(panelView); + } catch (error) {} + try { + this.closeSearchView(panelView); + if (!panelView.webContents.isDestroyed()) panelView.webContents.destroy(); + } catch (error) {} + if (global.PanelViews[view_key] === panelView) delete global.PanelViews[view_key]; + if (ShowKey === view_key) ShowKey = ''; + }; + + const finishCreate = (status, message) => { + if (settled) return; + settled = true; + if (loadTimer) clearTimeout(loadTimer); + hideCurrentLoadView(); + + if (!status) { + destroyPendingView(); + return pub.send_error(event, channel, message || pub.lang('面板页面加载失败')); } - if (errorDescription != 'ERR_CONNECTION_REFUSED' && global.PanelViews[view_key].reload_num < 40) { - global.PanelViews[view_key].webContents.loadURL('file://' + pub.get_public_path() + '/html/error.html'); - if (LoadView.is_show) this.hide_load(); - - dialog.showErrorBox(pub.lang('页面加载失败,{}', message), pub.lang('错误码:{},错误描述:{}', errorCode, errorDescription)); + panelView.loaded = true; + if (panelView.is_show && ShowKey == view_key) mainWindow.setBrowserView(panelView); + return pub.send_success(event, channel, { + view_key: view_key, + url: panelView.webContents.getURL() + }); + }; + + panelView.webContents.on('did-fail-load', (loadEvent, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorDescription === 'ERR_ABORTED') return; + const message = getLoadErrorMessage(errorDescription); + if (!settled) { + return finishCreate(false, pub.lang('面板页面加载失败:{}', message)); } - }); - - - - // 页面加载完成 - global.PanelViews[view_key].webContents.on('did-finish-load', () => { - // 隐藏加载视图 - if (LoadView.is_show) this.hide_load(); - - // 显示子视图 - global.PanelViews[view_key].loaded = true; - if (global.PanelViews[view_key].is_show && ShowKey == view_key) { - mainWindow.setBrowserView(global.PanelViews[view_key]); - } + panelView.webContents.loadURL('file://' + pub.get_public_path() + '/html/error.html'); + hideCurrentLoadView(); + dialog.showErrorBox(pub.lang('页面加载失败,{}', message), pub.lang('错误码:{},错误描述:{}', errorCode, errorDescription)); }); + panelView.webContents.on('did-finish-load', () => finishCreate(true)); + loadTimer = setTimeout(() => { + finishCreate(false, pub.lang('面板页面加载超时')); + }, 20000); - // 打开开发者工具 - // global.PanelViews[view_key].webContents.openDevTools(); - - // Log.info('create view:', view_key); - return pub.send_success_msg(event, channel, pub.lang('视图创建成功')); + panelView.webContents.loadURL(url).catch((error) => { + if (error && (error.code === 'ERR_ABORTED' || error.errno === -3 || /ERR_ABORTED/i.test(error.message || ''))) return; + finishCreate(false, pub.lang('面板页面加载失败:{}', error.message)); + }); } /** @@ -232,7 +225,10 @@ class WindowController extends Controller { } // 从主窗口移除视图 mainWindow.removeBrowserView(global.PanelViews[view_key]); - if (LoadView) this.hide_load(); + if (ShowKey === view_key) { + this.hide_load(); + ShowKey = ''; + } // 关闭搜索视图 this.closeSearchView(global.PanelViews[view_key]); @@ -284,6 +280,7 @@ class WindowController extends Controller { global.PanelViews[view_key].webContents.focus(); } else { + ShowKey = view_key; this.show_load(); } return pub.send_success_msg(event, channel, pub.lang('视图已显示')); @@ -320,7 +317,10 @@ class WindowController extends Controller { global.PanelViews[view_key].is_show = false; } - this.hide_load(); + if (ShowKey === view_key) { + this.hide_load(); + ShowKey = ''; + } return pub.send_success_msg(event, channel, pub.lang('视图已隐藏')); } @@ -678,5 +678,3 @@ class WindowController extends Controller { WindowController.toString = () => '[class WindowController]'; module.exports = WindowController; - - diff --git a/frontend/electron.route.js b/frontend/electron.route.js index da655ae..fcce067 100644 --- a/frontend/electron.route.js +++ b/frontend/electron.route.js @@ -57,6 +57,25 @@ const routes = { }, }, }, + set_sort: { + title: '保存面板默认排序', + method: 'ipc', + path: 'controller.panel.set_sort', + args: { + channel: { + type: 'string', + required: true, + description: '通道标识', + }, + data: { + panel_ids: { + type: 'array', + required: true, + description: '按展示顺序排列的面板ID', + }, + }, + }, + }, record_disk: { title: '记录面板选中的磁盘', method: 'ipc', diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 76260f2..6f17033 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -6,8 +6,11 @@ import routes from '*electron.route.js' interface AsyncProps { route: string data: any + timeout?: number } class IpcCommon { + private requestId = 0 + time() { return Math.round(new Date().getTime() / 1000) } @@ -30,21 +33,29 @@ class IpcCommon { } //异步发送 - sendAsync({ route, data }: AsyncProps) { + sendAsync({ route, data, timeout }: AsyncProps) { return new Promise((resolve, reject) => { - let channel = route - ipc.removeAllListeners(channel) - ipc.on(channel, (event: any, result: any) => { + const channel = `${route}:reply:${Date.now()}:${++this.requestId}` + let timer: ReturnType | null = null + const onResult = (event: any, result: any) => { // console.log(channel, result, 'Async') if (result) { + if (timer) clearTimeout(timer) resolve(result) } - }) + } + ipc.once(channel, onResult) + if (timeout && timeout > 0) { + timer = setTimeout(() => { + ipc.removeListener(channel, onResult) + reject(new Error('请求超时')) + }, timeout) + } let pdata = { channel: channel, data: toRaw(data), } - ipc.send(channel, pdata) + ipc.send(route, pdata) }) } } diff --git a/frontend/src/store/panel/index.ts b/frontend/src/store/panel/index.ts index 1b4c200..539086c 100644 --- a/frontend/src/store/panel/index.ts +++ b/frontend/src/store/panel/index.ts @@ -34,6 +34,7 @@ export const usePanelBase = defineStore( const groupManageVisible = ref(false) // 分组管理弹窗 const isShowIP = ref(true) // 是否显示IP + const showGroupCount = ref(true) // 是否显示分组数量 const addPanelVisible = ref(false) // 添加面板弹窗 const isEdit = ref(false) // 是否编辑模式 const panelParams = ref() @@ -54,6 +55,7 @@ export const usePanelBase = defineStore( editGroupParams, groupManageVisible, isShowIP, + showGroupCount, addPanelVisible, isEdit, panelParams, @@ -64,7 +66,7 @@ export const usePanelBase = defineStore( }, { persist: { - paths: ['isShowIP', 'currentGroupID'], + paths: ['isShowIP', 'showGroupCount', 'currentGroupID'], }, } ) diff --git a/frontend/src/styles/element.scss b/frontend/src/styles/element.scss index 691583e..8b44b32 100644 --- a/frontend/src/styles/element.scss +++ b/frontend/src/styles/element.scss @@ -438,17 +438,38 @@ $tabs-bg-color: #f5f7fa; height: 100%; .el-tabs__header-vertical { margin: 0 !important; + width: 42px; + flex: 0 0 42px; + padding: 4px 0; + box-sizing: border-box; background-color: $table-th-bg-color; } + .el-tabs__nav, + .el-tabs__nav-scroll { + width: 100%; + } .el-tabs__active-bar { display: none; } .el-tabs__item { - writing-mode: vertical-lr; /*从左向右 从右向左是 writing-mode: vertical-rl;*/ - // writing-mode: tb-lr; /*IE浏览器的从左向右 从右向左是 writing-mode: tb-rl;*/ + width: 42px; + min-height: 8rem; + margin-bottom: 4px; + box-sizing: border-box; + writing-mode: vertical-rl; + text-orientation: upright; height: auto !important; - line-height: inherit !important; - padding: 8px 5px; + line-height: 1.2 !important; + padding: 12px 0; + justify-content: center; + letter-spacing: 3px; + font-size: 1.2rem; + font-weight: 500; + transition: color 0.16s ease, background-color 0.16s ease; + + &:hover { + background-color: var(--el-fill-color-light); + } } .el-tabs__nav-wrap.is-right::after, .el-tabs__nav-wrap.is-left::after { @@ -463,6 +484,11 @@ $tabs-bg-color: #f5f7fa; } } &.el-tabs--right { + .el-tabs__item.is-active { + border-radius: 6px 0 0 6px; + background-color: var(--el-color-primary-light-9); + box-shadow: inset -3px 0 0 var(--el-color-primary); + } .el-tabs__content { .el-tab-pane { border-left: 1px solid $border-color; @@ -470,6 +496,11 @@ $tabs-bg-color: #f5f7fa; } } &.el-tabs--left { + .el-tabs__item.is-active { + border-radius: 0 6px 6px 0; + background-color: var(--el-color-primary-light-9); + box-shadow: inset 3px 0 0 var(--el-color-primary); + } .el-tabs__content { .el-tab-pane { border-right: 1px solid $border-color; diff --git a/frontend/src/types/auto-imports.d.ts b/frontend/src/types/auto-imports.d.ts index 8bf5c3d..4250e51 100644 --- a/frontend/src/types/auto-imports.d.ts +++ b/frontend/src/types/auto-imports.d.ts @@ -84,6 +84,6 @@ declare global { // for type re-export declare global { // @ts-ignore - export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue' + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' import('vue') } diff --git a/frontend/src/types/base.d.ts b/frontend/src/types/base.d.ts index 35b273f..68afe2b 100644 --- a/frontend/src/types/base.d.ts +++ b/frontend/src/types/base.d.ts @@ -14,6 +14,7 @@ declare type proxyOptions = { declare type groupOptions = { group_id: number group_name: string + panel_count?: number } /** * @description 下拉框格式 diff --git a/frontend/src/types/components.d.ts b/frontend/src/types/components.d.ts index 941bcb8..0becfa6 100644 --- a/frontend/src/types/components.d.ts +++ b/frontend/src/types/components.d.ts @@ -41,7 +41,6 @@ declare module 'vue' { ElTabs: typeof import('element-plus/es')['ElTabs'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTree: typeof import('element-plus/es')['ElTree'] - ElUpload: typeof import('element-plus/es')['ElUpload'] RenderTemp: typeof import('./../components/TabsMenu/renderTemp.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/frontend/src/views/panel/Views/details/index.vue b/frontend/src/views/panel/Views/details/index.vue index 34dd44e..02786fa 100644 --- a/frontend/src/views/panel/Views/details/index.vue +++ b/frontend/src/views/panel/Views/details/index.vue @@ -18,6 +18,7 @@ const router = useRouter() const Message = useMessage() // 消息提示 const xBounds = 0 // x轴偏移量 const yBounds = 40 // y轴偏移量 +let openAttempt = 0 // 获取当前key const getCurrentRefKey = () => { @@ -76,23 +77,16 @@ const onPanelSwitch = (index: any) => { } } -// 返回列表 -const goBack = () => { - router.push(`/home`) -} // 创建面板 const createPanel = (item: any) => { - // item.key = new Date().getTime() // 随机id addPanel.value = item // 添加面板tab panelActive.value = item.key // 激活当前面板 - // 创建面板子视图 - createChildView(item) } // 创建面板子视图 const createChildView = (item: any) => { - common.send( - routes.window.create.path, - { + return common.sendAsync({ + route: routes.window.create.path, + data: { view_key: item.key, url: item.url, options: {}, @@ -110,54 +104,80 @@ const createChildView = (item: any) => { }, proxy_id: item.proxy_id, }, - (result: any) => { - // console.log(result, item, '创建') - } - ) + timeout: 25000, + }) +} + +const restorePreviousPanel = (previousActiveKey: string) => { + const previousPanel = panelList.value.find((item: any) => item.key === previousActiveKey) + if (!previousPanel) return router.replace('/home') + panelActive.value = previousPanel.key + return router.replace({ + name: 'details', + params: { id: previousPanel.id, key: previousPanel.key }, + }) } + // 初始化面板 -const initPanel = () => { - const {id,key} = route.params +const initPanel = async () => { + const id = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id + const key = String(Array.isArray(route.params.key) ? route.params.key[0] : route.params.key) // 情况一:key是否已经存在(当处于其他路由切换回details时) if(panelList.value.length > 0 && panelList.value.find((item: any) => item.key === key)){ - panelActive.value = key as string + panelActive.value = key showPanelView() return } - // 情况二:创建新的列表 - const load = Message.load(pub.lang('正在创建面板应用')) - + const attempt = ++openAttempt + const previousActiveKey = panelActive.value + const load = Message.load(pub.lang('正在创建面板应用')) try { - // 获取面板信息 - common.send(routes.panel.find.path, { panel_id: id }, (info: any) => { - // 获取拼接的token - common.send(routes.panel.get_tmp_token.path, { panel_id: id }, (res: any) => { - if (!res.data) { - Message.error(res.msg) // 错误提示 - - if (panelList.value.length === 0) return goBack() - // 切换上一个面板 - panelActive.value = panelList.value[panelList.value.length - 1].key - showPanelView() - } else { - // 检查id是否存在当前列表 - const isIdExist = panelList.value.find((item: any) => item.id === id) - // 创建新面板 - createPanel({ - id, - url: isIdExist ? info.data.url : res.data, // ID存在使用旧的token - label: info.data.title, - proxy_id: info.data.proxy_id, - key: key, - favico: () => { - return ' ' - }, - }) - } - }) + const info: any = await common.sendAsync({ + route: routes.panel.find.path, + data: { panel_id: id }, + timeout: 5000, }) + if (!info?.status || !info.data) throw new Error(info?.msg || pub.lang('获取面板信息失败')) + if (attempt !== openAttempt) return + + const tokenResult: any = await common.sendAsync({ + route: routes.panel.get_tmp_token.path, + data: { panel_id: id }, + timeout: 12000, + }) + if (!tokenResult?.status || !tokenResult.data) { + throw new Error(tokenResult?.msg || pub.lang('获取面板登录地址失败')) + } + if (attempt !== openAttempt) return + + const panel = { + id, + url: tokenResult.data, + label: info.data.title, + proxy_id: info.data.proxy_id, + key, + favico: () => ' ', + } + const createResult: any = await createChildView(panel) + if (!createResult?.status || createResult.data?.view_key !== key) { + throw new Error(createResult?.msg || pub.lang('面板窗口创建失败')) + } + + if (attempt !== openAttempt) { + common.sendAsync({ + route: routes.window.destroy.path, + data: { view_key: key }, + timeout: 5000, + }).catch(() => {}) + return + } + createPanel(panel) + } catch (error: any) { + if (attempt !== openAttempt) return + Message.error(error?.message || pub.lang('面板打开失败')) + await restorePreviousPanel(previousActiveKey) } finally { load.close() } @@ -176,6 +196,8 @@ onMounted(() => { }) onBeforeUnmount(() => { + openAttempt++ + ipc.removeAllListeners('panel-switch') common.send(routes.window.list.path, {}, (res: any) => { res.data.forEach((item: any) => { common.send(routes.window.hide.path, { view_key: item }, (result: any) => { diff --git a/frontend/src/views/panel/components/GroupManage/index.vue b/frontend/src/views/panel/components/GroupManage/index.vue index 4ec753c..ca0d74d 100644 --- a/frontend/src/views/panel/components/GroupManage/index.vue +++ b/frontend/src/views/panel/components/GroupManage/index.vue @@ -12,6 +12,15 @@ v-html="pub.lang('分组管理')"> +
+
+
{{ pub.lang('显示分组数量') }}
+
+ {{ pub.lang('在顶部的分组选项中显示面板数量') }} +
+
+ +
{ } }) + diff --git a/frontend/src/views/panel/controller/index.ts b/frontend/src/views/panel/controller/index.ts index b7476dc..0fec7ee 100644 --- a/frontend/src/views/panel/controller/index.ts +++ b/frontend/src/views/panel/controller/index.ts @@ -43,6 +43,13 @@ export const record_disk = (parent: { panel_id: number; disk_path: string }) => common.send(routes.panel.record_disk.path, parent) } +/** + * @description 保存面板默认展示顺序 + */ +export const set_panel_sort = (panel_ids: number[]) => { + common.send(routes.panel.set_sort.path, { panel_ids }) +} + /** * @description 获取面板安装脚本列表 */ diff --git a/frontend/src/views/panel/index.vue b/frontend/src/views/panel/index.vue index 51ed8a1..9562f60 100644 --- a/frontend/src/views/panel/index.vue +++ b/frontend/src/views/panel/index.vue @@ -1,18 +1,85 @@