606 lines
22 KiB
JavaScript
606 lines
22 KiB
JavaScript
(function () {
|
||
const DEFAULT_BASE_URL = 'https://doc.luozili.work';
|
||
const DEFAULT_API_KEY = '';
|
||
const POLL_INTERVAL_MS = 3000;
|
||
const MAX_POLL_ROUNDS = 120;
|
||
const MAX_CONCURRENCY = 2;
|
||
const DEBUG_PREFIX = '[ConvertX-Debug]';
|
||
const DEBUG_LOG_LIMIT = 200;
|
||
const ASSET_STORAGE_KEY = 'convertx_asset_center_v1';
|
||
|
||
const FALLBACK_CONVERTER_MAP = {
|
||
jpg: 'imagemagick', png: 'imagemagick', webp: 'imagemagick',
|
||
pdf: 'libreoffice', docx: 'libreoffice', csv: 'dasel',
|
||
xlsx: 'libreoffice', md: 'markitDown', txt: 'pandoc',
|
||
gif: 'ffmpeg', mp4: 'ffmpeg', mp3: 'ffmpeg'
|
||
};
|
||
|
||
const Bus = {
|
||
listeners: {},
|
||
on(event, handler) {
|
||
if (!this.listeners[event]) this.listeners[event] = [];
|
||
this.listeners[event].push(handler);
|
||
},
|
||
emit(event, payload) {
|
||
(this.listeners[event] || []).forEach((fn) => {
|
||
try { fn(payload); } catch (e) { console.warn(e); }
|
||
});
|
||
}
|
||
};
|
||
|
||
const Store = {
|
||
state: {
|
||
files: [],
|
||
targetFormat: null,
|
||
tasks: {},
|
||
running: 0,
|
||
converterCapabilities: {}
|
||
},
|
||
setFiles(files) {
|
||
this.state.files = files;
|
||
Bus.emit('files:changed', files);
|
||
},
|
||
setTargetFormat(fmt) {
|
||
this.state.targetFormat = fmt;
|
||
Bus.emit('target:changed', fmt);
|
||
},
|
||
upsertTask(task) {
|
||
this.state.tasks[task.id] = { ...(this.state.tasks[task.id] || {}), ...task };
|
||
Bus.emit('task:updated', this.state.tasks[task.id]);
|
||
},
|
||
getTask(id) {
|
||
return this.state.tasks[id] || null;
|
||
},
|
||
getTasks() {
|
||
return Object.values(this.state.tasks).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
},
|
||
clearTasks() {
|
||
this.state.tasks = {};
|
||
Bus.emit('queue:reset');
|
||
}
|
||
};
|
||
|
||
const Api = {
|
||
getBaseUrl() {
|
||
const val = (document.getElementById('convertx-base-url')?.value || '').trim();
|
||
return (val || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||
},
|
||
getApiKey() {
|
||
const val = (document.getElementById('api-key')?.value || '').trim();
|
||
return val || DEFAULT_API_KEY;
|
||
},
|
||
getAuthMode(apiKey) {
|
||
return apiKey ? 'X-API-Key + Cookie' : 'Cookie';
|
||
},
|
||
async request(path, options = {}) {
|
||
const baseUrl = this.getBaseUrl();
|
||
const apiKey = this.getApiKey();
|
||
const url = `${baseUrl}${path}`;
|
||
const pageOrigin = window.location.origin;
|
||
let credentialsMode = 'include';
|
||
let sameOrigin = true;
|
||
try {
|
||
sameOrigin = new URL(url, window.location.href).origin === window.location.origin;
|
||
credentialsMode = sameOrigin ? 'include' : 'omit';
|
||
} catch (_) {
|
||
credentialsMode = 'include';
|
||
sameOrigin = true;
|
||
}
|
||
const traceId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||
const headers = { ...(options.headers || {}) };
|
||
const useApiKey = options.useApiKey !== false;
|
||
if (useApiKey && apiKey) headers['X-API-Key'] = apiKey;
|
||
const debugMeta = options.debugMeta || null;
|
||
|
||
const parseResponse = async (res, traceIdForLog, requestUrl) => {
|
||
const text = await res.text();
|
||
debugLog('api.response', { traceId: traceIdForLog, status: res.status, url: requestUrl, body_preview: (text || '').slice(0, 300) });
|
||
let data = null;
|
||
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
|
||
return { ok: res.ok, status: res.status, text, data, headers: res.headers, traceId: traceIdForLog };
|
||
};
|
||
|
||
debugLog('api.request', {
|
||
traceId,
|
||
url,
|
||
method: options.method || 'GET',
|
||
auth: this.getAuthMode(useApiKey ? apiKey : ''),
|
||
page_origin: pageOrigin,
|
||
online: navigator.onLine,
|
||
has_custom_headers: Object.keys(headers).length > 0,
|
||
use_api_key: useApiKey,
|
||
credentials_mode: credentialsMode,
|
||
same_origin: sameOrigin,
|
||
debug_meta: debugMeta
|
||
});
|
||
|
||
let res;
|
||
try {
|
||
res = await fetch(url, { ...options, headers, credentials: credentialsMode });
|
||
} catch (err) {
|
||
debugLog('api.network_error', {
|
||
traceId,
|
||
url,
|
||
page_origin: pageOrigin,
|
||
error_name: err?.name || 'UnknownError',
|
||
error_message: err?.message || 'Failed to fetch',
|
||
online: navigator.onLine,
|
||
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto'),
|
||
credentials_mode: credentialsMode,
|
||
same_origin: sameOrigin,
|
||
debug_meta: debugMeta,
|
||
curl_probe: {
|
||
options: `curl -i -X OPTIONS "${url}" -H "Origin: ${pageOrigin}" -H "Access-Control-Request-Method: ${options.method || 'POST'}" -H "Access-Control-Request-Headers: x-api-key,content-type"`,
|
||
post_no_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}"`,
|
||
post_with_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}" -H "X-API-Key: <api_key>"`
|
||
}
|
||
});
|
||
if (url.includes('/convert')) {
|
||
throw new Error('网络层失败:浏览器未拿到可用的跨域响应(非业务 4xx/5xx)。请检查 POST /convert 是否返回 Access-Control-Allow-Origin,以及 OPTIONS/POST 的 CORS 头是否一致,并确认放行 X-API-Key');
|
||
}
|
||
throw new Error('网络层失败(可能是 CSP connect-src / 跨域预检 / 浏览器安全策略),请检查浏览器 Network 与响应头');
|
||
}
|
||
|
||
return await parseResponse(res, traceId, url);
|
||
}
|
||
};
|
||
|
||
function createTaskFromFile(file) {
|
||
const id = Date.now() + Math.floor(Math.random() * 100000);
|
||
const manualConverter = (document.getElementById('converter-name')?.value || '').trim();
|
||
const converter = manualConverter || getRecommendedConverter(Store.state.targetFormat) || FALLBACK_CONVERTER_MAP[Store.state.targetFormat] || '';
|
||
return {
|
||
id,
|
||
createdAt: Date.now(),
|
||
file,
|
||
filename: file.name,
|
||
targetFormat: Store.state.targetFormat,
|
||
converter,
|
||
status: 'pending',
|
||
attempts: 0,
|
||
message: '等待中...'
|
||
};
|
||
}
|
||
|
||
async function runScheduler() {
|
||
const pending = Store.getTasks().filter((t) => t.status === 'pending');
|
||
if (!pending.length) return;
|
||
|
||
while (Store.state.running < MAX_CONCURRENCY && pending.length) {
|
||
const task = pending.shift();
|
||
Store.state.running += 1;
|
||
processTask(task.id).finally(() => {
|
||
Store.state.running -= 1;
|
||
runScheduler();
|
||
});
|
||
}
|
||
}
|
||
|
||
async function processTask(taskId) {
|
||
const task = Store.getTask(taskId);
|
||
if (!task) return;
|
||
|
||
Store.upsertTask({ id: taskId, status: 'running', message: '准备提交任务...', attempts: (task.attempts || 0) + 1 });
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', task.file);
|
||
formData.append('target_format', task.targetFormat);
|
||
if (task.converter) formData.append('converter', task.converter);
|
||
|
||
debugLog('task.submit.meta', {
|
||
taskId,
|
||
filename: task.filename,
|
||
file_size: task.file?.size || 0,
|
||
file_size_bucket: (function () {
|
||
const size = task.file?.size || 0;
|
||
if (size < 100 * 1024) return '<100KB';
|
||
if (size < 1024 * 1024) return '100KB-1MB';
|
||
if (size < 10 * 1024 * 1024) return '1MB-10MB';
|
||
return '>=10MB';
|
||
})(),
|
||
target_format: task.targetFormat,
|
||
converter: task.converter || null,
|
||
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto')
|
||
});
|
||
|
||
let submit;
|
||
try {
|
||
submit = await Api.request('/convert', {
|
||
method: 'POST',
|
||
body: formData,
|
||
useApiKey: true,
|
||
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat }
|
||
});
|
||
} catch (firstErr) {
|
||
const msg = String(firstErr?.message || firstErr || '');
|
||
const looksLikeCors = /Failed to fetch|CORS|预检|跨域|X-API-Key/i.test(msg);
|
||
if (!looksLikeCors) throw firstErr;
|
||
|
||
debugLog('task.submit.fallback_without_api_key', {
|
||
taskId,
|
||
reason: msg,
|
||
hint: '首次带 X-API-Key 网络失败,降级为不带自定义头重试一次'
|
||
});
|
||
submit = await Api.request('/convert', {
|
||
method: 'POST',
|
||
body: formData,
|
||
useApiKey: false,
|
||
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat, retry_without_api_key: true }
|
||
});
|
||
}
|
||
if (!submit.ok) throw new Error(`提交失败(${submit.status}): ${(submit.text || '').slice(0, 200)}`);
|
||
|
||
const protocolMode = (document.getElementById('protocol-mode')?.value || 'auto').toLowerCase();
|
||
|
||
const isSyncPayload = Boolean(submit.data && (submit.data.status === 'completed' || Array.isArray(submit.data.files)));
|
||
if (protocolMode !== 'async' && isSyncPayload) {
|
||
persistAsset(task, submit.data, null);
|
||
Store.upsertTask({
|
||
id: taskId,
|
||
status: 'done',
|
||
message: '同步模式完成',
|
||
submitData: submit.data,
|
||
statusData: submit.data,
|
||
downloadData: { kind: 'json', data: submit.data }
|
||
});
|
||
return;
|
||
}
|
||
|
||
const jobId = submit.data?.job_id || submit.data?.jobId;
|
||
if (protocolMode === 'sync' && !isSyncPayload) {
|
||
throw new Error('当前协议模式为仅同步,但后端返回非同步结构');
|
||
}
|
||
if (!jobId) throw new Error('后端返回成功但无 job_id / files');
|
||
Store.upsertTask({ id: taskId, jobId, submitData: submit.data, message: `任务已提交: ${jobId}` });
|
||
|
||
for (let round = 1; round <= MAX_POLL_ROUNDS; round++) {
|
||
const st = await Api.request(`/job/${encodeURIComponent(jobId)}/status`, { method: 'GET' });
|
||
if (!st.ok) throw new Error(`状态查询失败(${st.status}): ${(st.text || '').slice(0, 160)}`);
|
||
|
||
if (st.data && st.data.success === false && /unauthorized/i.test(st.data.message || '')) {
|
||
throw new Error('状态接口鉴权失败(Unauthorized)');
|
||
}
|
||
|
||
const status = st.data?.status || 'processing';
|
||
Store.upsertTask({ id: taskId, statusData: st.data, message: `状态: ${status} (${round}/${MAX_POLL_ROUNDS})` });
|
||
|
||
if (status === 'completed') {
|
||
const dl = await fetchDownloadPayload(jobId);
|
||
persistAsset(Store.getTask(taskId) || task, st.data, dl);
|
||
Store.upsertTask({ id: taskId, status: 'done', message: '转换完成', downloadData: dl });
|
||
return;
|
||
}
|
||
|
||
if (status === 'failed' || status === 'timeout') throw new Error(`任务结束状态: ${status}`);
|
||
await wait(POLL_INTERVAL_MS);
|
||
}
|
||
|
||
throw new Error('轮询超时,请稍后重试');
|
||
} catch (err) {
|
||
Store.upsertTask({ id: taskId, status: 'error', message: err.message || '未知错误' });
|
||
}
|
||
}
|
||
|
||
async function fetchDownloadPayload(jobId) {
|
||
const res = await Api.request(`/job/${encodeURIComponent(jobId)}/download`, { method: 'GET' });
|
||
if (!res.ok) throw new Error(`下载接口失败(${res.status})`);
|
||
if (res.data) return { kind: 'json', data: res.data };
|
||
const blob = new Blob([res.text || ''], { type: 'text/plain' });
|
||
return { kind: 'blob', blob, filename: `convertx-${jobId}.txt` };
|
||
}
|
||
|
||
function initConverterTool() {
|
||
const container = document.getElementById('tool-converter-container');
|
||
if (!container) return;
|
||
|
||
const fileInput = document.getElementById('file-input');
|
||
const dropZone = container.querySelector('.drop-zone');
|
||
const baseUrlEl = document.getElementById('convertx-base-url');
|
||
const apiKeyEl = document.getElementById('api-key');
|
||
if (baseUrlEl && !baseUrlEl.value) baseUrlEl.value = DEFAULT_BASE_URL;
|
||
if (apiKeyEl && !apiKeyEl.value) apiKeyEl.value = DEFAULT_API_KEY;
|
||
|
||
fileInput.addEventListener('change', (e) => handleFileSelect(e.target.files));
|
||
|
||
dropZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropZone.style.background = '#eff6ff';
|
||
});
|
||
dropZone.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
dropZone.style.background = 'white';
|
||
});
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.style.background = 'white';
|
||
if (!e.dataTransfer.files.length) return;
|
||
fileInput.files = e.dataTransfer.files;
|
||
handleFileSelect(e.dataTransfer.files);
|
||
});
|
||
|
||
window.filterFormats = function (type) {
|
||
document.querySelectorAll('.format-tab').forEach((t) => t.classList.remove('active'));
|
||
if (window.event?.target) window.event.target.classList.add('active');
|
||
document.querySelectorAll('.format-item').forEach((item) => {
|
||
item.style.display = (type === 'all' || item.dataset.type === type) ? 'flex' : 'none';
|
||
});
|
||
};
|
||
|
||
window.selectFormat = function (fmt) {
|
||
if (!Store.state.files.length) {
|
||
alert('请先选择文件');
|
||
return;
|
||
}
|
||
Store.setTargetFormat(fmt);
|
||
document.getElementById('target-format-name').innerText = fmt.toUpperCase();
|
||
document.getElementById('action-bar').style.display = 'block';
|
||
suggestConverterForTarget(fmt);
|
||
document.querySelectorAll('.format-item').forEach((el) => el.classList.remove('selected'));
|
||
if (window.event?.currentTarget) window.event.currentTarget.classList.add('selected');
|
||
};
|
||
|
||
window.startConversion = startBatchConversion;
|
||
window.startBatchConversion = startBatchConversion;
|
||
window.retryFailedTasks = retryFailedTasks;
|
||
window.clearTaskQueue = clearTaskQueue;
|
||
window.downloadResult = downloadResult;
|
||
window.filterTaskView = renderAllTasks;
|
||
window.exportDiagnostics = exportDiagnostics;
|
||
window.clearDebugLogs = clearDebugLogs;
|
||
window.refreshAssetCenter = renderAssetCenter;
|
||
window.clearAssetCenter = clearAssetCenter;
|
||
window.checkBackendConnectivity = refreshBackendMeta;
|
||
|
||
Bus.on('task:updated', renderTaskRow);
|
||
Bus.on('queue:reset', renderQueueEmpty);
|
||
|
||
setBackendStatus('未检测后端(点击“手动检测后端”)', 'warn');
|
||
renderAssetCenter();
|
||
}
|
||
|
||
function handleFileSelect(fileList) {
|
||
const files = Array.from(fileList || []);
|
||
if (!files.length) return;
|
||
Store.setFiles(files);
|
||
|
||
const selectedFileName = document.getElementById('selected-file-name');
|
||
const uploadTitle = document.querySelector('.upload-section h3');
|
||
if (selectedFileName) selectedFileName.innerText = files[0].name;
|
||
if (uploadTitle) {
|
||
uploadTitle.innerText = files.length === 1
|
||
? `已选择: ${files[0].name}`
|
||
: `已选择 ${files.length} 个文件(首个: ${files[0].name})`;
|
||
}
|
||
}
|
||
|
||
async function startBatchConversion() {
|
||
if (!Store.state.files.length || !Store.state.targetFormat) return;
|
||
Store.state.files.forEach((file) => {
|
||
const task = createTaskFromFile(file);
|
||
Store.upsertTask(task);
|
||
});
|
||
runScheduler();
|
||
}
|
||
|
||
function retryFailedTasks() {
|
||
Store.getTasks().filter((t) => t.status === 'error').forEach((t) => {
|
||
Store.upsertTask({ id: t.id, status: 'pending', message: '重试排队中...' });
|
||
});
|
||
runScheduler();
|
||
}
|
||
|
||
function clearTaskQueue() {
|
||
Store.clearTasks();
|
||
}
|
||
|
||
async function downloadResult(id) {
|
||
const task = Store.getTask(id);
|
||
if (!task) return;
|
||
try {
|
||
const payload = task.downloadData || (task.jobId ? await fetchDownloadPayload(task.jobId) : { kind: 'json', data: task.submitData || {} });
|
||
if (payload.kind === 'blob') {
|
||
saveAs(payload.blob, payload.filename || `result-${id}.bin`);
|
||
} else {
|
||
const blob = new Blob([JSON.stringify(payload.data || {}, null, 2)], { type: 'application/json' });
|
||
saveAs(blob, `convertx-${id}-result.json`);
|
||
}
|
||
} catch (err) {
|
||
alert(`下载失败: ${err.message || err}`);
|
||
}
|
||
}
|
||
|
||
function renderTaskRow(task) {
|
||
const filter = (document.getElementById('task-filter')?.value || 'all');
|
||
|
||
const q = document.getElementById('conversion-queue');
|
||
if (!q) return;
|
||
if (q.children[0] && q.children[0].innerText.includes('暂无任务')) q.innerHTML = '';
|
||
|
||
let row = document.getElementById(`task-${task.id}`);
|
||
if (!row) {
|
||
row = document.createElement('div');
|
||
row.className = 'queue-item';
|
||
row.id = `task-${task.id}`;
|
||
q.prepend(row);
|
||
}
|
||
|
||
if (filter !== 'all' && task.status !== filter) {
|
||
row.style.display = 'none';
|
||
return;
|
||
}
|
||
row.style.display = 'flex';
|
||
|
||
const statusClass = task.status === 'done' ? 'status-done'
|
||
: task.status === 'error' ? 'status-error'
|
||
: task.status === 'running' ? 'status-processing'
|
||
: 'status-pending';
|
||
|
||
const action = task.status === 'done'
|
||
? `完成 <a href="#" onclick="downloadResult(${task.id});return false;">下载</a>`
|
||
: task.status === 'error'
|
||
? `失败: ${task.message || '未知错误'}`
|
||
: (task.message || '处理中...');
|
||
|
||
row.innerHTML = `<div><strong>${task.filename}</strong> <span style="color:#999">→ ${(task.targetFormat || '').toUpperCase()}</span> <span style="font-size:11px;color:#bbb">#${task.attempts || 0}</span></div><div class="queue-status ${statusClass}">${action}</div>`;
|
||
}
|
||
|
||
function renderAllTasks() {
|
||
const q = document.getElementById('conversion-queue');
|
||
if (!q) return;
|
||
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
|
||
Store.getTasks().forEach((task) => renderTaskRow(task));
|
||
}
|
||
|
||
function renderQueueEmpty() {
|
||
const q = document.getElementById('conversion-queue');
|
||
if (!q) return;
|
||
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
|
||
}
|
||
|
||
async function refreshBackendMeta() {
|
||
try {
|
||
const health = await Api.request('/health', { method: 'GET', useApiKey: false });
|
||
if (!health.ok) throw new Error(`health ${health.status}`);
|
||
|
||
const converters = await Api.request('/converters', { method: 'GET', useApiKey: false });
|
||
if (converters.ok && converters.data) {
|
||
Store.state.converterCapabilities = converters.data || {};
|
||
const names = Object.keys(converters.data || {});
|
||
setBackendStatus(`后端在线,已加载 ${names.length} 个转换器`, 'ok');
|
||
} else {
|
||
setBackendStatus('初始化告警:转换器列表不可用,仍可尝试手动转换', 'warn');
|
||
}
|
||
} catch (err) {
|
||
setBackendStatus(`初始化告警: ${err.message || err}(不阻断,可继续尝试转换)`, 'warn');
|
||
debugLog('init.non_blocking_warning', {
|
||
reason: String(err?.message || err),
|
||
hint: '请检查 CSP connect-src / CORS / 代理策略'
|
||
});
|
||
}
|
||
}
|
||
|
||
function getRecommendedConverter(targetFormat) {
|
||
const caps = Store.state.converterCapabilities || {};
|
||
const names = Object.keys(caps);
|
||
for (const name of names) {
|
||
const targets = caps[name]?.targets || [];
|
||
if (targets.includes(targetFormat)) return name;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function suggestConverterForTarget(targetFormat) {
|
||
const input = document.getElementById('converter-name');
|
||
if (!input || input.value.trim()) return;
|
||
const rec = getRecommendedConverter(targetFormat);
|
||
if (rec) input.value = rec;
|
||
}
|
||
|
||
function getAssetList() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(ASSET_STORAGE_KEY) || '[]');
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function setAssetList(list) {
|
||
localStorage.setItem(ASSET_STORAGE_KEY, JSON.stringify(list));
|
||
}
|
||
|
||
function persistAsset(task, statusData, downloadData) {
|
||
const list = getAssetList();
|
||
const item = {
|
||
id: task.id,
|
||
filename: task.filename,
|
||
targetFormat: task.targetFormat,
|
||
converter: task.converter,
|
||
jobId: task.jobId || null,
|
||
createdAt: Date.now(),
|
||
statusData: statusData || null,
|
||
hasDownloadBlob: Boolean(downloadData && downloadData.kind === 'blob')
|
||
};
|
||
list.unshift(item);
|
||
setAssetList(list.slice(0, 500));
|
||
renderAssetCenter();
|
||
}
|
||
|
||
function renderAssetCenter() {
|
||
const el = document.getElementById('asset-center-list');
|
||
if (!el) return;
|
||
const list = getAssetList();
|
||
if (!list.length) {
|
||
el.innerHTML = '暂无本地资产';
|
||
return;
|
||
}
|
||
el.innerHTML = list.slice(0, 50).map((x) => {
|
||
const t = new Date(x.createdAt || Date.now()).toLocaleString();
|
||
return `<div style="padding:6px 0; border-bottom:1px dashed #eee;"><strong>${x.filename}</strong> → ${String(x.targetFormat || '').toUpperCase()} <span style="color:#999; font-size:12px;">${t}</span></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clearAssetCenter() {
|
||
localStorage.removeItem(ASSET_STORAGE_KEY);
|
||
renderAssetCenter();
|
||
}
|
||
|
||
function exportDiagnostics() {
|
||
const payload = {
|
||
exportedAt: new Date().toISOString(),
|
||
baseUrl: Api.getBaseUrl(),
|
||
taskCount: Store.getTasks().length,
|
||
tasks: Store.getTasks(),
|
||
assets: getAssetList(),
|
||
logs: window.__convertxDebugLogs || []
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
saveAs(blob, `convertx-diagnostics-${Date.now()}.json`);
|
||
}
|
||
|
||
function clearDebugLogs() {
|
||
window.__convertxDebugLogs = [];
|
||
const panel = document.getElementById('debug-log-panel');
|
||
if (panel) panel.innerHTML = '<div style="color:#6b7280;">暂无日志</div>';
|
||
}
|
||
|
||
function setBackendStatus(text, type) {
|
||
const el = document.getElementById('backend-status');
|
||
if (!el) return;
|
||
const color = type === 'ok' ? '#16a34a' : type === 'warn' ? '#d97706' : '#ef4444';
|
||
el.style.color = color;
|
||
el.innerText = text;
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function debugLog(event, payload) {
|
||
try {
|
||
const entry = { ts: new Date().toISOString(), event, payload: payload || {} };
|
||
window.__convertxDebugLogs = window.__convertxDebugLogs || [];
|
||
window.__convertxDebugLogs.push(entry);
|
||
if (window.__convertxDebugLogs.length > DEBUG_LOG_LIMIT) window.__convertxDebugLogs.shift();
|
||
|
||
const panel = document.getElementById('debug-log-panel');
|
||
if (panel) {
|
||
if (panel.innerText.includes('暂无日志')) panel.innerHTML = '';
|
||
const line = document.createElement('div');
|
||
line.textContent = `[${entry.ts}] ${event} ${JSON.stringify(entry.payload)}`;
|
||
panel.appendChild(line);
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
console.log(`${DEBUG_PREFIX} ${event}`, payload || {});
|
||
} catch (_) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initConverterTool);
|
||
} else {
|
||
initConverterTool();
|
||
}
|
||
})();
|