backup-ui.html 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8"/>
  5. <meta name="viewport" content="width=device-width, initial-scale=1"/>
  6. <title>MES 智能备份控制台</title>
  7. <style>
  8. :root {
  9. --bg: #07080d;
  10. --bg2: #10131c;
  11. --card: rgba(16, 20, 32, .72);
  12. --line: rgba(94, 234, 212, .18);
  13. --text: #eef4ff;
  14. --muted: #8b95ab;
  15. --cyan: #5eead4;
  16. --cyan2: #22d3ee;
  17. --violet: #818cf8;
  18. --ok: #34d399;
  19. --warn: #fbbf24;
  20. --bad: #fb7185;
  21. --shadow: 0 24px 60px rgba(0, 0, 0, .45);
  22. }
  23. * { box-sizing: border-box; }
  24. html, body { height: 100%; }
  25. body {
  26. margin: 0;
  27. color: var(--text);
  28. font-family: "Segoe UI Variable", "Microsoft YaHei UI", "Segoe UI", sans-serif;
  29. background:
  30. radial-gradient(900px 520px at 8% -10%, rgba(34, 211, 238, .18), transparent 55%),
  31. radial-gradient(780px 480px at 108% 8%, rgba(129, 140, 248, .2), transparent 50%),
  32. radial-gradient(640px 420px at 70% 110%, rgba(94, 234, 212, .1), transparent 50%),
  33. var(--bg);
  34. overflow-x: hidden;
  35. }
  36. body::before {
  37. content: "";
  38. position: fixed; inset: 0; pointer-events: none; z-index: 0;
  39. background-image:
  40. linear-gradient(rgba(148, 163, 184, .05) 1px, transparent 1px),
  41. linear-gradient(90deg, rgba(148, 163, 184, .05) 1px, transparent 1px);
  42. background-size: 42px 42px;
  43. mask-image: radial-gradient(ellipse at center, #000 40%, transparent 78%);
  44. }
  45. .orb {
  46. position: fixed; border-radius: 50%; filter: blur(40px); pointer-events: none; z-index: 0;
  47. animation: float 12s ease-in-out infinite;
  48. }
  49. .orb.a { width: 220px; height: 220px; left: -40px; top: 120px; background: rgba(34, 211, 238, .16); }
  50. .orb.b { width: 280px; height: 280px; right: -80px; top: -40px; background: rgba(129, 140, 248, .18); animation-delay: -4s; }
  51. @keyframes float {
  52. 0%, 100% { transform: translateY(0); }
  53. 50% { transform: translateY(18px); }
  54. }
  55. .wrap { position: relative; z-index: 1; max-width: 1080px; margin: 0 auto; padding: 28px 22px 48px; }
  56. .hero {
  57. display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
  58. margin-bottom: 22px;
  59. }
  60. .mark {
  61. width: 46px; height: 46px; border-radius: 14px; flex: 0 0 auto;
  62. display: grid; place-items: center;
  63. background: linear-gradient(145deg, rgba(94, 234, 212, .95), rgba(129, 140, 248, .85));
  64. box-shadow: 0 0 28px rgba(94, 234, 212, .35);
  65. color: #071018; font-weight: 800; font-size: 18px; letter-spacing: -.04em;
  66. }
  67. .brand { display: flex; gap: 14px; align-items: center; }
  68. .kicker {
  69. font-size: 11px; letter-spacing: .28em; text-transform: uppercase;
  70. color: var(--cyan); margin: 0 0 4px;
  71. }
  72. h1 { font-size: 28px; margin: 0; letter-spacing: -.03em; font-weight: 700; }
  73. .sub { color: var(--muted); margin: 6px 0 0; font-size: 13px; line-height: 1.6; }
  74. .chips { display: flex; gap: 8px; flex-wrap: wrap; }
  75. .chip {
  76. font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  77. border: 1px solid var(--line); color: var(--muted);
  78. padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,.03);
  79. }
  80. .chip.on { color: var(--cyan); border-color: rgba(94, 234, 212, .4); }
  81. .layout { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; }
  82. @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
  83. .stack { display: flex; flex-direction: column; gap: 16px; }
  84. .card {
  85. background: var(--card);
  86. border: 1px solid var(--line);
  87. border-radius: 20px;
  88. padding: 20px 20px 18px;
  89. backdrop-filter: blur(18px);
  90. box-shadow: var(--shadow);
  91. position: relative;
  92. overflow: hidden;
  93. }
  94. .card::after {
  95. content: "";
  96. position: absolute; inset: 0 auto auto 0; width: 100%; height: 1px;
  97. background: linear-gradient(90deg, transparent, rgba(94,234,212,.55), transparent);
  98. }
  99. .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
  100. .row h2, .card > h2 { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: .02em; }
  101. .step {
  102. width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center;
  103. font-size: 11px; font-weight: 700; color: #071018;
  104. background: linear-gradient(145deg, var(--cyan), var(--violet));
  105. }
  106. .head { display: flex; align-items: center; gap: 10px; }
  107. .head .hint { color: var(--muted); font-size: 12px; margin-left: 4px; }
  108. .btn {
  109. border: 0; border-radius: 12px; padding: 9px 14px; cursor: pointer;
  110. font-size: 13px; font-weight: 650; color: #071018;
  111. background: linear-gradient(180deg, #7ff5e0, #5eead4);
  112. box-shadow: 0 8px 22px rgba(94, 234, 212, .22);
  113. transition: transform .15s ease, filter .15s ease;
  114. }
  115. .btn:hover { transform: translateY(-1px); filter: brightness(1.05); }
  116. .btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
  117. .btn.ghost {
  118. background: transparent; color: var(--text);
  119. border: 1px solid rgba(255,255,255,.12);
  120. box-shadow: none;
  121. }
  122. .btn.go {
  123. width: 100%; padding: 14px 22px; font-size: 15px; letter-spacing: .12em;
  124. text-transform: uppercase;
  125. background: linear-gradient(90deg, #22d3ee, #818cf8 60%, #5eead4);
  126. color: #061018;
  127. }
  128. .form {
  129. display: grid;
  130. grid-template-columns: 1fr 1fr;
  131. gap: 14px 18px;
  132. }
  133. .field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
  134. .field label { color: var(--muted); font-size: 12px; letter-spacing: .08em; }
  135. .field input {
  136. background: rgba(7, 8, 13, .72);
  137. border: 1px solid rgba(255,255,255,.08);
  138. color: var(--text);
  139. border-radius: 12px; padding: 10px 12px; font-size: 14px; width: 100%;
  140. outline: none;
  141. }
  142. .field input:focus { border-color: rgba(94, 234, 212, .55); box-shadow: 0 0 0 3px rgba(94, 234, 212, .12); }
  143. .field .readonly { color: var(--muted); }
  144. .form-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
  145. .cfg-msg { display: block; margin: 0; min-height: 22px; font-size: 13px; font-weight: 600; flex: 1; }
  146. .picked {
  147. background: rgba(7, 8, 13, .65);
  148. border: 1px dashed rgba(94, 234, 212, .28);
  149. border-radius: 14px;
  150. padding: 16px 14px; font-size: 14px; word-break: break-all; min-height: 52px;
  151. }
  152. .picked.empty { color: var(--muted); }
  153. .check { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; margin: 12px 0; }
  154. .hint { color: var(--muted); font-size: 12px; margin: 8px 0 0; }
  155. .bar {
  156. height: 8px; background: rgba(255,255,255,.06); border-radius: 999px;
  157. overflow: hidden; margin: 12px 0 8px; position: relative;
  158. }
  159. .bar > span {
  160. display: block; height: 100%; width: 0;
  161. background: linear-gradient(90deg, #22d3ee, #818cf8, #5eead4);
  162. box-shadow: 0 0 16px rgba(34, 211, 238, .65);
  163. transition: width .4s;
  164. }
  165. .bar.indeterminate > span { width: 38% !important; animation: slide 1.2s infinite; }
  166. @keyframes slide { 0% { transform: translateX(-110%); } 100% { transform: translateX(280%); } }
  167. .log {
  168. background: #05070c;
  169. border: 1px solid rgba(255,255,255,.06);
  170. border-radius: 14px;
  171. padding: 14px; height: 100%; min-height: 280px; overflow: auto;
  172. font-family: Consolas, "Cascadia Mono", monospace;
  173. font-size: 12px; white-space: pre-wrap; color: #9fe8dc; line-height: 1.55;
  174. }
  175. .status { font-size: 13px; font-weight: 650; }
  176. .ok { color: var(--ok); } .bad { color: var(--bad); } .run { color: var(--warn); }
  177. </style>
  178. </head>
  179. <body>
  180. <div class="orb a"></div>
  181. <div class="orb b"></div>
  182. <div class="wrap">
  183. <div class="hero">
  184. <div class="brand">
  185. <div class="mark">M</div>
  186. <div>
  187. <p class="kicker">Neural Archive</p>
  188. <h1>MES 智能备份控制台</h1>
  189. <p class="sub">独立工具,仅支持 MySQL。填写连接后选择 U 盘或硬盘目录。数据边导出边压缩,完成前不要拔盘。</p>
  190. </div>
  191. </div>
  192. <div class="chips">
  193. <span class="chip on">Local Only</span>
  194. <span class="chip">MySQL</span>
  195. <span class="chip">Air-gapped</span>
  196. </div>
  197. </div>
  198. <div class="layout">
  199. <div class="stack">
  200. <div class="card">
  201. <div class="row">
  202. <div class="head"><span class="step">01</span><h2>连接矩阵</h2><span class="hint">可先测试,再保存到本机</span></div>
  203. </div>
  204. <div class="form">
  205. <div class="field">
  206. <label>类型</label>
  207. <input class="readonly" value="MySQL" readonly />
  208. </div>
  209. <div class="field">
  210. <label>端口</label>
  211. <input id="dbPort" type="number" min="1" max="65535" value="3306" />
  212. </div>
  213. <div class="field">
  214. <label>IP / 主机</label>
  215. <input id="dbHost" type="text" placeholder="127.0.0.1 或 192.168.x.x" />
  216. </div>
  217. <div class="field">
  218. <label>库名</label>
  219. <input id="dbName" type="text" placeholder="mes_cloud" />
  220. </div>
  221. <div class="field">
  222. <label>账号</label>
  223. <input id="dbUser" type="text" autocomplete="off" />
  224. </div>
  225. <div class="field">
  226. <label>密码</label>
  227. <input id="dbPass" type="password" autocomplete="new-password" />
  228. </div>
  229. </div>
  230. <div class="form-actions">
  231. <button class="btn ghost" id="btnTest" type="button">测试连接</button>
  232. <button class="btn" id="btnSaveCfg" type="button">保存配置</button>
  233. <div class="cfg-msg" id="cfgStatus">填写 IP、账号、密码后点「测试连接」</div>
  234. </div>
  235. </div>
  236. <div class="card">
  237. <div class="row">
  238. <div class="head"><span class="step">02</span><h2>归档目标</h2></div>
  239. <button class="btn" id="btnBrowse" type="button">浏览文件夹</button>
  240. </div>
  241. <div class="picked empty" id="picked">尚未选择保存位置</div>
  242. <p class="hint" id="spaceHint">插入 U 盘或移动硬盘后选择目录,无需手输路径。</p>
  243. <label class="check">
  244. <input id="keepOld" type="checkbox"/>
  245. 同时删除该目录中超过 7 天的旧备份(U 盘建议不勾选)
  246. </label>
  247. <div class="bar" id="bar"><span id="barInner"></span></div>
  248. <div class="hint" id="progHint"></div>
  249. <button class="btn go" id="btnBackup" type="button">开始备份</button>
  250. <span class="status" id="status"></span>
  251. </div>
  252. </div>
  253. <div class="card" style="min-height: 520px; display:flex; flex-direction:column;">
  254. <div class="row">
  255. <div class="head"><span class="step">03</span><h2>运行控制台</h2></div>
  256. </div>
  257. <div class="log" id="log">等待操作...</div>
  258. </div>
  259. </div>
  260. </div>
  261. <script>
  262. var selectedPath = "";
  263. var parentPath = "";
  264. var polling = null;
  265. var dbBytes = 0;
  266. function $(id) {
  267. return document.getElementById(id);
  268. }
  269. function dbBody() {
  270. return {
  271. engine: "mysql",
  272. host: ($("dbHost").value || "").trim(),
  273. port: parseInt($("dbPort").value || "3306", 10),
  274. database: ($("dbName").value || "").trim(),
  275. user: ($("dbUser").value || "").trim(),
  276. password: $("dbPass").value || ""
  277. };
  278. }
  279. function setCfgStatus(text, cls) {
  280. $("cfgStatus").className = "cfg-msg " + (cls || "");
  281. $("cfgStatus").textContent = text || "";
  282. }
  283. function loadConfig() {
  284. api("/api/config").then(function (j) {
  285. $("dbHost").value = j.host || "";
  286. $("dbPort").value = j.port || 3306;
  287. $("dbName").value = j.database || "";
  288. $("dbUser").value = j.user || "";
  289. $("dbPass").value = j.password || "";
  290. }).catch(function (e) {
  291. setCfgStatus(e.message, "bad");
  292. });
  293. }
  294. function asArray(x) {
  295. if (!x) return [];
  296. return Object.prototype.toString.call(x) === "[object Array]" ? x : [x];
  297. }
  298. function gb(n) { return (n / 1073741824).toFixed(2); }
  299. function mb(n) { return (n / 1048576).toFixed(1); }
  300. function setStatus(text, cls) {
  301. $("status").className = "status " + (cls || "");
  302. $("status").textContent = text ? (" " + text) : "";
  303. }
  304. function setLog(lines) {
  305. $("log").textContent = (lines && lines.length) ? lines.join("\n") : "等待操作...";
  306. $("log").scrollTop = $("log").scrollHeight;
  307. }
  308. function setPicked(path) {
  309. selectedPath = path || "";
  310. var el = $("picked");
  311. if (!selectedPath) {
  312. el.className = "picked empty";
  313. el.textContent = "尚未选择保存位置";
  314. } else {
  315. el.className = "picked";
  316. el.textContent = selectedPath;
  317. }
  318. }
  319. function api(url, opt) {
  320. opt = opt || {};
  321. var ctrl = new AbortController();
  322. var ms = opt.timeoutMs || 12000;
  323. var timer = setTimeout(function () { ctrl.abort(); }, ms);
  324. var fo = Object.assign({}, opt, { signal: ctrl.signal });
  325. delete fo.timeoutMs;
  326. return fetch(url, fo).then(function (r) {
  327. return r.text().then(function (t) {
  328. var j = {};
  329. try { j = t ? JSON.parse(t) : {}; } catch (e) { throw new Error(t || "接口无响应"); }
  330. if (!r.ok || j.ok === false) throw new Error(j.message || "请求失败");
  331. return j;
  332. });
  333. }).catch(function (e) {
  334. if (e.name === "AbortError") throw new Error("等待超时,请确认已关掉旧的备份窗口后重新打开页面");
  335. throw e;
  336. }).then(function (v) {
  337. clearTimeout(timer);
  338. return v;
  339. }, function (e) {
  340. clearTimeout(timer);
  341. throw e;
  342. });
  343. }
  344. function showSpace(freeBytes) {
  345. if (!freeBytes) { $("spaceHint").textContent = ""; return; }
  346. var msg = "该磁盘剩余 " + gb(freeBytes) + " GB。";
  347. if (dbBytes > 0 && freeBytes < dbBytes * 1.2) {
  348. msg += " 空间可能不够,建议换更大的盘。";
  349. }
  350. $("spaceHint").textContent = msg;
  351. }
  352. function pollStatus() {
  353. api("/api/status").then(function (j) {
  354. setLog(asArray(j.log));
  355. var written = j.writtenBytes || 0;
  356. var est = j.dbBytes || dbBytes || 0;
  357. $("bar").className = "bar" + (j.running && (!est || written < 1024) ? " indeterminate" : "");
  358. var pct = 0;
  359. if (est > 0 && written > 0) {
  360. pct = Math.max(2, Math.min(99, Math.round(written / est * 100)));
  361. } else if (!j.running && j.result === "ok") {
  362. pct = 100;
  363. }
  364. $("barInner").style.width = pct + "%";
  365. if (j.running) {
  366. $("btnBackup").disabled = true;
  367. $("progHint").textContent = "已写入 " + mb(written) + " MB" + (j.fileName ? ("(" + j.fileName + ")") : "") + ",请勿拔盘";
  368. setStatus("正在备份,数据量大时可能需要较长时间...", "run");
  369. } else {
  370. $("btnBackup").disabled = false;
  371. $("bar").className = "bar";
  372. if (j.result === "ok") {
  373. $("barInner").style.width = "100%";
  374. $("progHint").textContent = "完成,文件约 " + mb(written) + " MB:" + (j.fileName || "");
  375. setStatus("备份完成", "ok");
  376. } else if (j.result === "error") {
  377. setStatus(j.error || "备份失败", "bad");
  378. }
  379. if (polling) { clearInterval(polling); polling = null; }
  380. }
  381. }).catch(function () {});
  382. }
  383. $("btnBrowse").onclick = function () {
  384. setStatus("请在最前面弹出的窗口中选择文件夹", "run");
  385. api("/api/browse", { method: "POST" }).then(function (j) {
  386. if (j.cancelled) { setStatus("", ""); return; }
  387. setPicked(j.path);
  388. showSpace(j.freeBytes || 0);
  389. setStatus("已选择保存位置", "ok");
  390. }).catch(function (e) {
  391. setStatus(e.message, "bad");
  392. });
  393. };
  394. $("btnBackup").onclick = function () {
  395. if (!selectedPath) { setStatus("请先点「浏览文件夹」选择保存位置", "bad"); return; }
  396. $("btnBackup").disabled = true;
  397. setStatus("正在启动备份...", "run");
  398. $("bar").className = "bar indeterminate";
  399. api("/api/backup", {
  400. method: "POST",
  401. headers: { "Content-Type": "application/json" },
  402. body: JSON.stringify(Object.assign(dbBody(), {
  403. path: selectedPath,
  404. keepDays: $("keepOld").checked ? 7 : 0
  405. }))
  406. }).then(function () {
  407. if (polling) clearInterval(polling);
  408. polling = setInterval(pollStatus, 1000);
  409. pollStatus();
  410. }).catch(function (e) {
  411. $("btnBackup").disabled = false;
  412. $("bar").className = "bar";
  413. setStatus(e.message, "bad");
  414. });
  415. };
  416. $("btnTest").onclick = function () {
  417. setCfgStatus("正在测试连接,请稍候…", "run");
  418. var b = dbBody();
  419. var q = "host=" + encodeURIComponent(b.host)
  420. + "&port=" + encodeURIComponent(b.port)
  421. + "&database=" + encodeURIComponent(b.database)
  422. + "&user=" + encodeURIComponent(b.user)
  423. + "&password=" + encodeURIComponent(b.password);
  424. api("/api/test?" + q, { timeoutMs: 15000 }).then(function (j) {
  425. setCfgStatus(j.message || "连接成功", "ok");
  426. }).catch(function (e) {
  427. setCfgStatus(e.message || "连接失败", "bad");
  428. });
  429. };
  430. $("btnSaveCfg").onclick = function () {
  431. setCfgStatus("正在保存...", "run");
  432. api("/api/config", {
  433. method: "POST",
  434. headers: { "Content-Type": "application/json" },
  435. body: JSON.stringify(dbBody())
  436. }).then(function (j) {
  437. setCfgStatus(j.message || "已保存", "ok");
  438. }).catch(function (e) {
  439. setCfgStatus(e.message, "bad");
  440. });
  441. };
  442. loadConfig();
  443. </script>
  444. </body>
  445. </html>