|
|
@@ -66,34 +66,61 @@
|
|
|
padding: 12px; height: 200px; overflow: auto; font-family: Consolas, monospace;
|
|
|
font-size: 12px; white-space: pre-wrap; color: #cbd5e1;
|
|
|
}
|
|
|
- .status { font-size: 13px; margin-left: 10px; }
|
|
|
+ .form {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 72px 1fr 72px 1fr;
|
|
|
+ gap: 10px 12px;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+ .form label { color: var(--muted); font-size: 13px; }
|
|
|
+ .form input, .form select {
|
|
|
+ background: #0f172a; border: 1px solid var(--line); color: var(--text);
|
|
|
+ border-radius: 8px; padding: 8px 10px; font-size: 14px; width: 100%;
|
|
|
+ }
|
|
|
+ .form .readonly {
|
|
|
+ background: #0b1220; color: var(--muted);
|
|
|
+ }
|
|
|
+ .cfg-msg { display: block; margin-top: 10px; min-height: 22px; font-size: 14px; font-weight: 600; }
|
|
|
.ok { color: var(--ok); } .bad { color: var(--bad); } .run { color: var(--warn); }
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
<div class="wrap">
|
|
|
- <h1>MES 数据库手动备份</h1>
|
|
|
- <p class="sub">独立工具。点磁盘或点「浏览文件夹」选择保存位置(U 盘 / 移动硬盘 / 本机磁盘),不用手输路径。数据会边导出边压缩;库很大时请预留足够空间,完成前不要拔盘。</p>
|
|
|
+ <h1>MES 数据库手动备份 · 选文件夹</h1>
|
|
|
+ <p class="sub">独立工具,仅支持 MySQL。填写连接后,点「浏览文件夹」选择 U 盘或硬盘目录(不用手输、打开时不扫描磁盘)。数据会边导出边压缩,完成前不要拔盘。</p>
|
|
|
|
|
|
<div class="card">
|
|
|
<div class="row">
|
|
|
- <h2>1. 点选磁盘</h2>
|
|
|
- <button class="btn ghost" id="btnRefresh" type="button">刷新磁盘</button>
|
|
|
+ <h2>1. MySQL 连接(可配置)</h2>
|
|
|
+ </div>
|
|
|
+ <div class="form">
|
|
|
+ <label>类型</label>
|
|
|
+ <input class="readonly" value="MySQL" readonly />
|
|
|
+ <label>端口</label>
|
|
|
+ <input id="dbPort" type="number" min="1" max="65535" value="3306" />
|
|
|
+ <label>IP/主机</label>
|
|
|
+ <input id="dbHost" type="text" placeholder="127.0.0.1 或 192.168.x.x" />
|
|
|
+ <label>库名</label>
|
|
|
+ <input id="dbName" type="text" placeholder="mes_cloud" />
|
|
|
+ <label>账号</label>
|
|
|
+ <input id="dbUser" type="text" autocomplete="off" />
|
|
|
+ <label>密码</label>
|
|
|
+ <input id="dbPass" type="password" autocomplete="new-password" />
|
|
|
</div>
|
|
|
- <div class="drives" id="drives">正在读取磁盘...</div>
|
|
|
- <p class="hint" id="dbHint"></p>
|
|
|
+ <div class="form-actions">
|
|
|
+ <button class="btn ghost" id="btnTest" type="button">测试连接</button>
|
|
|
+ <button class="btn" id="btnSaveCfg" type="button">保存配置</button>
|
|
|
+ </div>
|
|
|
+ <div class="cfg-msg" id="cfgStatus">填写 IP、账号、密码后点「测试连接」</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
<div class="row">
|
|
|
- <h2>2. 保存位置(点选,不用输入)</h2>
|
|
|
- <div>
|
|
|
- <button class="btn ghost" id="btnUp" type="button">上一级</button>
|
|
|
- <button class="btn" id="btnBrowse" type="button">浏览文件夹...</button>
|
|
|
- </div>
|
|
|
+ <h2>2. 选择保存位置</h2>
|
|
|
+ <button class="btn" id="btnBrowse" type="button">浏览文件夹...</button>
|
|
|
</div>
|
|
|
- <div class="picked empty" id="picked">尚未选择。请点上方磁盘,或点「浏览文件夹」弹出系统窗口选择。</div>
|
|
|
- <div class="folders" id="folders"></div>
|
|
|
+ <p class="hint">打开页面不会扫描磁盘。插入 U 盘后点「浏览文件夹」,在系统窗口里选目录即可。</p>
|
|
|
+ <div class="picked empty" id="picked">尚未选择。请点「浏览文件夹」。</div>
|
|
|
<p class="hint" id="spaceHint"></p>
|
|
|
<label class="check">
|
|
|
<input id="keepOld" type="checkbox"/>
|
|
|
@@ -116,7 +143,35 @@
|
|
|
var polling = null;
|
|
|
var dbBytes = 0;
|
|
|
|
|
|
- function $(id) { return document.getElementById(id); }
|
|
|
+ function $(id) {
|
|
|
+ return document.getElementById(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ function dbBody() {
|
|
|
+ return {
|
|
|
+ engine: "mysql",
|
|
|
+ host: ($("dbHost").value || "").trim(),
|
|
|
+ port: parseInt($("dbPort").value || "3306", 10),
|
|
|
+ database: ($("dbName").value || "").trim(),
|
|
|
+ user: ($("dbUser").value || "").trim(),
|
|
|
+ password: $("dbPass").value || ""
|
|
|
+ };
|
|
|
+ }
|
|
|
+ function setCfgStatus(text, cls) {
|
|
|
+ $("cfgStatus").className = "cfg-msg " + (cls || "");
|
|
|
+ $("cfgStatus").textContent = text || "";
|
|
|
+ }
|
|
|
+ function loadConfig() {
|
|
|
+ api("/api/config").then(function (j) {
|
|
|
+ $("dbHost").value = j.host || "";
|
|
|
+ $("dbPort").value = j.port || 3306;
|
|
|
+ $("dbName").value = j.database || "";
|
|
|
+ $("dbUser").value = j.user || "";
|
|
|
+ $("dbPass").value = j.password || "";
|
|
|
+ }).catch(function (e) {
|
|
|
+ setCfgStatus(e.message, "bad");
|
|
|
+ });
|
|
|
+ }
|
|
|
function asArray(x) {
|
|
|
if (!x) return [];
|
|
|
return Object.prototype.toString.call(x) === "[object Array]" ? x : [x];
|
|
|
@@ -137,7 +192,7 @@
|
|
|
var el = $("picked");
|
|
|
if (!selectedPath) {
|
|
|
el.className = "picked empty";
|
|
|
- el.textContent = "尚未选择。请点上方磁盘,或点「浏览文件夹」弹出系统窗口选择。";
|
|
|
+ el.textContent = "尚未选择。请点「浏览文件夹」。";
|
|
|
} else {
|
|
|
el.className = "picked";
|
|
|
el.textContent = selectedPath;
|
|
|
@@ -145,45 +200,28 @@
|
|
|
}
|
|
|
|
|
|
function api(url, opt) {
|
|
|
- return fetch(url, opt).then(function (r) {
|
|
|
+ opt = opt || {};
|
|
|
+ var ctrl = new AbortController();
|
|
|
+ var ms = opt.timeoutMs || 12000;
|
|
|
+ var timer = setTimeout(function () { ctrl.abort(); }, ms);
|
|
|
+ var fo = Object.assign({}, opt, { signal: ctrl.signal });
|
|
|
+ delete fo.timeoutMs;
|
|
|
+ return fetch(url, fo).then(function (r) {
|
|
|
return r.text().then(function (t) {
|
|
|
var j = {};
|
|
|
- try { j = JSON.parse(t); } catch (e) { throw new Error(t || "接口无响应"); }
|
|
|
+ try { j = t ? JSON.parse(t) : {}; } catch (e) { throw new Error(t || "接口无响应"); }
|
|
|
if (!r.ok || j.ok === false) throw new Error(j.message || "请求失败");
|
|
|
return j;
|
|
|
});
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- function loadDrives() {
|
|
|
- $("drives").textContent = "正在读取磁盘...";
|
|
|
- api("/api/drives").then(function (j) {
|
|
|
- dbBytes = j.dbBytes || 0;
|
|
|
- if (dbBytes > 0) {
|
|
|
- $("dbHint").textContent = "当前数据库约 " + gb(dbBytes) + " GB。建议目标盘剩余空间大于该值。备份采用 gzip 压缩,边导边压。";
|
|
|
- }
|
|
|
- var html = "";
|
|
|
- asArray(j.drives).forEach(function (d) {
|
|
|
- var cls = "drive" + (d.removable ? " usb" : "");
|
|
|
- var space = d.ready ? ("剩余 " + d.freeGB + " GB / 共 " + d.sizeGB + " GB") : "未就绪";
|
|
|
- html += '<button type="button" class="' + cls + '" data-path="' + d.path + '" data-free="' + (d.freeBytes || 0) + '">'
|
|
|
- + '<div class="t"><span class="tag' + (d.removable ? " usb" : "") + '">' + d.type + "</span></div>"
|
|
|
- + '<div class="n">' + d.letter + " " + (d.name || "") + "</div>"
|
|
|
- + '<div class="s">' + space + "</div></button>";
|
|
|
- });
|
|
|
- $("drives").innerHTML = html || "没有发现可用磁盘";
|
|
|
- Array.prototype.forEach.call($("drives").querySelectorAll(".drive"), function (el) {
|
|
|
- el.onclick = function () {
|
|
|
- Array.prototype.forEach.call($("drives").querySelectorAll(".drive"), function (x) { x.classList.remove("on"); });
|
|
|
- el.classList.add("on");
|
|
|
- var p = el.getAttribute("data-path");
|
|
|
- setPicked(p + "MES_DB_Backup");
|
|
|
- showSpace(parseInt(el.getAttribute("data-free") || "0", 10));
|
|
|
- loadFolders(p);
|
|
|
- };
|
|
|
- });
|
|
|
}).catch(function (e) {
|
|
|
- $("drives").textContent = "读取磁盘失败:" + e.message;
|
|
|
+ if (e.name === "AbortError") throw new Error("等待超时,请确认已关掉旧的备份窗口后重新打开页面");
|
|
|
+ throw e;
|
|
|
+ }).then(function (v) {
|
|
|
+ clearTimeout(timer);
|
|
|
+ return v;
|
|
|
+ }, function (e) {
|
|
|
+ clearTimeout(timer);
|
|
|
+ throw e;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -196,28 +234,6 @@
|
|
|
$("spaceHint").textContent = msg;
|
|
|
}
|
|
|
|
|
|
- function loadFolders(path) {
|
|
|
- if (!path) return;
|
|
|
- api("/api/folders?path=" + encodeURIComponent(path)).then(function (j) {
|
|
|
- var d = j.data || {};
|
|
|
- parentPath = d.parent || "";
|
|
|
- var html = "";
|
|
|
- asArray(d.folders).forEach(function (f) {
|
|
|
- html += '<button class="folder" type="button" data-path="' + f.path.replace(/"/g, "") + '">📁 ' + f.name + "</button>";
|
|
|
- });
|
|
|
- $("folders").innerHTML = html || '<span style="color:#94a3b8;font-size:13px;">该目录下没有子文件夹,将备份到上面显示的位置</span>';
|
|
|
- Array.prototype.forEach.call($("folders").querySelectorAll(".folder"), function (el) {
|
|
|
- el.onclick = function () {
|
|
|
- var p = el.getAttribute("data-path");
|
|
|
- setPicked(p);
|
|
|
- loadFolders(p);
|
|
|
- };
|
|
|
- });
|
|
|
- }).catch(function (e) {
|
|
|
- $("folders").innerHTML = '<span style="color:#f87171;font-size:13px;">' + e.message + "</span>";
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
function pollStatus() {
|
|
|
api("/api/status").then(function (j) {
|
|
|
setLog(asArray(j.log));
|
|
|
@@ -250,34 +266,29 @@
|
|
|
}).catch(function () {});
|
|
|
}
|
|
|
|
|
|
- $("btnRefresh").onclick = loadDrives;
|
|
|
- $("btnUp").onclick = function () {
|
|
|
- if (parentPath) {
|
|
|
- setPicked(parentPath);
|
|
|
- loadFolders(parentPath);
|
|
|
- }
|
|
|
- };
|
|
|
$("btnBrowse").onclick = function () {
|
|
|
setStatus("请在弹出的系统窗口中选择文件夹(窗口可能在浏览器后面)", "run");
|
|
|
api("/api/browse", { method: "POST" }).then(function (j) {
|
|
|
if (j.cancelled) { setStatus("", ""); return; }
|
|
|
setPicked(j.path);
|
|
|
showSpace(j.freeBytes || 0);
|
|
|
- loadFolders(j.path);
|
|
|
setStatus("已选择保存位置", "ok");
|
|
|
}).catch(function (e) {
|
|
|
setStatus(e.message, "bad");
|
|
|
});
|
|
|
};
|
|
|
$("btnBackup").onclick = function () {
|
|
|
- if (!selectedPath) { setStatus("请先点选磁盘或浏览文件夹", "bad"); return; }
|
|
|
+ if (!selectedPath) { setStatus("请先点「浏览文件夹」选择保存位置", "bad"); return; }
|
|
|
$("btnBackup").disabled = true;
|
|
|
setStatus("正在启动备份...", "run");
|
|
|
$("bar").className = "bar indeterminate";
|
|
|
api("/api/backup", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ path: selectedPath, keepDays: $("keepOld").checked ? 7 : 0 })
|
|
|
+ body: JSON.stringify(Object.assign(dbBody(), {
|
|
|
+ path: selectedPath,
|
|
|
+ keepDays: $("keepOld").checked ? 7 : 0
|
|
|
+ }))
|
|
|
}).then(function () {
|
|
|
if (polling) clearInterval(polling);
|
|
|
polling = setInterval(pollStatus, 1000);
|
|
|
@@ -289,7 +300,34 @@
|
|
|
});
|
|
|
};
|
|
|
|
|
|
- loadDrives();
|
|
|
+ $("btnTest").onclick = function () {
|
|
|
+ setCfgStatus("正在测试连接,请稍候…", "run");
|
|
|
+ var b = dbBody();
|
|
|
+ var q = "host=" + encodeURIComponent(b.host)
|
|
|
+ + "&port=" + encodeURIComponent(b.port)
|
|
|
+ + "&database=" + encodeURIComponent(b.database)
|
|
|
+ + "&user=" + encodeURIComponent(b.user)
|
|
|
+ + "&password=" + encodeURIComponent(b.password);
|
|
|
+ api("/api/test?" + q, { timeoutMs: 15000 }).then(function (j) {
|
|
|
+ setCfgStatus(j.message || "连接成功", "ok");
|
|
|
+ }).catch(function (e) {
|
|
|
+ setCfgStatus(e.message || "连接失败", "bad");
|
|
|
+ });
|
|
|
+ };
|
|
|
+ $("btnSaveCfg").onclick = function () {
|
|
|
+ setCfgStatus("正在保存...", "run");
|
|
|
+ api("/api/config", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(dbBody())
|
|
|
+ }).then(function (j) {
|
|
|
+ setCfgStatus(j.message || "已保存", "ok");
|
|
|
+ }).catch(function (e) {
|
|
|
+ setCfgStatus(e.message, "bad");
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ loadConfig();
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|