| async function apiJson(url, options = undefined) { |
| const resp = await fetch(url, options); |
| if (!resp.ok) { |
| const data = await resp.text(); |
| throw new Error(data || `HTTP ${resp.status}`); |
| } |
| return resp.json(); |
| } |
|
|
| function esc(s) { |
| return String(s || "").replace(/[&<>"']/g, (c) => ({ |
| "&": "&", |
| "<": "<", |
| ">": ">", |
| '"': """, |
| "'": "'", |
| })[c]); |
| } |
|
|
| async function refreshBilling() { |
| const summary = await apiJson("/api/billing/me"); |
| const rows = await apiJson("/api/billing/me/records?limit=20"); |
|
|
| document.getElementById("billingSummary").textContent = |
| `总 tokens=${summary.total_tokens} | 总费用(USD)=${Number( |
| summary.total_cost_usd, |
| ).toFixed(6)}(仅统计计费模型,不含 SiliconFlowFree 等免费模型)`; |
|
|
| const body = document.getElementById("billingBody"); |
| body.innerHTML = ""; |
| for (const r of rows.records) { |
| const cost = Number(r.cost_usd).toFixed(6); |
| const costLabel = cost === "0.000000" ? `${cost}(不计费模型)` : cost; |
|
|
| const tr = document.createElement("tr"); |
| tr.innerHTML = ` |
| <td>${esc(r.created_at)}</td> |
| <td class="mono">${esc(r.model)}</td> |
| <td>${r.prompt_tokens}</td> |
| <td>${r.completion_tokens}</td> |
| <td>${r.total_tokens}</td> |
| <td>${costLabel}</td> |
| `; |
| body.appendChild(tr); |
| } |
| } |
|
|
| |
| const jobsState = new Map(); |
|
|
| function actionButtons(job) { |
| const actions = []; |
| if (job.status === "queued" || job.status === "running") { |
| actions.push( |
| `<button class="danger" onclick="cancelJob('${job.id}')">取消</button>`, |
| ); |
| } |
| if (job.artifact_urls?.mono) { |
| actions.push( |
| `<a href="${job.artifact_urls.mono}"><button class="muted">单语版</button></a>`, |
| ); |
| } |
| if (job.artifact_urls?.dual) { |
| actions.push( |
| `<a href="${job.artifact_urls.dual}"><button class="muted">双语版</button></a>`, |
| ); |
| } |
| if (job.artifact_urls?.glossary) { |
| actions.push( |
| `<a href="${job.artifact_urls.glossary}"><button class="muted">术语表</button></a>`, |
| ); |
| } |
| return actions.join(" "); |
| } |
|
|
| function statusText(status) { |
| const statusMap = { |
| queued: "排队中", |
| running: "进行中", |
| succeeded: "成功", |
| failed: "失败", |
| cancelled: "已取消", |
| }; |
| return statusMap[status] || status; |
| } |
|
|
| function renderJobsFromState() { |
| const body = document.getElementById("jobsBody"); |
| body.innerHTML = ""; |
|
|
| const jobs = Array.from(jobsState.values()); |
| jobs.sort((a, b) => |
| (b.created_at || "").localeCompare(a.created_at || ""), |
| ); |
|
|
| for (const job of jobs) { |
| const tr = document.createElement("tr"); |
| tr.innerHTML = ` |
| <td class="mono">${esc(job.id)}</td> |
| <td>${esc(job.filename)}</td> |
| <td>${esc(statusText(job.status))}${job.error ? " / " + esc(job.error) : ""}</td> |
| <td>${Number(job.progress).toFixed(1)}%</td> |
| <td class="mono">${esc(job.model)}</td> |
| <td class="mono">${esc(job.updated_at)}</td> |
| <td class="actions">${actionButtons(job)}</td> |
| `; |
| body.appendChild(tr); |
| } |
| } |
|
|
| function upsertJob(jobPatch) { |
| const existing = jobsState.get(jobPatch.id) || {}; |
| jobsState.set(jobPatch.id, { ...existing, ...jobPatch }); |
| renderJobsFromState(); |
| } |
|
|
| async function refreshJobs() { |
| const data = await apiJson("/api/jobs?limit=50"); |
| jobsState.clear(); |
| for (const job of data.jobs) { |
| jobsState.set(job.id, job); |
| } |
| renderJobsFromState(); |
| } |
|
|
| async function cancelJob(jobId) { |
| try { |
| await apiJson(`/api/jobs/${jobId}/cancel`, { method: "POST" }); |
| await refreshJobs(); |
| } catch (err) { |
| alert(`取消失败: ${err.message}`); |
| } |
| } |
|
|
| document.getElementById("jobForm").addEventListener("submit", async (event) => { |
| event.preventDefault(); |
| const status = document.getElementById("jobStatus"); |
| status.textContent = "提交中..."; |
|
|
| const formData = new FormData(event.target); |
| try { |
| const created = await apiJson("/api/jobs", { method: "POST", body: formData }); |
| status.textContent = `任务已入队: ${created.job.id}`; |
| event.target.reset(); |
| await refreshJobs(); |
| } catch (err) { |
| status.textContent = `提交失败: ${err.message}`; |
| } |
| }); |
|
|
| async function refreshAll() { |
| await Promise.all([refreshJobs(), refreshBilling()]); |
| } |
|
|
| let jobEventSource = null; |
| let pollingEnabled = true; |
| const POLL_INTERVAL_MS = 10000; |
|
|
| function setupJobStream() { |
| if (!("EventSource" in window)) { |
| console.warn("EventSource not supported, fallback to polling"); |
| pollingEnabled = true; |
| return; |
| } |
|
|
| jobEventSource = new EventSource("/api/jobs/stream"); |
|
|
| jobEventSource.onmessage = (event) => { |
| try { |
| const payload = JSON.parse(event.data); |
| if (!payload || !payload.id) { |
| return; |
| } |
| upsertJob(payload); |
| } catch (err) { |
| console.error("Failed to parse job SSE payload:", err); |
| } |
| }; |
|
|
| jobEventSource.onerror = () => { |
| console.error("Job SSE error, switching back to polling"); |
| if (jobEventSource) { |
| jobEventSource.close(); |
| jobEventSource = null; |
| } |
| pollingEnabled = true; |
| }; |
|
|
| pollingEnabled = false; |
| } |
|
|
| refreshAll(); |
| setupJobStream(); |
|
|
| setInterval(async () => { |
| if (document.hidden) { |
| |
| return; |
| } |
|
|
| if (pollingEnabled) { |
| await refreshAll(); |
| } else { |
| await refreshBilling(); |
| } |
| }, POLL_INTERVAL_MS); |
|
|