| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>VRTX 迁移工具</title>
- <style>
- :root {
- --bg: #f5f6f8;
- --card: #ffffff;
- --ink: #1b1f27;
- --dim: #6b7280;
- --line: #e3e6ec;
- --brand: #4f46e5;
- --brand-dark: #4338ca;
- --ok: #15803d;
- --warn: #b45309;
- --err: #b91c1c;
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0;
- background: var(--bg);
- color: var(--ink);
- font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
- "Microsoft YaHei", Roboto, sans-serif;
- }
- header {
- padding: 30px 24px 22px;
- background: linear-gradient(135deg, #4f46e5, #7c3aed);
- color: #fff;
- }
- header h1 { margin: 0 0 6px; font-size: 21px; font-weight: 650; letter-spacing: .2px; }
- header p { margin: 0; font-size: 13px; opacity: .86; }
- header code { background: rgba(255,255,255,.18); padding: 1px 6px; border-radius: 5px; font-family: var(--mono); }
- main { max-width: 900px; margin: 0 auto; padding: 22px 24px 60px; }
- .card {
- background: var(--card);
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 20px;
- margin-bottom: 16px;
- box-shadow: 0 1px 2px rgba(16,24,40,.04);
- }
- .card h2 {
- margin: 0 0 14px;
- font-size: 15px;
- font-weight: 620;
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .card h2 .n {
- display: inline-flex; align-items: center; justify-content: center;
- width: 22px; height: 22px; border-radius: 50%;
- background: var(--brand); color: #fff; font-size: 12px; font-weight: 700;
- }
- label { display: block; font-size: 12.5px; color: var(--dim); margin-bottom: 4px; }
- input[type=text], input[type=number], input[type=search], input[type=password] {
- width: 100%; padding: 9px 11px; font-size: 13px;
- border: 1px solid var(--line); border-radius: 8px; background: #fbfbfd;
- color: var(--ink); font-family: var(--mono);
- transition: border-color .15s, box-shadow .15s;
- }
- input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.13); background: #fff; }
- .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
- @media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
- .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
- .btn {
- padding: 9px 16px; font-size: 13px; font-weight: 560;
- border: 1px solid var(--line); border-radius: 8px;
- background: #fff; color: var(--ink); cursor: pointer;
- transition: background .15s, border-color .15s, transform .05s;
- }
- .btn:hover { background: #f4f5f8; }
- .btn:active { transform: translateY(1px); }
- .btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
- .btn.primary:hover { background: var(--brand-dark); }
- .btn.primary:disabled { background: #a5a5d8; border-color: #a5a5d8; cursor: default; }
- .btn.tiny { padding: 5px 11px; font-size: 12px; }
- .hint { font-size: 12px; color: var(--dim); }
- .msg { margin-top: 10px; font-size: 12.5px; min-height: 18px; }
- .msg.err { color: var(--err); }
- .msg.ok { color: var(--ok); }
- .opts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 18px; margin-bottom: 14px; }
- @media (max-width: 640px) { .opts { grid-template-columns: 1fr; } }
- .chk { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink); margin: 0; cursor: pointer; }
- .chk input { width: 15px; height: 15px; accent-color: var(--brand); cursor: pointer; }
- .tbl { border: 1px solid var(--line); border-radius: 9px; padding: 11px 13px; margin-bottom: 9px; background: #fcfcfe; }
- .tblHead { display: flex; align-items: center; gap: 9px; cursor: pointer; margin: 0; font-size: 13.5px; color: var(--ink); }
- .tblHead input { width: 15px; height: 15px; accent-color: var(--brand); cursor: pointer; }
- .tblHead b { font-weight: 620; }
- .tblHead .dim { color: var(--dim); font-size: 12px; margin-left: auto; font-family: var(--mono); }
- .cols { margin: 9px 0 0 24px; display: flex; flex-wrap: wrap; gap: 5px; }
- .col {
- font-family: var(--mono); font-size: 11.5px; padding: 2px 7px;
- border-radius: 5px; background: #eef0f6; color: #374151;
- }
- .col.pk { background: #e0e7ff; color: #3730a3; font-weight: 600; }
- .col.syn { background: #fef3c7; color: #92400e; }
- .idxs { margin: 7px 0 0 24px; font-family: var(--mono); font-size: 11.5px; color: var(--dim); }
- .idxs span { margin-right: 12px; }
- .log {
- margin: 14px 0 0; padding: 13px 15px; min-height: 90px; max-height: 380px; overflow: auto;
- background: #111827; color: #d1fae5; border-radius: 9px;
- font-family: var(--mono); font-size: 12px; line-height: 1.65;
- white-space: pre-wrap; word-break: break-all;
- scrollbar-width: thin; scrollbar-color: #4b5563 #1f2937;
- }
- /* 深色底上系统默认滚动条几乎看不见, 单独画一条 */
- .log::-webkit-scrollbar { width: 10px; height: 10px; }
- .log::-webkit-scrollbar-track { background: #1f2937; border-radius: 8px; }
- .log::-webkit-scrollbar-thumb {
- background: #4b5563; border: 2px solid #1f2937; border-radius: 8px;
- }
- .log::-webkit-scrollbar-thumb:hover { background: #6b7280; }
- .log .t { color: #6b7280; }
- .log .e { color: #fca5a5; }
- .log .empty { color: #4b5563; }
- .log .stage { color: #fcd34d; font-weight: 600; margin-top: 3px; }
- .log .r { color: #94a3b8; } /* 逐行数据: 刻意低调, 免得盖过关键日志 */
- /* 执行区一分为二: 左边表清单 (自己带滚动条), 右边还是那面滚动的日志墙。
- 日志不筛不拆 —— 左表负责回答"现在迁到哪张、那张什么状态", 点一下能跳过去。
- 两栏各自 overflow: auto, 一个撑不长, 另一个也不跟着抖。 */
- .split { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
- @media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
- .split .log { margin: 0; }
- .side {
- position: relative; /* 子项 offsetTop 要相对这里算, 好做滚动定位 */
- border: 1px solid var(--line); border-radius: 9px; background: #fcfcfe;
- max-height: 380px; min-height: 90px; overflow: auto; scrollbar-width: thin;
- }
- .sideHead {
- position: sticky; top: 0; z-index: 1;
- display: flex; align-items: center; gap: 6px;
- padding: 7px 10px; background: #f4f5f8; border-bottom: 1px solid var(--line);
- font-size: 12px; color: var(--dim);
- }
- .sideHead b { color: var(--ink); }
- .sideHead .pin {
- margin-left: auto; max-width: 132px; color: var(--brand); font-weight: 560;
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
- }
- .sideList { padding: 5px; }
- .sideList .empty { display: block; padding: 5px 6px; color: #9ca3af; font-size: 12px; }
- .ti {
- padding: 6px 8px; border: 1px solid transparent; border-radius: 7px;
- font-size: 12.5px; line-height: 1.5; cursor: pointer;
- }
- .ti:hover { background: #eef0f6; }
- .ti .nm { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
- .ti .nm b { font-weight: 560; }
- .ti .dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: #d1d5db; }
- .ti .sub {
- margin-left: 13px; font-family: var(--mono); font-size: 11px;
- color: var(--dim); overflow-wrap: anywhere;
- }
- .ti.run { background: #eef2ff; border-color: #c7d2fe; }
- .ti.run .dot { background: var(--brand); animation: pulse 1s ease-in-out infinite; }
- .ti.ok .dot { background: var(--ok); }
- .ti.ok .sub { color: #4d7c5f; }
- .ti.skip .dot { background: var(--warn); }
- .ti.skip .sub { color: var(--warn); }
- .ti.bad { background: #fef2f2; border-color: #fecaca; }
- .ti.bad .dot { background: var(--err); }
- .ti.bad .sub { color: var(--err); }
- /* 失败原因挂在这行的 title 上 (行里放不下): 虚线 + 问号光标提示"悬停能看全" */
- .ti.bad .sub { text-decoration: underline dotted; text-underline-offset: 2px; cursor: help; }
- .ti.pick { box-shadow: inset 0 0 0 2px var(--brand); }
- /* 进行中那一行: 计时器在走 + 行数在涨 = 没死锁 */
- .progress {
- display: flex; align-items: center; gap: 9px;
- margin-top: 9px; padding: 9px 14px; border-radius: 8px;
- background: #eef2ff; border: 1px solid #c7d2fe; color: #3730a3;
- font-family: var(--mono); font-size: 12px;
- }
- .progress .dot {
- flex: none; width: 7px; height: 7px; border-radius: 50%; background: #4f46e5;
- animation: pulse 1s ease-in-out infinite;
- }
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .2; } }
- table.rep { width: 100%; border-collapse: collapse; margin-top: 14px; font-size: 12.5px; }
- table.rep th, table.rep td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; }
- table.rep th { font-weight: 600; color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .4px; }
- table.rep td.num { font-family: var(--mono); text-align: right; }
- table.rep tr.skip td { color: var(--dim); }
- table.rep tr.bad td { color: #b91c1c; background: #fef2f2; }
- .total.warn { color: #b91c1c; }
- .total { margin-top: 12px; font-size: 13px; font-weight: 600; color: var(--ok); }
- select {
- width: 100%; padding: 9px 32px 9px 11px; font-size: 13px;
- border: 1px solid var(--line); border-radius: 8px; background: #fbfbfd;
- color: var(--ink); cursor: pointer; appearance: none;
- transition: border-color .15s, box-shadow .15s;
- }
- select:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.13); background: #fff; }
- .selWrap { position: relative; }
- .selWrap::after {
- content: ""; position: absolute; right: 13px; top: 50%;
- width: 7px; height: 7px; margin-top: -5px;
- border-right: 2px solid var(--dim); border-bottom: 2px solid var(--dim);
- transform: rotate(45deg); pointer-events: none;
- }
- .conn { border: 1px solid var(--line); border-radius: 10px; padding: 15px; background: #fcfcfe; }
- .connTitle { font-size: 13px; font-weight: 620; margin-bottom: 11px; display: flex; align-items: center; gap: 7px; }
- .connTitle::before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: var(--brand); }
- .field { margin-bottom: 10px; }
- .conn > .field:last-of-type { margin-bottom: 0; }
- .fileRow { display: flex; gap: 8px; }
- .fileRow input { flex: 1; min-width: 0; }
- .fileRow .btn { flex: none; white-space: nowrap; }
- .onlyMySQL .grid2 { margin-bottom: 0; }
- /* 库名候选: 点一下填进去, 比手打库名可靠 */
- .dbList {
- display: flex; flex-wrap: wrap; gap: 6px;
- margin-top: 8px; max-height: 132px; overflow: auto;
- }
- .dbList .chip {
- font-family: var(--mono); font-size: 11.5px; line-height: 1.5;
- padding: 3px 10px; cursor: pointer; white-space: nowrap;
- border: 1px solid var(--line); border-radius: 999px;
- background: #fbfbfd; color: var(--ink);
- transition: border-color .15s, color .15s, background .15s;
- }
- .dbList .chip:hover { border-color: var(--brand); color: var(--brand); }
- .dbList .chip.on { background: var(--brand); border-color: var(--brand); color: #fff; }
- .dbList .tip { font-size: 11.5px; color: var(--dim); }
- .dbList .tip.bad { color: var(--err); }
- .dsn {
- margin-top: 12px; padding: 8px 10px; background: #f1f2f7; border-radius: 7px;
- font-size: 11.5px; display: flex; gap: 8px; align-items: baseline;
- }
- .dsn b { color: var(--dim); font-weight: 600; flex: none; }
- .dsn code { font-family: var(--mono); color: #374151; word-break: break-all; }
- .modal { position: fixed; inset: 0; z-index: 50; padding: 22px;
- background: rgba(16,24,40,.45); display: flex; align-items: center; justify-content: center; }
- .modal[hidden] { display: none; }
- .modalBox {
- background: #fff; border-radius: 12px; width: 100%; max-width: 640px;
- display: flex; flex-direction: column; max-height: 84vh;
- border: 1px solid #d3d7e2;
- box-shadow: 0 24px 60px rgba(16,24,40,.32), 0 0 0 1px rgba(255,255,255,.65);
- }
- .modalHead { display: flex; align-items: center; justify-content: space-between;
- padding: 15px 18px; border-bottom: 1px solid var(--line); font-size: 14px; }
- .modalPath { display: flex; gap: 8px; padding: 12px 18px 0; }
- .modalPath input { flex: 1; min-width: 0; }
- .modalPath .btn { flex: none; }
- .modalAll { padding: 10px 18px 0; }
- .modalList {
- margin: 8px 18px 0; border: 1px solid var(--line); border-radius: 9px;
- overflow: auto; min-height: 150px; max-height: 46vh;
- }
- .item { display: flex; align-items: center; gap: 10px; padding: 8px 12px;
- font-size: 13px; cursor: pointer; border-bottom: 1px solid #f2f3f7; }
- .item:last-child { border-bottom: 0; }
- .item:hover { background: #f5f6fa; }
- .item.sel { background: #eef0ff; }
- .item.dir .nm { font-weight: 560; }
- .item .ico { flex: none; position: relative; width: 16px; height: 14px; }
- .item .ico.dir::before { content: ""; position: absolute; left: 0; top: 3px; right: 0; bottom: 0;
- background: #f6c453; border-radius: 2px; }
- .item .ico.dir::after { content: ""; position: absolute; left: 0; top: 0; width: 7px; height: 5px;
- background: #f6c453; border-radius: 2px 2px 0 0; }
- .item .ico.file::before { content: ""; position: absolute; left: 3px; top: 0; width: 9px; height: 13px;
- border: 1.5px solid #9aa3b2; border-radius: 2px; }
- .item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .item .sz { flex: none; color: var(--dim); font-size: 11.5px; font-family: var(--mono); }
- .modalFoot { display: flex; gap: 8px; padding: 14px 18px 16px; }
- .modalFoot input { flex: 1; min-width: 0; }
- .modalFoot .btn { flex: none; }
- .modalEmpty { padding: 26px; text-align: center; color: var(--dim); font-size: 12.5px; }
- .dsn code { flex: 1; min-width: 0; }
- .dsnBadge { flex: none; font-size: 11px; font-weight: 560; padding: 1px 8px;
- border-radius: 20px; white-space: nowrap; }
- .dsnBadge[hidden] { display: none; }
- .dsnBadge.new { background: #dcfce7; color: #15803d; }
- .dsnBadge.has { background: #eef0f6; color: #6b7280; }
- .dsnBadge.bad { background: #fee2e2; color: #b91c1c; }
- .modalFoot { padding: 14px 18px 8px; }
- .modalTip { padding: 0 18px 14px; font-size: 12px; min-height: 15px;
- font-family: var(--mono); color: var(--dim); word-break: break-all; }
- .modalTip.new { color: var(--ok); }
- .modalTip.bad { color: var(--err); }
- </style>
- </head>
- <body>
- <header>
- <h1>VRTX 迁移工具</h1>
- <p><code>vrtx</code> / <code>sqlite</code> / <code>mysql</code> 三库之间搬表 —— 结构、数据、索引、主键一起走,任意方向。</p>
- </header>
- <main>
- <section class="card">
- <h2><span class="n">1</span>连接</h2>
- <div class="grid2">
- <div class="conn" id="connFrom" data-role="source">
- <div class="connTitle">源库</div>
- <div class="field">
- <label>数据库类型</label>
- <div class="selWrap">
- <select class="dbType">
- <option value="vrtx">VRTX(.db 文件)</option>
- <option value="sqlite">SQLite(.sqlite 文件)</option>
- <option value="mysql">MySQL(数据库服务器)</option>
- </select>
- </div>
- </div>
- <div class="onlyFile field">
- <label>数据库文件</label>
- <div class="fileRow">
- <input class="fPath" type="text" spellcheck="false" placeholder="点「浏览」挑一个已有文件">
- <button class="btn tiny browseBtn" type="button">浏览…</button>
- </div>
- </div>
- <div class="onlyMySQL" hidden>
- <div class="grid2">
- <div class="field"><label>主机</label><input class="fHost" type="text" value="127.0.0.1" spellcheck="false"></div>
- <div class="field"><label>端口</label><input class="fPort" type="number" value="3306" min="1" max="65535"></div>
- </div>
- <div class="grid2">
- <div class="field"><label>用户名</label><input class="fUser" type="text" value="root" spellcheck="false"></div>
- <div class="field"><label>密码</label><input class="fPass" type="password"></div>
- </div>
- <div class="field">
- <label>数据库名</label>
- <div class="fileRow">
- <input class="fDB" type="text" spellcheck="false" placeholder="例如 app_db">
- <button class="btn tiny dbListBtn" type="button">读取列表</button>
- </div>
- <div class="dbList" hidden></div>
- </div>
- </div>
- <div class="dsn"><b>DSN</b><code class="dsnOut">—</code><span class="dsnBadge" hidden></span></div>
- </div>
- <div class="conn" id="connTo" data-role="target">
- <div class="connTitle">目标库</div>
- <div class="field">
- <label>数据库类型</label>
- <div class="selWrap">
- <select class="dbType">
- <option value="sqlite">SQLite(.sqlite 文件)</option>
- <option value="vrtx">VRTX(.db 文件)</option>
- <option value="mysql">MySQL(数据库服务器)</option>
- </select>
- </div>
- </div>
- <div class="onlyFile field">
- <label>数据库文件</label>
- <div class="fileRow">
- <input class="fPath" type="text" spellcheck="false" placeholder="点「选择/新建」,或直接输入一个不存在的路径">
- <button class="btn tiny browseBtn" type="button">选择/新建…</button>
- </div>
- </div>
- <div class="onlyMySQL" hidden>
- <div class="grid2">
- <div class="field"><label>主机</label><input class="fHost" type="text" value="127.0.0.1" spellcheck="false"></div>
- <div class="field"><label>端口</label><input class="fPort" type="number" value="3306" min="1" max="65535"></div>
- </div>
- <div class="grid2">
- <div class="field"><label>用户名</label><input class="fUser" type="text" value="root" spellcheck="false"></div>
- <div class="field"><label>密码</label><input class="fPass" type="password"></div>
- </div>
- <div class="field">
- <label>数据库名</label>
- <div class="fileRow">
- <input class="fDB" type="text" spellcheck="false" placeholder="例如 app_db">
- <button class="btn tiny dbListBtn" type="button">读取列表</button>
- </div>
- <div class="dbList" hidden></div>
- </div>
- </div>
- <div class="dsn"><b>DSN</b><code class="dsnOut">—</code><span class="dsnBadge" hidden></span></div>
- </div>
- </div>
- <div class="row">
- <button id="listBtn" class="btn">查看源库结构</button>
- <span class="hint">选好源库后点这里,先看它有哪些表</span>
- </div>
- <div id="connMsg" class="msg"></div>
- </section>
- <section class="card" id="tablesCard" hidden>
- <h2><span class="n">2</span>选择表</h2>
- <div id="srcHint" class="hint" style="margin-bottom:10px"></div>
- <div class="row" style="margin-top:0">
- <button class="btn tiny" id="allBtn">全选</button>
- <button class="btn tiny" id="noneBtn">全不选</button>
- <span class="hint">勾选要迁的表;全选或不选都表示「全部表」</span>
- </div>
- <div id="tables" style="margin-top:12px"></div>
- </section>
- <section class="card">
- <h2><span class="n">3</span>选项</h2>
- <div class="opts">
- <label class="chk"><input type="checkbox" id="recreate"> 先删目标同名表(recreate)</label>
- <label class="chk"><input type="checkbox" id="dryRun"> 试跑,不写数据(dry-run)</label>
- <label class="chk"><input type="checkbox" id="noMeta"> 不写保真表 __vrtx_meta</label>
- <label class="chk"><input type="checkbox" id="autoPk"> 源表无主键时自动补 _id</label>
- <label class="chk"><input type="checkbox" id="loose"> 装不下时按引擎语义截断(可能丢数据)</label>
- <label class="chk"><input type="checkbox" id="keepGoing" checked> 某张表失败时跳过它,继续迁其余表</label>
- <label class="chk"><input type="checkbox" id="eachRow" checked> 逐行显示数据(大表会拖慢,可关)</label>
- </div>
- <div class="grid2">
- <div>
- <label for="batch">每批写入行数</label>
- <input id="batch" type="number" value="500" min="1">
- </div>
- <div>
- <label for="limit">每表最多迁移行数(0 = 不限)</label>
- <input id="limit" type="number" value="0" min="0">
- </div>
- </div>
- </section>
- <section class="card">
- <h2><span class="n">4</span>执行</h2>
- <div class="row" style="margin-top:0">
- <button id="runBtn" class="btn primary">开始迁移</button>
- <span class="hint" id="runHint"></span>
- <label class="chk" id="followWrap" style="margin-left:auto" hidden>
- <input type="checkbox" id="follow" checked> 自动滚到底部
- </label>
- </div>
- <div class="split">
- <div class="side" id="tblSide">
- <div class="sideHead">表 <b id="tblCount">0</b><span class="pin" id="pinHint" hidden></span></div>
- <div class="sideList" id="tblList"><span class="empty">等待开始…</span></div>
- </div>
- <pre id="log" class="log"><span class="empty">等待开始…</span></pre>
- </div>
- <div id="progress" class="progress" hidden></div>
- <div id="report"></div>
- </section>
- </main>
- <div class="modal" id="modal" hidden>
- <div class="modalBox">
- <div class="modalHead">
- <b id="modalTitle">选择文件</b>
- <button class="btn tiny" id="modalClose" type="button">关闭</button>
- </div>
- <div class="modalPath">
- <input id="modalPath" type="text" spellcheck="false" placeholder="路径">
- <button class="btn tiny" id="modalUp" type="button">上一级</button>
- <button class="btn tiny" id="modalGo" type="button">前往</button>
- </div>
- <div class="modalAll">
- <label class="chk"><input type="checkbox" id="modalAll"> 显示全部文件(默认只看数据库文件)</label>
- </div>
- <div class="modalList" id="modalList"></div>
- <div class="modalFoot">
- <input id="modalName" type="text" spellcheck="false" placeholder="文件名">
- <button class="btn primary" id="modalOk" type="button">选用</button>
- </div>
- <div class="modalTip" id="modalTip"></div>
- </div>
- </div>
- <script>
- const $ = (id) => document.getElementById(id);
- let schemaCache = [];
- /* ---------------- 查看源库结构 ---------------- */
- $('listBtn').onclick = async () => {
- const dsn = buildDSN($('connFrom'));
- const msg = $('connMsg');
- if (!dsn) { msg.className = 'msg err'; msg.textContent = '请先选好源库类型并填好连接信息'; return; }
- msg.className = 'msg';
- msg.textContent = '读取中…';
- $('listBtn').disabled = true;
- try {
- const resp = await fetch('/api/list', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dsn })
- });
- const data = await resp.json();
- if (data.error) {
- msg.className = 'msg err';
- msg.textContent = data.error;
- return;
- }
- schemaCache = data.tables || [];
- msg.className = 'msg ok';
- msg.textContent = '读取成功';
- renderTables(data);
- } catch (e) {
- msg.className = 'msg err';
- msg.textContent = '请求失败:' + e;
- } finally {
- $('listBtn').disabled = false;
- }
- };
- function renderTables(data) {
- $('srcHint').textContent =
- `${data.kind} · ${data.identity} · ${data.tables.length} 张表`;
- const box = $('tables');
- box.innerHTML = '';
- (data.tables || []).forEach((t) => {
- const div = document.createElement('div');
- div.className = 'tbl';
- const head = document.createElement('label');
- head.className = 'tblHead';
- const chk = document.createElement('input');
- chk.type = 'checkbox';
- chk.className = 'tblChk';
- chk.value = t.name;
- chk.checked = true;
- const name = document.createElement('b');
- name.textContent = t.name;
- const dim = document.createElement('span');
- dim.className = 'dim';
- dim.textContent = `${t.columns ? t.columns.length : 0} 列 · 主键 ${t.pkColumn || '无'}[${t.pkIndexType}]`;
- head.append(chk, name, dim);
- div.appendChild(head);
- if (t.columns && t.columns.length) {
- const cols = document.createElement('div');
- cols.className = 'cols';
- t.columns.forEach((c) => {
- const span = document.createElement('span');
- span.className = 'col' + (c.isPK ? ' pk' : '') + (c.synthetic ? ' syn' : '');
- span.textContent = `${c.name} : ${c.type}${c.dynamic ? '' : '(' + c.size + 'B)'}`;
- cols.appendChild(span);
- });
- div.appendChild(cols);
- }
- if (t.indexes && t.indexes.length) {
- const idx = document.createElement('div');
- idx.className = 'idxs';
- idx.innerHTML = t.indexes
- .map((i) => `<span>${i.name}(${i.columns.join(',')})[${i.type}]</span>`)
- .join('');
- div.appendChild(idx);
- }
- box.appendChild(div);
- });
- $('tablesCard').hidden = false;
- }
- $('allBtn').onclick = () => document.querySelectorAll('.tblChk').forEach((c) => (c.checked = true));
- $('noneBtn').onclick = () => document.querySelectorAll('.tblChk').forEach((c) => (c.checked = false));
- /* 全选/不选都视作「全部表」→ 传空数组, 由后端按源库全表处理 */
- function collectTables() {
- const all = [...document.querySelectorAll('.tblChk')];
- const picked = all.filter((c) => c.checked).map((c) => c.value);
- if (all.length === 0 || picked.length === all.length) return [];
- return picked;
- }
- /* ---------------- 执行迁移 (流式读取 NDJSON) ---------------- */
- const logBox = $('log');
- /* 日志区要回答三个问题: 还在动吗、能不能滚、我翻历史时别被拽回去 */
- let followTail = true; // 只有它开着才自动滚到底
- let runStart = 0;
- let runTimer = null;
- let runHadError = false; // 本次跑是否报过错: 报过错就不能把结束说成"完成"
- function scrollLogTail() { logBox.scrollTop = logBox.scrollHeight; }
- // 用户自己往上翻 = 在读历史, 这时再把他拽回底部最烦人
- logBox.addEventListener('scroll', () => {
- const atBottom = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 24;
- if (atBottom !== followTail) {
- followTail = atBottom;
- $('follow').checked = atBottom;
- }
- });
- $('follow').onchange = () => {
- followTail = $('follow').checked;
- if (followTail) scrollLogTail();
- };
- // 日志区最多留这么多行: DOM 无限长会把浏览器拖垮, 超出上限的从头丢弃。
- const LOG_MAX = 2000;
- function trimLog() {
- while (logBox.childElementCount > LOG_MAX) {
- logBox.removeChild(logBox.firstElementChild);
- }
- }
- // table 用来给行打标记 (点左表的某一项能跳到它的第一行); 不给就跟着"正在迁的那张"
- function logLine(text, cls, table) {
- if (logBox.querySelector('.empty')) logBox.innerHTML = '';
- const line = document.createElement('div');
- if (cls) line.className = cls;
- const t = table === undefined ? curTable : table;
- if (t) line.dataset.t = t;
- line.textContent = text;
- logBox.appendChild(line);
- trimLog();
- if (followTail) scrollLogTail();
- }
- /* 逐行数据一条一行事件, 一张表就能有几万条 —— 攒 30ms 一次性插进去。
- 每条都单独碰一次 DOM 的话, 光渲染就能把迁移拖慢一个数量级。 */
- let rowQueue = [];
- let rowTimer = null;
- function pushRow(text, table) {
- rowQueue.push({ text, table });
- if (!rowTimer) rowTimer = setTimeout(flushRows, 30);
- }
- function flushRows() {
- rowTimer = null;
- if (rowQueue.length === 0) return;
- const frag = document.createDocumentFragment();
- for (const it of rowQueue) {
- const d = document.createElement('div');
- d.className = 'r';
- if (it.table) d.dataset.t = it.table;
- d.textContent = it.text;
- frag.appendChild(d);
- }
- rowQueue = [];
- if (logBox.querySelector('.empty')) logBox.innerHTML = '';
- logBox.appendChild(frag);
- trimLog();
- if (followTail) scrollLogTail();
- }
- /* ---------------- 左侧表清单: 迁到哪张、那张什么状态 ---------------- */
- const tblSide = $('tblSide');
- const tblList = $('tblList');
- /* 表名 → 清单里的那一行。只增不重排: 状态在变而位置不动, 高亮才不会满屏跳。 */
- const tblRows = new Map();
- let curTable = ''; // 正在迁的那张 (左表自动高亮它)
- let pickTable = ''; // 用户点选锁定的那张 (再点一次取消); 空 = 跟随正在迁的
- let pinTimer = null;
- /* 本次要迁哪些表: 由勾选情况决定 —— 全选/不选都当"全部表"(与后端同一套语义)。 */
- function plannedTables() {
- const all = [...document.querySelectorAll('.tblChk')];
- const picked = all.filter((c) => c.checked).map((c) => c.value);
- if (all.length === 0 || picked.length === all.length) return all.map((c) => c.value);
- return picked;
- }
- function resetTablePane(names) {
- tblRows.clear();
- tblList.innerHTML = '';
- curTable = '';
- pickTable = '';
- clearTimeout(pinTimer);
- $('tblCount').textContent = '0';
- if (!names.length) {
- tblList.innerHTML = '<span class="empty">等待开始…</span>';
- paintTables();
- return;
- }
- names.forEach((n) => addTableRow(n));
- paintTables();
- }
- /* 结构信息取自「查看源库结构」的缓存; 没点过那一步就只剩表名 —— 不编造列数。 */
- function colHint(name) {
- const t = schemaCache.find((x) => x.name === name);
- if (!t) return '';
- const cols = t.columns ? t.columns.length : 0;
- const idxs = t.indexes ? t.indexes.length : 0;
- return `${cols} 列 · ${idxs} 索引 · 主键 ${t.pkColumn || '无'}`;
- }
- function addTableRow(name) {
- const had = tblRows.get(name);
- if (had) return had;
- if (tblList.querySelector('.empty')) tblList.innerHTML = '';
- const el = document.createElement('div');
- el.className = 'ti';
- const nm = document.createElement('div');
- nm.className = 'nm';
- const dot = document.createElement('span');
- dot.className = 'dot';
- const b = document.createElement('b');
- b.textContent = name;
- nm.append(dot, b);
- const sub = document.createElement('div');
- sub.className = 'sub';
- sub.textContent = colHint(name);
- el.append(nm, sub);
- el.onclick = () => {
- pickTable = pickTable === name ? '' : name;
- paintTables();
- if (pickTable) jumpToTable(name);
- };
- tblList.appendChild(el);
- tblRows.set(name, el);
- $('tblCount').textContent = String(tblRows.size);
- return el;
- }
- /* state: run / ok / bad / skip, 空 = 未开始。
- 进度一秒可能刷很多次, 所以这里只写 textContent (不读布局), 只有"状态真的变了"
- 才重画 —— 否则每来一条进度都要读一次 offsetTop, 大表上白白拖慢界面。
- tip: 悬停提示, 用来放"行里放不下"的细节 (失败原因、备注); 不传就清掉, 免得
- 留着上一次的旧话。 */
- function setTableState(name, state, sub, tip) {
- const el = addTableRow(name);
- const st = state || '';
- const changed = el.dataset.st !== st;
- el.dataset.st = st;
- el.classList.remove('run', 'ok', 'bad', 'skip');
- if (st) el.classList.add(st);
- if (sub) el.querySelector('.sub').textContent = sub;
- el.title = tip || '';
- if (changed) paintTables();
- }
- function paintTables() {
- tblRows.forEach((el, name) => {
- // 已经判失败的表不再亮"进行中": 否则红点还在一跳一跳, 看着像还在跑
- el.classList.toggle('run', name === curTable && !el.classList.contains('bad'));
- el.classList.toggle('pick', name === pickTable);
- });
- const pin = $('pinHint');
- pin.hidden = !pickTable;
- if (pickTable) pin.textContent = '已锁定';
- revealActive();
- }
- /* 亮着的那张要是滚出了可视区, 就把它带回来 —— 表多时不然等于没高亮 */
- function revealActive() {
- const el = tblRows.get(pickTable || curTable);
- if (!el || !tblSide) return;
- const top = el.offsetTop;
- const bottom = top + el.offsetHeight;
- if (top < tblSide.scrollTop || bottom > tblSide.scrollTop + tblSide.clientHeight) {
- tblSide.scrollTop = Math.max(0, top - (tblSide.clientHeight - el.offsetHeight) / 2);
- }
- }
- const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'));
- /* 点表名 → 右边日志滚到这张表的第一行。日志不筛不拆, 只是把人送过去; 之后照旧往下滚
- (往上翻会触发已有的滚动逻辑, 自动把"自动滚到底"取消掉, 不会被拽回底部)。 */
- function jumpToTable(name) {
- const first = logBox.querySelector(`[data-t="${cssEsc(name)}"]`);
- if (!first) { flashPin('日志里已找不到'); return; }
- first.scrollIntoView({ block: 'start' });
- }
- function flashPin(text) {
- const pin = $('pinHint');
- pin.hidden = false;
- pin.textContent = text;
- clearTimeout(pinTimer);
- pinTimer = setTimeout(paintTables, 2600);
- }
- /* 正在迁的表名从两类事件推出:
- 结构化事件 (progress / row) 自带 table; 但 0 行的表不会有这两类事件, 所以再认一下
- 后端每张表开头必打的那行日志 "[migrate] 表 X (i/n): 开始"。 */
- const START_RE = /^\[migrate\] 表 (.+) \(\d+\/\d+\): 开始$/;
- /* 后半截 ": <错误原文>" 必须留住 —— 左栏一行只放得下"失败, 已跳过", 真正的原因挂到
- title 上悬停可见 (错误原文常常很长, 直接塞进那一行会把行撑成一大块)。
- 原因用 [\s\S]* 而不是 .*: 驱动/SQL 的报错原文常自带换行, 用 . 会整行都匹配不上,
- 失败的表就既不变红也拿不到原因 —— 那才是真的"只有失败、没有原因"。 */
- const FAIL_RE = /^\[migrate\] 表 (.+?) 失败, 跳过继续(?::\s*([\s\S]*))?$/;
- function setCurrentTable(name) {
- if (!name) return;
- addTableRow(name);
- if (curTable === name) return;
- curTable = name;
- paintTables();
- }
- /* 左栏就两指宽, 而错误原文动不动好几行 (SQL 报错、驱动报错都长): 全塞进 sub 会把
- 那一行撑成一大块, 上下邻居全被推走。所以 sub 只留第一行的头一小截, 全文交给
- title 悬停看 —— 想读完整原因, 鼠标停在行上就行。 */
- function shortWhy(s, max = 56) {
- const one = String(s == null ? '' : s).split('\n')[0].trim();
- const r = [...one];
- return r.length > max ? r.slice(0, max).join('') + '…' : one;
- }
- /* 结束时按报告落最终状态: 报告是唯一权威 —— 小表可能一次 progress 都没发过。 */
- function finishTables(rep) {
- curTable = '';
- ((rep && rep.tables) || []).forEach((t) => {
- const state = t.error ? 'bad' : (t.skipped ? 'skip' : 'ok');
- let sub;
- if (t.error) sub = '失败:' + shortWhy(t.error);
- else if (t.skipped) sub = t.note || '跳过'; // 跳过的表行数没意义, 只说原因
- else {
- sub = `${Number(t.rows).toLocaleString()} 行 · ${t.elapsed}`;
- if (t.note) sub += ' · ' + t.note; // 备注 (试跑/仅结构/截断/跳过索引) 不能丢
- }
- const tip = t.error ? `表 ${t.table} 失败:${t.error}` : (t.note ? '备注:' + t.note : '');
- setTableState(t.table, state, sub, tip);
- });
- paintTables();
- }
- function fmtDur(ms) {
- const s = Math.max(0, Math.floor(ms / 1000));
- return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
- }
- // 计时器一直走 —— 哪怕某张大表几分钟不吐日志, 也看得出进程没死
- function startClock() {
- runStart = Date.now();
- $('followWrap').hidden = false;
- stopClock();
- $('runHint').textContent = '迁移中… 已用 0s';
- runTimer = setInterval(() => {
- $('runHint').textContent = '迁移中… 已用 ' + fmtDur(Date.now() - runStart);
- }, 1000);
- }
- function stopClock() {
- if (runTimer) { clearInterval(runTimer); runTimer = null; }
- }
- // 进行中进度原地刷新同一行, 不往日志里灌流水 (灌进去会把有用的行顶走)
- function showProgress(table, rows, elapsed) {
- const box = $('progress');
- box.hidden = false;
- box.innerHTML = `<span class="dot"></span><b>${esc(table)}</b> 已写 ` +
- `${Number(rows).toLocaleString()} 行 · ${esc(elapsed)}`;
- }
- $('runBtn').onclick = async () => {
- const from = buildDSN($('connFrom'));
- const to = buildDSN($('connTo'));
- const runHint = $('runHint');
- if (!from || !to) { runHint.textContent = '请先填好源库与目标库的连接信息'; return; }
- if (from === to) { runHint.textContent = '源库与目标库不能是同一个'; return; }
- const payload = {
- from, to,
- tables: collectTables(),
- recreate: $('recreate').checked,
- dryRun: $('dryRun').checked,
- noMeta: $('noMeta').checked,
- autoPk: $('autoPk').checked,
- loose: $('loose').checked,
- keepGoing: $('keepGoing').checked,
- eachRow: $('eachRow').checked,
- batch: parseInt($('batch').value, 10) || 0,
- limit: parseInt($('limit').value, 10) || 0
- };
- logBox.innerHTML = '';
- rowQueue = [];
- if (rowTimer) { clearTimeout(rowTimer); rowTimer = null; }
- runHadError = false;
- $('report').innerHTML = '';
- $('progress').hidden = true;
- resetTablePane(plannedTables());
- followTail = true;
- $('follow').checked = true;
- startClock();
- const btn = $('runBtn');
- btn.disabled = true;
- try {
- const resp = await fetch('/api/migrate', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- });
- if (!resp.ok) {
- logLine('服务返回 ' + resp.status + ':' + (await resp.text()), 'e');
- return;
- }
- const reader = resp.body.getReader();
- const decoder = new TextDecoder();
- let buf = '';
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- let i;
- while ((i = buf.indexOf('\n')) >= 0) {
- const line = buf.slice(0, i).trim();
- buf = buf.slice(i + 1);
- if (line) handleEvent(JSON.parse(line));
- }
- }
- if (buf.trim()) handleEvent(JSON.parse(buf.trim()));
- // 报错过就不能报"完成" —— 否则用户根本看不出有问题。
- runHint.textContent = runHadError
- ? '结束(有错误),用时 ' + fmtDur(Date.now() - runStart)
- : '完成,用时 ' + fmtDur(Date.now() - runStart);
- } catch (e) {
- logLine('连接中断:' + e, 'e');
- runHint.textContent = '中断';
- } finally {
- stopClock();
- flushRows();
- $('progress').hidden = true;
- btn.disabled = false;
- curTable = ''; // 没有表在迁了, 别让最后那张一直亮着"进行中"
- paintTables();
- }
- };
- function handleEvent(ev) {
- if (ev.type === 'log') {
- const msg = String(ev.msg || '');
- const started = START_RE.exec(msg.trim());
- if (started) setCurrentTable(started[1]); // 先认表, 这行日志才挂得上标记
- logLine(`[${ev.at || ''}] ${msg}`, ev.level === 'stage' ? 'stage' : null);
- const failed = FAIL_RE.exec(msg.trim());
- if (failed) {
- const why = (failed[2] || '').trim();
- setTableState(failed[1], 'bad', '失败,已跳过', why ? `表 ${failed[1]} 失败:${why}` : '');
- }
- } else if (ev.type === 'row') {
- setCurrentTable(ev.table);
- pushRow(`${ev.table} #${Number(ev.rows).toLocaleString()} ${ev.msg}`, ev.table);
- } else if (ev.type === 'progress') {
- setCurrentTable(ev.table);
- setTableState(ev.table, 'run', `${Number(ev.rows).toLocaleString()} 行 · ${ev.elapsed}`);
- showProgress(ev.table, ev.rows, ev.elapsed);
- } else if (ev.type === 'error') {
- runHadError = true;
- logLine('错误:' + ev.msg, 'e');
- if (curTable) setTableState(curTable, 'bad', '中断', '错误:' + ev.msg);
- } else if (ev.type === 'done') {
- $('progress').hidden = true;
- renderReport(ev.report);
- finishTables(ev.report);
- }
- }
- function renderReport(rep) {
- if (!rep) return;
- const rows = (rep.tables || []).map((t) => {
- const note = t.error ? '失败:' + t.error : (t.note || (t.skipped ? '跳过' : ''));
- return `
- <tr class="${t.error ? 'bad' : (t.skipped ? 'skip' : '')}">
- <td>${esc(t.table)}</td>
- <td class="num">${t.rows}</td>
- <td class="num">${t.columns}</td>
- <td class="num">${t.indexes}</td>
- <td class="num">${t.elapsed}</td>
- <td title="${esc(note)}">${esc(note)}</td>
- </tr>`;
- }).join('');
- const bad = (rep.tables || []).filter((t) => t.error).length;
- $('report').innerHTML = `
- <table class="rep">
- <thead><tr>
- <th>表</th><th style="text-align:right">行数</th><th style="text-align:right">列</th>
- <th style="text-align:right">索引</th><th style="text-align:right">耗时</th><th>备注</th>
- </tr></thead>
- <tbody>${rows}</tbody>
- </table>
- <div class="total${bad ? ' warn' : ''}">${rep.from} → ${rep.to}:${rep.tables.length} 张表 / ${rep.rows} 行,用时 ${rep.elapsed}${bad ? `,其中 ${bad} 张失败` : ''}</div>`;
- }
- function esc(s) {
- return String(s == null ? '' : s).replace(/[&<>"']/g, (m) => (
- { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]
- ));
- }
- /* ---------------- 连接表单: 类型 + 参数 → DSN ---------------- */
- const DB_EXTS = ['.db', '.sqlite', '.sqlite3', '.db3', '.vrtx'];
- // 目标库新建时用的默认后缀, 免得生成一个引擎认不出的文件
- const EXT_BY_TYPE = { vrtx: '.db', sqlite: '.sqlite' };
- const connOf = (el) => el.closest('.conn');
- const roleOf = (conn) => conn.dataset.role || 'source';
- function buildDSN(conn) {
- if (!conn) return '';
- const type = conn.querySelector('.dbType').value;
- if (type === 'mysql') {
- const host = conn.querySelector('.fHost').value.trim() || '127.0.0.1';
- const port = conn.querySelector('.fPort').value.trim() || '3306';
- const user = conn.querySelector('.fUser').value.trim();
- const pass = conn.querySelector('.fPass').value;
- const db = conn.querySelector('.fDB').value.trim();
- if (!user || !db) return '';
- return `mysql:${user}:${pass}@tcp(${host}:${port})/${db}`;
- }
- const path = conn.querySelector('.fPath').value.trim();
- return path ? `${type}:${path}` : '';
- }
- function basename(p) {
- return (p || '').replace(/\\/g, '/').split('/').pop() || '';
- }
- /* 主机 / 账号填好就能先把库名列出来 —— 库名是连接串的一部分, 少打一个字母就得
- 对着"连接失败"猜, 不如直接给候选点。 */
- async function loadDatabases(conn) {
- const box = conn.querySelector('.dbList');
- const btn = conn.querySelector('.dbListBtn');
- const host = conn.querySelector('.fHost').value.trim() || '127.0.0.1';
- const port = conn.querySelector('.fPort').value.trim() || '3306';
- const user = conn.querySelector('.fUser').value.trim();
- const pass = conn.querySelector('.fPass').value;
- box.hidden = false;
- if (!user) {
- box.innerHTML = '<span class="tip bad">先填用户名</span>';
- return;
- }
- btn.disabled = true;
- box.innerHTML = '<span class="tip">连接中…</span>';
- try {
- const r = await fetch('/api/databases', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ host, port, user, pass })
- });
- const data = await r.json();
- if (data.error) {
- box.innerHTML = `<span class="tip bad">${esc(data.error)}</span>`;
- return;
- }
- renderDBList(conn, data.databases || []);
- } catch (e) {
- box.innerHTML = `<span class="tip bad">请求失败:${esc(e)}</span>`;
- } finally {
- btn.disabled = false;
- }
- }
- function renderDBList(conn, names) {
- const box = conn.querySelector('.dbList');
- const input = conn.querySelector('.fDB');
- if (!names.length) {
- box.innerHTML = '<span class="tip">这台服务器上没有可选的库</span>';
- return;
- }
- box.innerHTML = '';
- names.forEach((n) => {
- const chip = document.createElement('button');
- chip.type = 'button';
- chip.className = 'chip' + (n === input.value.trim() ? ' on' : '');
- chip.textContent = n;
- chip.onclick = () => {
- input.value = n;
- box.querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === chip));
- syncConn(conn);
- };
- box.appendChild(chip);
- });
- }
- async function statPath(path) {
- try {
- const r = await fetch('/api/stat', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path })
- });
- return await r.json();
- } catch {
- return null;
- }
- }
- /* 文件类型的 DSN 右侧挂个小标签: 「将新建」「已存在」「找不到」一眼可见 */
- const checkTimers = new WeakMap();
- function setBadge(el, text, cls) {
- el.textContent = text;
- el.className = 'dsnBadge ' + cls;
- el.hidden = false;
- }
- function scheduleFileCheck(conn) {
- const badge = conn.querySelector('.dsnBadge');
- const type = conn.querySelector('.dbType').value;
- const path = conn.querySelector('.fPath').value.trim();
- clearTimeout(checkTimers.get(conn));
- if (type === 'mysql' || !path) { badge.hidden = true; return; }
- checkTimers.set(conn, setTimeout(async () => {
- const st = await statPath(path);
- if (!st || st.error) { badge.hidden = true; return; }
- if (!st.parentExists) setBadge(badge, '父目录不存在', 'bad');
- else if (st.isDir) setBadge(badge, '是个目录', 'bad');
- else if (st.exists) setBadge(badge, '已存在', 'has');
- else if (roleOf(conn) === 'target') setBadge(badge, '将新建', 'new');
- else setBadge(badge, '找不到', 'bad');
- }, 350));
- }
- function syncConn(conn) {
- const type = conn.querySelector('.dbType').value;
- const isMySQL = type === 'mysql';
- conn.querySelectorAll('.onlyFile').forEach((e) => (e.hidden = isMySQL));
- conn.querySelectorAll('.onlyMySQL').forEach((e) => (e.hidden = !isMySQL));
- const dsn = buildDSN(conn);
- conn.querySelector('.dsnOut').textContent = dsn || '—';
- scheduleFileCheck(conn);
- }
- function initConn(conn) {
- conn.querySelectorAll('input, select').forEach((el) => {
- el.addEventListener('input', () => syncConn(conn));
- el.addEventListener('change', () => syncConn(conn));
- });
- conn.querySelector('.browseBtn').onclick = () => openBrowser(conn.querySelector('.fPath'));
- conn.querySelector('.dbListBtn').onclick = () => loadDatabases(conn);
- syncConn(conn);
- }
- document.querySelectorAll('.conn').forEach(initConn);
- /* ---------------- 文件浏览 / 新建弹窗 ---------------- */
- let browseTarget = null;
- let browseRole = 'source';
- let browseType = 'vrtx';
- let browseState = { path: '', parent: '', root: false };
- let browsePick = null;
- let browseSuggest = false;
- // 目标库还没填路径时, 拿源库的文件名换个后缀当建议名
- function suggestName() {
- const ext = EXT_BY_TYPE[browseType] || '';
- const srcPath = $('connFrom').querySelector('.fPath').value.trim();
- let stem = 'newdb';
- if (srcPath) {
- const file = basename(srcPath);
- const dot = file.lastIndexOf('.');
- const s = dot > 0 ? file.slice(0, dot) : file;
- const srcExt = dot > 0 ? file.slice(dot).toLowerCase() : '';
- if (s) stem = s + (srcExt === ext ? '_copy' : '');
- }
- return stem + ext;
- }
- function openBrowser(input) {
- const conn = connOf(input);
- browseTarget = input;
- browseRole = roleOf(conn);
- browseType = conn.querySelector('.dbType').value;
- browsePick = null;
- const isTarget = browseRole === 'target';
- const ext = EXT_BY_TYPE[browseType] || '';
- $('modalTitle').textContent = isTarget ? '选择或新建目标库文件' : '选择源库文件';
- $('modalName').placeholder = isTarget ? `输入文件名即可新建,例如 app${ext}` : '文件名';
- $('modalName').value = '';
- $('modalTip').textContent = '';
- $('modalTip').className = 'modalTip';
- browseSuggest = isTarget;
- $('modal').hidden = false;
- const cur = input.value.trim();
- browse(cur ? { path: cur } : {}); // 有路径就从它所在目录开始, 否则从服务进程当前目录
- }
- function closeBrowser() {
- $('modal').hidden = true;
- browseTarget = null;
- browsePick = null;
- browseSuggest = false;
- clearTimeout(tipTimer);
- }
- async function browse(req) {
- const list = $('modalList');
- list.innerHTML = '<div class="modalEmpty">读取中…</div>';
- try {
- const resp = await fetch('/api/browse', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(req)
- });
- const data = await resp.json();
- if (data.error) {
- list.innerHTML = `<div class="modalEmpty">${esc(data.error)}</div>`;
- return;
- }
- browseState = data;
- browsePick = null;
- $('modalPath').value = data.path;
- $('modalUp').disabled = !!data.root;
- if (browseSuggest) { // 目标库首次列目录时塞一个建议名
- $('modalName').value = suggestName();
- browseSuggest = false;
- }
- renderBrowser(data);
- updateModalTip();
- } catch (e) {
- list.innerHTML = `<div class="modalEmpty">请求失败:${esc(String(e))}</div>`;
- }
- }
- function renderBrowser(data) {
- const list = $('modalList');
- list.innerHTML = '';
- const showAll = $('modalAll').checked;
- const rows = [];
- (data.dirs || []).forEach((d) => rows.push({ ...d, kind: 'dir' }));
- (data.files || []).forEach((f) => {
- if (!showAll && !DB_EXTS.includes(f.ext)) return;
- rows.push({ ...f, kind: 'file' });
- });
- if (!rows.length) {
- list.innerHTML = '<div class="modalEmpty">这个目录里没有可选项</div>';
- return;
- }
- rows.forEach((r) => {
- const div = document.createElement('div');
- div.className = 'item ' + r.kind;
- const ico = document.createElement('span');
- ico.className = 'ico ' + (r.kind === 'dir' ? 'dir' : 'file');
- const nm = document.createElement('span');
- nm.className = 'nm';
- nm.textContent = r.name;
- div.append(ico, nm);
- if (r.kind === 'file') {
- const sz = document.createElement('span');
- sz.className = 'sz';
- sz.textContent = fmtSize(r.size);
- div.appendChild(sz);
- }
- div.onclick = () => {
- if (r.kind === 'dir') { browse({ path: r.path }); return; }
- browsePick = r.path;
- $('modalName').value = r.name;
- [...list.children].forEach((c) => c.classList.remove('sel'));
- div.classList.add('sel');
- updateModalTip();
- };
- div.ondblclick = () => {
- if (r.kind === 'file') pickPath(r.path);
- };
- list.appendChild(div);
- });
- }
- function pickPath(full) {
- if (!browseTarget) return;
- const conn = connOf(browseTarget);
- // 目标库没写后缀就补一个, 否则生成的文件引擎可能不认
- if (roleOf(conn) === 'target') {
- const ext = EXT_BY_TYPE[conn.querySelector('.dbType').value];
- if (ext && !basename(full).includes('.')) full += ext;
- }
- browseTarget.value = full;
- syncConn(conn);
- closeBrowser();
- }
- function joinPath(dir, name) {
- if (!dir) return name;
- const sep = dir.includes('\\') ? '\\' : '/';
- return dir.replace(/[\\/]+$/, '') + sep + name;
- }
- function fmtSize(n) {
- if (typeof n !== 'number') return '';
- const unit = ['B', 'KB', 'MB', 'GB', 'TB'];
- let i = 0, v = n;
- while (v >= 1024 && i < unit.length - 1) { v /= 1024; i++; }
- return (i === 0 ? v : v.toFixed(1)) + ' ' + unit[i];
- }
- /* ---------------- 弹窗底部的路径状态提示 ---------------- */
- let tipTimer = null;
- let tipSeq = 0;
- function setModalTip(text, cls) {
- const tip = $('modalTip');
- tip.textContent = text;
- tip.className = 'modalTip' + (cls ? ' ' + cls : '');
- }
- function updateModalTip() {
- clearTimeout(tipTimer);
- tipTimer = setTimeout(showModalTip, 220);
- }
- async function showModalTip() {
- const seq = ++tipSeq;
- const name = $('modalName').value.trim();
- if (!name) { setModalTip('', ''); return; }
- const full = /[\\/]/.test(name) ? name : joinPath(browseState.path, name);
- const st = await statPath(full);
- if (seq !== tipSeq) return; // 用户又敲了新内容, 丢弃这次结果
- if (!st || st.error) { setModalTip('', ''); return; }
- if (!st.parentExists) setModalTip('父目录不存在:' + st.parent, 'bad');
- else if (st.isDir) setModalTip('这是个目录,请填文件名', 'bad');
- else if (st.exists) setModalTip('已存在,会写进这个文件(同名表按上面的选项处理)', '');
- else if (browseRole === 'target') setModalTip('还不存在,迁移时会在这里新建:' + full, 'new');
- else setModalTip('文件不存在,源库要选一个已有的库文件', 'bad');
- }
- $('modalClose').onclick = closeBrowser;
- $('modalUp').onclick = () => browse({ path: browseState.parent });
- $('modalGo').onclick = () => browse({ path: $('modalPath').value.trim() });
- $('modalAll').onchange = () => renderBrowser(browseState);
- $('modalName').oninput = updateModalTip;
- $('modalName').onkeydown = (e) => { if (e.key === 'Enter') $('modalOk').click(); };
- $('modalPath').onkeydown = (e) => { if (e.key === 'Enter') browse({ path: $('modalPath').value.trim() }); };
- $('modalOk').onclick = () => {
- const name = $('modalName').value.trim();
- if (!name) return;
- // 名字没改、且列表里选中了文件 → 直接用选中的完整路径
- if (browsePick && !/[\\/]/.test(name) && name === basename(browsePick)) {
- pickPath(browsePick);
- return;
- }
- // 带分隔符就当作完整路径, 否则拼到当前目录下
- pickPath(/[\\/]/.test(name) ? name : joinPath(browseState.path, name));
- };
- /* 点遮罩空白处可以关弹窗, 但要求「按下」和「松开」都落在遮罩上、且基本没移动。
- 否则在弹窗里拖拽(选中文字、拖滚动条)时手滑出边界, 松手就会把弹窗弄没。*/
- let backdropDown = null;
- $('modal').addEventListener('mousedown', (e) => {
- backdropDown = e.target === $('modal') ? { x: e.clientX, y: e.clientY } : null;
- });
- $('modal').addEventListener('mouseup', (e) => {
- const down = backdropDown;
- backdropDown = null;
- if (!down || e.target !== $('modal')) return; // 起点不在遮罩 → 不是在点空白
- if (Math.abs(e.clientX - down.x) > 6) return; // 拖拽过 → 不是点击
- if (Math.abs(e.clientY - down.y) > 6) return;
- closeBrowser();
- });
- document.addEventListener('keydown', (e) => {
- if (e.key !== 'Escape' || $('modal').hidden) return;
- // 正在输文件名时, ESC 先把输入清掉, 别一下子把整个选择都关掉
- const nameEl = $('modalName');
- if (document.activeElement === nameEl && nameEl.value.trim()) {
- nameEl.value = '';
- updateModalTip();
- return;
- }
- closeBrowser();
- });
- </script>
- </body>
- </html>
|