Files
2meet-data-optimizer/admin/assets/wpdo-migration-wizard.js
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

315 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WP Data Optimizer — One-click User Migration Wizard frontend.
*
* - Confirms options + checkbox before starting.
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
* - Polls /status every 500ms while job is active.
* - Streams log lines with fade-in; live-updates ratio + progress bar.
*
* @since 2.8.0
*/
(function () {
'use strict';
const POLL_INTERVAL_MS = 500;
const config = window.wpdoMigrationWizard || {};
const i18n = config.i18n || {};
const restUrl = (config.restUrl || '').replace(/\/$/, '');
const nonce = config.nonce || '';
const $ = (id) => document.getElementById(id);
const root = document.querySelector('.wpdo-migration-wizard');
if (!root) return;
const els = {
runPanel: $('wpdo-mw-run-panel'),
progressPanel: $('wpdo-mw-progress-panel'),
donePanel: $('wpdo-mw-done-panel'),
startBtn: $('wpdo-mw-start'),
cancelBtn: $('wpdo-mw-cancel'),
resumeBtn: $('wpdo-mw-resume'),
resetBtn: $('wpdo-mw-reset'),
confirmBox: $('wpdo-mw-confirm-backup'),
optBackup: $('wpdo-mw-opt-backup'),
optStrict: $('wpdo-mw-opt-strict'),
opt24h: $('wpdo-mw-opt-24h'),
optAsync: $('wpdo-mw-opt-async'),
optDryRun: $('wpdo-mw-opt-dryrun'),
progressFill: $('wpdo-mw-progress-fill'),
progressPct: $('wpdo-mw-progress-pct'),
progressTitle: $('wpdo-mw-progress-title'),
phaseName: $('wpdo-mw-current-phase-name'),
elapsed: $('wpdo-mw-elapsed'),
liveRatio: $('wpdo-mw-live-ratio-text'),
log: $('wpdo-mw-log'),
ratio: $('wpdo-mw-ratio'),
residue: $('wpdo-mw-residue'),
usermeta: $('wpdo-mw-usermeta'),
doneSummary: $('wpdo-mw-done-summary'),
};
let pollTimer = null;
let startedAt = 0;
let elapsedTimer = null;
function show(panel) {
[els.runPanel, els.progressPanel, els.donePanel].forEach((p) => {
if (!p) return;
if (p === panel) {
p.removeAttribute('hidden');
} else {
p.setAttribute('hidden', '');
}
});
}
function fetchJson(path, options = {}) {
const url = restUrl + path;
const opts = Object.assign(
{
method: 'GET',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
},
options
);
return fetch(url, opts).then(async (r) => {
let body;
try {
body = await r.json();
} catch (e) {
body = null;
}
return { ok: r.ok, status: r.status, body };
});
}
// ── Start handling ───────────────────────────────────────────────────
if (els.confirmBox && els.startBtn) {
els.confirmBox.addEventListener('change', () => {
els.startBtn.disabled = !els.confirmBox.checked;
});
}
if (els.startBtn) {
els.startBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmStart || 'Start migration?')) return;
els.startBtn.disabled = true;
els.startBtn.textContent = '…';
const opts = {
auto_backup: els.optBackup ? !!els.optBackup.checked : true,
verify_strict: els.optStrict ? !!els.optStrict.checked : true,
verify_24h: els.opt24h ? !!els.opt24h.checked : false,
force_async: els.optAsync ? !!els.optAsync.checked : false,
dry_run: els.optDryRun ? !!els.optDryRun.checked : false,
};
fetchJson('/migration/start', {
method: 'POST',
body: JSON.stringify(opts),
}).then((res) => {
if (!res.ok) {
if (res.body && res.body.reason === 'nothing_to_do') {
alert(i18n.nothingToDo);
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
alert((res.body && res.body.error) || 'Start failed');
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
});
});
}
// ── Cancel & Resume ──────────────────────────────────────────────────
if (els.cancelBtn) {
els.cancelBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmCancel || 'Cancel?')) return;
els.cancelBtn.disabled = true;
fetchJson('/migration/cancel', { method: 'POST' }).then(() => {
stopPolling();
stopElapsedTimer();
setTimeout(() => location.reload(), 600);
});
});
}
if (els.resumeBtn) {
els.resumeBtn.addEventListener('click', () => {
els.resumeBtn.disabled = true;
fetchJson('/migration/resume', { method: 'POST' }).then((res) => {
if (res.ok) {
els.resumeBtn.setAttribute('hidden', '');
els.cancelBtn.disabled = false;
startPolling();
} else {
alert((res.body && res.body.error) || 'Resume failed');
els.resumeBtn.disabled = false;
}
});
});
}
if (els.resetBtn) {
els.resetBtn.addEventListener('click', () => location.reload());
}
// ── Polling ──────────────────────────────────────────────────────────
function startPolling() {
if (pollTimer) return;
const tick = () => {
fetchJson('/migration/status').then((res) => {
if (!res.ok || !res.body) {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
return;
}
renderStatus(res.body);
const state = res.body.state;
if (state === 'running') {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
} else if (state === 'completed') {
stopPolling();
stopElapsedTimer();
renderDone(res.body);
} else if (state === 'failed') {
stopPolling();
stopElapsedTimer();
renderFailed(res.body);
} else if (state === 'cancelled' || state === 'idle') {
stopPolling();
stopElapsedTimer();
}
});
};
tick();
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
function startElapsedTimer() {
if (elapsedTimer) return;
elapsedTimer = setInterval(() => {
if (!els.elapsed) return;
const sec = (Date.now() - startedAt) / 1000;
els.elapsed.textContent = sec.toFixed(1) + 's';
}, 100);
}
function stopElapsedTimer() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
}
// ── Render ───────────────────────────────────────────────────────────
function renderStatus(s) {
if (els.progressFill) {
els.progressFill.style.width = (s.overall_progress || 0) + '%';
}
if (els.progressPct) {
els.progressPct.textContent = (s.overall_progress || 0) + '%';
}
if (els.phaseName) {
els.phaseName.textContent = s.phase || '';
}
if (els.liveRatio && s.metrics) {
els.liveRatio.textContent =
'1:' + (s.metrics.ratio_start || '?') +
' → 1:' + (s.metrics.ratio_now || '?');
}
// Update top metrics live too
if (els.ratio && s.metrics && s.metrics.ratio_now != null) {
els.ratio.textContent = '1:' + s.metrics.ratio_now;
}
if (els.residue && s.metrics && s.metrics.eav_rows_now != null) {
els.residue.textContent = String(s.metrics.eav_rows_now);
}
updateLog(s.log || []);
}
let lastLogLength = 0;
function updateLog(lines) {
if (!els.log) return;
if (lines.length === lastLogLength) return;
els.log.textContent = lines.join('\n');
els.log.scrollTop = els.log.scrollHeight;
lastLogLength = lines.length;
// Apply a subtle highlight on the last line via CSS animation
els.log.classList.remove('wpdo-mw-log-flash');
// Force reflow so re-adding the class re-triggers the animation
// eslint-disable-next-line no-unused-expressions
void els.log.offsetWidth;
els.log.classList.add('wpdo-mw-log-flash');
}
function renderDone(s) {
show(els.donePanel);
if (!els.doneSummary) return;
const m = s.metrics || {};
const elapsed = ((s.completed_at || 0) - (s.started_at || 0));
els.doneSummary.innerHTML = '';
const lines = [
['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')],
['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)],
['mode', (m.mode_start || '?') + ' → aeav_only'],
['耗時', elapsed + ' 秒'],
['備份', s.backup_path || '(無)'],
];
lines.forEach(([label, value]) => {
const div = document.createElement('div');
div.className = 'wpdo-mw-done-line';
const lab = document.createElement('span'); lab.textContent = label + '';
const val = document.createElement('strong'); val.textContent = value;
div.append(lab, val);
els.doneSummary.appendChild(div);
});
}
function renderFailed(s) {
els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel');
els.progressTitle.classList.add('wpdo-mw-failed');
if (els.resumeBtn) {
els.resumeBtn.removeAttribute('hidden');
els.resumeBtn.disabled = false;
}
}
// ── On-load: if a job is already running, hop into progress mode ────
const initialState = root.getAttribute('data-state');
if (initialState === 'running' || initialState === 'paused') {
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
} else if (initialState === 'failed') {
show(els.progressPanel);
renderFailed({ state: 'failed' });
} else if (initialState === 'completed') {
// Already shown via PHP hidden flag — but populate summary if available
fetchJson('/migration/status').then((res) => {
if (res.ok && res.body && res.body.state === 'completed') {
renderDone(res.body);
}
});
}
})();