index.html 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372
  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>VRTX 迁移工具</title>
  7. <style>
  8. :root {
  9. --bg: #f5f6f8;
  10. --card: #ffffff;
  11. --ink: #1b1f27;
  12. --dim: #6b7280;
  13. --line: #e3e6ec;
  14. --brand: #4f46e5;
  15. --brand-dark: #4338ca;
  16. --ok: #15803d;
  17. --warn: #b45309;
  18. --err: #b91c1c;
  19. --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  20. }
  21. * { box-sizing: border-box; }
  22. body {
  23. margin: 0;
  24. background: var(--bg);
  25. color: var(--ink);
  26. font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
  27. "Microsoft YaHei", Roboto, sans-serif;
  28. }
  29. header {
  30. padding: 30px 24px 22px;
  31. background: linear-gradient(135deg, #4f46e5, #7c3aed);
  32. color: #fff;
  33. }
  34. header h1 { margin: 0 0 6px; font-size: 21px; font-weight: 650; letter-spacing: .2px; }
  35. header p { margin: 0; font-size: 13px; opacity: .86; }
  36. header code { background: rgba(255,255,255,.18); padding: 1px 6px; border-radius: 5px; font-family: var(--mono); }
  37. main { max-width: 900px; margin: 0 auto; padding: 22px 24px 60px; }
  38. .card {
  39. background: var(--card);
  40. border: 1px solid var(--line);
  41. border-radius: 12px;
  42. padding: 20px;
  43. margin-bottom: 16px;
  44. box-shadow: 0 1px 2px rgba(16,24,40,.04);
  45. }
  46. .card h2 {
  47. margin: 0 0 14px;
  48. font-size: 15px;
  49. font-weight: 620;
  50. display: flex;
  51. align-items: center;
  52. gap: 10px;
  53. }
  54. .card h2 .n {
  55. display: inline-flex; align-items: center; justify-content: center;
  56. width: 22px; height: 22px; border-radius: 50%;
  57. background: var(--brand); color: #fff; font-size: 12px; font-weight: 700;
  58. }
  59. label { display: block; font-size: 12.5px; color: var(--dim); margin-bottom: 4px; }
  60. input[type=text], input[type=number], input[type=search], input[type=password] {
  61. width: 100%; padding: 9px 11px; font-size: 13px;
  62. border: 1px solid var(--line); border-radius: 8px; background: #fbfbfd;
  63. color: var(--ink); font-family: var(--mono);
  64. transition: border-color .15s, box-shadow .15s;
  65. }
  66. input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.13); background: #fff; }
  67. .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
  68. @media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
  69. .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
  70. .btn {
  71. padding: 9px 16px; font-size: 13px; font-weight: 560;
  72. border: 1px solid var(--line); border-radius: 8px;
  73. background: #fff; color: var(--ink); cursor: pointer;
  74. transition: background .15s, border-color .15s, transform .05s;
  75. }
  76. .btn:hover { background: #f4f5f8; }
  77. .btn:active { transform: translateY(1px); }
  78. .btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
  79. .btn.primary:hover { background: var(--brand-dark); }
  80. .btn.primary:disabled { background: #a5a5d8; border-color: #a5a5d8; cursor: default; }
  81. .btn.tiny { padding: 5px 11px; font-size: 12px; }
  82. .hint { font-size: 12px; color: var(--dim); }
  83. .msg { margin-top: 10px; font-size: 12.5px; min-height: 18px; }
  84. .msg.err { color: var(--err); }
  85. .msg.ok { color: var(--ok); }
  86. .opts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; margin-bottom: 14px; }
  87. @media (max-width: 640px) { .opts { grid-template-columns: 1fr; } }
  88. .chk { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink); margin: 0; cursor: pointer; }
  89. .chk input { width: 15px; height: 15px; accent-color: var(--brand); cursor: pointer; }
  90. .tbl { border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; margin-bottom: 9px; background: #fcfcfe; }
  91. .tblHead { display: flex; align-items: center; gap: 9px; cursor: pointer; margin: 0; font-size: 13.5px; color: var(--ink); }
  92. .tblHead input { width: 15px; height: 15px; accent-color: var(--brand); cursor: pointer; }
  93. .tblHead b { font-weight: 620; }
  94. .tblHead .dim { color: var(--dim); font-size: 12px; margin-left: auto; font-family: var(--mono); }
  95. .cols { margin: 9px 0 0 24px; display: flex; flex-wrap: wrap; gap: 5px; }
  96. .col {
  97. font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
  98. border-radius: 5px; background: #eef0f6; color: #374151;
  99. }
  100. .col.pk { background: #e0e7ff; color: #3730a3; font-weight: 600; }
  101. .col.syn { background: #fef3c7; color: #92400e; }
  102. .idxs { margin: 7px 0 0 24px; font-family: var(--mono); font-size: 11.5px; color: var(--dim); }
  103. .idxs span { margin-right: 12px; }
  104. .log {
  105. margin: 14px 0 0; padding: 13px 15px; min-height: 90px; max-height: 380px; overflow: auto;
  106. background: #111827; color: #d1fae5; border-radius: 9px;
  107. font-family: var(--mono); font-size: 12px; line-height: 1.65;
  108. white-space: pre-wrap; word-break: break-all;
  109. scrollbar-width: thin; scrollbar-color: #4b5563 #1f2937;
  110. }
  111. /* 深色底上系统默认滚动条几乎看不见, 单独画一条 */
  112. .log::-webkit-scrollbar { width: 10px; height: 10px; }
  113. .log::-webkit-scrollbar-track { background: #1f2937; border-radius: 8px; }
  114. .log::-webkit-scrollbar-thumb {
  115. background: #4b5563; border: 2px solid #1f2937; border-radius: 8px;
  116. }
  117. .log::-webkit-scrollbar-thumb:hover { background: #6b7280; }
  118. .log .t { color: #6b7280; }
  119. .log .e { color: #fca5a5; }
  120. .log .empty { color: #4b5563; }
  121. .log .stage { color: #fcd34d; font-weight: 600; margin-top: 3px; }
  122. .log .r { color: #94a3b8; } /* 逐行数据: 刻意低调, 免得盖过关键日志 */
  123. /* 执行区一分为二: 左边表清单 (自己带滚动条), 右边还是那面滚动的日志墙。
  124. 日志不筛不拆 —— 左表负责回答"现在迁到哪张、那张什么状态", 点一下能跳过去。
  125. 两栏各自 overflow: auto, 一个撑不长, 另一个也不跟着抖。 */
  126. .split { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
  127. @media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
  128. .split .log { margin: 0; }
  129. .side {
  130. position: relative; /* 子项 offsetTop 要相对这里算, 好做滚动定位 */
  131. border: 1px solid var(--line); border-radius: 9px; background: #fcfcfe;
  132. max-height: 380px; min-height: 90px; overflow: auto; scrollbar-width: thin;
  133. }
  134. .sideHead {
  135. position: sticky; top: 0; z-index: 1;
  136. display: flex; align-items: center; gap: 6px;
  137. padding: 7px 10px; background: #f4f5f8; border-bottom: 1px solid var(--line);
  138. font-size: 12px; color: var(--dim);
  139. }
  140. .sideHead b { color: var(--ink); }
  141. .sideHead .pin {
  142. margin-left: auto; max-width: 132px; color: var(--brand); font-weight: 560;
  143. overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  144. }
  145. .sideList { padding: 5px; }
  146. .sideList .empty { display: block; padding: 5px 6px; color: #9ca3af; font-size: 12px; }
  147. .ti {
  148. padding: 6px 8px; border: 1px solid transparent; border-radius: 7px;
  149. font-size: 12.5px; line-height: 1.5; cursor: pointer;
  150. }
  151. .ti:hover { background: #eef0f6; }
  152. .ti .nm { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
  153. .ti .nm b { font-weight: 560; }
  154. .ti .dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: #d1d5db; }
  155. .ti .sub {
  156. margin-left: 13px; font-family: var(--mono); font-size: 11px;
  157. color: var(--dim); overflow-wrap: anywhere;
  158. }
  159. .ti.run { background: #eef2ff; border-color: #c7d2fe; }
  160. .ti.run .dot { background: var(--brand); animation: pulse 1s ease-in-out infinite; }
  161. .ti.ok .dot { background: var(--ok); }
  162. .ti.ok .sub { color: #4d7c5f; }
  163. .ti.skip .dot { background: var(--warn); }
  164. .ti.skip .sub { color: var(--warn); }
  165. .ti.bad { background: #fef2f2; border-color: #fecaca; }
  166. .ti.bad .dot { background: var(--err); }
  167. .ti.bad .sub { color: var(--err); }
  168. /* 失败原因挂在这行的 title 上 (行里放不下): 虚线 + 问号光标提示"悬停能看全" */
  169. .ti.bad .sub { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; }
  170. .ti.pick { box-shadow: inset 0 0 0 2px var(--brand); }
  171. /* 进行中那一行: 计时器在走 + 行数在涨 = 没死锁 */
  172. .progress {
  173. display: flex; align-items: center; gap: 9px;
  174. margin-top: 9px; padding: 9px 14px; border-radius: 8px;
  175. background: #eef2ff; border: 1px solid #c7d2fe; color: #3730a3;
  176. font-family: var(--mono); font-size: 12px;
  177. }
  178. .progress .dot {
  179. flex: none; width: 7px; height: 7px; border-radius: 50%; background: #4f46e5;
  180. animation: pulse 1s ease-in-out infinite;
  181. }
  182. @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .2; } }
  183. table.rep { width: 100%; border-collapse: collapse; margin-top: 14px; font-size: 12.5px; }
  184. table.rep th, table.rep td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; }
  185. table.rep th { font-weight: 600; color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .4px; }
  186. table.rep td.num { font-family: var(--mono); text-align: right; }
  187. table.rep tr.skip td { color: var(--dim); }
  188. table.rep tr.bad td { color: #b91c1c; background: #fef2f2; }
  189. .total.warn { color: #b91c1c; }
  190. .total { margin-top: 12px; font-size: 13px; font-weight: 600; color: var(--ok); }
  191. select {
  192. width: 100%; padding: 9px 32px 9px 11px; font-size: 13px;
  193. border: 1px solid var(--line); border-radius: 8px; background: #fbfbfd;
  194. color: var(--ink); cursor: pointer; appearance: none;
  195. transition: border-color .15s, box-shadow .15s;
  196. }
  197. select:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.13); background: #fff; }
  198. .selWrap { position: relative; }
  199. .selWrap::after {
  200. content: ""; position: absolute; right: 13px; top: 50%;
  201. width: 7px; height: 7px; margin-top: -5px;
  202. border-right: 2px solid var(--dim); border-bottom: 2px solid var(--dim);
  203. transform: rotate(45deg); pointer-events: none;
  204. }
  205. .conn { border: 1px solid var(--line); border-radius: 10px; padding: 15px; background: #fcfcfe; }
  206. .connTitle { font-size: 13px; font-weight: 620; margin-bottom: 11px; display: flex; align-items: center; gap: 7px; }
  207. .connTitle::before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: var(--brand); }
  208. .field { margin-bottom: 10px; }
  209. .conn > .field:last-of-type { margin-bottom: 0; }
  210. .fileRow { display: flex; gap: 8px; }
  211. .fileRow input { flex: 1; min-width: 0; }
  212. .fileRow .btn { flex: none; white-space: nowrap; }
  213. .onlyMySQL .grid2 { margin-bottom: 0; }
  214. /* 库名候选: 点一下填进去, 比手打库名可靠 */
  215. .dbList {
  216. display: flex; flex-wrap: wrap; gap: 6px;
  217. margin-top: 8px; max-height: 132px; overflow: auto;
  218. }
  219. .dbList .chip {
  220. font-family: var(--mono); font-size: 11.5px; line-height: 1.5;
  221. padding: 3px 10px; cursor: pointer; white-space: nowrap;
  222. border: 1px solid var(--line); border-radius: 999px;
  223. background: #fbfbfd; color: var(--ink);
  224. transition: border-color .15s, color .15s, background .15s;
  225. }
  226. .dbList .chip:hover { border-color: var(--brand); color: var(--brand); }
  227. .dbList .chip.on { background: var(--brand); border-color: var(--brand); color: #fff; }
  228. .dbList .tip { font-size: 11.5px; color: var(--dim); }
  229. .dbList .tip.bad { color: var(--err); }
  230. .dsn {
  231. margin-top: 12px; padding: 8px 10px; background: #f1f2f7; border-radius: 7px;
  232. font-size: 11.5px; display: flex; gap: 8px; align-items: baseline;
  233. }
  234. .dsn b { color: var(--dim); font-weight: 600; flex: none; }
  235. .dsn code { font-family: var(--mono); color: #374151; word-break: break-all; }
  236. .modal { position: fixed; inset: 0; z-index: 50; padding: 22px;
  237. background: rgba(16,24,40,.45); display: flex; align-items: center; justify-content: center; }
  238. .modal[hidden] { display: none; }
  239. .modalBox {
  240. background: #fff; border-radius: 12px; width: 100%; max-width: 640px;
  241. display: flex; flex-direction: column; max-height: 84vh;
  242. border: 1px solid #d3d7e2;
  243. box-shadow: 0 24px 60px rgba(16,24,40,.32), 0 0 0 1px rgba(255,255,255,.65);
  244. }
  245. .modalHead { display: flex; align-items: center; justify-content: space-between;
  246. padding: 15px 18px; border-bottom: 1px solid var(--line); font-size: 14px; }
  247. .modalPath { display: flex; gap: 8px; padding: 12px 18px 0; }
  248. .modalPath input { flex: 1; min-width: 0; }
  249. .modalPath .btn { flex: none; }
  250. .modalAll { padding: 10px 18px 0; }
  251. .modalList {
  252. margin: 8px 18px 0; border: 1px solid var(--line); border-radius: 9px;
  253. overflow: auto; min-height: 150px; max-height: 46vh;
  254. }
  255. .item { display: flex; align-items: center; gap: 10px; padding: 8px 12px;
  256. font-size: 13px; cursor: pointer; border-bottom: 1px solid #f2f3f7; }
  257. .item:last-child { border-bottom: 0; }
  258. .item:hover { background: #f5f6fa; }
  259. .item.sel { background: #eef0ff; }
  260. .item.dir .nm { font-weight: 560; }
  261. .item .ico { flex: none; position: relative; width: 16px; height: 14px; }
  262. .item .ico.dir::before { content: ""; position: absolute; left: 0; top: 3px; right: 0; bottom: 0;
  263. background: #f6c453; border-radius: 2px; }
  264. .item .ico.dir::after { content: ""; position: absolute; left: 0; top: 0; width: 7px; height: 5px;
  265. background: #f6c453; border-radius: 2px 2px 0 0; }
  266. .item .ico.file::before { content: ""; position: absolute; left: 3px; top: 0; width: 9px; height: 13px;
  267. border: 1.5px solid #9aa3b2; border-radius: 2px; }
  268. .item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  269. .item .sz { flex: none; color: var(--dim); font-size: 11.5px; font-family: var(--mono); }
  270. .modalFoot { display: flex; gap: 8px; padding: 14px 18px 16px; }
  271. .modalFoot input { flex: 1; min-width: 0; }
  272. .modalFoot .btn { flex: none; }
  273. .modalEmpty { padding: 26px; text-align: center; color: var(--dim); font-size: 12.5px; }
  274. .dsn code { flex: 1; min-width: 0; }
  275. .dsnBadge { flex: none; font-size: 11px; font-weight: 560; padding: 1px 8px;
  276. border-radius: 20px; white-space: nowrap; }
  277. .dsnBadge[hidden] { display: none; }
  278. .dsnBadge.new { background: #dcfce7; color: #15803d; }
  279. .dsnBadge.has { background: #eef0f6; color: #6b7280; }
  280. .dsnBadge.bad { background: #fee2e2; color: #b91c1c; }
  281. .modalFoot { padding: 14px 18px 8px; }
  282. .modalTip { padding: 0 18px 14px; font-size: 12px; min-height: 15px;
  283. font-family: var(--mono); color: var(--dim); word-break: break-all; }
  284. .modalTip.new { color: var(--ok); }
  285. .modalTip.bad { color: var(--err); }
  286. </style>
  287. </head>
  288. <body>
  289. <header>
  290. <h1>VRTX 迁移工具</h1>
  291. <p><code>vrtx</code> / <code>sqlite</code> / <code>mysql</code> 三库之间搬表 —— 结构、数据、索引、主键一起走,任意方向。</p>
  292. </header>
  293. <main>
  294. <section class="card">
  295. <h2><span class="n">1</span>连接</h2>
  296. <div class="grid2">
  297. <div class="conn" id="connFrom" data-role="source">
  298. <div class="connTitle">源库</div>
  299. <div class="field">
  300. <label>数据库类型</label>
  301. <div class="selWrap">
  302. <select class="dbType">
  303. <option value="vrtx">VRTX(.db 文件)</option>
  304. <option value="sqlite">SQLite(.sqlite 文件)</option>
  305. <option value="mysql">MySQL(数据库服务器)</option>
  306. </select>
  307. </div>
  308. </div>
  309. <div class="onlyFile field">
  310. <label>数据库文件</label>
  311. <div class="fileRow">
  312. <input class="fPath" type="text" spellcheck="false" placeholder="点「浏览」挑一个已有文件">
  313. <button class="btn tiny browseBtn" type="button">浏览…</button>
  314. </div>
  315. </div>
  316. <div class="onlyMySQL" hidden>
  317. <div class="grid2">
  318. <div class="field"><label>主机</label><input class="fHost" type="text" value="127.0.0.1" spellcheck="false"></div>
  319. <div class="field"><label>端口</label><input class="fPort" type="number" value="3306" min="1" max="65535"></div>
  320. </div>
  321. <div class="grid2">
  322. <div class="field"><label>用户名</label><input class="fUser" type="text" value="root" spellcheck="false"></div>
  323. <div class="field"><label>密码</label><input class="fPass" type="password"></div>
  324. </div>
  325. <div class="field">
  326. <label>数据库名</label>
  327. <div class="fileRow">
  328. <input class="fDB" type="text" spellcheck="false" placeholder="例如 app_db">
  329. <button class="btn tiny dbListBtn" type="button">读取列表</button>
  330. </div>
  331. <div class="dbList" hidden></div>
  332. </div>
  333. </div>
  334. <div class="dsn"><b>DSN</b><code class="dsnOut">—</code><span class="dsnBadge" hidden></span></div>
  335. </div>
  336. <div class="conn" id="connTo" data-role="target">
  337. <div class="connTitle">目标库</div>
  338. <div class="field">
  339. <label>数据库类型</label>
  340. <div class="selWrap">
  341. <select class="dbType">
  342. <option value="sqlite">SQLite(.sqlite 文件)</option>
  343. <option value="vrtx">VRTX(.db 文件)</option>
  344. <option value="mysql">MySQL(数据库服务器)</option>
  345. </select>
  346. </div>
  347. </div>
  348. <div class="onlyFile field">
  349. <label>数据库文件</label>
  350. <div class="fileRow">
  351. <input class="fPath" type="text" spellcheck="false" placeholder="点「选择/新建」,或直接输入一个不存在的路径">
  352. <button class="btn tiny browseBtn" type="button">选择/新建…</button>
  353. </div>
  354. </div>
  355. <div class="onlyMySQL" hidden>
  356. <div class="grid2">
  357. <div class="field"><label>主机</label><input class="fHost" type="text" value="127.0.0.1" spellcheck="false"></div>
  358. <div class="field"><label>端口</label><input class="fPort" type="number" value="3306" min="1" max="65535"></div>
  359. </div>
  360. <div class="grid2">
  361. <div class="field"><label>用户名</label><input class="fUser" type="text" value="root" spellcheck="false"></div>
  362. <div class="field"><label>密码</label><input class="fPass" type="password"></div>
  363. </div>
  364. <div class="field">
  365. <label>数据库名</label>
  366. <div class="fileRow">
  367. <input class="fDB" type="text" spellcheck="false" placeholder="例如 app_db">
  368. <button class="btn tiny dbListBtn" type="button">读取列表</button>
  369. </div>
  370. <div class="dbList" hidden></div>
  371. </div>
  372. </div>
  373. <div class="dsn"><b>DSN</b><code class="dsnOut">—</code><span class="dsnBadge" hidden></span></div>
  374. </div>
  375. </div>
  376. <div class="row">
  377. <button id="listBtn" class="btn">查看源库结构</button>
  378. <span class="hint">选好源库后点这里,先看它有哪些表</span>
  379. </div>
  380. <div id="connMsg" class="msg"></div>
  381. </section>
  382. <section class="card" id="tablesCard" hidden>
  383. <h2><span class="n">2</span>选择表</h2>
  384. <div id="srcHint" class="hint" style="margin-bottom:10px"></div>
  385. <div class="row" style="margin-top:0">
  386. <button class="btn tiny" id="allBtn">全选</button>
  387. <button class="btn tiny" id="noneBtn">全不选</button>
  388. <span class="hint">勾选要迁的表;全选或不选都表示「全部表」</span>
  389. </div>
  390. <div id="tables" style="margin-top:12px"></div>
  391. </section>
  392. <section class="card">
  393. <h2><span class="n">3</span>选项</h2>
  394. <div class="opts">
  395. <label class="chk"><input type="checkbox" id="recreate"> 先删目标同名表(recreate)</label>
  396. <label class="chk"><input type="checkbox" id="dryRun"> 试跑,不写数据(dry-run)</label>
  397. <label class="chk"><input type="checkbox" id="noMeta"> 不写保真表 __vrtx_meta</label>
  398. <label class="chk"><input type="checkbox" id="autoPk"> 源表无主键时自动补 _id</label>
  399. <label class="chk"><input type="checkbox" id="loose"> 装不下时按引擎语义截断(可能丢数据)</label>
  400. <label class="chk"><input type="checkbox" id="keepGoing" checked> 某张表失败时跳过它,继续迁其余表</label>
  401. <label class="chk"><input type="checkbox" id="eachRow" checked> 逐行显示数据(大表会拖慢,可关)</label>
  402. </div>
  403. <div class="grid2">
  404. <div>
  405. <label for="batch">每批写入行数</label>
  406. <input id="batch" type="number" value="500" min="1">
  407. </div>
  408. <div>
  409. <label for="limit">每表最多迁移行数(0 = 不限)</label>
  410. <input id="limit" type="number" value="0" min="0">
  411. </div>
  412. </div>
  413. </section>
  414. <section class="card">
  415. <h2><span class="n">4</span>执行</h2>
  416. <div class="row" style="margin-top:0">
  417. <button id="runBtn" class="btn primary">开始迁移</button>
  418. <span class="hint" id="runHint"></span>
  419. <label class="chk" id="followWrap" style="margin-left:auto" hidden>
  420. <input type="checkbox" id="follow" checked> 自动滚到底部
  421. </label>
  422. </div>
  423. <div class="split">
  424. <div class="side" id="tblSide">
  425. <div class="sideHead">表 <b id="tblCount">0</b><span class="pin" id="pinHint" hidden></span></div>
  426. <div class="sideList" id="tblList"><span class="empty">等待开始…</span></div>
  427. </div>
  428. <pre id="log" class="log"><span class="empty">等待开始…</span></pre>
  429. </div>
  430. <div id="progress" class="progress" hidden></div>
  431. <div id="report"></div>
  432. </section>
  433. </main>
  434. <div class="modal" id="modal" hidden>
  435. <div class="modalBox">
  436. <div class="modalHead">
  437. <b id="modalTitle">选择文件</b>
  438. <button class="btn tiny" id="modalClose" type="button">关闭</button>
  439. </div>
  440. <div class="modalPath">
  441. <input id="modalPath" type="text" spellcheck="false" placeholder="路径">
  442. <button class="btn tiny" id="modalUp" type="button">上一级</button>
  443. <button class="btn tiny" id="modalGo" type="button">前往</button>
  444. </div>
  445. <div class="modalAll">
  446. <label class="chk"><input type="checkbox" id="modalAll"> 显示全部文件(默认只看数据库文件)</label>
  447. </div>
  448. <div class="modalList" id="modalList"></div>
  449. <div class="modalFoot">
  450. <input id="modalName" type="text" spellcheck="false" placeholder="文件名">
  451. <button class="btn primary" id="modalOk" type="button">选用</button>
  452. </div>
  453. <div class="modalTip" id="modalTip"></div>
  454. </div>
  455. </div>
  456. <script>
  457. const $ = (id) => document.getElementById(id);
  458. let schemaCache = [];
  459. /* ---------------- 查看源库结构 ---------------- */
  460. $('listBtn').onclick = async () => {
  461. const dsn = buildDSN($('connFrom'));
  462. const msg = $('connMsg');
  463. if (!dsn) { msg.className = 'msg err'; msg.textContent = '请先选好源库类型并填好连接信息'; return; }
  464. msg.className = 'msg';
  465. msg.textContent = '读取中…';
  466. $('listBtn').disabled = true;
  467. try {
  468. const resp = await fetch('/api/list', {
  469. method: 'POST',
  470. headers: { 'Content-Type': 'application/json' },
  471. body: JSON.stringify({ dsn })
  472. });
  473. const data = await resp.json();
  474. if (data.error) {
  475. msg.className = 'msg err';
  476. msg.textContent = data.error;
  477. return;
  478. }
  479. schemaCache = data.tables || [];
  480. msg.className = 'msg ok';
  481. msg.textContent = '读取成功';
  482. renderTables(data);
  483. } catch (e) {
  484. msg.className = 'msg err';
  485. msg.textContent = '请求失败:' + e;
  486. } finally {
  487. $('listBtn').disabled = false;
  488. }
  489. };
  490. function renderTables(data) {
  491. $('srcHint').textContent =
  492. `${data.kind} · ${data.identity} · ${data.tables.length} 张表`;
  493. const box = $('tables');
  494. box.innerHTML = '';
  495. (data.tables || []).forEach((t) => {
  496. const div = document.createElement('div');
  497. div.className = 'tbl';
  498. const head = document.createElement('label');
  499. head.className = 'tblHead';
  500. const chk = document.createElement('input');
  501. chk.type = 'checkbox';
  502. chk.className = 'tblChk';
  503. chk.value = t.name;
  504. chk.checked = true;
  505. const name = document.createElement('b');
  506. name.textContent = t.name;
  507. const dim = document.createElement('span');
  508. dim.className = 'dim';
  509. dim.textContent = `${t.columns ? t.columns.length : 0} 列 · 主键 ${t.pkColumn || '无'}[${t.pkIndexType}]`;
  510. head.append(chk, name, dim);
  511. div.appendChild(head);
  512. if (t.columns && t.columns.length) {
  513. const cols = document.createElement('div');
  514. cols.className = 'cols';
  515. t.columns.forEach((c) => {
  516. const span = document.createElement('span');
  517. span.className = 'col' + (c.isPK ? ' pk' : '') + (c.synthetic ? ' syn' : '');
  518. span.textContent = `${c.name} : ${c.type}${c.dynamic ? '' : '(' + c.size + 'B)'}`;
  519. cols.appendChild(span);
  520. });
  521. div.appendChild(cols);
  522. }
  523. if (t.indexes && t.indexes.length) {
  524. const idx = document.createElement('div');
  525. idx.className = 'idxs';
  526. idx.innerHTML = t.indexes
  527. .map((i) => `<span>${i.name}(${i.columns.join(',')})[${i.type}]</span>`)
  528. .join('');
  529. div.appendChild(idx);
  530. }
  531. box.appendChild(div);
  532. });
  533. $('tablesCard').hidden = false;
  534. }
  535. $('allBtn').onclick = () => document.querySelectorAll('.tblChk').forEach((c) => (c.checked = true));
  536. $('noneBtn').onclick = () => document.querySelectorAll('.tblChk').forEach((c) => (c.checked = false));
  537. /* 全选/不选都视作「全部表」→ 传空数组, 由后端按源库全表处理 */
  538. function collectTables() {
  539. const all = [...document.querySelectorAll('.tblChk')];
  540. const picked = all.filter((c) => c.checked).map((c) => c.value);
  541. if (all.length === 0 || picked.length === all.length) return [];
  542. return picked;
  543. }
  544. /* ---------------- 执行迁移 (流式读取 NDJSON) ---------------- */
  545. const logBox = $('log');
  546. /* 日志区要回答三个问题: 还在动吗、能不能滚、我翻历史时别被拽回去 */
  547. let followTail = true; // 只有它开着才自动滚到底
  548. let runStart = 0;
  549. let runTimer = null;
  550. let runHadError = false; // 本次跑是否报过错: 报过错就不能把结束说成"完成"
  551. function scrollLogTail() { logBox.scrollTop = logBox.scrollHeight; }
  552. // 用户自己往上翻 = 在读历史, 这时再把他拽回底部最烦人
  553. logBox.addEventListener('scroll', () => {
  554. const atBottom = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 24;
  555. if (atBottom !== followTail) {
  556. followTail = atBottom;
  557. $('follow').checked = atBottom;
  558. }
  559. });
  560. $('follow').onchange = () => {
  561. followTail = $('follow').checked;
  562. if (followTail) scrollLogTail();
  563. };
  564. // 日志区最多留这么多行: DOM 无限长会把浏览器拖垮, 超出上限的从头丢弃。
  565. const LOG_MAX = 2000;
  566. function trimLog() {
  567. while (logBox.childElementCount > LOG_MAX) {
  568. logBox.removeChild(logBox.firstElementChild);
  569. }
  570. }
  571. // table 用来给行打标记 (点左表的某一项能跳到它的第一行); 不给就跟着"正在迁的那张"
  572. function logLine(text, cls, table) {
  573. if (logBox.querySelector('.empty')) logBox.innerHTML = '';
  574. const line = document.createElement('div');
  575. if (cls) line.className = cls;
  576. const t = table === undefined ? curTable : table;
  577. if (t) line.dataset.t = t;
  578. line.textContent = text;
  579. logBox.appendChild(line);
  580. trimLog();
  581. if (followTail) scrollLogTail();
  582. }
  583. /* 逐行数据一条一行事件, 一张表就能有几万条 —— 攒 30ms 一次性插进去。
  584. 每条都单独碰一次 DOM 的话, 光渲染就能把迁移拖慢一个数量级。 */
  585. let rowQueue = [];
  586. let rowTimer = null;
  587. function pushRow(text, table) {
  588. rowQueue.push({ text, table });
  589. if (!rowTimer) rowTimer = setTimeout(flushRows, 30);
  590. }
  591. function flushRows() {
  592. rowTimer = null;
  593. if (rowQueue.length === 0) return;
  594. const frag = document.createDocumentFragment();
  595. for (const it of rowQueue) {
  596. const d = document.createElement('div');
  597. d.className = 'r';
  598. if (it.table) d.dataset.t = it.table;
  599. d.textContent = it.text;
  600. frag.appendChild(d);
  601. }
  602. rowQueue = [];
  603. if (logBox.querySelector('.empty')) logBox.innerHTML = '';
  604. logBox.appendChild(frag);
  605. trimLog();
  606. if (followTail) scrollLogTail();
  607. }
  608. /* ---------------- 左侧表清单: 迁到哪张、那张什么状态 ---------------- */
  609. const tblSide = $('tblSide');
  610. const tblList = $('tblList');
  611. /* 表名 → 清单里的那一行。只增不重排: 状态在变而位置不动, 高亮才不会满屏跳。 */
  612. const tblRows = new Map();
  613. let curTable = ''; // 正在迁的那张 (左表自动高亮它)
  614. let pickTable = ''; // 用户点选锁定的那张 (再点一次取消); 空 = 跟随正在迁的
  615. let pinTimer = null;
  616. /* 本次要迁哪些表: 由勾选情况决定 —— 全选/不选都当"全部表"(与后端同一套语义)。 */
  617. function plannedTables() {
  618. const all = [...document.querySelectorAll('.tblChk')];
  619. const picked = all.filter((c) => c.checked).map((c) => c.value);
  620. if (all.length === 0 || picked.length === all.length) return all.map((c) => c.value);
  621. return picked;
  622. }
  623. function resetTablePane(names) {
  624. tblRows.clear();
  625. tblList.innerHTML = '';
  626. curTable = '';
  627. pickTable = '';
  628. clearTimeout(pinTimer);
  629. $('tblCount').textContent = '0';
  630. if (!names.length) {
  631. tblList.innerHTML = '<span class="empty">等待开始…</span>';
  632. paintTables();
  633. return;
  634. }
  635. names.forEach((n) => addTableRow(n));
  636. paintTables();
  637. }
  638. /* 结构信息取自「查看源库结构」的缓存; 没点过那一步就只剩表名 —— 不编造列数。 */
  639. function colHint(name) {
  640. const t = schemaCache.find((x) => x.name === name);
  641. if (!t) return '';
  642. const cols = t.columns ? t.columns.length : 0;
  643. const idxs = t.indexes ? t.indexes.length : 0;
  644. return `${cols} 列 · ${idxs} 索引 · 主键 ${t.pkColumn || '无'}`;
  645. }
  646. function addTableRow(name) {
  647. const had = tblRows.get(name);
  648. if (had) return had;
  649. if (tblList.querySelector('.empty')) tblList.innerHTML = '';
  650. const el = document.createElement('div');
  651. el.className = 'ti';
  652. const nm = document.createElement('div');
  653. nm.className = 'nm';
  654. const dot = document.createElement('span');
  655. dot.className = 'dot';
  656. const b = document.createElement('b');
  657. b.textContent = name;
  658. nm.append(dot, b);
  659. const sub = document.createElement('div');
  660. sub.className = 'sub';
  661. sub.textContent = colHint(name);
  662. el.append(nm, sub);
  663. el.onclick = () => {
  664. pickTable = pickTable === name ? '' : name;
  665. paintTables();
  666. if (pickTable) jumpToTable(name);
  667. };
  668. tblList.appendChild(el);
  669. tblRows.set(name, el);
  670. $('tblCount').textContent = String(tblRows.size);
  671. return el;
  672. }
  673. /* state: run / ok / bad / skip, 空 = 未开始。
  674. 进度一秒可能刷很多次, 所以这里只写 textContent (不读布局), 只有"状态真的变了"
  675. 才重画 —— 否则每来一条进度都要读一次 offsetTop, 大表上白白拖慢界面。
  676. tip: 悬停提示, 用来放"行里放不下"的细节 (失败原因、备注); 不传就清掉, 免得
  677. 留着上一次的旧话。 */
  678. function setTableState(name, state, sub, tip) {
  679. const el = addTableRow(name);
  680. const st = state || '';
  681. const changed = el.dataset.st !== st;
  682. el.dataset.st = st;
  683. el.classList.remove('run', 'ok', 'bad', 'skip');
  684. if (st) el.classList.add(st);
  685. if (sub) el.querySelector('.sub').textContent = sub;
  686. el.title = tip || '';
  687. if (changed) paintTables();
  688. }
  689. function paintTables() {
  690. tblRows.forEach((el, name) => {
  691. // 已经判失败的表不再亮"进行中": 否则红点还在一跳一跳, 看着像还在跑
  692. el.classList.toggle('run', name === curTable && !el.classList.contains('bad'));
  693. el.classList.toggle('pick', name === pickTable);
  694. });
  695. const pin = $('pinHint');
  696. pin.hidden = !pickTable;
  697. if (pickTable) pin.textContent = '已锁定';
  698. revealActive();
  699. }
  700. /* 亮着的那张要是滚出了可视区, 就把它带回来 —— 表多时不然等于没高亮 */
  701. function revealActive() {
  702. const el = tblRows.get(pickTable || curTable);
  703. if (!el || !tblSide) return;
  704. const top = el.offsetTop;
  705. const bottom = top + el.offsetHeight;
  706. if (top < tblSide.scrollTop || bottom > tblSide.scrollTop + tblSide.clientHeight) {
  707. tblSide.scrollTop = Math.max(0, top - (tblSide.clientHeight - el.offsetHeight) / 2);
  708. }
  709. }
  710. const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'));
  711. /* 点表名 → 右边日志滚到这张表的第一行。日志不筛不拆, 只是把人送过去; 之后照旧往下滚
  712. (往上翻会触发已有的滚动逻辑, 自动把"自动滚到底"取消掉, 不会被拽回底部)。 */
  713. function jumpToTable(name) {
  714. const first = logBox.querySelector(`[data-t="${cssEsc(name)}"]`);
  715. if (!first) { flashPin('日志里已找不到'); return; }
  716. first.scrollIntoView({ block: 'start' });
  717. }
  718. function flashPin(text) {
  719. const pin = $('pinHint');
  720. pin.hidden = false;
  721. pin.textContent = text;
  722. clearTimeout(pinTimer);
  723. pinTimer = setTimeout(paintTables, 2600);
  724. }
  725. /* 正在迁的表名从两类事件推出:
  726. 结构化事件 (progress / row) 自带 table; 但 0 行的表不会有这两类事件, 所以再认一下
  727. 后端每张表开头必打的那行日志 "[migrate] 表 X (i/n): 开始"。 */
  728. const START_RE = /^\[migrate\] 表 (.+) \(\d+\/\d+\): 开始$/;
  729. /* 后半截 ": <错误原文>" 必须留住 —— 左栏一行只放得下"失败, 已跳过", 真正的原因挂到
  730. title 上悬停可见 (错误原文常常很长, 直接塞进那一行会把行撑成一大块)。
  731. 原因用 [\s\S]* 而不是 .*: 驱动/SQL 的报错原文常自带换行, 用 . 会整行都匹配不上,
  732. 失败的表就既不变红也拿不到原因 —— 那才是真的"只有失败、没有原因"。 */
  733. const FAIL_RE = /^\[migrate\] 表 (.+?) 失败, 跳过继续(?::\s*([\s\S]*))?$/;
  734. function setCurrentTable(name) {
  735. if (!name) return;
  736. addTableRow(name);
  737. if (curTable === name) return;
  738. curTable = name;
  739. paintTables();
  740. }
  741. /* 左栏就两指宽, 而错误原文动不动好几行 (SQL 报错、驱动报错都长): 全塞进 sub 会把
  742. 那一行撑成一大块, 上下邻居全被推走。所以 sub 只留第一行的头一小截, 全文交给
  743. title 悬停看 —— 想读完整原因, 鼠标停在行上就行。 */
  744. function shortWhy(s, max = 56) {
  745. const one = String(s == null ? '' : s).split('\n')[0].trim();
  746. const r = [...one];
  747. return r.length > max ? r.slice(0, max).join('') + '…' : one;
  748. }
  749. /* 结束时按报告落最终状态: 报告是唯一权威 —— 小表可能一次 progress 都没发过。 */
  750. function finishTables(rep) {
  751. curTable = '';
  752. ((rep && rep.tables) || []).forEach((t) => {
  753. const state = t.error ? 'bad' : (t.skipped ? 'skip' : 'ok');
  754. let sub;
  755. if (t.error) sub = '失败:' + shortWhy(t.error);
  756. else if (t.skipped) sub = t.note || '跳过'; // 跳过的表行数没意义, 只说原因
  757. else {
  758. sub = `${Number(t.rows).toLocaleString()} 行 · ${t.elapsed}`;
  759. if (t.note) sub += ' · ' + t.note; // 备注 (试跑/仅结构/截断/跳过索引) 不能丢
  760. }
  761. const tip = t.error ? `表 ${t.table} 失败:${t.error}` : (t.note ? '备注:' + t.note : '');
  762. setTableState(t.table, state, sub, tip);
  763. });
  764. paintTables();
  765. }
  766. function fmtDur(ms) {
  767. const s = Math.max(0, Math.floor(ms / 1000));
  768. return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
  769. }
  770. // 计时器一直走 —— 哪怕某张大表几分钟不吐日志, 也看得出进程没死
  771. function startClock() {
  772. runStart = Date.now();
  773. $('followWrap').hidden = false;
  774. stopClock();
  775. $('runHint').textContent = '迁移中… 已用 0s';
  776. runTimer = setInterval(() => {
  777. $('runHint').textContent = '迁移中… 已用 ' + fmtDur(Date.now() - runStart);
  778. }, 1000);
  779. }
  780. function stopClock() {
  781. if (runTimer) { clearInterval(runTimer); runTimer = null; }
  782. }
  783. // 进行中进度原地刷新同一行, 不往日志里灌流水 (灌进去会把有用的行顶走)
  784. function showProgress(table, rows, elapsed) {
  785. const box = $('progress');
  786. box.hidden = false;
  787. box.innerHTML = `<span class="dot"></span><b>${esc(table)}</b> 已写 ` +
  788. `${Number(rows).toLocaleString()} 行 · ${esc(elapsed)}`;
  789. }
  790. $('runBtn').onclick = async () => {
  791. const from = buildDSN($('connFrom'));
  792. const to = buildDSN($('connTo'));
  793. const runHint = $('runHint');
  794. if (!from || !to) { runHint.textContent = '请先填好源库与目标库的连接信息'; return; }
  795. if (from === to) { runHint.textContent = '源库与目标库不能是同一个'; return; }
  796. const payload = {
  797. from, to,
  798. tables: collectTables(),
  799. recreate: $('recreate').checked,
  800. dryRun: $('dryRun').checked,
  801. noMeta: $('noMeta').checked,
  802. autoPk: $('autoPk').checked,
  803. loose: $('loose').checked,
  804. keepGoing: $('keepGoing').checked,
  805. eachRow: $('eachRow').checked,
  806. batch: parseInt($('batch').value, 10) || 0,
  807. limit: parseInt($('limit').value, 10) || 0
  808. };
  809. logBox.innerHTML = '';
  810. rowQueue = [];
  811. if (rowTimer) { clearTimeout(rowTimer); rowTimer = null; }
  812. runHadError = false;
  813. $('report').innerHTML = '';
  814. $('progress').hidden = true;
  815. resetTablePane(plannedTables());
  816. followTail = true;
  817. $('follow').checked = true;
  818. startClock();
  819. const btn = $('runBtn');
  820. btn.disabled = true;
  821. try {
  822. const resp = await fetch('/api/migrate', {
  823. method: 'POST',
  824. headers: { 'Content-Type': 'application/json' },
  825. body: JSON.stringify(payload)
  826. });
  827. if (!resp.ok) {
  828. logLine('服务返回 ' + resp.status + ':' + (await resp.text()), 'e');
  829. return;
  830. }
  831. const reader = resp.body.getReader();
  832. const decoder = new TextDecoder();
  833. let buf = '';
  834. for (;;) {
  835. const { done, value } = await reader.read();
  836. if (done) break;
  837. buf += decoder.decode(value, { stream: true });
  838. let i;
  839. while ((i = buf.indexOf('\n')) >= 0) {
  840. const line = buf.slice(0, i).trim();
  841. buf = buf.slice(i + 1);
  842. if (line) handleEvent(JSON.parse(line));
  843. }
  844. }
  845. if (buf.trim()) handleEvent(JSON.parse(buf.trim()));
  846. // 报错过就不能报"完成" —— 否则用户根本看不出有问题。
  847. runHint.textContent = runHadError
  848. ? '结束(有错误),用时 ' + fmtDur(Date.now() - runStart)
  849. : '完成,用时 ' + fmtDur(Date.now() - runStart);
  850. } catch (e) {
  851. logLine('连接中断:' + e, 'e');
  852. runHint.textContent = '中断';
  853. } finally {
  854. stopClock();
  855. flushRows();
  856. $('progress').hidden = true;
  857. btn.disabled = false;
  858. curTable = ''; // 没有表在迁了, 别让最后那张一直亮着"进行中"
  859. paintTables();
  860. }
  861. };
  862. function handleEvent(ev) {
  863. if (ev.type === 'log') {
  864. const msg = String(ev.msg || '');
  865. const started = START_RE.exec(msg.trim());
  866. if (started) setCurrentTable(started[1]); // 先认表, 这行日志才挂得上标记
  867. logLine(`[${ev.at || ''}] ${msg}`, ev.level === 'stage' ? 'stage' : null);
  868. const failed = FAIL_RE.exec(msg.trim());
  869. if (failed) {
  870. const why = (failed[2] || '').trim();
  871. setTableState(failed[1], 'bad', '失败,已跳过', why ? `表 ${failed[1]} 失败:${why}` : '');
  872. }
  873. } else if (ev.type === 'row') {
  874. setCurrentTable(ev.table);
  875. pushRow(`${ev.table} #${Number(ev.rows).toLocaleString()} ${ev.msg}`, ev.table);
  876. } else if (ev.type === 'progress') {
  877. setCurrentTable(ev.table);
  878. setTableState(ev.table, 'run', `${Number(ev.rows).toLocaleString()} 行 · ${ev.elapsed}`);
  879. showProgress(ev.table, ev.rows, ev.elapsed);
  880. } else if (ev.type === 'error') {
  881. runHadError = true;
  882. logLine('错误:' + ev.msg, 'e');
  883. if (curTable) setTableState(curTable, 'bad', '中断', '错误:' + ev.msg);
  884. } else if (ev.type === 'done') {
  885. $('progress').hidden = true;
  886. renderReport(ev.report);
  887. finishTables(ev.report);
  888. }
  889. }
  890. function renderReport(rep) {
  891. if (!rep) return;
  892. const rows = (rep.tables || []).map((t) => {
  893. const note = t.error ? '失败:' + t.error : (t.note || (t.skipped ? '跳过' : ''));
  894. return `
  895. <tr class="${t.error ? 'bad' : (t.skipped ? 'skip' : '')}">
  896. <td>${esc(t.table)}</td>
  897. <td class="num">${t.rows}</td>
  898. <td class="num">${t.columns}</td>
  899. <td class="num">${t.indexes}</td>
  900. <td class="num">${t.elapsed}</td>
  901. <td title="${esc(note)}">${esc(note)}</td>
  902. </tr>`;
  903. }).join('');
  904. const bad = (rep.tables || []).filter((t) => t.error).length;
  905. $('report').innerHTML = `
  906. <table class="rep">
  907. <thead><tr>
  908. <th>表</th><th style="text-align:right">行数</th><th style="text-align:right">列</th>
  909. <th style="text-align:right">索引</th><th style="text-align:right">耗时</th><th>备注</th>
  910. </tr></thead>
  911. <tbody>${rows}</tbody>
  912. </table>
  913. <div class="total${bad ? ' warn' : ''}">${rep.from} → ${rep.to}:${rep.tables.length} 张表 / ${rep.rows} 行,用时 ${rep.elapsed}${bad ? `,其中 ${bad} 张失败` : ''}</div>`;
  914. }
  915. function esc(s) {
  916. return String(s == null ? '' : s).replace(/[&<>"']/g, (m) => (
  917. { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]
  918. ));
  919. }
  920. /* ---------------- 连接表单: 类型 + 参数 → DSN ---------------- */
  921. const DB_EXTS = ['.db', '.sqlite', '.sqlite3', '.db3', '.vrtx'];
  922. // 目标库新建时用的默认后缀, 免得生成一个引擎认不出的文件
  923. const EXT_BY_TYPE = { vrtx: '.db', sqlite: '.sqlite' };
  924. const connOf = (el) => el.closest('.conn');
  925. const roleOf = (conn) => conn.dataset.role || 'source';
  926. function buildDSN(conn) {
  927. if (!conn) return '';
  928. const type = conn.querySelector('.dbType').value;
  929. if (type === 'mysql') {
  930. const host = conn.querySelector('.fHost').value.trim() || '127.0.0.1';
  931. const port = conn.querySelector('.fPort').value.trim() || '3306';
  932. const user = conn.querySelector('.fUser').value.trim();
  933. const pass = conn.querySelector('.fPass').value;
  934. const db = conn.querySelector('.fDB').value.trim();
  935. if (!user || !db) return '';
  936. return `mysql:${user}:${pass}@tcp(${host}:${port})/${db}`;
  937. }
  938. const path = conn.querySelector('.fPath').value.trim();
  939. return path ? `${type}:${path}` : '';
  940. }
  941. function basename(p) {
  942. return (p || '').replace(/\\/g, '/').split('/').pop() || '';
  943. }
  944. /* 主机 / 账号填好就能先把库名列出来 —— 库名是连接串的一部分, 少打一个字母就得
  945. 对着"连接失败"猜, 不如直接给候选点。 */
  946. async function loadDatabases(conn) {
  947. const box = conn.querySelector('.dbList');
  948. const btn = conn.querySelector('.dbListBtn');
  949. const host = conn.querySelector('.fHost').value.trim() || '127.0.0.1';
  950. const port = conn.querySelector('.fPort').value.trim() || '3306';
  951. const user = conn.querySelector('.fUser').value.trim();
  952. const pass = conn.querySelector('.fPass').value;
  953. box.hidden = false;
  954. if (!user) {
  955. box.innerHTML = '<span class="tip bad">先填用户名</span>';
  956. return;
  957. }
  958. btn.disabled = true;
  959. box.innerHTML = '<span class="tip">连接中…</span>';
  960. try {
  961. const r = await fetch('/api/databases', {
  962. method: 'POST',
  963. headers: { 'Content-Type': 'application/json' },
  964. body: JSON.stringify({ host, port, user, pass })
  965. });
  966. const data = await r.json();
  967. if (data.error) {
  968. box.innerHTML = `<span class="tip bad">${esc(data.error)}</span>`;
  969. return;
  970. }
  971. renderDBList(conn, data.databases || []);
  972. } catch (e) {
  973. box.innerHTML = `<span class="tip bad">请求失败:${esc(e)}</span>`;
  974. } finally {
  975. btn.disabled = false;
  976. }
  977. }
  978. function renderDBList(conn, names) {
  979. const box = conn.querySelector('.dbList');
  980. const input = conn.querySelector('.fDB');
  981. if (!names.length) {
  982. box.innerHTML = '<span class="tip">这台服务器上没有可选的库</span>';
  983. return;
  984. }
  985. box.innerHTML = '';
  986. names.forEach((n) => {
  987. const chip = document.createElement('button');
  988. chip.type = 'button';
  989. chip.className = 'chip' + (n === input.value.trim() ? ' on' : '');
  990. chip.textContent = n;
  991. chip.onclick = () => {
  992. input.value = n;
  993. box.querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === chip));
  994. syncConn(conn);
  995. };
  996. box.appendChild(chip);
  997. });
  998. }
  999. async function statPath(path) {
  1000. try {
  1001. const r = await fetch('/api/stat', {
  1002. method: 'POST',
  1003. headers: { 'Content-Type': 'application/json' },
  1004. body: JSON.stringify({ path })
  1005. });
  1006. return await r.json();
  1007. } catch {
  1008. return null;
  1009. }
  1010. }
  1011. /* 文件类型的 DSN 右侧挂个小标签: 「将新建」「已存在」「找不到」一眼可见 */
  1012. const checkTimers = new WeakMap();
  1013. function setBadge(el, text, cls) {
  1014. el.textContent = text;
  1015. el.className = 'dsnBadge ' + cls;
  1016. el.hidden = false;
  1017. }
  1018. function scheduleFileCheck(conn) {
  1019. const badge = conn.querySelector('.dsnBadge');
  1020. const type = conn.querySelector('.dbType').value;
  1021. const path = conn.querySelector('.fPath').value.trim();
  1022. clearTimeout(checkTimers.get(conn));
  1023. if (type === 'mysql' || !path) { badge.hidden = true; return; }
  1024. checkTimers.set(conn, setTimeout(async () => {
  1025. const st = await statPath(path);
  1026. if (!st || st.error) { badge.hidden = true; return; }
  1027. if (!st.parentExists) setBadge(badge, '父目录不存在', 'bad');
  1028. else if (st.isDir) setBadge(badge, '是个目录', 'bad');
  1029. else if (st.exists) setBadge(badge, '已存在', 'has');
  1030. else if (roleOf(conn) === 'target') setBadge(badge, '将新建', 'new');
  1031. else setBadge(badge, '找不到', 'bad');
  1032. }, 350));
  1033. }
  1034. function syncConn(conn) {
  1035. const type = conn.querySelector('.dbType').value;
  1036. const isMySQL = type === 'mysql';
  1037. conn.querySelectorAll('.onlyFile').forEach((e) => (e.hidden = isMySQL));
  1038. conn.querySelectorAll('.onlyMySQL').forEach((e) => (e.hidden = !isMySQL));
  1039. const dsn = buildDSN(conn);
  1040. conn.querySelector('.dsnOut').textContent = dsn || '—';
  1041. scheduleFileCheck(conn);
  1042. }
  1043. function initConn(conn) {
  1044. conn.querySelectorAll('input, select').forEach((el) => {
  1045. el.addEventListener('input', () => syncConn(conn));
  1046. el.addEventListener('change', () => syncConn(conn));
  1047. });
  1048. conn.querySelector('.browseBtn').onclick = () => openBrowser(conn.querySelector('.fPath'));
  1049. conn.querySelector('.dbListBtn').onclick = () => loadDatabases(conn);
  1050. syncConn(conn);
  1051. }
  1052. document.querySelectorAll('.conn').forEach(initConn);
  1053. /* ---------------- 文件浏览 / 新建弹窗 ---------------- */
  1054. let browseTarget = null;
  1055. let browseRole = 'source';
  1056. let browseType = 'vrtx';
  1057. let browseState = { path: '', parent: '', root: false };
  1058. let browsePick = null;
  1059. let browseSuggest = false;
  1060. // 目标库还没填路径时, 拿源库的文件名换个后缀当建议名
  1061. function suggestName() {
  1062. const ext = EXT_BY_TYPE[browseType] || '';
  1063. const srcPath = $('connFrom').querySelector('.fPath').value.trim();
  1064. let stem = 'newdb';
  1065. if (srcPath) {
  1066. const file = basename(srcPath);
  1067. const dot = file.lastIndexOf('.');
  1068. const s = dot > 0 ? file.slice(0, dot) : file;
  1069. const srcExt = dot > 0 ? file.slice(dot).toLowerCase() : '';
  1070. if (s) stem = s + (srcExt === ext ? '_copy' : '');
  1071. }
  1072. return stem + ext;
  1073. }
  1074. function openBrowser(input) {
  1075. const conn = connOf(input);
  1076. browseTarget = input;
  1077. browseRole = roleOf(conn);
  1078. browseType = conn.querySelector('.dbType').value;
  1079. browsePick = null;
  1080. const isTarget = browseRole === 'target';
  1081. const ext = EXT_BY_TYPE[browseType] || '';
  1082. $('modalTitle').textContent = isTarget ? '选择或新建目标库文件' : '选择源库文件';
  1083. $('modalName').placeholder = isTarget ? `输入文件名即可新建,例如 app${ext}` : '文件名';
  1084. $('modalName').value = '';
  1085. $('modalTip').textContent = '';
  1086. $('modalTip').className = 'modalTip';
  1087. browseSuggest = isTarget;
  1088. $('modal').hidden = false;
  1089. const cur = input.value.trim();
  1090. browse(cur ? { path: cur } : {}); // 有路径就从它所在目录开始, 否则从服务进程当前目录
  1091. }
  1092. function closeBrowser() {
  1093. $('modal').hidden = true;
  1094. browseTarget = null;
  1095. browsePick = null;
  1096. browseSuggest = false;
  1097. clearTimeout(tipTimer);
  1098. }
  1099. async function browse(req) {
  1100. const list = $('modalList');
  1101. list.innerHTML = '<div class="modalEmpty">读取中…</div>';
  1102. try {
  1103. const resp = await fetch('/api/browse', {
  1104. method: 'POST',
  1105. headers: { 'Content-Type': 'application/json' },
  1106. body: JSON.stringify(req)
  1107. });
  1108. const data = await resp.json();
  1109. if (data.error) {
  1110. list.innerHTML = `<div class="modalEmpty">${esc(data.error)}</div>`;
  1111. return;
  1112. }
  1113. browseState = data;
  1114. browsePick = null;
  1115. $('modalPath').value = data.path;
  1116. $('modalUp').disabled = !!data.root;
  1117. if (browseSuggest) { // 目标库首次列目录时塞一个建议名
  1118. $('modalName').value = suggestName();
  1119. browseSuggest = false;
  1120. }
  1121. renderBrowser(data);
  1122. updateModalTip();
  1123. } catch (e) {
  1124. list.innerHTML = `<div class="modalEmpty">请求失败:${esc(String(e))}</div>`;
  1125. }
  1126. }
  1127. function renderBrowser(data) {
  1128. const list = $('modalList');
  1129. list.innerHTML = '';
  1130. const showAll = $('modalAll').checked;
  1131. const rows = [];
  1132. (data.dirs || []).forEach((d) => rows.push({ ...d, kind: 'dir' }));
  1133. (data.files || []).forEach((f) => {
  1134. if (!showAll && !DB_EXTS.includes(f.ext)) return;
  1135. rows.push({ ...f, kind: 'file' });
  1136. });
  1137. if (!rows.length) {
  1138. list.innerHTML = '<div class="modalEmpty">这个目录里没有可选项</div>';
  1139. return;
  1140. }
  1141. rows.forEach((r) => {
  1142. const div = document.createElement('div');
  1143. div.className = 'item ' + r.kind;
  1144. const ico = document.createElement('span');
  1145. ico.className = 'ico ' + (r.kind === 'dir' ? 'dir' : 'file');
  1146. const nm = document.createElement('span');
  1147. nm.className = 'nm';
  1148. nm.textContent = r.name;
  1149. div.append(ico, nm);
  1150. if (r.kind === 'file') {
  1151. const sz = document.createElement('span');
  1152. sz.className = 'sz';
  1153. sz.textContent = fmtSize(r.size);
  1154. div.appendChild(sz);
  1155. }
  1156. div.onclick = () => {
  1157. if (r.kind === 'dir') { browse({ path: r.path }); return; }
  1158. browsePick = r.path;
  1159. $('modalName').value = r.name;
  1160. [...list.children].forEach((c) => c.classList.remove('sel'));
  1161. div.classList.add('sel');
  1162. updateModalTip();
  1163. };
  1164. div.ondblclick = () => {
  1165. if (r.kind === 'file') pickPath(r.path);
  1166. };
  1167. list.appendChild(div);
  1168. });
  1169. }
  1170. function pickPath(full) {
  1171. if (!browseTarget) return;
  1172. const conn = connOf(browseTarget);
  1173. // 目标库没写后缀就补一个, 否则生成的文件引擎可能不认
  1174. if (roleOf(conn) === 'target') {
  1175. const ext = EXT_BY_TYPE[conn.querySelector('.dbType').value];
  1176. if (ext && !basename(full).includes('.')) full += ext;
  1177. }
  1178. browseTarget.value = full;
  1179. syncConn(conn);
  1180. closeBrowser();
  1181. }
  1182. function joinPath(dir, name) {
  1183. if (!dir) return name;
  1184. const sep = dir.includes('\\') ? '\\' : '/';
  1185. return dir.replace(/[\\/]+$/, '') + sep + name;
  1186. }
  1187. function fmtSize(n) {
  1188. if (typeof n !== 'number') return '';
  1189. const unit = ['B', 'KB', 'MB', 'GB', 'TB'];
  1190. let i = 0, v = n;
  1191. while (v >= 1024 && i < unit.length - 1) { v /= 1024; i++; }
  1192. return (i === 0 ? v : v.toFixed(1)) + ' ' + unit[i];
  1193. }
  1194. /* ---------------- 弹窗底部的路径状态提示 ---------------- */
  1195. let tipTimer = null;
  1196. let tipSeq = 0;
  1197. function setModalTip(text, cls) {
  1198. const tip = $('modalTip');
  1199. tip.textContent = text;
  1200. tip.className = 'modalTip' + (cls ? ' ' + cls : '');
  1201. }
  1202. function updateModalTip() {
  1203. clearTimeout(tipTimer);
  1204. tipTimer = setTimeout(showModalTip, 220);
  1205. }
  1206. async function showModalTip() {
  1207. const seq = ++tipSeq;
  1208. const name = $('modalName').value.trim();
  1209. if (!name) { setModalTip('', ''); return; }
  1210. const full = /[\\/]/.test(name) ? name : joinPath(browseState.path, name);
  1211. const st = await statPath(full);
  1212. if (seq !== tipSeq) return; // 用户又敲了新内容, 丢弃这次结果
  1213. if (!st || st.error) { setModalTip('', ''); return; }
  1214. if (!st.parentExists) setModalTip('父目录不存在:' + st.parent, 'bad');
  1215. else if (st.isDir) setModalTip('这是个目录,请填文件名', 'bad');
  1216. else if (st.exists) setModalTip('已存在,会写进这个文件(同名表按上面的选项处理)', '');
  1217. else if (browseRole === 'target') setModalTip('还不存在,迁移时会在这里新建:' + full, 'new');
  1218. else setModalTip('文件不存在,源库要选一个已有的库文件', 'bad');
  1219. }
  1220. $('modalClose').onclick = closeBrowser;
  1221. $('modalUp').onclick = () => browse({ path: browseState.parent });
  1222. $('modalGo').onclick = () => browse({ path: $('modalPath').value.trim() });
  1223. $('modalAll').onchange = () => renderBrowser(browseState);
  1224. $('modalName').oninput = updateModalTip;
  1225. $('modalName').onkeydown = (e) => { if (e.key === 'Enter') $('modalOk').click(); };
  1226. $('modalPath').onkeydown = (e) => { if (e.key === 'Enter') browse({ path: $('modalPath').value.trim() }); };
  1227. $('modalOk').onclick = () => {
  1228. const name = $('modalName').value.trim();
  1229. if (!name) return;
  1230. // 名字没改、且列表里选中了文件 → 直接用选中的完整路径
  1231. if (browsePick && !/[\\/]/.test(name) && name === basename(browsePick)) {
  1232. pickPath(browsePick);
  1233. return;
  1234. }
  1235. // 带分隔符就当作完整路径, 否则拼到当前目录下
  1236. pickPath(/[\\/]/.test(name) ? name : joinPath(browseState.path, name));
  1237. };
  1238. /* 点遮罩空白处可以关弹窗, 但要求「按下」和「松开」都落在遮罩上、且基本没移动。
  1239. 否则在弹窗里拖拽(选中文字、拖滚动条)时手滑出边界, 松手就会把弹窗弄没。*/
  1240. let backdropDown = null;
  1241. $('modal').addEventListener('mousedown', (e) => {
  1242. backdropDown = e.target === $('modal') ? { x: e.clientX, y: e.clientY } : null;
  1243. });
  1244. $('modal').addEventListener('mouseup', (e) => {
  1245. const down = backdropDown;
  1246. backdropDown = null;
  1247. if (!down || e.target !== $('modal')) return; // 起点不在遮罩 → 不是在点空白
  1248. if (Math.abs(e.clientX - down.x) > 6) return; // 拖拽过 → 不是点击
  1249. if (Math.abs(e.clientY - down.y) > 6) return;
  1250. closeBrowser();
  1251. });
  1252. document.addEventListener('keydown', (e) => {
  1253. if (e.key !== 'Escape' || $('modal').hidden) return;
  1254. // 正在输文件名时, ESC 先把输入清掉, 别一下子把整个选择都关掉
  1255. const nameEl = $('modalName');
  1256. if (document.activeElement === nameEl && nameEl.value.trim()) {
  1257. nameEl.value = '';
  1258. updateModalTip();
  1259. return;
  1260. }
  1261. closeBrowser();
  1262. });
  1263. </script>
  1264. </body>
  1265. </html>