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 = `
${esc(r.created_at)} |
${esc(r.model)} |
${r.prompt_tokens} |
${r.completion_tokens} |
${r.total_tokens} |
${costLabel} |
`;
body.appendChild(tr);
}
}
// 任务状态缓存:在前端维护一个简单的内存表,方便 SSE/轮询统一渲染
const jobsState = new Map();
function actionButtons(job) {
const actions = [];
if (job.status === "queued" || job.status === "running") {
actions.push(
``,
);
}
if (job.artifact_urls?.mono) {
actions.push(
``,
);
}
if (job.artifact_urls?.dual) {
actions.push(
``,
);
}
if (job.artifact_urls?.glossary) {
actions.push(
``,
);
}
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 = `
${esc(job.id)} |
${esc(job.filename)} |
${esc(statusText(job.status))}${job.error ? " / " + esc(job.error) : ""} |
${Number(job.progress).toFixed(1)}% |
${esc(job.model)} |
${esc(job.updated_at)} |
${actionButtons(job)} |
`;
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);