Site updated: 2026-05-13 16:50:34

This commit is contained in:
llbzow
2026-05-13 16:50:38 +08:00
parent 8f2e89e58c
commit 54f0d09b31
361 changed files with 204078 additions and 0 deletions

605
js/tools/converter.js Normal file
View File

@@ -0,0 +1,605 @@
(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();
}
})();