(() => { "use strict"; const state = { csrf: "", maxUploadBytes: 25 * 1024 * 1024, selectedFile: null, uploadInFlight: false, uploadProgressTimer: null, uploadProgressValue: 0, companySourceFile: null, companySource: null, companySourceDraft: null, companySourceUploading: false, companyReviewLoading: false, companyReviewMutating: false, companyReviewOffset: 0, companyReviewSelectedIds: new Set(), companyReviewConfirmRequest: null, companyReportConfirmRequest: null, health: { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false }, analytics: null, toastTimer: null, jobs: [], selectedJobId: "", jobTrace: null, jobTraceLoading: false, jobTracePollTimer: null, jobsLoading: false, jobsTotal: 0, jobsOffset: 0, monthlyRuns: [], monthlyLoaded: false, monthlyLoading: false, monthlyPollTimer: null, monthlyTotal: 0, monthlyOffset: 0, companyReportJobs: [], companyReportCurrentJob: null, companyReportPollTimer: null, companyReportSubmittingPeriod: "", companyHistoryLoading: false, companyReportsTotal: 0, companyReportsOffset: 0, companyReportsReady: false, companySourceUploadReady: false, }; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; const TRACE_POLL_INTERVAL = 4000; const MONTHLY_POLL_INTERVAL = 4000; const HISTORY_PAGE_SIZE = 50; const COMPANY_REVIEW_PAGE_SIZE = 50; const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"]; const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"]; const DAILY_UPLOAD_PROGRESS_STAGES = [ { value: 42, label: "正在运行固定处理器", delay: 600 }, { value: 68, label: "正在独立验收结果", delay: 1400 }, { value: 88, label: "正在提交数据库", delay: 1800 }, ]; const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const jobStatus = { received: ["已接收", "running"], uploaded: ["已上传", "running"], queued: ["等待处理", "running"], running: ["处理中", "running"], delivered: ["等待验收", "running"], validating: ["正在验收", "running"], accepted: ["已完成", "success"], succeeded: ["已完成", "success"], rejected: ["验收失败", "failed"], failed: ["处理失败", "failed"], cancelled: ["已取消", "failed"], }; const reportStatus = { reserved: ["生成中", "running"], generating: ["生成中", "running"], validated: ["已验收", "running"], active: ["当前版本", "success current"], superseded: ["历史版本", ""], failed: ["生成失败", "failed"], }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function bangkokDateParts() { return Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(new Date()).map((part) => [part.type, part.value])); } function localMonth() { const parts = bangkokDateParts(); return `${parts.year}-${parts.month}`; } function localDate() { const parts = bangkokDateParts(); return `${parts.year}-${parts.month}-${parts.day}`; } function showToast(message, isError = false) { const toast = $("#toast"); toast.textContent = message; toast.classList.toggle("is-error", isError); toast.classList.add("is-visible"); window.clearTimeout(state.toastTimer); state.toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), 3600); } function loginLocation() { const next = location.pathname === "/h5" ? "/h5" : "/"; return `/login?next=${encodeURIComponent(next)}`; } function redirectToLogin() { window.location.replace(loginLocation()); } async function api(path, options = {}) { const { returnEnvelope = false, ...requestOptions } = options; const headers = new Headers(requestOptions.headers || {}); if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-ARR-CSRF", state.csrf); const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" }); if (response.status === 401) { redirectToLogin(); throw new Error("登录状态已失效"); } let payload; try { payload = await response.json(); } catch (_) { throw new Error("服务返回了无法识别的结果"); } if (!response.ok || !payload.ok) { throw new Error(payload?.error?.message || "请求未完成"); } return returnEnvelope ? payload : payload.data; } function readPage(envelope) { const items = Array.isArray(envelope?.data) ? envelope.data : []; const rawTotal = Number(envelope?.pagination?.total); const total = Number.isInteger(rawTotal) && rawTotal >= 0 ? rawTotal : items.length; return { items, total }; } function pageOffset(total, offset, direction) { const lastOffset = total > 0 ? Math.floor((total - 1) / HISTORY_PAGE_SIZE) * HISTORY_PAGE_SIZE : 0; return Math.max(0, Math.min(offset + direction * HISTORY_PAGE_SIZE, lastOffset)); } function renderPagination(scope, total, offset, loading = false) { const pageCount = Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE)); const page = Math.min(pageCount, Math.floor(offset / HISTORY_PAGE_SIZE) + 1); const start = total > 0 && offset < total ? offset + 1 : 0; const end = start > 0 ? Math.min(offset + HISTORY_PAGE_SIZE, total) : 0; $(`#${scope}-pagination-summary`).textContent = total > 0 ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}` : "共 0 条"; $(`#${scope}-page-label`).textContent = `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`; $(`#${scope}-prev`).disabled = loading || offset <= 0; $(`#${scope}-next`).disabled = loading || offset + HISTORY_PAGE_SIZE >= total; } function formatInteger(value) { return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0)); } function formatMoney(value, compact = false) { const amount = Number(value || 0); if (compact && Math.abs(amount) >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`; return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount); } function formatDate(value, withTime = false) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return escapeHtml(value); return new Intl.DateTimeFormat("zh-CN", withTime ? { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" } : { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit" } ).format(date); } function duration(job) { if (!job.created_at || !(job.finished_at || job.updated_at)) return "—"; const seconds = Math.max(0, Math.round((new Date(job.finished_at || job.updated_at) - new Date(job.created_at)) / 1000)); if (!Number.isFinite(seconds)) return "—"; if (seconds < 60) return `${seconds} 秒`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return `${minutes} 分 ${rest} 秒`; } function chip(status, map) { const [label, style] = map[status] || [status || "未知", ""]; return `${escapeHtml(label)}`; } function activateTab(name, updateHash = true) { const valid = ["daily", "monthly", "bi", "usage"].includes(name) ? name : "daily"; $$("[data-tab]").forEach((tab) => { const active = tab.dataset.tab === valid; tab.classList.toggle("is-active", active); tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; }); $$("[data-panel]").forEach((panel) => { const active = panel.dataset.panel === valid; panel.hidden = !active; panel.classList.toggle("is-active", active); }); if (updateHash) history.replaceState(null, "", `#${valid}`); if (valid === "bi" && !state.analytics) loadAnalytics(); if (valid === "usage" && state.csrf) { loadCompanySource(); loadCompanyDraft(); loadCompanyReportHistory(true); } if (valid === "monthly" && state.csrf) loadMonthly(false, state.monthlyLoaded); else clearMonthlyPoll(); if (taskLogIsOpen()) scheduleTracePoll(250); else clearTracePoll(); } async function initSession() { const session = await api("/api/session"); state.csrf = session.csrf_token; state.maxUploadBytes = Number(session.max_upload_bytes) || state.maxUploadBytes; const button = $("#logout-button"); if (!session.username) return; button.hidden = false; button.disabled = false; button.title = `${session.username} · 退出登录`; } async function logout() { const button = $("#logout-button"); button.disabled = true; try { await api("/api/logout", { method: "POST" }); window.location.replace("/login"); } catch (error) { if (document.visibilityState === "visible") { showToast(error.message || "退出登录失败,请重试", true); button.disabled = false; } } } async function loadHealth() { try { state.health = await api("/api/health"); } catch (_) { state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false }; } const node = $("#system-status"); node.classList.toggle("is-ready", state.health.database_ready); node.classList.toggle("is-error", !state.health.database_ready); $("span", node).textContent = state.health.database_ready ? "数据库已连接" : "数据服务未连接"; const fileInput = $("#xml-file"); fileInput.disabled = !state.health.processing_ready; $("#dropzone").classList.toggle("is-disabled", !state.health.processing_ready); $("#upload-hint").textContent = state.health.processing_ready ? "" : "文件接收服务尚未完成生产接线。"; state.companyReportsReady = Boolean(state.health.company_reports_ready); state.companySourceUploadReady = Boolean(state.health.company_source_upload_ready); $("#company-report-unavailable").hidden = state.companyReportsReady; updateCompanySourceUploadControls(); updateCompanyReportControls(); } function renderJobs(jobs) { const body = $("#jobs-body"); if (!jobs.length) { body.innerHTML = '本月暂无 Daily Report 记录'; $("#metric-arrival").textContent = "—"; $("#metric-duration").textContent = "—"; $("#metric-rooms").textContent = "—"; return; } body.innerHTML = jobs.map((job) => ` ${escapeHtml(job.filename || "—")} ${chip(job.status, jobStatus)}${job.failure_code ? `${escapeHtml(job.failure_code)}` : ""} ${escapeHtml(job.arrival_date || "—")} ${formatInteger(job.no_of_rooms)} ${duration(job)} ${formatDate(job.created_at, true)} ${state.health.download_ready && job.daily_report_sha256 ? `下载` : ''} `).join(""); const latest = jobs.find((job) => job.status === "succeeded"); $("#metric-arrival").textContent = latest?.arrival_date || "—"; $("#metric-duration").textContent = latest ? duration(latest) : "—"; $("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—"; } function taskLogIsOpen() { return Boolean($("#task-log-dialog")?.open); } function openTaskLog() { const dialog = $("#task-log-dialog"); if (!dialog || taskLogIsOpen()) return; if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); dialog.scrollTop = 0; scheduleTracePoll(250); } function closeTaskLog() { const dialog = $("#task-log-dialog"); if (!dialog || !taskLogIsOpen()) return; if (typeof dialog.close === "function") dialog.close(); else { dialog.removeAttribute("open"); clearTracePoll(); } } function clearTracePoll() { window.clearTimeout(state.jobTracePollTimer); state.jobTracePollTimer = null; } function setTraceLiveState(copy, style = "") { const node = $("#trace-live-state"); node.className = `trace-live-state${style ? ` ${style}` : ""}`; node.textContent = copy; } function traceConsoleValue(value) { return JSON.stringify(value ?? null); } function copyTextWithSelection(text) { const activeElement = document.activeElement; const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.inset = "0 auto auto 0"; textarea.style.opacity = "0"; textarea.style.pointerEvents = "none"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); let copied = false; try { copied = document.execCommand("copy"); } finally { textarea.remove(); activeElement?.focus?.(); } return copied; } async function writeClipboardText(text) { let clipboardError = null; if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (error) { clipboardError = error; } } if (copyTextWithSelection(text)) return; throw clipboardError || new Error("浏览器未允许访问剪贴板"); } async function copyAllTraceLogs() { const button = $("#copy-trace"); const text = $("#process-log").textContent || ""; if (!state.jobTrace || !text.trim()) { showToast("暂无可复制的任务日志", true); return; } button.disabled = true; button.textContent = "复制中…"; try { await writeClipboardText(text); button.textContent = "已复制"; showToast(`已复制全部任务日志(${text.split("\n").length} 行)`); } catch (_) { button.textContent = "复制失败"; showToast("无法复制日志,请检查浏览器剪贴板权限", true); } finally { window.setTimeout(() => { button.textContent = "复制全部日志"; button.disabled = !state.jobTrace; }, 1600); } } function renderTracePlaceholder(title, detail, tone = "", logCopy = "") { const liveCopy = tone === "error" ? "ERROR" : tone === "running" ? "LOADING" : "IDLE"; setTraceLiveState(liveCopy, tone ? `is-${tone}` : ""); $("#refresh-trace").disabled = !state.selectedJobId; $("#copy-trace").disabled = true; $("#trace-log-count").textContent = "0 events"; $("#process-log").textContent = [ `$ arr trace${state.selectedJobId ? ` --job ${state.selectedJobId}` : ""}`, `# ${title}`, `# ${logCopy || detail || "请上传文件,或从下方选择一个历史任务。"}`, ].join("\n"); } function renderTraceConsole(trace) { const job = trace.job || {}; const logs = trace.logs || []; const lines = [ `$ arr trace --job ${job.job_id || state.selectedJobId || "-"}`, `trace_version=${traceConsoleValue(trace.trace_version)} status=${traceConsoleValue(job.status)} active=${Boolean(job.active)} current_stage=${traceConsoleValue(job.current_stage)}`, `attempt_no=${traceConsoleValue(job.attempt_no)} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`, `created_at=${traceConsoleValue(job.created_at)} updated_at=${traceConsoleValue(job.updated_at)} finished_at=${traceConsoleValue(job.finished_at)}`, `evidence=${JSON.stringify(trace.evidence || {})}`, "--------------------------------------------------------------------------------", ...logs.map((log) => { const level = String(log.level || "info").toUpperCase().padEnd(7, " "); return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(log.title)} message=${traceConsoleValue(log.message)} details=${JSON.stringify(log.details || {})}`; }), "--------------------------------------------------------------------------------", ]; if (job.failure) { lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(job.failure.message)}`); } lines.push(`END status=${job.status || "unknown"} events=${logs.length} refreshed_at=${traceConsoleValue(trace.refreshed_at)}`); const output = $("#process-log"); output.textContent = lines.join("\n"); output.scrollTop = output.scrollHeight; $("#trace-log-count").textContent = `${logs.length} events`; $("#copy-trace").disabled = false; } function syncTraceToJobs(trace) { const summary = trace.job || {}; const item = state.jobs.find((job) => job.job_id === summary.job_id); if (!item) return; item.status = summary.status; item.failure_code = summary.failure?.code || null; item.updated_at = summary.updated_at || item.updated_at; item.finished_at = summary.finished_at || item.finished_at; item.arrival_date = summary.business_date || item.arrival_date; item.output_rows = summary.output_rows ?? item.output_rows; item.version_no = summary.version_no ?? item.version_no; renderJobs(state.jobs); } function renderTrace(trace) { const job = trace.job || {}; syncTraceToJobs(trace); if (job.failure) { setTraceLiveState(`FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error"); } else if (job.active) { setTraceLiveState(`RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live"); } else if (job.status === "succeeded") { setTraceLiveState("SUCCEEDED", "is-complete"); } else { setTraceLiveState(String(job.status || "UNKNOWN").toUpperCase()); } $("#refresh-trace").disabled = !job.job_id; renderTraceConsole(trace); } function scheduleTracePoll(delay = TRACE_POLL_INTERVAL) { clearTracePoll(); if ( !state.selectedJobId || !state.jobTrace?.job?.active || document.hidden || !taskLogIsOpen() ) return; state.jobTracePollTimer = window.setTimeout(() => loadJobTrace(false, true), delay); } async function loadJobTrace(showErrors = false, silent = false) { const jobId = state.selectedJobId; if (!jobId || state.jobTraceLoading) return; state.jobTraceLoading = true; if (!silent && state.jobTrace?.job?.job_id !== jobId) { renderTracePlaceholder("正在读取任务日志", `任务 ${jobId}`, "running", "正在聚合数据库中的处理证据…"); setTraceLiveState("正在读取", "is-live"); } try { const trace = await api(`/api/jobs/${encodeURIComponent(jobId)}/trace`); if (state.selectedJobId !== jobId) return; state.jobTrace = trace; renderTrace(trace); } catch (error) { if (state.selectedJobId !== jobId) return; if (!state.jobTrace) { renderTracePlaceholder("任务日志暂时无法读取", error.message, "error", "服务端没有返回可用日志。"); } setTraceLiveState(showErrors ? "日志刷新失败" : "自动刷新失败", "is-error"); if (showErrors) showToast(error.message, true); } finally { const selectionChanged = state.selectedJobId !== jobId; state.jobTraceLoading = false; if (selectionChanged) { loadJobTrace(false, false); return; } scheduleTracePoll(state.jobTrace?.job?.active ? TRACE_POLL_INTERVAL : 0); } } async function selectJob(jobId, { showLog = false } = {}) { if (!jobId) return; clearTracePoll(); const changed = state.selectedJobId !== jobId; state.selectedJobId = jobId; if (changed) state.jobTrace = null; renderJobs(state.jobs); if (showLog) openTaskLog(); await loadJobTrace(true, !changed); } async function loadJobs(showErrors = false, loadTrace = true) { if (state.jobsLoading) return; state.jobsLoading = true; renderPagination("jobs", state.jobsTotal, state.jobsOffset, true); try { const month = encodeURIComponent(localMonth()); let page = readPage(await api( `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`, { returnEnvelope: true }, )); if (!page.items.length && page.total > 0 && state.jobsOffset >= page.total) { state.jobsOffset = pageOffset(page.total, state.jobsOffset, -1); page = readPage(await api( `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`, { returnEnvelope: true }, )); } state.jobs = page.items; state.jobsTotal = page.total; renderJobs(state.jobs); if (!state.selectedJobId && state.jobs.length) { await selectJob(state.jobs[0].job_id); } else if (loadTrace && state.selectedJobId) { await loadJobTrace(showErrors, Boolean(state.jobTrace)); } else if (!state.jobs.length && !state.selectedJobId) { renderTracePlaceholder( "等待上传 ARR.XML", "新任务创建后,这里会显示服务端保存的全流程日志。", ); setTraceLiveState("未选择任务"); } } catch (error) { $("#jobs-body").innerHTML = '日报记录暂时无法读取'; if (showErrors) showToast(error.message, true); } finally { state.jobsLoading = false; renderPagination("jobs", state.jobsTotal, state.jobsOffset); } } function clearMonthlyPoll() { window.clearTimeout(state.monthlyPollTimer); state.monthlyPollTimer = null; } function monthlyViewActive() { return !document.hidden && $("#panel-monthly")?.classList.contains("is-active"); } function setMonthlyLiveState(copy, style = "") { const node = $("#monthly-live-state"); if (!node) return; node.className = `trace-live-state${style ? ` ${style}` : ""}`; node.textContent = copy; } function scheduleMonthlyPoll(delay = MONTHLY_POLL_INTERVAL) { clearMonthlyPoll(); if (!monthlyViewActive()) return; state.monthlyPollTimer = window.setTimeout(() => loadMonthly(false, true), delay); } function renderMonthly(runs) { const body = $("#monthly-body"); if (!runs.length) { body.innerHTML = '该月份暂无月报处理记录'; return; } body.innerHTML = runs.map((run) => ` ${escapeHtml(run.max_arrival_date || run.as_of_date || "—")} ${chip(run.status, reportStatus)} ${formatInteger(run.row_count)} ${formatInteger(run.channel_count)} ${formatDate(run.updated_at, true)} ${state.health.download_ready && run.artifact_sha256 ? `下载` : ''} `).join(""); } async function loadMonthly(showErrors = false, silent = false) { if (state.monthlyLoading) return; state.monthlyLoading = true; const month = localMonth(); const previousTotal = state.monthlyTotal; renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, true); if (!silent) setMonthlyLiveState("正在读取…", "is-live"); try { const encodedMonth = encodeURIComponent(month); let page = readPage(await api( `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`, { returnEnvelope: true }, )); const hasNewPublication = state.monthlyLoaded && page.total > previousTotal; if (hasNewPublication && state.monthlyOffset > 0) { state.monthlyOffset = 0; page = readPage(await api( `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=0`, { returnEnvelope: true }, )); } else if (!page.items.length && page.total > 0 && state.monthlyOffset >= page.total) { state.monthlyOffset = pageOffset(page.total, state.monthlyOffset, -1); page = readPage(await api( `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`, { returnEnvelope: true }, )); } state.monthlyRuns = page.items; state.monthlyTotal = page.total; state.monthlyLoaded = true; renderMonthly(state.monthlyRuns); setMonthlyLiveState(`自动更新 · ${MONTHLY_POLL_INTERVAL / 1000} 秒`, "is-live"); if (hasNewPublication) showToast("新月报已自动加入列表"); } catch (error) { if (!silent || !state.monthlyLoaded) { $("#monthly-body").innerHTML = '月报记录暂时无法读取'; } setMonthlyLiveState("自动更新重试中", "is-error"); if (showErrors) showToast(error.message, true); } finally { state.monthlyLoading = false; renderPagination("monthly", state.monthlyTotal, state.monthlyOffset); scheduleMonthlyPoll(); } } function populateMonths(months) { const select = $("#bi-month"); const current = select.value; if (!months.length) { select.innerHTML = ``; return; } select.innerHTML = months.map((item) => ``).join(""); if (months.some((item) => item.month_key === current)) select.value = current; } function renderCompanySales(channels) { const sorted = [...channels].sort((a, b) => Number(b.totals.total_price) - Number(a.totals.total_price)); const maximum = Math.max(1, ...sorted.map((item) => Number(item.totals.total_price || 0))); $("#company-ranking").innerHTML = sorted.length ? sorted.map((item) => `
${escapeHtml(item.worksheet)} ${formatMoney(item.totals.total_price, true)}
`).join("") : '

暂无公司销售数据

'; $$(".rank-track i", $("#company-ranking")).forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; }); const total = sorted.reduce((sum, item) => sum + Number(item.totals.total_price || 0), 0); let cursor = 0; const stops = sorted.map((item, index) => { const start = cursor; cursor += total > 0 ? Number(item.totals.total_price || 0) / total * 100 : 0; return `${colors[index % colors.length]} ${start.toFixed(3)}% ${cursor.toFixed(3)}%`; }); $("#company-donut").style.background = total > 0 ? `conic-gradient(${stops.join(",")})` : "#d8e0ec"; $("#donut-total").textContent = formatMoney(total, true); $("#donut-legend").innerHTML = sorted.map((item, index) => ` ${escapeHtml(item.worksheet)}`).join(""); $$(".legend-item i", $("#donut-legend")).forEach((marker) => { marker.style.backgroundColor = marker.dataset.color; }); } function renderMatrix(data) { const roomTypes = data.overall.room_types.map((item) => item.room_type); if (!roomTypes.length || !data.channels.length) { $("#room-matrix").innerHTML = '

暂无渠道与房型数据

'; return; } const heading = roomTypes.map((room) => `${escapeHtml(room)}`).join(""); const rows = data.channels.map((channel) => { const values = new Map(channel.room_types.map((room) => [room.room_type, room.rooms_sold])); return `${escapeHtml(channel.worksheet)}${roomTypes.map((room) => `${formatInteger(values.get(room) || 0)}`).join("")}${formatInteger(channel.totals.rooms_sold)}`; }).join(""); const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold])); $("#room-matrix").innerHTML = `${heading}${rows}${roomTypes.map((room) => ``).join("")}
渠道合计
合计${formatInteger(totals.get(room) || 0)}${formatInteger(data.overall.totals.rooms_sold)}
`; } function renderAnalytics(data) { state.analytics = data; const totals = data.overall.totals; $("#bi-rooms").textContent = formatInteger(totals.rooms_sold); $("#bi-revenue").textContent = formatMoney(totals.total_price, true); $("#bi-nights").textContent = formatInteger(totals.room_nights); $("#bi-channels").textContent = formatInteger(totals.channel_count); $("#bi-subtitle").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`; renderCompanySales(data.channels); renderMatrix(data); } function resetAnalytics(message) { ["#bi-rooms", "#bi-revenue", "#bi-nights", "#bi-channels", "#donut-total"].forEach((id) => $(id).textContent = "—"); $("#company-ranking").innerHTML = `

${escapeHtml(message)}

`; $("#donut-legend").innerHTML = ""; $("#company-donut").style.background = "#d8e0ec"; $("#room-matrix").innerHTML = `

${escapeHtml(message)}

`; } async function loadAnalytics(showErrors = false) { const month = $("#bi-month").value || localMonth(); try { renderAnalytics(await api(`/api/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { resetAnalytics("该月份暂无可用看板数据"); if (showErrors) showToast(error.message, true); } } function encodedFilename(value) { const filenameBytes = new TextEncoder().encode(value); let binary = ""; filenameBytes.forEach((byte) => { binary += String.fromCharCode(byte); }); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } function clearUploadProgressTimer() { window.clearTimeout(state.uploadProgressTimer); state.uploadProgressTimer = null; } function setUploadProgress(value, label, tone = "running") { const progress = $("#upload-progress"); const labelNode = $("#upload-progress-label"); const percentNode = $("#upload-progress-percent"); const track = $("#upload-progress-track"); const bar = $("#upload-progress-bar"); if (!progress || !labelNode || !percentNode || !track || !bar) return; const bounded = Math.max(0, Math.min(100, Math.round(Number(value) || 0))); state.uploadProgressValue = bounded; progress.hidden = false; progress.className = `upload-progress${tone ? ` is-${tone}` : ""}`; labelNode.textContent = label; percentNode.textContent = `${bounded}%`; track.setAttribute("aria-valuenow", String(bounded)); track.setAttribute("aria-valuetext", `${label} ${bounded}%`); bar.style.transform = `scaleX(${bounded / 100})`; } function resetUploadProgress() { clearUploadProgressTimer(); const progress = $("#upload-progress"); if (!progress) return; progress.hidden = true; progress.className = "upload-progress"; state.uploadProgressValue = 0; $("#upload-progress-label").textContent = "等待开始"; $("#upload-progress-percent").textContent = "0%"; $("#upload-progress-track").setAttribute("aria-valuenow", "0"); $("#upload-progress-track").setAttribute("aria-valuetext", "等待开始 0%"); $("#upload-progress-bar").style.transform = "scaleX(0)"; } function startUploadProgress() { clearUploadProgressTimer(); setUploadProgress(12, "正在上传 ARR.XML"); let stageIndex = 0; const advance = () => { const stage = DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]; if (!stage) return; setUploadProgress(stage.value, stage.label); stageIndex += 1; if (DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]) { state.uploadProgressTimer = window.setTimeout(advance, stage.delay); } }; state.uploadProgressTimer = window.setTimeout(advance, 500); } function finishUploadProgress(success, label) { clearUploadProgressTimer(); if (success) { setUploadProgress(100, label, "success"); return; } setUploadProgress(Math.max(12, state.uploadProgressValue), label, "error"); } async function handleUpload() { if (!state.selectedFile || !state.health.processing_ready) return; const file = state.selectedFile; const button = $("#upload-button"); const fileInput = $("#xml-file"); const dropzone = $("#dropzone"); state.uploadInFlight = true; button.disabled = true; fileInput.disabled = true; dropzone.classList.add("is-disabled"); button.setAttribute("aria-busy", "true"); button.textContent = "处理中…"; clearTracePoll(); state.selectedJobId = ""; state.jobTrace = null; renderJobs(state.jobs); renderTracePlaceholder( "正在处理ARR.XML文件", "正在安全上传、运行固定处理器、独立验收并提交数据库。", "running", "处理完成后将立即读取服务端全流程日志…", ); setTraceLiveState("正在创建任务", "is-live"); startUploadProgress(); try { const receipt = await api("/api/jobs", { method: "POST", headers: { "Content-Type": "application/xml", "X-ARR-Filename-B64": encodedFilename(file.name) }, body: await file.arrayBuffer(), }); state.selectedFile = null; $("#xml-file").value = ""; $("#selected-file").textContent = "尚未选择文件"; const failed = receipt.status === "failed"; finishUploadProgress(!failed, failed ? "处理失败" : "处理完成"); showToast(failed ? "ARR.XML 处理失败,请查看任务日志" : "ARR.XML 已处理并完成入库", failed); state.jobsOffset = 0; await loadJobs(false, false); if (receipt.job_id) { await selectJob(receipt.job_id); } else { renderTracePlaceholder("处理已结束", "任务编号暂不可用,请刷新 Daily Report。", "success"); } } catch (error) { finishUploadProgress(false, "处理失败"); renderTracePlaceholder("处理未能完成", error.message, "error", "请刷新 Daily Report,确认是否已登记失败任务。"); setTraceLiveState("处理失败", "is-error"); showToast(error.message, true); } finally { state.uploadInFlight = false; fileInput.disabled = !state.health.processing_ready; dropzone.classList.toggle("is-disabled", !state.health.processing_ready); button.removeAttribute("aria-busy"); button.textContent = "开始处理"; button.disabled = !state.selectedFile || !state.health.processing_ready; } } async function loadMonthsAndAnalytics() { try { const months = await api("/api/months"); populateMonths(months); if (months.length) $("#bi-month").value = months[0].month_key; } catch (_) { populateMonths([]); } await loadAnalytics(); } function setCompanySourceError(message = "") { const node = $("#company-source-error"); node.textContent = message; node.hidden = !message; } function updateCompanySourceUploadControls() { const busy = state.companySourceUploading || state.companyReviewMutating || isCompanyReportActive(); const enabled = state.companySourceUploadReady && !busy; const input = $("#company-excel-file"); input.disabled = !enabled; $("#company-excel-dropzone").classList.toggle("is-disabled", !enabled); const button = $("#company-upload-button"); button.disabled = !enabled || !state.companySourceFile; button.setAttribute("aria-busy", String(state.companySourceUploading)); $("span", button).textContent = state.companySourceUploading ? "正在提取" : "提取并核对"; } function clearCompanyReviewSelection() { state.companyReviewSelectedIds.clear(); } function syncCompanyReviewSelection(items = [], busy = false) { const visibleIds = items .map((item) => Number(item.item_id)) .filter((itemId) => Number.isInteger(itemId) && itemId > 0); const visibleIdSet = new Set(visibleIds); [...state.companyReviewSelectedIds].forEach((itemId) => { if (!visibleIdSet.has(itemId)) state.companyReviewSelectedIds.delete(itemId); }); const selectedCount = state.companyReviewSelectedIds.size; const pageCheckbox = $("#company-review-select-page"); pageCheckbox.checked = visibleIds.length > 0 && selectedCount === visibleIds.length; pageCheckbox.indeterminate = selectedCount > 0 && selectedCount < visibleIds.length; pageCheckbox.disabled = busy || visibleIds.length === 0; $("#company-review-selection-count").textContent = `已选 ${formatInteger(selectedCount)} 条`; $("#company-review-delete-selected").disabled = busy || selectedCount === 0; $$("[data-review-select]", $("#company-review-body")).forEach((checkbox) => { const itemId = Number(checkbox.value); const selected = state.companyReviewSelectedIds.has(itemId); checkbox.checked = selected; checkbox.disabled = busy; const row = checkbox.closest("[data-review-item-id]"); row?.classList.toggle("is-selected", selected); row?.setAttribute("aria-selected", String(selected)); }); } function renderCompanyDraft(page) { const panel = $("#company-review-panel"); const body = $("#company-review-body"); const empty = $("#company-review-empty"); if (!page?.summary) { panel.hidden = true; panel.classList.remove("is-busy"); $("#company-review-filename").textContent = ""; $("#company-review-filename").removeAttribute("title"); body.innerHTML = ""; empty.hidden = true; clearCompanyReviewSelection(); syncCompanyReviewSelection(); return; } const summary = page.summary; const items = Array.isArray(page.items) ? page.items : []; const pagination = page.pagination || {}; const pending = Number(summary.pending_items) || 0; const confirmed = Number(summary.confirmed_items) || 0; const deleted = Number(summary.deleted_items) || 0; const busy = state.companyReviewLoading || state.companyReviewMutating; const filename = String(summary.filename || "").trim(); panel.hidden = false; panel.classList.toggle("is-busy", busy); $("#company-review-filename").textContent = filename || "未记录文件名"; if (filename) $("#company-review-filename").title = filename; else $("#company-review-filename").removeAttribute("title"); $("#company-review-confirmed").textContent = formatInteger(confirmed); $("#company-review-pending").textContent = formatInteger(pending); $("#company-review-deleted").textContent = formatInteger(deleted); body.innerHTML = items.map((item) => { const isPending = item.review_status === "pending"; const itemId = Number(item.item_id); const selected = state.companyReviewSelectedIds.has(itemId); const tourCode = escapeHtml(item.tour_code || "暂无"); const raw = escapeHtml(item.room_type_raw || "暂无"); const roomType = escapeHtml(item.room_type || ""); const quantity = Number(item.quantity) || 1; return ` ${tourCode} ${raw} ${isPending ? "待人工确认" : "已确认"}
`; }).join(""); empty.hidden = items.length > 0; syncCompanyReviewSelection(items, busy); const total = Number(pagination.total) || 0; const limit = Number(pagination.limit) || COMPANY_REVIEW_PAGE_SIZE; const offset = Number(pagination.offset) || 0; const pageCount = Math.max(1, Math.ceil(total / limit)); const currentPage = Math.min(pageCount, Math.floor(offset / limit) + 1); const start = total > 0 ? offset + 1 : 0; const end = total > 0 ? Math.min(offset + items.length, total) : 0; $("#company-review-page-summary").textContent = total > 0 ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}` : "共 0 条"; $("#company-review-page-label").textContent = `第 ${formatInteger(currentPage)} / ${formatInteger(pageCount)} 页`; $("#company-review-prev").disabled = busy || offset <= 0; $("#company-review-next").disabled = busy || offset + items.length >= total; $("#company-review-discard").disabled = busy; $("#company-review-activate").disabled = busy || pending > 0 || confirmed < 1; } function selectCompanySourceFile(file) { setCompanySourceError(); state.companySourceFile = null; if (file) { if (!file.name.toLowerCase().endsWith(".xlsx")) { setCompanySourceError("请选择 .xlsx 格式的 Excel 文件"); } else if (!file.size) { setCompanySourceError("Excel 文件为空"); } else if (file.size > state.maxUploadBytes) { setCompanySourceError("Excel 文件超过 25 MB"); } else { state.companySourceFile = file; } } $("#company-selected-file").textContent = state.companySourceFile ? `${state.companySourceFile.name} · ${formatInteger(state.companySourceFile.size / 1024)} KB` : "尚未选择文件"; updateCompanySourceUploadControls(); } async function loadCompanySource(showErrors = false) { if (!state.companySourceUploadReady) { state.companySource = null; updateCompanyReportControls(); return; } try { state.companySource = await api("/api/company-reports/source"); setCompanySourceError(); } catch (error) { state.companySource = null; setCompanySourceError("当前 Excel 数据源暂时无法读取"); if (showErrors) showToast(error.message, true); } updateCompanyReportControls(); } async function loadCompanyDraft(showErrors = false) { if (!state.companySourceUploadReady) { state.companySourceDraft = null; clearCompanyReviewSelection(); renderCompanyDraft(null); updateCompanyReportControls(); return; } state.companyReviewLoading = true; renderCompanyDraft(state.companySourceDraft); try { let page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`); if (page?.pagination?.total > 0 && !page.items?.length && state.companyReviewOffset > 0) { state.companyReviewOffset = Math.max(0, Math.floor((page.pagination.total - 1) / COMPANY_REVIEW_PAGE_SIZE) * COMPANY_REVIEW_PAGE_SIZE); page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`); } state.companySourceDraft = page; } catch (error) { state.companySourceDraft = null; setCompanySourceError("当前提取结果暂时无法读取"); if (showErrors) showToast(error.message, true); } finally { state.companyReviewLoading = false; } renderCompanyDraft(state.companySourceDraft); updateCompanySourceUploadControls(); updateCompanyReportControls(); } async function handleCompanySourceUpload() { if (!state.companySourceFile || !state.companySourceUploadReady || state.companySourceUploading) return; state.companySourceUploading = true; setCompanySourceError(); updateCompanySourceUploadControls(); updateCompanyReportControls(); try { const receipt = await api("/api/company-reports/source", { method: "POST", headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "X-ARR-Filename-B64": encodedFilename(state.companySourceFile.name), }, body: await state.companySourceFile.arrayBuffer(), }); state.companySourceDraft = receipt; state.companyReviewOffset = 0; clearCompanyReviewSelection(); state.companySourceFile = null; $("#company-excel-file").value = ""; $("#company-selected-file").textContent = "尚未选择文件"; renderCompanyDraft(state.companySourceDraft); const pending = Number(receipt?.summary?.pending_items) || 0; showToast(pending > 0 ? `提取完成,${formatInteger(pending)} 条需要人工确认` : "提取完成,可以确认并启用"); } catch (error) { setCompanySourceError(error.message || "Excel 文件未能完成提取"); showToast(error.message || "Excel 文件未能完成提取", true); } finally { state.companySourceUploading = false; updateCompanySourceUploadControls(); updateCompanyReportControls(); } } async function saveCompanyReviewItem(button) { if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; const row = button.closest("[data-review-item-id]"); const itemId = Number(row?.dataset.reviewItemId); const roomType = $("[data-review-room]", row)?.value.trim() || ""; const quantity = Number($("[data-review-quantity]", row)?.value); if (!roomType) { setCompanySourceError("请填写房型后再保存"); return; } if (!Number.isInteger(quantity) || quantity < 1 || quantity > 9999) { setCompanySourceError("房间数量必须是 1 至 9999 的整数"); return; } state.companyReviewMutating = true; setCompanySourceError(); renderCompanyDraft(state.companySourceDraft); try { await api(`/api/company-reports/source/draft/items/${itemId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_id: state.companySourceDraft.summary.draft_id, room_type: roomType, quantity, }), }); state.companyReviewMutating = false; await loadCompanyDraft(); showToast("房型记录已保存"); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); setCompanySourceError(error.message || "房型记录未能保存"); showToast(error.message || "房型记录未能保存", true); } } function companyReviewConfirmIsOpen() { return Boolean($("#company-review-confirm-dialog")?.open); } function setCompanyReviewConfirmBusy(busy) { const request = state.companyReviewConfirmRequest; const dialog = $("#company-review-confirm-dialog"); const cancel = $("#company-review-confirm-cancel"); const submit = $("#company-review-confirm-submit"); dialog.setAttribute("aria-busy", String(busy)); cancel.disabled = busy; submit.disabled = busy; submit.textContent = busy ? (request?.kind === "discard" ? "正在放弃" : "正在删除") : (request?.confirmLabel || "确认删除"); } function resetCompanyReviewConfirm(restoreFocus = true) { const request = state.companyReviewConfirmRequest; state.companyReviewConfirmRequest = null; $("#company-review-confirm-dialog").setAttribute("aria-busy", "false"); $("#company-review-confirm-error").hidden = true; if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) { window.requestAnimationFrame(() => request.trigger.focus()); } } function closeCompanyReviewConfirm({ force = false, restoreFocus = true } = {}) { if (state.companyReviewMutating && !force) return; const dialog = $("#company-review-confirm-dialog"); const request = state.companyReviewConfirmRequest; if (state.companyReviewConfirmRequest === request) resetCompanyReviewConfirm(restoreFocus); if (dialog.open && typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open"); } function openCompanyReviewConfirm(request) { if (!state.companySourceDraft?.summary || state.companyReviewMutating || companyReviewConfirmIsOpen()) return; const dialog = $("#company-review-confirm-dialog"); const error = $("#company-review-confirm-error"); error.textContent = ""; error.hidden = true; if (request.kind === "discard") { state.companyReviewConfirmRequest = { ...request, confirmLabel: "确认放弃", }; $("#company-review-confirm-title").textContent = "放弃本次提取"; $("#company-review-confirm-description").textContent = "确认放弃本次提取?"; $("#company-review-confirm-note").textContent = "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。"; } else { const itemIds = [...new Set(request.itemIds || [])]; if (!itemIds.length) return; const single = itemIds.length === 1; state.companyReviewConfirmRequest = { ...request, itemIds, confirmLabel: single ? "确认删除" : `删除 ${formatInteger(itemIds.length)} 条`, }; $("#company-review-confirm-title").textContent = "删除房型记录"; $("#company-review-confirm-description").textContent = single ? `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?` : `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`; $("#company-review-confirm-note").textContent = "此操作只影响本次提取结果,确认启用前原数据源不会改变。"; } setCompanyReviewConfirmBusy(false); if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); window.requestAnimationFrame(() => $("#company-review-confirm-cancel").focus()); } function deleteCompanyReviewItem(button) { if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; const row = button.closest("[data-review-item-id]"); const itemId = Number(row?.dataset.reviewItemId); if (!Number.isInteger(itemId) || itemId <= 0) return; const tourCode = $(".company-review-tour", row)?.textContent?.trim() || "这条记录"; openCompanyReviewConfirm({ kind: "items", itemIds: [itemId], tourCode, trigger: button }); } function deleteSelectedCompanyReviewItems(event) { if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; const itemIds = (state.companySourceDraft.items || []) .map((item) => Number(item.item_id)) .filter((itemId) => state.companyReviewSelectedIds.has(itemId)); if (!itemIds.length) return; openCompanyReviewConfirm({ kind: "items", itemIds, trigger: event.currentTarget }); } function discardCompanyReviewDraft(event) { if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; openCompanyReviewConfirm({ kind: "discard", trigger: event.currentTarget }); } async function confirmCompanyReviewAction() { const request = state.companyReviewConfirmRequest; const summary = state.companySourceDraft?.summary; if (!request || !summary || state.companyReviewMutating) return; state.companyReviewMutating = true; setCompanySourceError(); setCompanyReviewConfirmBusy(true); renderCompanyDraft(state.companySourceDraft); try { if (request.kind === "discard") { await api("/api/company-reports/source/draft", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_id: summary.draft_id }), }); state.companySourceDraft = null; state.companyReviewOffset = 0; state.companyReviewMutating = false; clearCompanyReviewSelection(); renderCompanyDraft(null); updateCompanySourceUploadControls(); updateCompanyReportControls(); closeCompanyReviewConfirm({ force: true, restoreFocus: false }); showToast("本次提取已放弃,原数据源未改变"); return; } await api("/api/company-reports/source/draft/items", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_id: summary.draft_id, item_ids: request.itemIds }), }); const deletedCount = request.itemIds.length; clearCompanyReviewSelection(); state.companyReviewMutating = false; await loadCompanyDraft(); closeCompanyReviewConfirm({ force: true, restoreFocus: false }); showToast(deletedCount === 1 ? "房型记录已删除" : `已删除 ${formatInteger(deletedCount)} 条房型记录`); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); setCompanyReviewConfirmBusy(false); const message = error.message || (request.kind === "discard" ? "本次提取未能放弃" : "房型记录未能删除"); const errorNode = $("#company-review-confirm-error"); errorNode.textContent = message; errorNode.hidden = false; setCompanySourceError(message); showToast(message, true); } } async function activateCompanyReviewDraft() { const summary = state.companySourceDraft?.summary; if (!summary || state.companyReviewMutating || Number(summary.pending_items) > 0) return; state.companyReviewMutating = true; setCompanySourceError(); renderCompanyDraft(state.companySourceDraft); try { const source = await api("/api/company-reports/source/draft/activate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_id: summary.draft_id }), }); state.companySource = source; state.companySourceDraft = null; state.companyReviewOffset = 0; state.companyReviewMutating = false; clearCompanyReviewSelection(); renderCompanyDraft(null); updateCompanyReportControls(); showToast("人工核对完成,Booking 数据源已启用"); } catch (error) { state.companyReviewMutating = false; renderCompanyDraft(state.companySourceDraft); setCompanySourceError(error.message || "Booking 数据源未能启用"); showToast(error.message || "Booking 数据源未能启用", true); } } function companyMonthEndDay(reportMonth) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return null; return new Date(Date.UTC(Number(match[1]), Number(match[2]), 0)).getUTCDate(); } function companyPeriodRange(reportMonth, period) { if (period === "01-10") return "01-10"; if (period === "11-20") return "11-20"; const monthEnd = companyMonthEndDay(reportMonth); return monthEnd ? `21-${monthEnd}` : "21-月末"; } function companyPeriodDisplayLabel(reportMonth, period) { if (period === "01-10") return "C/O:01-10"; if (period === "11-20") return "C/O:11-20"; const monthEnd = companyMonthEndDay(reportMonth); return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30"; } function companyPeriodCountKey(reportMonth, period) { if (period !== "21-month-end") return period; const monthEnd = companyMonthEndDay(reportMonth); return monthEnd ? `21-${monthEnd}` : ""; } function companyPeriodCompleteDate(reportMonth, period) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return ""; if (period === "01-10") return `${reportMonth}-11`; if (period === "11-20") return `${reportMonth}-21`; if (period !== "21-month-end") return ""; let year = Number(match[1]); let month = Number(match[2]) + 1; if (month === 13) { year += 1; month = 1; } return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`; } function companyPeriodIncluded(cutoff, period) { return COMPANY_REPORT_PERIODS.indexOf(period) <= COMPANY_REPORT_PERIODS.indexOf(cutoff); } function monthLabel(value) { const match = /^(\d{4})-(\d{2})$/.exec(value || ""); return match ? `${match[1]}年${match[2]}月` : "未选择"; } function formatDurationSeconds(value) { const seconds = Number(value); if (!Number.isFinite(seconds)) return "暂无"; if (seconds < 60) return `${Math.max(1, Math.round(seconds))} 秒`; return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`; } function isCompanyReportActive(job = state.companyReportCurrentJob) { return Boolean(job && ["queued", "running"].includes(job.state)); } function setCompanyReportError(message = "") { const node = $("#company-report-error"); node.textContent = message; node.hidden = !message; } function updateCompanyReportControls() { const reportMonth = $("#company-report-month").value || ""; const monthValid = /^\d{4}-(0[1-9]|1[0-2])$/.test(reportMonth); const currentMonth = localMonth(); const monthFuture = monthValid && reportMonth > currentMonth; const reviewOpen = Boolean(state.companySourceDraft?.summary); const sourceReady = Boolean(state.companySource) && !reviewOpen; const active = isCompanyReportActive() || Boolean(state.companyReportSubmittingPeriod) || Boolean(state.companyReportConfirmRequest) || state.companySourceUploading || state.companyReviewMutating; const today = localDate(); const monthInput = $("#company-report-month"); monthInput.max = currentMonth; monthInput.disabled = active; $$('[data-company-period]').forEach((button) => { const period = button.dataset.companyPeriod; const completeDate = companyPeriodCompleteDate(reportMonth, period); const periodComplete = Boolean(completeDate) && today >= completeDate; const range = companyPeriodRange(reportMonth, period); const displayRange = companyPeriodDisplayLabel(reportMonth, period); const rangeNode = $('[data-company-period-range]', button); const releaseNode = $('[data-company-period-release]', button); const statusNode = $('[data-company-period-state]', button); if (rangeNode) rangeNode.textContent = displayRange; if (releaseNode) releaseNode.textContent = completeDate ? `周期结束:${completeDate} 00:00(曼谷)` : "选择月份后显示周期结束时间"; statusNode.className = "company-period-state"; if (!monthValid) { statusNode.textContent = "选择月份"; } else if (reviewOpen) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "先完成核对"; } else if (!sourceReady) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "先上传 Excel"; } else if (!state.companyReportsReady) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "服务未就绪"; } else if (monthFuture) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "未来月份"; } else if (!periodComplete) { statusNode.classList.add("is-in-progress"); statusNode.textContent = "周期未结束"; } else { statusNode.classList.add("is-complete"); statusNode.textContent = "周期已结束"; } button.disabled = active || !state.companyReportsReady || !sourceReady || !monthValid || monthFuture; button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period)); if (!monthValid) button.title = "请先选择有效的报表月份"; else if (reviewOpen) button.title = "请先确认或放弃当前 Excel 提取结果"; else if (!sourceReady) button.title = "请先提取并启用 Excel 报表"; else if (!state.companyReportsReady) button.title = "公司渠道明细服务尚未就绪"; else if (monthFuture) button.title = "未来报表月份暂不可生成"; else if (!periodComplete) button.title = `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`; else button.removeAttribute("title"); }); } function companyStateLabel(value) { return { queued: "已排队", running: "生成中", succeeded: "全部成功", partial_failure: "部分成功", failed: "未完成", }[value] || "未知"; } function companyStateStyle(value) { if (value === "succeeded") return "success"; if (value === "partial_failure") return "review"; if (value === "failed") return "failed"; return "running"; } function companyCutoffLabel(job) { if (job.period === "01-10") return "截至 10 日"; if (job.period === "11-20") return "截至 20 日"; return "截至月末"; } function companyProblemLabel(code) { return { COMPANY_REPORT_MULTI_PRICE_REVIEW: "同房型存在不同静态价格,需人工复核", COMPANY_REPORT_SOURCE_VERSION_MISSING: "数据源版本缺失", COMPANY_REPORT_GROUP_CODE_MISSING: "Group Code 缺失", COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "Group Code 未找到", COMPANY_REPORT_BOOKING_PARSE_FAILED: "预订解析异常", COMPANY_REPORT_ROOM_ITEMS_MISSING: "房型明细缺失", COMPANY_REPORT_STAY_DATE_INVALID: "住宿日期异常", COMPANY_REPORT_NIGHTS_CONFLICT: "间夜数据冲突", COMPANY_REPORT_TOTAL_PRICE_INVALID: "静态价格异常", COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "Excel 输出校验失败", COMPANY_REPORT_PUBLISH_FAILED: "正式表格发布失败", COMPANY_REPORT_INTERNAL_ERROR: "处理服务异常", COMPANY_REPORT_EXECUTION_FAILED: "任务执行未完成", COMPANY_REPORT_JOB_INTERRUPTED: "服务重启中断任务", }[code] || "需复核处理结果"; } function uniqueCompanyProblems(problems) { const seen = new Set(); return (problems || []).filter((item) => { const key = `${item.kind || "problem"}|${item.code || ""}|${item.period || ""}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function companyProblemsForResult(result) { const warnings = (result?.warnings || []).map((item) => ({ ...item, kind: "warning" })); const errors = (result?.errors || []).map((item) => ({ ...item, kind: "error" })); return uniqueCompanyProblems([...warnings, ...errors]); } function companySuccessCount(job) { return (job.company_results || []).filter((item) => item.status === "success").length; } function companyWarningCount(job) { return (job.company_results || []).reduce( (sum, item) => sum + companyProblemsForResult(item).filter((problem) => problem.kind === "warning").length, 0, ); } function companyPeriodCell(job, result, period) { const range = companyPeriodRange(job.report_month, period); if (!companyPeriodIncluded(job.period, period)) { return `待生成`; } if (!result) { return `${isCompanyReportActive(job) ? "等待生成" : "无结果"}`; } if (result.status === "failed") { return `未生成`; } const countKey = companyPeriodCountKey(job.report_month, period); const count = Object.prototype.hasOwnProperty.call(result.period_row_counts || {}, countKey) ? result.period_row_counts[countKey] : null; return `${count === null ? "未记录" : `✓ ${formatInteger(count)} 条`}`; } function renderCompanyResultRows(job) { const byCompany = new Map((job.company_results || []).map((item) => [item.company, item])); $("#company-report-result-rows").innerHTML = COMPANY_REPORT_NAMES.map((company) => { const result = byCompany.get(company); const warnings = result?.warnings || []; const rowStyle = result?.status === "failed" ? "is-failed" : warnings.length ? "is-review" : ""; let resultStatus = isCompanyReportActive(job) ? "等待生成" : "无结果"; let resultStyle = isCompanyReportActive(job) ? "running" : "failed"; if (result?.status === "success") { resultStatus = warnings.length ? "成功 · 待复核" : "成功"; resultStyle = warnings.length ? "review" : "success"; } else if (result?.status === "failed") { resultStatus = "失败"; resultStyle = "failed"; } const problems = companyProblemsForResult(result); const problemHtml = problems.length ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "暂无")}`).join("") : '暂无'; const download = result?.download_url?.startsWith("/api/company-reports/") ? `下载` : `${isCompanyReportActive(job) ? "待生成" : "暂无"}`; return ` ${escapeHtml(company)} ${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")} ${result ? formatInteger(result.row_count) : "暂无"} ${escapeHtml(resultStatus)}
${problemHtml}
${formatDate(job.finished_at || job.created_at, true)} ${download} `; }).join(""); } function renderCompanyReportJob(job) { state.companyReportCurrentJob = job; $("#company-report-job-panel").hidden = false; const terminal = ["succeeded", "partial_failure", "failed"].includes(job.state); const kicker = $("#company-report-job-kicker"); kicker.textContent = "TASK STATUS"; kicker.hidden = terminal; $("#company-report-job-title").textContent = `${monthLabel(job.report_month)} · ${companyCutoffLabel(job)}`; const message = $("#company-report-job-message"); const messageText = job.state === "failed" && !job.failure_code ? "" : (job.message || ""); message.textContent = messageText; message.hidden = !messageText; const status = $("#company-report-job-state"); status.className = `status-chip ${companyStateStyle(job.state)}`; status.textContent = companyStateLabel(job.state); $("#company-report-success-count").textContent = `${companySuccessCount(job)} / 5`; const percent = Math.max(0, Math.min(100, Number(job.progress?.percent) || 0)); $("#company-report-progress-label").textContent = job.progress?.label || "等待生成"; $("#company-report-progress-percent").textContent = `${percent}%`; $("#company-report-progress-track").setAttribute("aria-valuenow", String(percent)); $("#company-report-progress-bar").style.transform = `scaleX(${percent / 100})`; $("#company-report-job-month").textContent = monthLabel(job.report_month); $("#company-report-job-as-of").textContent = job.as_of_date || "暂无"; $("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds); $("#company-report-warning-count").textContent = `${companyWarningCount(job)} 项`; $("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end"); renderCompanyResultRows(job); localStorage.setItem("arr:last-company-report-job", job.job_id); updateCompanyReportControls(); updateCompanySourceUploadControls(); } function renderCompanyReportHistory(jobs) { state.companyReportJobs = jobs; const body = $("#company-history-rows"); if (!jobs.length) { body.innerHTML = '该月份还没有公司渠道明细任务'; return; } body.innerHTML = jobs.map((job) => ` ${formatDate(job.created_at, true)} ${escapeHtml(job.report_month || "暂无")} ${escapeHtml(companyCutoffLabel(job))} ${escapeHtml(companyStateLabel(job.state))} ${companySuccessCount(job)} / 5 ${companyWarningCount(job)} `).join(""); $$('[data-company-job-id]', body).forEach((button) => button.addEventListener("click", () => loadCompanyReportJob(button.dataset.companyJobId, true))); } async function loadCompanyReportHistory(restore = false) { if (state.companyHistoryLoading) return; if (!state.companyReportsReady) { state.companyReportsOffset = 0; state.companyReportsTotal = 0; renderCompanyReportHistory([]); renderPagination("company", 0, 0); return; } state.companyHistoryLoading = true; renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, true); const month = $("#company-report-month").value || localMonth(); try { const encodedMonth = encodeURIComponent(month); let page = readPage(await api( `/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`, { returnEnvelope: true }, )); if (!page.items.length && page.total > 0 && state.companyReportsOffset >= page.total) { state.companyReportsOffset = pageOffset(page.total, state.companyReportsOffset, -1); page = readPage(await api( `/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`, { returnEnvelope: true }, )); } state.companyReportsTotal = page.total; renderCompanyReportHistory(page.items); if (restore) { const remembered = localStorage.getItem("arr:last-company-report-job"); const target = page.items.find((job) => job.job_id === remembered) || page.items.find((job) => ["queued", "running"].includes(job.state)); if (target) await loadCompanyReportJob(target.job_id); } } catch (error) { $("#company-history-rows").innerHTML = '任务记录暂时无法读取'; if (restore) showToast(error.message, true); } finally { state.companyHistoryLoading = false; renderPagination("company", state.companyReportsTotal, state.companyReportsOffset); } } async function loadCompanyReportJob(jobId, scroll = false) { if (state.companyReportPollTimer) window.clearTimeout(state.companyReportPollTimer); try { const job = await api(`/api/company-reports/jobs/${encodeURIComponent(jobId)}`); renderCompanyReportJob(job); if (scroll) $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); if (isCompanyReportActive(job)) { state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 1200); } else { await loadCompanyReportHistory(false); } } catch (error) { showToast(error.message, true); state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 2500); } } function companyReportConfirmIsOpen() { return Boolean($("#company-report-confirm-dialog")?.open); } function setCompanyReportConfirmBusy(busy) { const dialog = $("#company-report-confirm-dialog"); const cancel = $("#company-report-confirm-cancel"); const submit = $("#company-report-confirm-submit"); dialog.setAttribute("aria-busy", String(busy)); cancel.disabled = busy; submit.disabled = busy; submit.textContent = busy ? "正在生成" : "确认生成"; } function resetCompanyReportConfirm(restoreFocus = true) { const request = state.companyReportConfirmRequest; state.companyReportConfirmRequest = null; const dialog = $("#company-report-confirm-dialog"); const error = $("#company-report-confirm-error"); dialog.setAttribute("aria-busy", "false"); error.textContent = ""; error.hidden = true; if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) { window.requestAnimationFrame(() => request.trigger.focus()); } updateCompanyReportControls(); } function closeCompanyReportConfirm({ force = false, restoreFocus = true } = {}) { if (state.companyReportSubmittingPeriod && !force) return; const dialog = $("#company-report-confirm-dialog"); if (state.companyReportConfirmRequest) resetCompanyReportConfirm(restoreFocus); if (dialog.open && typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open"); } function openCompanyReportConfirm({ period, trigger }) { if (!state.companyReportsReady || !state.companySource || isCompanyReportActive() || state.companyReportSubmittingPeriod || companyReportConfirmIsOpen()) return; const reportMonth = $("#company-report-month").value; if (!reportMonth || reportMonth > localMonth()) return; const dialog = $("#company-report-confirm-dialog"); state.companyReportConfirmRequest = { period, reportMonth, trigger }; $("#company-report-confirm-description").textContent = `${monthLabel(reportMonth)},${companyPeriodRange(reportMonth, period)}`; const completeDate = companyPeriodCompleteDate(reportMonth, period); const periodComplete = Boolean(completeDate) && localDate() >= completeDate; $("#company-report-confirm-note").textContent = periodComplete ? "将一次生成五家公司的正式 Excel。" : "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。"; setCompanyReportConfirmBusy(false); updateCompanyReportControls(); if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); window.requestAnimationFrame(() => $("#company-report-confirm-cancel").focus()); } async function confirmCompanyReportAction() { const request = state.companyReportConfirmRequest; if (!request || state.companyReportSubmittingPeriod) return; setCompanyReportError(); state.companyReportSubmittingPeriod = request.period; setCompanyReportConfirmBusy(true); updateCompanyReportControls(); try { const created = await api("/api/company-reports/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ report_month: request.reportMonth, period: request.period }), }); localStorage.setItem("arr:last-company-report-job", created.job_id); state.companyReportsOffset = 0; closeCompanyReportConfirm({ force: true, restoreFocus: false }); renderCompanyReportJob(created); $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); showToast("五家公司渠道明细任务已提交"); await loadCompanyReportJob(created.job_id); } catch (error) { const message = error.message || "公司渠道明细任务未能提交"; const errorNode = $("#company-report-confirm-error"); errorNode.textContent = message; errorNode.hidden = false; setCompanyReportConfirmBusy(false); setCompanyReportError(message); showToast(message, true); } finally { state.companyReportSubmittingPeriod = ""; updateCompanyReportControls(); } } function startCompanyReport(period, trigger) { openCompanyReportConfirm({ period, trigger }); } async function refreshCompanyReports() { setCompanyReportError(); await loadHealth(); await Promise.all([loadCompanySource(true), loadCompanyDraft(true), loadCompanyReportHistory(true)]); showToast("公司渠道明细任务已刷新"); } async function changeHistoryPage(totalKey, offsetKey, direction, loader) { const nextOffset = pageOffset(state[totalKey], state[offsetKey], direction); if (nextOffset === state[offsetKey]) return; state[offsetKey] = nextOffset; await loader(); } function bindEvents() { $$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab))); $("#xml-file").addEventListener("change", (event) => { if (state.uploadInFlight) return; state.selectedFile = event.target.files?.[0] || null; if (!state.uploadInFlight) resetUploadProgress(); $("#selected-file").textContent = state.selectedFile ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` : "尚未选择文件"; $("#upload-button").disabled = !state.selectedFile || !state.health.processing_ready; }); const dropzone = $("#dropzone"); ["dragenter", "dragover"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); if (state.health.processing_ready) dropzone.classList.add("is-over"); })); ["dragleave", "drop"].forEach((name) => dropzone.addEventListener(name, (event) => { event.preventDefault(); dropzone.classList.remove("is-over"); })); dropzone.addEventListener("drop", (event) => { if (!state.health.processing_ready || state.uploadInFlight) return; const file = event.dataTransfer?.files?.[0]; if (!file) return; state.selectedFile = file; if (!state.uploadInFlight) resetUploadProgress(); $("#selected-file").textContent = `${file.name} · ${formatInteger(file.size / 1024)} KB`; $("#upload-button").disabled = false; }); $("#upload-button").addEventListener("click", handleUpload); $("#company-excel-file").addEventListener("change", (event) => { selectCompanySourceFile(event.target.files?.[0] || null); }); const companyDropzone = $("#company-excel-dropzone"); ["dragenter", "dragover"].forEach((name) => companyDropzone.addEventListener(name, (event) => { event.preventDefault(); if (state.companySourceUploadReady && !state.companySourceUploading && !isCompanyReportActive()) { companyDropzone.classList.add("is-over"); } })); ["dragleave", "drop"].forEach((name) => companyDropzone.addEventListener(name, (event) => { event.preventDefault(); companyDropzone.classList.remove("is-over"); })); companyDropzone.addEventListener("drop", (event) => { if (!state.companySourceUploadReady || state.companySourceUploading || isCompanyReportActive()) return; selectCompanySourceFile(event.dataTransfer?.files?.[0] || null); }); $("#company-upload-button").addEventListener("click", handleCompanySourceUpload); $("#company-review-body").addEventListener("click", (event) => { const save = event.target.closest("[data-review-save]"); if (save) { saveCompanyReviewItem(save); return; } const remove = event.target.closest("[data-review-delete]"); if (remove) deleteCompanyReviewItem(remove); }); $("#company-review-body").addEventListener("change", (event) => { const checkbox = event.target.closest("[data-review-select]"); if (!checkbox || state.companyReviewMutating) return; const itemId = Number(checkbox.value); if (!Number.isInteger(itemId) || itemId <= 0) return; if (checkbox.checked) state.companyReviewSelectedIds.add(itemId); else state.companyReviewSelectedIds.delete(itemId); syncCompanyReviewSelection(state.companySourceDraft?.items || [], false); }); $("#company-review-select-page").addEventListener("change", (event) => { if (state.companyReviewMutating) return; (state.companySourceDraft?.items || []).forEach((item) => { const itemId = Number(item.item_id); if (!Number.isInteger(itemId) || itemId <= 0) return; if (event.currentTarget.checked) state.companyReviewSelectedIds.add(itemId); else state.companyReviewSelectedIds.delete(itemId); }); syncCompanyReviewSelection(state.companySourceDraft?.items || [], false); }); $("#company-review-delete-selected").addEventListener("click", deleteSelectedCompanyReviewItems); $("#company-review-discard").addEventListener("click", discardCompanyReviewDraft); $("#company-review-activate").addEventListener("click", activateCompanyReviewDraft); $("#company-review-prev").addEventListener("click", async () => { clearCompanyReviewSelection(); state.companyReviewOffset = Math.max(0, state.companyReviewOffset - COMPANY_REVIEW_PAGE_SIZE); await loadCompanyDraft(true); }); $("#company-review-next").addEventListener("click", async () => { clearCompanyReviewSelection(); state.companyReviewOffset += COMPANY_REVIEW_PAGE_SIZE; await loadCompanyDraft(true); }); $("#refresh-jobs").addEventListener("click", () => loadJobs(true)); $("#jobs-prev").addEventListener("click", () => changeHistoryPage("jobsTotal", "jobsOffset", -1, () => loadJobs(true, false)) ); $("#jobs-next").addEventListener("click", () => changeHistoryPage("jobsTotal", "jobsOffset", 1, () => loadJobs(true, false)) ); $("#monthly-prev").addEventListener("click", () => changeHistoryPage("monthlyTotal", "monthlyOffset", -1, () => loadMonthly(true, false)) ); $("#monthly-next").addEventListener("click", () => changeHistoryPage("monthlyTotal", "monthlyOffset", 1, () => loadMonthly(true, false)) ); $("#company-prev").addEventListener("click", () => changeHistoryPage("companyReportsTotal", "companyReportsOffset", -1, () => loadCompanyReportHistory(false)) ); $("#company-next").addEventListener("click", () => changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false)) ); $("#jobs-body").addEventListener("click", (event) => { if (event.target.closest("a, button")) return; const row = event.target.closest("[data-job-id]"); if (row) selectJob(row.dataset.jobId, { showLog: true }); }); $("#jobs-body").addEventListener("keydown", (event) => { if (!["Enter", " "].includes(event.key)) return; const row = event.target.closest("[data-job-id]"); if (!row) return; event.preventDefault(); selectJob(row.dataset.jobId, { showLog: true }); }); $("#task-log-trigger").addEventListener("click", openTaskLog); $("#close-task-log").addEventListener("click", closeTaskLog); $("#task-log-dialog").addEventListener("close", clearTracePoll); $("#task-log-dialog").addEventListener("click", (event) => { if (event.target === event.currentTarget) closeTaskLog(); }); $("#company-review-confirm-cancel").addEventListener("click", () => closeCompanyReviewConfirm()); $("#company-review-confirm-submit").addEventListener("click", confirmCompanyReviewAction); $("#company-review-confirm-dialog").addEventListener("cancel", (event) => { if (state.companyReviewMutating) event.preventDefault(); }); $("#company-review-confirm-dialog").addEventListener("keydown", (event) => { if (event.key !== "Escape") return; event.preventDefault(); closeCompanyReviewConfirm(); }); $("#company-review-confirm-dialog").addEventListener("close", () => { if (state.companyReviewConfirmRequest) resetCompanyReviewConfirm(); }); $("#company-review-confirm-dialog").addEventListener("click", (event) => { if (event.target === event.currentTarget) closeCompanyReviewConfirm(); }); $("#company-report-confirm-cancel").addEventListener("click", () => closeCompanyReportConfirm()); $("#company-report-confirm-submit").addEventListener("click", confirmCompanyReportAction); $("#company-report-confirm-dialog").addEventListener("cancel", (event) => { if (state.companyReportSubmittingPeriod) event.preventDefault(); }); $("#company-report-confirm-dialog").addEventListener("keydown", (event) => { if (event.key !== "Escape") return; event.preventDefault(); closeCompanyReportConfirm(); }); $("#company-report-confirm-dialog").addEventListener("close", () => { if (state.companyReportConfirmRequest) resetCompanyReportConfirm(); }); $("#company-report-confirm-dialog").addEventListener("click", (event) => { if (event.target === event.currentTarget) closeCompanyReportConfirm(); }); $("#refresh-trace").addEventListener("click", async () => { await loadJobTrace(true); showToast("任务日志已刷新"); }); $("#copy-trace").addEventListener("click", copyAllTraceLogs); $("#logout-button").addEventListener("click", logout); $("#bi-month").addEventListener("change", () => loadAnalytics(true)); $("#refresh-company-reports").addEventListener("click", refreshCompanyReports); $("#company-report-month").addEventListener("change", async () => { state.companyReportCurrentJob = null; state.companyReportsOffset = 0; state.companyReportsTotal = 0; $("#company-report-job-panel").hidden = true; updateCompanyReportControls(); updateCompanySourceUploadControls(); await loadCompanyReportHistory(false); }); $$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod, button))); document.addEventListener("visibilitychange", () => { if (document.hidden) { clearTracePoll(); clearMonthlyPoll(); } else { scheduleTracePoll(250); if ($("#panel-monthly")?.classList.contains("is-active")) loadMonthly(false, true); } }); window.addEventListener("hashchange", () => activateTab(location.hash.slice(1), false)); } async function boot() { $("#company-report-month").value = localMonth(); populateMonths([]); bindEvents(); activateTab(location.hash.slice(1) || "daily", false); try { await initSession(); } catch (_) { showToast("页面会话初始化失败", true); return; } await loadHealth(); await Promise.all([ loadJobs(), loadMonthly(), loadMonthsAndAnalytics(), loadCompanySource(), loadCompanyDraft(), loadCompanyReportHistory(location.hash.slice(1) === "usage"), ]); } boot(); })();