PDF / src /web /static /dashboard.js
BirkhoffLee's picture
fix: 完成了第三阶段的修复与加强
757f620 unverified
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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);
}
}
// 任务状态缓存:在前端维护一个简单的内存表,方便 SSE/轮询统一渲染
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);