Files
wyndham-ARR/arr_web/static/app.js
2026-08-03 16:08:14 +08:00

2313 lines
111 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,
analyticsLoading: false,
biPollTimer: null,
biPollLoading: false,
toastTimer: null,
jobs: [],
selectedJobId: "",
jobTrace: null,
jobTraceLoading: false,
jobTracePollTimer: null,
jobsLoading: false,
jobsTotal: 0,
jobsOffset: 0,
jobsMonth: "",
monthlyRuns: [],
monthlyLoaded: false,
monthlyLoading: false,
monthlyPollTimer: null,
monthlyTotal: 0,
monthlyOffset: 0,
monthlyMonth: "",
historyMonths: [],
historyMonthsLoaded: false,
companyReportJobs: [],
companyReportCurrentJob: null,
companyReportPollTimer: null,
companyReportSubmittingPeriod: "",
companyHistoryLoading: false,
companyReportsTotal: 0,
companyReportsOffset: 0,
companyHistoryMonth: "",
companyReportsReady: false,
companySourceUploadReady: false,
};
const BUSINESS_TIME_ZONE = "Asia/Bangkok";
const I18N = window.ARRI18n;
const TRACE_POLL_INTERVAL = 4000;
const MONTHLY_POLL_INTERVAL = 4000;
const BI_POLL_INTERVAL = 5000;
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: "upload.stage_processor", delay: 600 },
{ value: 68, label: "upload.stage_validate", delay: 1400 },
{ value: 88, label: "upload.stage_commit", delay: 1800 },
];
const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"];
const jobStatus = {
received: ["status.received", "running"],
uploaded: ["status.uploaded", "running"],
queued: ["status.waiting_processing", "running"],
running: ["status.processing", "running"],
delivered: ["status.waiting_validation", "running"],
validating: ["status.validating", "running"],
accepted: ["status.completed", "success"],
succeeded: ["status.completed", "success"],
rejected: ["status.validation_failed", "failed"],
failed: ["status.failed", "failed"],
cancelled: ["status.cancelled", "failed"],
};
const reportStatus = {
reserved: ["status.generating", "running"],
generating: ["status.generating", "running"],
validated: ["status.validated", "running"],
active: ["status.current_version", "success current"],
superseded: ["status.history_version", ""],
failed: ["status.generation_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 = I18N?.text(message) || 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(I18N?.errorMessage("SESSION_INVALID", "登录状态已失效") || "登录状态已失效");
}
let payload;
try {
payload = await response.json();
} catch (_) {
throw new Error(I18N?.t("error.unrecognized_response") || "服务返回了无法识别的结果");
}
if (!response.ok || !payload.ok) {
const error = payload?.error || {};
const requestError = new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成");
requestError.code = error.code || "";
requestError.status = response.status;
throw requestError;
}
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));
}
const HISTORY_SCOPES = {
jobs: { stateKey: "jobsMonth", countKey: "daily_count" },
monthly: { stateKey: "monthlyMonth", countKey: "monthly_count" },
company: { stateKey: "companyHistoryMonth", countKey: "company_count" },
};
function validMonth(value) {
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value || "");
}
function shiftMonth(value, amount) {
if (!validMonth(value)) return localMonth();
const [year, month] = value.split("-").map(Number);
const shifted = new Date(Date.UTC(year, month - 1 + amount, 1));
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}`;
}
function latestHistoryMonth(scope) {
const countKey = HISTORY_SCOPES[scope].countKey;
return state.historyMonths.find((item) => Number(item[countKey] || 0) > 0)?.month_key || "";
}
function historyMonth(scope) {
return state[HISTORY_SCOPES[scope].stateKey] || localMonth();
}
function renderHistoryMonthControl(scope) {
const selected = historyMonth(scope);
const current = localMonth();
const input = $(`#${scope}-history-month`);
if (!input) return;
input.value = selected;
input.max = current;
$(`#${scope}-month-previous`).disabled = selected <= input.min;
$(`#${scope}-month-next`).disabled = selected >= current;
$(`#${scope}-month-current`).disabled = selected === current;
}
function renderHistoryMonthControls() {
Object.keys(HISTORY_SCOPES).forEach(renderHistoryMonthControl);
}
async function loadHistoryMonths() {
try {
const months = await api("/api/history-months");
state.historyMonths = Array.isArray(months)
? months.filter((item) => validMonth(item?.month_key))
: [];
state.historyMonthsLoaded = true;
} catch (_) {
state.historyMonths = [];
}
Object.entries(HISTORY_SCOPES).forEach(([scope, config]) => {
if (!state[config.stateKey]) {
state[config.stateKey] = latestHistoryMonth(scope) || localMonth();
}
});
renderHistoryMonthControls();
}
function historyEmptyMarkup(scope, emptyKey, fallback) {
const selected = historyMonth(scope);
const recent = latestHistoryMonth(scope);
const message = I18N?.t(emptyKey, { month: monthLabel(selected) }) || `${monthLabel(selected)} · ${fallback}`;
const jump = recent && recent !== selected
? `<button class="history-empty-jump" type="button" data-history-jump="${escapeHtml(scope)}" data-history-month="${escapeHtml(recent)}">${escapeHtml(I18N?.t("history.view_recent", { month: monthLabel(recent) }) || `查看 ${monthLabel(recent)}`)}</button>`
: "";
return `<span class="history-empty-content"><span>${escapeHtml(message)}</span>${jump}</span>`;
}
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;
const summary = total > 0
? (I18N?.t("pagination.summary", { total: formatInteger(total), start: formatInteger(start), end: formatInteger(end) }) || `${formatInteger(total)} 条 · 本页 ${formatInteger(start)}${formatInteger(end)}`)
: (I18N?.t("pagination.zero") || "共 0 条");
$(`#${scope}-pagination-summary`).textContent = I18N?.t("history.pagination", { month: monthLabel(historyMonth(scope)), summary }) || `${monthLabel(historyMonth(scope))} · ${summary}`;
$(`#${scope}-page-label`).textContent = I18N?.t("pagination.page", { page: formatInteger(page), pages: formatInteger(pageCount) }) || `${formatInteger(page)} / ${formatInteger(pageCount)}`;
$(`#${scope}-prev`).disabled = loading || offset <= 0;
$(`#${scope}-next`).disabled = loading || offset + HISTORY_PAGE_SIZE >= total;
}
function formatInteger(value) {
return I18N?.formatInteger(value) || new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
}
function formatMoney(value, compact = false) {
if (I18N) return I18N.formatMoney(value, compact);
const amount = Number(value || 0);
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount);
}
function formatDate(value, withTime = false) {
return I18N?.formatDate(value, withTime) || "—";
}
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 (I18N) return I18N.formatDurationSeconds(seconds);
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes}${rest}`;
}
function chip(status, map) {
const [labelKey, style] = map[status] || ["common.unknown", ""];
const label = I18N?.t(labelKey) || labelKey;
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") {
if (!state.analytics) loadAnalytics();
scheduleBiPoll(250);
} else {
clearBiPoll();
}
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.removeAttribute("aria-disabled");
button.classList.remove("is-busy");
button.title = `${session.username} · ${I18N?.t("auth.logout") || "退出登录"}`;
}
async function logout() {
const button = $("#logout-button");
if (button.getAttribute("aria-disabled") === "true") return;
button.setAttribute("aria-disabled", "true");
button.classList.add("is-busy");
try {
await api("/api/logout", { method: "POST" });
window.location.replace("/login");
} catch (error) {
if (document.visibilityState === "visible") {
showToast(error.message || I18N?.t("auth.logout_failed") || "退出登录失败,请重试", true);
button.removeAttribute("aria-disabled");
button.classList.remove("is-busy");
}
}
}
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");
const databaseReady = Boolean(state.health.database_ready);
const statusText = $("span", node);
node.classList.toggle("is-ready", databaseReady);
node.classList.toggle("is-error", !databaseReady);
statusText.textContent = databaseReady
? ""
: (I18N?.t("status.service_disconnected") || "数据服务未连接");
node.setAttribute(
"aria-label",
databaseReady
? (I18N?.t("status.service_ready") || "服务正常")
: (I18N?.t("status.service_disconnected") || "数据服务未连接"),
);
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 ? "" : (I18N?.t("upload.service_unready") || "文件接收服务尚未完成生产接线。");
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">${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 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(I18N?.t("task.view_job", { jobId: job.job_id }) || `查看任务 ${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 ? formatDate(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)}">${escapeHtml(I18N?.t("common.download") || "下载")}</a>` : '<span class="cell-subtle">—</span>'}</td>
</tr>`).join("");
const latest = jobs.find((job) => job.status === "succeeded");
$("#metric-arrival").textContent = latest?.arrival_date ? formatDate(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 = I18N?.text(copy) || 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(I18N?.t("task.browser_clipboard") || "浏览器未允许访问剪贴板");
}
async function copyAllTraceLogs() {
const button = $("#copy-trace");
const text = $("#process-log").textContent || "";
if (!state.jobTrace || !text.trim()) {
showToast(I18N?.t("task.none_to_copy") || "暂无可复制的任务日志", true);
return;
}
button.disabled = true;
button.textContent = I18N?.t("task.copying_button") || "复制中…";
try {
await writeClipboardText(text);
button.textContent = I18N?.t("task.copied_button") || "已复制";
showToast(I18N?.t("task.copying_lines", { count: formatInteger(text.split("\n").length) }) || `已复制全部任务日志(${text.split("\n").length} 行)`);
} catch (_) {
button.textContent = I18N?.t("task.copy_failed_button") || "复制失败";
showToast(I18N?.t("task.copy_denied") || "无法复制日志,请检查浏览器剪贴板权限", true);
} finally {
window.setTimeout(() => {
button.textContent = I18N?.t("task.copy_button") || "复制全部日志";
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 = I18N?.t("trace.events", { count: 0 }) || "0 events";
$("#process-log").textContent = [
`$ arr trace${state.selectedJobId ? ` --job ${state.selectedJobId}` : ""}`,
`# ${I18N?.text(title) || title}`,
`# ${I18N?.text(logCopy || detail || "请上传文件,或从下方选择一个历史任务。") || 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)} execution_scope=${traceConsoleValue(job.execution_scope || "unknown")} processor_mode=${traceConsoleValue(job.processor_mode || "unknown")} remote_dispatch=${traceConsoleValue(job.remote_dispatch || "unknown")} 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, " ");
const title = I18N?.text(log.title || "") || log.title || "";
const message = I18N?.errorMessage(log.code, log.message) || I18N?.text(log.message || "") || log.message || "";
return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(title)} message=${traceConsoleValue(message)} details=${JSON.stringify(log.details || {})}`;
}),
"--------------------------------------------------------------------------------",
];
if (job.failure) {
const failureMessage = I18N?.errorMessage(job.failure.code, job.failure.message) || I18N?.text(job.failure.message || "") || job.failure.message || "";
lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(failureMessage)}`);
}
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 = I18N?.t("trace.events_count", { count: formatInteger(logs.length) }) || `${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(I18N?.t("trace.failed_status", { stage: job.current_stage || "unknown", code: job.failure.code || "unknown" }) || `FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error");
} else if (job.active) {
setTraceLiveState(I18N?.t("trace.running_status", { seconds: TRACE_POLL_INTERVAL / 1000 }) || `RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live");
} else if (job.status === "succeeded") {
setTraceLiveState(I18N?.t("trace.succeeded_status") || "SUCCEEDED", "is-complete");
} else {
setTraceLiveState(I18N?.t("trace.unknown_status", { status: String(job.status || "UNKNOWN").toUpperCase() }) || 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(I18N?.t("task.reading") || "正在读取任务日志", I18N?.t("task.selected_job", { jobId }) || `任务 ${jobId}`, "running", I18N?.t("task.reading_detail") || "正在聚合数据库中的处理证据…");
setTraceLiveState(I18N?.t("task.live_reading") || "正在读取", "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(I18N?.t("task.unavailable") || "任务日志暂时无法读取", error.message, "error", I18N?.t("task.no_server_log") || "服务端没有返回可用日志。");
}
setTraceLiveState(showErrors ? (I18N?.t("task.live_failed") || "日志刷新失败") : (I18N?.t("task.live_auto_failed") || "自动刷新失败"), "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(historyMonth("jobs"));
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(
I18N?.t("task.waiting_upload") || "等待上传 ARR.XML",
I18N?.t("task.after_create") || "新任务创建后,这里会显示服务端保存的全流程日志。",
);
setTraceLiveState(I18N?.t("task.live_unselected") || "未选择任务");
}
} catch (error) {
$("#jobs-body").innerHTML = `<tr><td class="empty-cell" colspan="7">${escapeHtml(I18N?.t("daily.unavailable") || "日报记录暂时无法读取")}</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 clearBiPoll() {
window.clearTimeout(state.biPollTimer);
state.biPollTimer = null;
}
function biViewActive() {
return !document.hidden && $("#panel-bi")?.classList.contains("is-active");
}
function scheduleBiPoll(delay = BI_POLL_INTERVAL) {
clearBiPoll();
if (!biViewActive()) return;
state.biPollTimer = window.setTimeout(checkBiFreshness, delay);
}
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 = I18N?.text(copy) || 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">${historyEmptyMarkup("monthly", "history.monthly_empty", "暂无月报处理记录")}</td></tr>`;
return;
}
body.innerHTML = runs.map((run) => `
<tr>
<td><strong>${escapeHtml(run.max_arrival_date || run.as_of_date ? formatDate(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)}">${escapeHtml(I18N?.t("common.download") || "下载")}</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 = historyMonth("monthly");
const previousTotal = state.monthlyTotal;
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, true);
if (!silent) setMonthlyLiveState(I18N?.t("monthly.loading") || "正在读取月报记录…", "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(I18N?.t("progress.auto_update", { seconds: MONTHLY_POLL_INTERVAL / 1000 }) || `自动更新 · ${MONTHLY_POLL_INTERVAL / 1000}`, "is-live");
if (hasNewPublication) showToast(I18N?.t("monthly.new") || "新月报已自动加入列表");
} catch (error) {
if (!silent || !state.monthlyLoaded) {
$("#monthly-body").innerHTML = `<tr><td class="empty-cell" colspan="6">${escapeHtml(I18N?.t("monthly.unavailable") || "月报记录暂时无法读取")}</td></tr>`;
}
setMonthlyLiveState(I18N?.t("monthly.retry") || "自动更新重试中", "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;
}
async function checkBiFreshness() {
if (state.biPollLoading || !biViewActive()) {
scheduleBiPoll();
return;
}
state.biPollLoading = true;
try {
const months = await api("/api/months");
populateMonths(months);
const month = $("#bi-month").value || localMonth();
const metadata = months.find((item) => item.month_key === month);
const current = state.analytics;
const changed = !current
|| current.month_key !== month
|| Boolean(metadata && metadata.updated_at !== current.updated_at);
if (changed && !state.analyticsLoading) await loadAnalytics(false, Boolean(current));
} catch (_) {
// Keep the last good BI snapshot during a transient background failure.
} finally {
state.biPollLoading = false;
scheduleBiPoll();
}
}
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">${escapeHtml(I18N?.t("bi.empty_sales") || "暂无公司销售数据")}</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">${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}</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]));
const channelLabel = I18N?.t("bi.channel") || "渠道";
const totalLabel = I18N?.t("common.total") || "合计";
$("#room-matrix").innerHTML = `<table><thead><tr><th>${escapeHtml(channelLabel)}</th>${heading}<th>${escapeHtml(totalLabel)}</th></tr></thead><tbody>${rows}<tr><td class="matrix-total">${escapeHtml(totalLabel)}</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 analyticsDataRange(data = {}) {
const start = data.min_arrival_date ? formatDate(data.min_arrival_date) : "—";
const end = data.max_arrival_date ? formatDate(data.max_arrival_date) : "—";
return I18N?.t("bi.data_range", { start, end }) || `数据范围:${start} ${end}`;
}
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-data-range").textContent = analyticsDataRange(data);
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";
$("#bi-data-range").textContent = analyticsDataRange();
$("#room-matrix").innerHTML = `<p class="empty-state">${escapeHtml(message)}</p>`;
}
async function loadAnalytics(showErrors = false, preserveOnError = false) {
if (state.analyticsLoading) return;
state.analyticsLoading = true;
const month = $("#bi-month").value || localMonth();
try {
renderAnalytics(await api(`/api/analytics?month=${encodeURIComponent(month)}`));
} catch (error) {
if (!preserveOnError) resetAnalytics(I18N?.t("bi.empty") || "该月份暂无可用看板数据");
if (showErrors) showToast(error.message, true);
} finally {
state.analyticsLoading = false;
}
}
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 = I18N?.text(label) || label;
percentNode.textContent = `${bounded}%`;
track.setAttribute("aria-valuenow", String(bounded));
track.setAttribute("aria-valuetext", `${I18N?.text(label) || 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 = I18N?.t("upload.waiting") || "等待开始";
$("#upload-progress-percent").textContent = "0%";
$("#upload-progress-track").setAttribute("aria-valuenow", "0");
$("#upload-progress-track").setAttribute("aria-valuetext", `${I18N?.t("upload.waiting") || "等待开始"} 0%`);
$("#upload-progress-bar").style.transform = "scaleX(0)";
}
function startUploadProgress() {
clearUploadProgressTimer();
setUploadProgress(12, I18N?.t("upload.uploading") || "正在上传 ARR.XML");
let stageIndex = 0;
const advance = () => {
const stage = DAILY_UPLOAD_PROGRESS_STAGES[stageIndex];
if (!stage) return;
setUploadProgress(stage.value, I18N?.t(stage.label) || 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 = I18N?.t("upload.processing") || "处理中…";
clearTracePoll();
state.selectedJobId = "";
state.jobTrace = null;
renderJobs(state.jobs);
renderTracePlaceholder(
I18N?.t("upload.processing") || "正在处理ARR.XML文件",
I18N?.t("upload.processing_detail") || "正在安全上传、运行固定处理器、独立验收并提交数据库。",
"running",
I18N?.t("upload.after_processing") || "处理完成后将立即读取服务端全流程日志…",
);
setTraceLiveState(I18N?.t("upload.creating_task") || "正在创建任务", "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 = I18N?.t("upload.file_not_selected") || "尚未选择文件";
const failed = receipt.status === "failed";
finishUploadProgress(!failed, failed ? (I18N?.t("upload.processing_failed") || "处理失败") : (I18N?.t("upload.processing_done") || "处理完成"));
showToast(failed ? (I18N?.t("upload.failure_log") || "ARR.XML 处理失败,请查看任务日志") : (I18N?.t("upload.success") || "ARR.XML 已处理并完成入库"), failed);
const receiptMonth = String(receipt.business_date || receipt.arrival_date || "").slice(0, 7);
state.jobsMonth = validMonth(receiptMonth) ? receiptMonth : localMonth();
state.jobsOffset = 0;
renderHistoryMonthControl("jobs");
await loadJobs(false, false);
if (receipt.job_id) {
await selectJob(receipt.job_id);
} else {
renderTracePlaceholder(I18N?.t("task.trace_processing") || "处理已结束", I18N?.t("task.no_job_id") || "任务编号暂不可用,请刷新 Daily Report。", "success");
}
} catch (error) {
finishUploadProgress(false, I18N?.t("upload.processing_failed") || "处理失败");
renderTracePlaceholder(I18N?.t("task.trace_failed") || "处理未能完成", error.message, "error", I18N?.t("task.failed_refresh_hint") || "请刷新 Daily Report确认是否已登记失败任务。");
setTraceLiveState(I18N?.t("task.error_status") || "处理失败", "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 = I18N?.t("upload.start") || "开始处理";
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 = I18N?.text(message) || message;
node.hidden = !message;
}
function setCompanySourceStatus(message = "") {
const node = $("#company-source-status");
node.textContent = I18N?.text(message) || message;
node.hidden = !message;
}
function companySourceFilename(source) {
const filename = String(source?.filename || "").trim();
return filename || (I18N?.t("company.source_file_missing") || "未记录文件名");
}
function companySourceMeta(source) {
const batchId = Number(source?.source_batch_id);
const rows = Number(source?.source_rows);
const rooms = Number(source?.room_quantity);
const batch = Number.isInteger(batchId) && batchId > 0 ? formatInteger(batchId) : "—";
const rowCount = Number.isInteger(rows) && rows >= 0 ? formatInteger(rows) : "—";
const roomCount = Number.isInteger(rooms) && rooms >= 0 ? formatInteger(rooms) : "—";
const activated = source?.activated_at ? formatDate(source.activated_at, true) : (I18N?.t("company.review_no_record") || "暂无");
return I18N?.t("company.source_meta", { batch, rows: rowCount, rooms: roomCount, activated })
|| `批次 #${batch} · ${rowCount} 条记录 · ${roomCount} 间 · 启用于 ${activated}`;
}
function renderCompanySource(source) {
const panel = $("#company-source-current");
if (!panel) return;
if (!source) {
panel.hidden = true;
$("#company-source-current-filename").textContent = "";
$("#company-source-current-meta").textContent = "";
return;
}
panel.hidden = false;
$("#company-source-current-filename").textContent = companySourceFilename(source);
$("#company-source-current-filename").title = companySourceFilename(source);
$("#company-source-current-meta").textContent = companySourceMeta(source);
}
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
? (I18N?.t("company.checking") || "核对中")
: (I18N?.t("company.extract") || "提取并核对");
}
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 = I18N?.t("company.selected_count", { count: formatInteger(selectedCount) }) || `已选 ${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").textContent = I18N?.t("company.file_name_missing") || "未记录文件名";
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 || I18N?.t("company.review_no_record") || "暂无");
const raw = escapeHtml(item.room_type_raw || I18N?.t("company.review_no_record") || "暂无");
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="${escapeHtml(I18N?.t("company.review_select") || "选择")}"><input class="company-review-checkbox" data-review-select type="checkbox" value="${itemId}" aria-label="${escapeHtml(`${I18N?.t("common.select") || "选择"} ${item.tour_code || ""}`)}" ${selected ? "checked" : ""} ${busy ? "disabled" : ""}></td>
<td class="company-review-tour" data-label="Tour Code">${tourCode}</td>
<td class="company-review-raw" data-label="${escapeHtml(I18N?.t("company.raw_identifier") || "原始标识")}">${raw}</td>
<td data-label="${escapeHtml(I18N?.t("common.room_type") || "房型")}"><input class="company-review-input company-review-room-input" data-review-room type="text" maxlength="128" value="${roomType}" placeholder="${escapeHtml(I18N?.t("company.fill_room_type") || "请输入房型")}" aria-label="${escapeHtml(`${item.tour_code || ""} ${I18N?.t("common.room_type") || "房型"}`)}" ${busy ? "disabled" : ""}></td>
<td data-label="${escapeHtml(I18N?.t("common.quantity") || "数量")}"><input class="company-review-input company-review-quantity-input" data-review-quantity type="number" min="1" max="9999" step="1" value="${quantity}" aria-label="${escapeHtml(`${item.tour_code || ""} ${I18N?.t("common.quantity") || "数量"}`)}" ${busy ? "disabled" : ""}></td>
<td data-label="${escapeHtml(I18N?.t("common.status") || "状态")}"><span class="company-review-status${isPending ? " is-pending" : ""}">${isPending ? (I18N?.t("company.review_pending_confirm") || "待人工确认") : (I18N?.t("company.confirmed") || "已确认")}</span></td>
<td data-label="${escapeHtml(I18N?.t("common.operation") || "操作")}"><div class="company-review-row-actions"><button class="company-review-save" data-review-save type="button" ${busy ? "disabled" : ""}>${escapeHtml(I18N?.t("company.review_save") || "保存")}</button><button class="company-review-delete" data-review-delete type="button" ${busy ? "disabled" : ""}>${escapeHtml(I18N?.t("company.review_delete") || "删除")}</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
? (I18N?.t("pagination.summary", { total: formatInteger(total), start: formatInteger(start), end: formatInteger(end) }) || `${formatInteger(total)} 条 · 本页 ${formatInteger(start)}${formatInteger(end)}`)
: (I18N?.t("pagination.zero") || "共 0 条");
$("#company-review-page-label").textContent = I18N?.t("pagination.page", { page: formatInteger(currentPage), pages: formatInteger(pageCount) }) || `${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();
setCompanySourceStatus();
state.companySourceFile = null;
if (file) {
if (!file.name.toLowerCase().endsWith(".xlsx")) {
setCompanySourceError(I18N?.t("company.file_xlsx") || "请选择 .xlsx 格式的 Excel 文件");
} else if (!file.size) {
setCompanySourceError(I18N?.t("company.file_empty") || "Excel 文件为空");
} else if (file.size > state.maxUploadBytes) {
setCompanySourceError(I18N?.t("company.file_too_large") || "Excel 文件超过 25 MB");
} else {
state.companySourceFile = file;
}
}
$("#company-selected-file").textContent = state.companySourceFile
? `${state.companySourceFile.name} · ${formatInteger(state.companySourceFile.size / 1024)} KB`
: (I18N?.t("upload.file_not_selected") || "尚未选择文件");
updateCompanySourceUploadControls();
}
async function loadCompanySource(showErrors = false) {
if (!state.companySourceUploadReady) {
state.companySource = null;
renderCompanySource(null);
updateCompanyReportControls();
return;
}
try {
state.companySource = await api("/api/company-reports/source");
renderCompanySource(state.companySource);
setCompanySourceError();
} catch (error) {
state.companySource = null;
renderCompanySource(null);
setCompanySourceError(I18N?.t("company.source_error") || "当前 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(I18N?.t("company.draft_error") || "当前提取结果暂时无法读取");
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();
setCompanySourceStatus();
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 = I18N?.t("upload.file_not_selected") || "尚未选择文件";
renderCompanyDraft(state.companySourceDraft);
const pending = Number(receipt?.summary?.pending_items) || 0;
showToast(pending > 0
? (I18N?.t("company.extract_pending", { count: formatInteger(pending) }) || `提取完成,${formatInteger(pending)} 条需要人工确认`)
: (I18N?.t("company.extract_done") || "提取完成,可以确认并启用"));
} catch (error) {
if (error.code === "BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED") {
const message = I18N?.t("company.source_already_active") || "这份 Excel 已是当前启用来源;请在下方按月份查看或生成对应报表";
state.companySourceFile = null;
$("#company-excel-file").value = "";
$("#company-selected-file").textContent = I18N?.t("upload.file_not_selected") || "尚未选择文件";
setCompanySourceError();
setCompanySourceStatus(message);
await Promise.all([loadCompanySource(true), loadCompanyReportHistory(true)]);
showToast(message);
return;
}
setCompanySourceError(error.message || I18N?.t("company.file_extract_failed") || "Excel 文件未能完成提取");
showToast(error.message || I18N?.t("company.file_extract_failed") || "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(I18N?.t("company.fill_room_type") || "请填写房型后再保存");
return;
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 9999) {
setCompanySourceError(I18N?.t("company.quantity_invalid") || "房间数量必须是 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(I18N?.t("company.review_saved") || "房型记录已保存");
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanySourceError(error.message || I18N?.t("company.review_save_failed") || "房型记录未能保存");
showToast(error.message || I18N?.t("company.review_save_failed") || "房型记录未能保存", 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" ? (I18N?.t("company.discarding") || "正在放弃") : (I18N?.t("company.deleting") || "正在删除"))
: (request?.confirmLabel || I18N?.t("company.confirm_delete") || "确认删除");
}
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: I18N?.t("company.confirm_discard") || "确认放弃",
};
$("#company-review-confirm-title").textContent = I18N?.t("company.confirm_discard_title") || "放弃本次提取";
$("#company-review-confirm-description").textContent = I18N?.t("company.confirm_discard_description") || "确认放弃本次提取?";
$("#company-review-confirm-note").textContent = I18N?.t("company.confirm_discard_note") || "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。";
} else {
const itemIds = [...new Set(request.itemIds || [])];
if (!itemIds.length) return;
const single = itemIds.length === 1;
state.companyReviewConfirmRequest = {
...request,
itemIds,
confirmLabel: single ? (I18N?.t("company.confirm_delete") || "确认删除") : (I18N?.t("company.delete_count", { count: formatInteger(itemIds.length) }) || `删除 ${formatInteger(itemIds.length)}`),
};
$("#company-review-confirm-title").textContent = I18N?.t("company.confirm_delete_title") || "删除房型记录";
$("#company-review-confirm-description").textContent = single
? (I18N?.t("company.confirm_single_delete", { tourCode: request.tourCode || I18N?.t("company.this_record") || "这条记录" }) || `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?`)
: (I18N?.t("company.confirm_multi_delete", { count: formatInteger(itemIds.length) }) || `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`);
$("#company-review-confirm-note").textContent = I18N?.t("company.confirm_delete_note") || "此操作只影响本次提取结果,确认启用前原数据源不会改变。";
}
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() || I18N?.t("company.this_record") || "这条记录";
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(I18N?.t("company.review_discarded") || "本次提取已放弃,原数据源未改变");
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
? (I18N?.t("company.review_deleted") || "房型记录已删除")
: (I18N?.t("company.deleted_count", { count: formatInteger(deletedCount) }) || `已删除 ${formatInteger(deletedCount)} 条房型记录`));
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanyReviewConfirmBusy(false);
const message = error.message || (request.kind === "discard" ? (I18N?.t("company.review_discard_failed") || "本次提取未能放弃") : (I18N?.t("company.review_delete_failed") || "房型记录未能删除"));
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);
renderCompanySource(source);
setCompanySourceStatus();
updateCompanyReportControls();
showToast(I18N?.t("company.review_activated") || "人工核对完成Booking 数据源已启用");
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanySourceError(error.message || I18N?.t("company.source_activate_failed") || "Booking 数据源未能启用");
showToast(error.message || I18N?.t("company.source_activate_failed") || "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}` : (I18N?.t("company.month_end") || "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 ? (I18N?.formatMonth(value) || `${match[1]}${match[2]}`) : (I18N?.t("common.year_month_unselected") || "未选择");
}
function formatDurationSeconds(value) {
if (I18N) return I18N.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
? (I18N?.t("period.release", { date: formatDate(completeDate) }) || `周期结束:${completeDate} 00:00曼谷`)
: (I18N?.t("company.select_month") || "选择月份");
statusNode.className = "company-period-state";
if (!monthValid) {
statusNode.textContent = I18N?.t("company.select_month") || "选择月份";
} else if (reviewOpen) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = I18N?.t("company.review_open") || "先完成核对";
} else if (!sourceReady) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = I18N?.t("company.source_required") || "先上传 Excel";
} else if (!state.companyReportsReady) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = I18N?.t("company.service_required") || "服务未就绪";
} else if (monthFuture) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = I18N?.t("company.future_month") || "未来月份";
} else if (!periodComplete) {
statusNode.classList.add("is-in-progress");
statusNode.textContent = I18N?.t("company.period_not_finished") || "周期未结束";
} else {
statusNode.classList.add("is-complete");
statusNode.textContent = I18N?.t("company.period_finished") || "周期已结束";
}
button.disabled = active || !state.companyReportsReady || !sourceReady || !monthValid || monthFuture;
button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period));
if (!monthValid) button.title = I18N?.t("company.no_valid_month") || "请先选择有效的报表月份";
else if (reviewOpen) button.title = I18N?.t("company.review_open") || "请先确认或放弃当前 Excel 提取结果";
else if (!sourceReady) button.title = I18N?.t("company.source_required") || "请先提取并启用 Excel 报表";
else if (!state.companyReportsReady) button.title = I18N?.t("company.service_required") || "公司渠道明细服务尚未就绪";
else if (monthFuture) button.title = I18N?.t("company.future_month") || "未来报表月份暂不可生成";
else if (!periodComplete) button.title = I18N?.t("period.release_hint", { date: formatDate(completeDate) }) || `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`;
else button.removeAttribute("title");
});
}
function companyStateLabel(value) {
const key = {
queued: "company.queued",
running: "company.generating",
succeeded: "company.all_success",
partial_failure: "company.partial_success",
failed: "company.incomplete",
}[value];
return I18N?.t(key || "common.unknown") || "未知";
}
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 I18N?.t("company.cutoff_10") || "截至 10 日";
if (job.period === "11-20") return I18N?.t("company.cutoff_20") || "截至 20 日";
return I18N?.t("company.cutoff_month_end") || "截至月末";
}
function companyProblemLabel(code) {
const key = {
COMPANY_REPORT_MULTI_PRICE_REVIEW: "problem.multi_price",
COMPANY_REPORT_SOURCE_VERSION_MISSING: "problem.source_missing",
COMPANY_REPORT_GROUP_CODE_MISSING: "problem.group_missing",
COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "problem.group_not_found",
COMPANY_REPORT_BOOKING_PARSE_FAILED: "problem.booking_parse",
COMPANY_REPORT_ROOM_ITEMS_MISSING: "problem.room_items_missing",
COMPANY_REPORT_STAY_DATE_INVALID: "problem.stay_date",
COMPANY_REPORT_NIGHTS_CONFLICT: "problem.nights_conflict",
COMPANY_REPORT_TOTAL_PRICE_INVALID: "problem.total_price",
COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "problem.output_validation",
COMPANY_REPORT_PUBLISH_FAILED: "problem.publish_failed",
COMPANY_REPORT_INTERNAL_ERROR: "problem.internal",
COMPANY_REPORT_EXECUTION_FAILED: "problem.execution",
COMPANY_REPORT_JOB_INTERRUPTED: "problem.interrupted",
}[code];
return I18N?.t(key || "problem.review_result") || "需复核处理结果";
}
function companyProblemDetail(problem) {
if (problem?.code !== "COMPANY_REPORT_PUBLISH_FAILED") return "";
return I18N?.t("company.publish_failed_detail")
|| "数据已处理,但正式 Excel 保存失败。请重启报表服务后再生成。";
}
function companyPublishFailureSummary(job) {
const failedResults = (job?.company_results || []).filter((result) => result?.status === "failed");
const publishFailures = failedResults.filter((result) =>
(result.errors || []).some((problem) => problem?.code === "COMPANY_REPORT_PUBLISH_FAILED"),
);
if (!publishFailures.length) return "";
if (publishFailures.length === COMPANY_REPORT_NAMES.length) {
return I18N?.t("company.publish_failed_summary_all")
|| "数据已处理,但 5 家公司的正式 Excel 保存失败。请重启报表服务后再生成。";
}
return I18N?.t("company.publish_failed_summary_partial")
|| "数据已处理,但部分公司的正式 Excel 保存失败。请重启报表服务后再生成。";
}
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">${escapeHtml(I18N?.t("company.period_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"}">${escapeHtml(isCompanyReportActive(job) ? (I18N?.t("company.period_waiting") || "等待生成") : (I18N?.t("company.period_none") || "无结果"))}</span></td>`;
}
if (result.status === "failed") {
return `<td class="company-period-result-cell" data-label="${escapeHtml(range)}"><span class="company-period-result is-failed">${escapeHtml(I18N?.t("company.period_not_generated") || "未生成")}</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 ? (I18N?.t("company.period_not_recorded") || "未记录") : (I18N?.t("company.period_rows", { count: formatInteger(count) }) || ` ${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) ? (I18N?.t("company.waiting_generation") || "等待生成") : (I18N?.t("company.period_none") || "无结果");
let resultStyle = isCompanyReportActive(job) ? "running" : "failed";
if (result?.status === "success") {
resultStatus = warnings.length ? (I18N?.t("company.success_review") || "成功 · 待复核") : (I18N?.t("company.success") || "成功");
resultStyle = warnings.length ? "review" : "success";
} else if (result?.status === "failed") {
resultStatus = I18N?.t("company.failed") || "失败";
resultStyle = "failed";
}
const problems = companyProblemsForResult(result);
const problemHtml = problems.length
? problems.map((item) => {
const detail = companyProblemDetail(item);
return `<span class="company-problem is-${item.kind}">${escapeHtml(companyProblemLabel(item.code))}<small>${escapeHtml(item.period || I18N?.t("company.review_no_record") || "暂无")}</small>${detail ? `<small class="company-problem-detail">${escapeHtml(detail)}</small>` : ""}</span>`;
}).join("")
: `<span class="cell-subtle">${escapeHtml(I18N?.t("company.review_no_record") || "暂无")}</span>`;
const download = result?.download_url?.startsWith("/api/company-reports/")
? `<a class="download-link" href="${escapeHtml(result.download_url)}">${escapeHtml(I18N?.t("common.download") || "下载")}</a>`
: `<span class="cell-subtle">${escapeHtml(isCompanyReportActive(job) ? (I18N?.t("company.period_pending") || "待生成") : (I18N?.t("company.review_no_record") || "暂无"))}</span>`;
const companyLabel = I18N?.t("common.company") || "公司";
const totalLabel = I18N?.t("common.total") || "合计";
const statusLabel = I18N?.t("common.status") || "状态";
const problemLabel = I18N?.t("company.warning_count") || "复核 / 异常";
const generatedLabel = I18N?.t("common.generated_at") || "生成时间";
return `<tr class="company-result-row ${rowStyle}">
<td class="company-name-cell" data-label="${escapeHtml(companyLabel)}">${escapeHtml(company)}</td>
${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")}
<td data-label="${escapeHtml(totalLabel)}">${result ? formatInteger(result.row_count) : escapeHtml(I18N?.t("company.review_no_record") || "暂无")}</td>
<td data-label="${escapeHtml(statusLabel)}"><span class="status-chip ${resultStyle}">${escapeHtml(resultStatus)}</span></td>
<td data-label="${escapeHtml(problemLabel)}"><div class="company-problem-list">${problemHtml}</div></td>
<td class="company-generated-at-cell" data-label="${escapeHtml(generatedLabel)}">${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 = I18N?.t("company.task_status") || "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 ? "" : (I18N?.errorMessage(job.failure_code, job.message) || I18N?.text(job.message || "") || job.message || "")) || companyPublishFailureSummary(job);
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 = I18N?.text(job.progress?.label || "等待生成") || (job.progress?.label || I18N?.t("company.report_waiting") || "等待生成");
$("#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 ? formatDate(job.as_of_date) : (I18N?.t("company.review_no_record") || "暂无");
$("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds);
$("#company-report-warning-count").textContent = I18N?.t("company.report_warning_count", { count: formatInteger(companyWarningCount(job)) }) || `${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">${historyEmptyMarkup("company", "history.company_empty", "暂无公司渠道明细任务")}</td></tr>`;
return;
}
body.innerHTML = jobs.map((job) => {
return `<tr>
<td>${formatDate(job.created_at, true)}</td>
<td><strong>${escapeHtml(job.report_month ? monthLabel(job.report_month) : (I18N?.t("company.review_no_record") || "暂无"))}</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)}">${escapeHtml(I18N?.t("common.view") || "查看")}</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 = historyMonth("company");
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="8">${escapeHtml(I18N?.t("company.draft_error") || "任务记录暂时无法读取")}</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 ? (I18N?.t("company.generating") || "正在生成") : (I18N?.t("company.confirm_generate") || "确认生成");
}
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 = I18N?.t("company.at_period", { month: monthLabel(reportMonth), period: companyPeriodRange(reportMonth, period) }) || `${monthLabel(reportMonth)}${companyPeriodRange(reportMonth, period)}`;
const completeDate = companyPeriodCompleteDate(reportMonth, period);
const periodComplete = Boolean(completeDate) && localDate() >= completeDate;
$("#company-report-confirm-note").textContent = periodComplete
? (I18N?.t("company.generate_five") || "将一次生成五家公司的正式 Excel。")
: (I18N?.t("company.generate_early") || "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。");
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.companyHistoryMonth = request.reportMonth;
state.companyReportsOffset = 0;
renderHistoryMonthControl("company");
closeCompanyReportConfirm({ force: true, restoreFocus: false });
renderCompanyReportJob(created);
$("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" });
showToast(I18N?.t("company.job_submitted") || "五家公司渠道明细任务已提交");
await loadCompanyReportJob(created.job_id);
} catch (error) {
const message = error.message || I18N?.t("company.job_submit_failed") || "公司渠道明细任务未能提交";
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(I18N?.t("company.jobs_refreshed") || "公司渠道明细任务已刷新");
}
function refreshLocalizedView() {
renderJobs(state.jobs);
renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading);
renderMonthly(state.monthlyRuns);
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, state.monthlyLoading);
if (state.analytics) renderAnalytics(state.analytics);
else resetAnalytics(I18N?.t("bi.empty") || "暂无渠道与房型数据");
renderCompanySource(state.companySource);
if (state.companySourceDraft?.summary) renderCompanyDraft(state.companySourceDraft);
else renderCompanyDraft(null);
renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, state.companyHistoryLoading);
renderCompanyReportHistory(state.companyReportJobs);
if (state.companyReportCurrentJob) renderCompanyReportJob(state.companyReportCurrentJob);
if (state.jobTrace) renderTrace(state.jobTrace);
else if (!state.selectedJobId) renderTracePlaceholder(I18N?.t("task.waiting_upload") || "等待上传 ARR.XML", I18N?.t("task.after_create") || "新任务创建后,这里会显示服务端保存的全流程日志。");
updateCompanySourceUploadControls();
updateCompanyReportControls();
I18N?.translateDom();
renderHistoryMonthControls();
}
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();
}
async function setHistoryMonth(scope, value) {
const config = HISTORY_SCOPES[scope];
if (!config || !validMonth(value) || value > localMonth()) {
renderHistoryMonthControl(scope);
return;
}
if (state[config.stateKey] === value) return;
state[config.stateKey] = value;
if (scope === "jobs") {
state.jobsOffset = 0;
state.jobsTotal = 0;
state.selectedJobId = "";
state.jobTrace = null;
clearTracePoll();
renderHistoryMonthControl(scope);
await loadJobs(true, false);
return;
}
if (scope === "monthly") {
state.monthlyOffset = 0;
state.monthlyTotal = 0;
state.monthlyLoaded = false;
clearMonthlyPoll();
renderHistoryMonthControl(scope);
await loadMonthly(true, false);
return;
}
state.companyReportsOffset = 0;
state.companyReportsTotal = 0;
renderHistoryMonthControl(scope);
await loadCompanyReportHistory(false);
}
function bindHistoryMonthControls() {
Object.keys(HISTORY_SCOPES).forEach((scope) => {
$(`#${scope}-history-month`).addEventListener("change", (event) => setHistoryMonth(scope, event.currentTarget.value));
$(`#${scope}-month-previous`).addEventListener("click", () => setHistoryMonth(scope, shiftMonth(historyMonth(scope), -1)));
$(`#${scope}-month-next`).addEventListener("click", () => setHistoryMonth(scope, shiftMonth(historyMonth(scope), 1)));
$(`#${scope}-month-current`).addEventListener("click", () => setHistoryMonth(scope, localMonth()));
});
document.addEventListener("click", (event) => {
const jump = event.target.closest("[data-history-jump][data-history-month]");
if (jump) setHistoryMonth(jump.dataset.historyJump, jump.dataset.historyMonth);
});
}
function bindEvents() {
bindHistoryMonthControls();
$$("[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`
: (I18N?.t("upload.file_not_selected") || "尚未选择文件");
$("#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", (event) => {
event.preventDefault();
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(I18N?.t("task.refreshed") || "任务日志已刷新");
});
$("#copy-trace").addEventListener("click", copyAllTraceLogs);
$("#logout-button").addEventListener("click", (event) => {
event.preventDefault();
logout();
});
$("#bi-month").addEventListener("change", () => loadAnalytics(true));
$("#refresh-company-reports").addEventListener("click", refreshCompanyReports);
$("#company-report-month").addEventListener("change", async () => {
updateCompanyReportControls();
updateCompanySourceUploadControls();
});
$$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod, button)));
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearTracePoll();
clearMonthlyPoll();
clearBiPoll();
} else {
scheduleTracePoll(250);
if ($("#panel-monthly")?.classList.contains("is-active")) loadMonthly(false, true);
if ($("#panel-bi")?.classList.contains("is-active")) checkBiFreshness();
}
});
document.addEventListener("arr:locale-change", refreshLocalizedView);
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(I18N?.t("auth.session_init_failed") || "页面会话初始化失败", true);
return;
}
await loadHistoryMonths();
await loadHealth();
await Promise.all([
loadJobs(),
loadMonthly(),
loadMonthsAndAnalytics(),
loadCompanySource(),
loadCompanyDraft(),
loadCompanyReportHistory(location.hash.slice(1) === "usage"),
]);
}
boot();
})();