Files
wyndham-ARR/arr_web/static/app.js
2026-07-31 15:11:42 +08:00

1980 lines
88 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 `<span class="status-chip ${escapeHtml(style)}">${escapeHtml(label)}</span>`;
}
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 = '<tr><td class="empty-cell" colspan="7">本月暂无 Daily Report 记录</td></tr>';
$("#metric-arrival").textContent = "—";
$("#metric-duration").textContent = "—";
$("#metric-rooms").textContent = "—";
return;
}
body.innerHTML = jobs.map((job) => `
<tr class="job-row${job.job_id === state.selectedJobId ? " is-selected" : ""}" data-job-id="${escapeHtml(job.job_id)}" tabindex="0" role="button" aria-selected="${job.job_id === state.selectedJobId}" aria-label="查看任务 ${escapeHtml(job.job_id)} 的全流程日志">
<td><span class="filename" title="${escapeHtml(job.filename || "—")}">${escapeHtml(job.filename || "—")}</span></td>
<td>${chip(job.status, jobStatus)}${job.failure_code ? `<code class="job-failure-code" title="${escapeHtml(job.failure_code)}">${escapeHtml(job.failure_code)}</code>` : ""}</td>
<td>${escapeHtml(job.arrival_date || "—")}</td>
<td>${formatInteger(job.no_of_rooms)}</td>
<td class="cell-subtle">${duration(job)}</td>
<td class="cell-subtle">${formatDate(job.created_at, true)}</td>
<td>${state.health.download_ready && job.daily_report_sha256 ? `<a class="download-link" href="/api/download/daily?job_id=${encodeURIComponent(job.job_id)}">下载</a>` : '<span class="cell-subtle">—</span>'}</td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="7">日报记录暂时无法读取</td></tr>';
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 = '<tr><td class="empty-cell" colspan="6">该月份暂无月报处理记录</td></tr>';
return;
}
body.innerHTML = runs.map((run) => `
<tr>
<td><strong>${escapeHtml(run.max_arrival_date || run.as_of_date || "—")}</strong></td>
<td>${chip(run.status, reportStatus)}</td>
<td>${formatInteger(run.row_count)}</td>
<td>${formatInteger(run.channel_count)}</td>
<td class="cell-subtle">${formatDate(run.updated_at, true)}</td>
<td>${state.health.download_ready && run.artifact_sha256 ? `<a class="download-link" href="/api/download/monthly?report_id=${encodeURIComponent(run.report_id)}">下载</a>` : '<span class="cell-subtle">—</span>'}</td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="6">月报记录暂时无法读取</td></tr>';
}
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 = `<option value="${localMonth()}">${localMonth()}</option>`;
return;
}
select.innerHTML = months.map((item) => `<option value="${escapeHtml(item.month_key)}">${escapeHtml(item.month_key)}</option>`).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) => `
<div class="rank-row">
<span class="rank-label" title="${escapeHtml(item.worksheet)}">${escapeHtml(item.worksheet)}</span>
<span class="rank-track"><i data-width="${Math.max(0, Number(item.totals.total_price || 0)) / maximum * 100}"></i></span>
<span class="rank-value">${formatMoney(item.totals.total_price, true)}</span>
</div>`).join("") : '<p class="empty-state">暂无公司销售数据</p>';
$$(".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) => `
<span class="legend-item"><i data-color="${colors[index % colors.length]}"></i><span title="${escapeHtml(item.worksheet)}">${escapeHtml(item.worksheet)}</span></span>`).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 = '<p class="empty-state">暂无渠道与房型数据</p>';
return;
}
const heading = roomTypes.map((room) => `<th>${escapeHtml(room)}</th>`).join("");
const rows = data.channels.map((channel) => {
const values = new Map(channel.room_types.map((room) => [room.room_type, room.rooms_sold]));
return `<tr><td><strong>${escapeHtml(channel.worksheet)}</strong></td>${roomTypes.map((room) => `<td>${formatInteger(values.get(room) || 0)}</td>`).join("")}<td class="matrix-total">${formatInteger(channel.totals.rooms_sold)}</td></tr>`;
}).join("");
const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold]));
$("#room-matrix").innerHTML = `<table><thead><tr><th>渠道</th>${heading}<th>合计</th></tr></thead><tbody>${rows}<tr><td class="matrix-total">合计</td>${roomTypes.map((room) => `<td class="matrix-total">${formatInteger(totals.get(room) || 0)}</td>`).join("")}<td class="matrix-total">${formatInteger(data.overall.totals.rooms_sold)}</td></tr></tbody></table>`;
}
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 = `<p class="empty-state">${escapeHtml(message)}</p>`;
$("#donut-legend").innerHTML = "";
$("#company-donut").style.background = "#d8e0ec";
$("#room-matrix").innerHTML = `<p class="empty-state">${escapeHtml(message)}</p>`;
}
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 `<tr class="company-review-row${isPending ? " is-pending" : ""}${selected ? " is-selected" : ""}" data-review-item-id="${itemId}" aria-selected="${selected}">
<td class="company-review-select-cell" data-label="选择"><input class="company-review-checkbox" data-review-select type="checkbox" value="${itemId}" aria-label="选择 ${tourCode}" ${selected ? "checked" : ""} ${busy ? "disabled" : ""}></td>
<td class="company-review-tour" data-label="Tour Code">${tourCode}</td>
<td class="company-review-raw" data-label="原始标识">${raw}</td>
<td data-label="房型"><input class="company-review-input company-review-room-input" data-review-room type="text" maxlength="128" value="${roomType}" placeholder="请输入房型" aria-label="${tourCode} 房型" ${busy ? "disabled" : ""}></td>
<td data-label="数量"><input class="company-review-input company-review-quantity-input" data-review-quantity type="number" min="1" max="9999" step="1" value="${quantity}" aria-label="${tourCode} 数量" ${busy ? "disabled" : ""}></td>
<td data-label="状态"><span class="company-review-status${isPending ? " is-pending" : ""}">${isPending ? "待人工确认" : "已确认"}</span></td>
<td data-label="操作"><div class="company-review-row-actions"><button class="company-review-save" data-review-save type="button" ${busy ? "disabled" : ""}>保存</button><button class="company-review-delete" data-review-delete type="button" ${busy ? "disabled" : ""}>删除</button></div></td>
</tr>`;
}).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 `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result is-pending">待生成</span></td>`;
}
if (!result) {
return `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result ${isCompanyReportActive(job) ? "is-waiting" : "is-empty"}">${isCompanyReportActive(job) ? "等待生成" : "无结果"}</span></td>`;
}
if (result.status === "failed") {
return `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result is-failed">未生成</span></td>`;
}
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 `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result ${count === null ? "is-empty" : "is-complete"}">${count === null ? "未记录" : `${formatInteger(count)}`}</span></td>`;
}
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) => `<span class="company-problem is-${item.kind}">${escapeHtml(companyProblemLabel(item.code))}<small>${escapeHtml(item.period || "暂无")}</small></span>`).join("")
: '<span class="cell-subtle">暂无</span>';
const download = result?.download_url?.startsWith("/api/company-reports/")
? `<a class="download-link" href="${escapeHtml(result.download_url)}">下载</a>`
: `<span class="cell-subtle">${isCompanyReportActive(job) ? "待生成" : "暂无"}</span>`;
return `<tr class="company-result-row ${rowStyle}">
<td class="company-name-cell" data-label="公司">${escapeHtml(company)}</td>
${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")}
<td data-label="合计">${result ? formatInteger(result.row_count) : "暂无"}</td>
<td data-label="状态"><span class="status-chip ${resultStyle}">${escapeHtml(resultStatus)}</span></td>
<td data-label="复核 / 异常"><div class="company-problem-list">${problemHtml}</div></td>
<td class="company-generated-at-cell" data-label="生成时间">${formatDate(job.finished_at || job.created_at, true)}</td>
<td data-label="Excel">${download}</td>
</tr>`;
}).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 = '<tr><td class="empty-cell" colspan="7">该月份还没有公司渠道明细任务</td></tr>';
return;
}
body.innerHTML = jobs.map((job) => `<tr>
<td>${formatDate(job.created_at, true)}</td>
<td><strong>${escapeHtml(job.report_month || "暂无")}</strong></td>
<td>${escapeHtml(companyCutoffLabel(job))}</td>
<td><span class="status-chip ${companyStateStyle(job.state)}">${escapeHtml(companyStateLabel(job.state))}</span></td>
<td>${companySuccessCount(job)} / 5</td>
<td>${companyWarningCount(job)}</td>
<td><button class="company-detail-button" type="button" data-company-job-id="${escapeHtml(job.job_id)}">查看</button></td>
</tr>`).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 = '<tr><td class="empty-cell" colspan="7">任务记录暂时无法读取</td></tr>';
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();
})();