(() => {
"use strict";
const state = {
csrf: "",
maxUploadBytes: 25 * 1024 * 1024,
selectedFile: null,
uploadInFlight: false,
uploadProgressTimer: null,
uploadProgressValue: 0,
companySourceFile: null,
companySource: null,
companySourceDraft: null,
companySourceUploading: false,
companyReviewLoading: false,
companyReviewMutating: false,
companyReviewOffset: 0,
companyReviewSelectedIds: new Set(),
companyReviewConfirmRequest: null,
companyReportConfirmRequest: null,
health: { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false },
analytics: null,
toastTimer: null,
jobs: [],
selectedJobId: "",
jobTrace: null,
jobTraceLoading: false,
jobTracePollTimer: null,
jobsLoading: false,
jobsTotal: 0,
jobsOffset: 0,
monthlyRuns: [],
monthlyLoaded: false,
monthlyLoading: false,
monthlyPollTimer: null,
monthlyTotal: 0,
monthlyOffset: 0,
companyReportJobs: [],
companyReportCurrentJob: null,
companyReportPollTimer: null,
companyReportSubmittingPeriod: "",
companyHistoryLoading: false,
companyReportsTotal: 0,
companyReportsOffset: 0,
companyReportsReady: false,
companySourceUploadReady: false,
};
const BUSINESS_TIME_ZONE = "Asia/Bangkok";
const TRACE_POLL_INTERVAL = 4000;
const MONTHLY_POLL_INTERVAL = 4000;
const HISTORY_PAGE_SIZE = 50;
const COMPANY_REVIEW_PAGE_SIZE = 50;
const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"];
const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"];
const DAILY_UPLOAD_PROGRESS_STAGES = [
{ value: 42, label: "正在运行固定处理器", delay: 600 },
{ value: 68, label: "正在独立验收结果", delay: 1400 },
{ value: 88, label: "正在提交数据库", delay: 1800 },
];
const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"];
const jobStatus = {
received: ["已接收", "running"],
uploaded: ["已上传", "running"],
queued: ["等待处理", "running"],
running: ["处理中", "running"],
delivered: ["等待验收", "running"],
validating: ["正在验收", "running"],
accepted: ["已完成", "success"],
succeeded: ["已完成", "success"],
rejected: ["验收失败", "failed"],
failed: ["处理失败", "failed"],
cancelled: ["已取消", "failed"],
};
const reportStatus = {
reserved: ["生成中", "running"],
generating: ["生成中", "running"],
validated: ["已验收", "running"],
active: ["当前版本", "success current"],
superseded: ["历史版本", ""],
failed: ["生成失败", "failed"],
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function bangkokDateParts() {
return Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
timeZone: BUSINESS_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date()).map((part) => [part.type, part.value]));
}
function localMonth() {
const parts = bangkokDateParts();
return `${parts.year}-${parts.month}`;
}
function localDate() {
const parts = bangkokDateParts();
return `${parts.year}-${parts.month}-${parts.day}`;
}
function showToast(message, isError = false) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.toggle("is-error", isError);
toast.classList.add("is-visible");
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), 3600);
}
function loginLocation() {
const next = location.pathname === "/h5" ? "/h5" : "/";
return `/login?next=${encodeURIComponent(next)}`;
}
function redirectToLogin() {
window.location.replace(loginLocation());
}
async function api(path, options = {}) {
const { returnEnvelope = false, ...requestOptions } = options;
const headers = new Headers(requestOptions.headers || {});
if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-ARR-CSRF", state.csrf);
const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" });
if (response.status === 401) {
redirectToLogin();
throw new Error("登录状态已失效");
}
let payload;
try {
payload = await response.json();
} catch (_) {
throw new Error("服务返回了无法识别的结果");
}
if (!response.ok || !payload.ok) {
throw new Error(payload?.error?.message || "请求未完成");
}
return returnEnvelope ? payload : payload.data;
}
function readPage(envelope) {
const items = Array.isArray(envelope?.data) ? envelope.data : [];
const rawTotal = Number(envelope?.pagination?.total);
const total = Number.isInteger(rawTotal) && rawTotal >= 0 ? rawTotal : items.length;
return { items, total };
}
function pageOffset(total, offset, direction) {
const lastOffset = total > 0 ? Math.floor((total - 1) / HISTORY_PAGE_SIZE) * HISTORY_PAGE_SIZE : 0;
return Math.max(0, Math.min(offset + direction * HISTORY_PAGE_SIZE, lastOffset));
}
function renderPagination(scope, total, offset, loading = false) {
const pageCount = Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE));
const page = Math.min(pageCount, Math.floor(offset / HISTORY_PAGE_SIZE) + 1);
const start = total > 0 && offset < total ? offset + 1 : 0;
const end = start > 0 ? Math.min(offset + HISTORY_PAGE_SIZE, total) : 0;
$(`#${scope}-pagination-summary`).textContent = total > 0
? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`
: "共 0 条";
$(`#${scope}-page-label`).textContent = `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`;
$(`#${scope}-prev`).disabled = loading || offset <= 0;
$(`#${scope}-next`).disabled = loading || offset + HISTORY_PAGE_SIZE >= total;
}
function formatInteger(value) {
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
}
function formatMoney(value, compact = false) {
const amount = Number(value || 0);
if (compact && Math.abs(amount) >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`;
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount);
}
function formatDate(value, withTime = false) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(value);
return new Intl.DateTimeFormat("zh-CN", withTime
? { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
: { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit" }
).format(date);
}
function duration(job) {
if (!job.created_at || !(job.finished_at || job.updated_at)) return "—";
const seconds = Math.max(0, Math.round((new Date(job.finished_at || job.updated_at) - new Date(job.created_at)) / 1000));
if (!Number.isFinite(seconds)) return "—";
if (seconds < 60) return `${seconds} 秒`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes} 分 ${rest} 秒`;
}
function chip(status, map) {
const [label, style] = map[status] || [status || "未知", ""];
return `${escapeHtml(label)}`;
}
function activateTab(name, updateHash = true) {
const valid = ["daily", "monthly", "bi", "usage"].includes(name) ? name : "daily";
$$("[data-tab]").forEach((tab) => {
const active = tab.dataset.tab === valid;
tab.classList.toggle("is-active", active);
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
$$("[data-panel]").forEach((panel) => {
const active = panel.dataset.panel === valid;
panel.hidden = !active;
panel.classList.toggle("is-active", active);
});
if (updateHash) history.replaceState(null, "", `#${valid}`);
if (valid === "bi" && !state.analytics) loadAnalytics();
if (valid === "usage" && state.csrf) {
loadCompanySource();
loadCompanyDraft();
loadCompanyReportHistory(true);
}
if (valid === "monthly" && state.csrf) loadMonthly(false, state.monthlyLoaded);
else clearMonthlyPoll();
if (taskLogIsOpen()) scheduleTracePoll(250);
else clearTracePoll();
}
async function initSession() {
const session = await api("/api/session");
state.csrf = session.csrf_token;
state.maxUploadBytes = Number(session.max_upload_bytes) || state.maxUploadBytes;
const button = $("#logout-button");
if (!session.username) return;
button.hidden = false;
button.disabled = false;
button.title = `${session.username} · 退出登录`;
}
async function logout() {
const button = $("#logout-button");
button.disabled = true;
try {
await api("/api/logout", { method: "POST" });
window.location.replace("/login");
} catch (error) {
if (document.visibilityState === "visible") {
showToast(error.message || "退出登录失败,请重试", true);
button.disabled = false;
}
}
}
async function loadHealth() {
try {
state.health = await api("/api/health");
} catch (_) {
state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false };
}
const node = $("#system-status");
node.classList.toggle("is-ready", state.health.database_ready);
node.classList.toggle("is-error", !state.health.database_ready);
$("span", node).textContent = state.health.database_ready ? "数据库已连接" : "数据服务未连接";
const fileInput = $("#xml-file");
fileInput.disabled = !state.health.processing_ready;
$("#dropzone").classList.toggle("is-disabled", !state.health.processing_ready);
$("#upload-hint").textContent = state.health.processing_ready ? "" : "文件接收服务尚未完成生产接线。";
state.companyReportsReady = Boolean(state.health.company_reports_ready);
state.companySourceUploadReady = Boolean(state.health.company_source_upload_ready);
$("#company-report-unavailable").hidden = state.companyReportsReady;
updateCompanySourceUploadControls();
updateCompanyReportControls();
}
function renderJobs(jobs) {
const body = $("#jobs-body");
if (!jobs.length) {
body.innerHTML = '
| 本月暂无 Daily Report 记录 |
';
$("#metric-arrival").textContent = "—";
$("#metric-duration").textContent = "—";
$("#metric-rooms").textContent = "—";
return;
}
body.innerHTML = jobs.map((job) => `
| ${escapeHtml(job.filename || "—")} |
${chip(job.status, jobStatus)}${job.failure_code ? `${escapeHtml(job.failure_code)}` : ""} |
${escapeHtml(job.arrival_date || "—")} |
${formatInteger(job.no_of_rooms)} |
${duration(job)} |
${formatDate(job.created_at, true)} |
${state.health.download_ready && job.daily_report_sha256 ? `下载` : '—'} |
`).join("");
const latest = jobs.find((job) => job.status === "succeeded");
$("#metric-arrival").textContent = latest?.arrival_date || "—";
$("#metric-duration").textContent = latest ? duration(latest) : "—";
$("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—";
}
function taskLogIsOpen() {
return Boolean($("#task-log-dialog")?.open);
}
function openTaskLog() {
const dialog = $("#task-log-dialog");
if (!dialog || taskLogIsOpen()) return;
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
dialog.scrollTop = 0;
scheduleTracePoll(250);
}
function closeTaskLog() {
const dialog = $("#task-log-dialog");
if (!dialog || !taskLogIsOpen()) return;
if (typeof dialog.close === "function") dialog.close();
else {
dialog.removeAttribute("open");
clearTracePoll();
}
}
function clearTracePoll() {
window.clearTimeout(state.jobTracePollTimer);
state.jobTracePollTimer = null;
}
function setTraceLiveState(copy, style = "") {
const node = $("#trace-live-state");
node.className = `trace-live-state${style ? ` ${style}` : ""}`;
node.textContent = copy;
}
function traceConsoleValue(value) {
return JSON.stringify(value ?? null);
}
function copyTextWithSelection(text) {
const activeElement = document.activeElement;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.inset = "0 auto auto 0";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let copied = false;
try {
copied = document.execCommand("copy");
} finally {
textarea.remove();
activeElement?.focus?.();
}
return copied;
}
async function writeClipboardText(text) {
let clipboardError = null;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (error) {
clipboardError = error;
}
}
if (copyTextWithSelection(text)) return;
throw clipboardError || new Error("浏览器未允许访问剪贴板");
}
async function copyAllTraceLogs() {
const button = $("#copy-trace");
const text = $("#process-log").textContent || "";
if (!state.jobTrace || !text.trim()) {
showToast("暂无可复制的任务日志", true);
return;
}
button.disabled = true;
button.textContent = "复制中…";
try {
await writeClipboardText(text);
button.textContent = "已复制";
showToast(`已复制全部任务日志(${text.split("\n").length} 行)`);
} catch (_) {
button.textContent = "复制失败";
showToast("无法复制日志,请检查浏览器剪贴板权限", true);
} finally {
window.setTimeout(() => {
button.textContent = "复制全部日志";
button.disabled = !state.jobTrace;
}, 1600);
}
}
function renderTracePlaceholder(title, detail, tone = "", logCopy = "") {
const liveCopy = tone === "error" ? "ERROR" : tone === "running" ? "LOADING" : "IDLE";
setTraceLiveState(liveCopy, tone ? `is-${tone}` : "");
$("#refresh-trace").disabled = !state.selectedJobId;
$("#copy-trace").disabled = true;
$("#trace-log-count").textContent = "0 events";
$("#process-log").textContent = [
`$ arr trace${state.selectedJobId ? ` --job ${state.selectedJobId}` : ""}`,
`# ${title}`,
`# ${logCopy || detail || "请上传文件,或从下方选择一个历史任务。"}`,
].join("\n");
}
function renderTraceConsole(trace) {
const job = trace.job || {};
const logs = trace.logs || [];
const lines = [
`$ arr trace --job ${job.job_id || state.selectedJobId || "-"}`,
`trace_version=${traceConsoleValue(trace.trace_version)} status=${traceConsoleValue(job.status)} active=${Boolean(job.active)} current_stage=${traceConsoleValue(job.current_stage)}`,
`attempt_no=${traceConsoleValue(job.attempt_no)} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`,
`created_at=${traceConsoleValue(job.created_at)} updated_at=${traceConsoleValue(job.updated_at)} finished_at=${traceConsoleValue(job.finished_at)}`,
`evidence=${JSON.stringify(trace.evidence || {})}`,
"--------------------------------------------------------------------------------",
...logs.map((log) => {
const level = String(log.level || "info").toUpperCase().padEnd(7, " ");
return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(log.title)} message=${traceConsoleValue(log.message)} details=${JSON.stringify(log.details || {})}`;
}),
"--------------------------------------------------------------------------------",
];
if (job.failure) {
lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(job.failure.message)}`);
}
lines.push(`END status=${job.status || "unknown"} events=${logs.length} refreshed_at=${traceConsoleValue(trace.refreshed_at)}`);
const output = $("#process-log");
output.textContent = lines.join("\n");
output.scrollTop = output.scrollHeight;
$("#trace-log-count").textContent = `${logs.length} events`;
$("#copy-trace").disabled = false;
}
function syncTraceToJobs(trace) {
const summary = trace.job || {};
const item = state.jobs.find((job) => job.job_id === summary.job_id);
if (!item) return;
item.status = summary.status;
item.failure_code = summary.failure?.code || null;
item.updated_at = summary.updated_at || item.updated_at;
item.finished_at = summary.finished_at || item.finished_at;
item.arrival_date = summary.business_date || item.arrival_date;
item.output_rows = summary.output_rows ?? item.output_rows;
item.version_no = summary.version_no ?? item.version_no;
renderJobs(state.jobs);
}
function renderTrace(trace) {
const job = trace.job || {};
syncTraceToJobs(trace);
if (job.failure) {
setTraceLiveState(`FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error");
} else if (job.active) {
setTraceLiveState(`RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live");
} else if (job.status === "succeeded") {
setTraceLiveState("SUCCEEDED", "is-complete");
} else {
setTraceLiveState(String(job.status || "UNKNOWN").toUpperCase());
}
$("#refresh-trace").disabled = !job.job_id;
renderTraceConsole(trace);
}
function scheduleTracePoll(delay = TRACE_POLL_INTERVAL) {
clearTracePoll();
if (
!state.selectedJobId
|| !state.jobTrace?.job?.active
|| document.hidden
|| !taskLogIsOpen()
) return;
state.jobTracePollTimer = window.setTimeout(() => loadJobTrace(false, true), delay);
}
async function loadJobTrace(showErrors = false, silent = false) {
const jobId = state.selectedJobId;
if (!jobId || state.jobTraceLoading) return;
state.jobTraceLoading = true;
if (!silent && state.jobTrace?.job?.job_id !== jobId) {
renderTracePlaceholder("正在读取任务日志", `任务 ${jobId}`, "running", "正在聚合数据库中的处理证据…");
setTraceLiveState("正在读取", "is-live");
}
try {
const trace = await api(`/api/jobs/${encodeURIComponent(jobId)}/trace`);
if (state.selectedJobId !== jobId) return;
state.jobTrace = trace;
renderTrace(trace);
} catch (error) {
if (state.selectedJobId !== jobId) return;
if (!state.jobTrace) {
renderTracePlaceholder("任务日志暂时无法读取", error.message, "error", "服务端没有返回可用日志。");
}
setTraceLiveState(showErrors ? "日志刷新失败" : "自动刷新失败", "is-error");
if (showErrors) showToast(error.message, true);
} finally {
const selectionChanged = state.selectedJobId !== jobId;
state.jobTraceLoading = false;
if (selectionChanged) {
loadJobTrace(false, false);
return;
}
scheduleTracePoll(state.jobTrace?.job?.active ? TRACE_POLL_INTERVAL : 0);
}
}
async function selectJob(jobId, { showLog = false } = {}) {
if (!jobId) return;
clearTracePoll();
const changed = state.selectedJobId !== jobId;
state.selectedJobId = jobId;
if (changed) state.jobTrace = null;
renderJobs(state.jobs);
if (showLog) openTaskLog();
await loadJobTrace(true, !changed);
}
async function loadJobs(showErrors = false, loadTrace = true) {
if (state.jobsLoading) return;
state.jobsLoading = true;
renderPagination("jobs", state.jobsTotal, state.jobsOffset, true);
try {
const month = encodeURIComponent(localMonth());
let page = readPage(await api(
`/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
{ returnEnvelope: true },
));
if (!page.items.length && page.total > 0 && state.jobsOffset >= page.total) {
state.jobsOffset = pageOffset(page.total, state.jobsOffset, -1);
page = readPage(await api(
`/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
{ returnEnvelope: true },
));
}
state.jobs = page.items;
state.jobsTotal = page.total;
renderJobs(state.jobs);
if (!state.selectedJobId && state.jobs.length) {
await selectJob(state.jobs[0].job_id);
} else if (loadTrace && state.selectedJobId) {
await loadJobTrace(showErrors, Boolean(state.jobTrace));
} else if (!state.jobs.length && !state.selectedJobId) {
renderTracePlaceholder(
"等待上传 ARR.XML",
"新任务创建后,这里会显示服务端保存的全流程日志。",
);
setTraceLiveState("未选择任务");
}
} catch (error) {
$("#jobs-body").innerHTML = '| 日报记录暂时无法读取 |
';
if (showErrors) showToast(error.message, true);
} finally {
state.jobsLoading = false;
renderPagination("jobs", state.jobsTotal, state.jobsOffset);
}
}
function clearMonthlyPoll() {
window.clearTimeout(state.monthlyPollTimer);
state.monthlyPollTimer = null;
}
function monthlyViewActive() {
return !document.hidden && $("#panel-monthly")?.classList.contains("is-active");
}
function setMonthlyLiveState(copy, style = "") {
const node = $("#monthly-live-state");
if (!node) return;
node.className = `trace-live-state${style ? ` ${style}` : ""}`;
node.textContent = copy;
}
function scheduleMonthlyPoll(delay = MONTHLY_POLL_INTERVAL) {
clearMonthlyPoll();
if (!monthlyViewActive()) return;
state.monthlyPollTimer = window.setTimeout(() => loadMonthly(false, true), delay);
}
function renderMonthly(runs) {
const body = $("#monthly-body");
if (!runs.length) {
body.innerHTML = '| 该月份暂无月报处理记录 |
';
return;
}
body.innerHTML = runs.map((run) => `
| ${escapeHtml(run.max_arrival_date || run.as_of_date || "—")} |
${chip(run.status, reportStatus)} |
${formatInteger(run.row_count)} |
${formatInteger(run.channel_count)} |
${formatDate(run.updated_at, true)} |
${state.health.download_ready && run.artifact_sha256 ? `下载` : '—'} |
`).join("");
}
async function loadMonthly(showErrors = false, silent = false) {
if (state.monthlyLoading) return;
state.monthlyLoading = true;
const month = localMonth();
const previousTotal = state.monthlyTotal;
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, true);
if (!silent) setMonthlyLiveState("正在读取…", "is-live");
try {
const encodedMonth = encodeURIComponent(month);
let page = readPage(await api(
`/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`,
{ returnEnvelope: true },
));
const hasNewPublication = state.monthlyLoaded && page.total > previousTotal;
if (hasNewPublication && state.monthlyOffset > 0) {
state.monthlyOffset = 0;
page = readPage(await api(
`/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=0`,
{ returnEnvelope: true },
));
} else if (!page.items.length && page.total > 0 && state.monthlyOffset >= page.total) {
state.monthlyOffset = pageOffset(page.total, state.monthlyOffset, -1);
page = readPage(await api(
`/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`,
{ returnEnvelope: true },
));
}
state.monthlyRuns = page.items;
state.monthlyTotal = page.total;
state.monthlyLoaded = true;
renderMonthly(state.monthlyRuns);
setMonthlyLiveState(`自动更新 · ${MONTHLY_POLL_INTERVAL / 1000} 秒`, "is-live");
if (hasNewPublication) showToast("新月报已自动加入列表");
} catch (error) {
if (!silent || !state.monthlyLoaded) {
$("#monthly-body").innerHTML = '| 月报记录暂时无法读取 |
';
}
setMonthlyLiveState("自动更新重试中", "is-error");
if (showErrors) showToast(error.message, true);
} finally {
state.monthlyLoading = false;
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset);
scheduleMonthlyPoll();
}
}
function populateMonths(months) {
const select = $("#bi-month");
const current = select.value;
if (!months.length) {
select.innerHTML = ``;
return;
}
select.innerHTML = months.map((item) => ``).join("");
if (months.some((item) => item.month_key === current)) select.value = current;
}
function renderCompanySales(channels) {
const sorted = [...channels].sort((a, b) => Number(b.totals.total_price) - Number(a.totals.total_price));
const maximum = Math.max(1, ...sorted.map((item) => Number(item.totals.total_price || 0)));
$("#company-ranking").innerHTML = sorted.length ? sorted.map((item) => `
${escapeHtml(item.worksheet)}
${formatMoney(item.totals.total_price, true)}
`).join("") : '暂无公司销售数据
';
$$(".rank-track i", $("#company-ranking")).forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; });
const total = sorted.reduce((sum, item) => sum + Number(item.totals.total_price || 0), 0);
let cursor = 0;
const stops = sorted.map((item, index) => {
const start = cursor;
cursor += total > 0 ? Number(item.totals.total_price || 0) / total * 100 : 0;
return `${colors[index % colors.length]} ${start.toFixed(3)}% ${cursor.toFixed(3)}%`;
});
$("#company-donut").style.background = total > 0 ? `conic-gradient(${stops.join(",")})` : "#d8e0ec";
$("#donut-total").textContent = formatMoney(total, true);
$("#donut-legend").innerHTML = sorted.map((item, index) => `
${escapeHtml(item.worksheet)}`).join("");
$$(".legend-item i", $("#donut-legend")).forEach((marker) => { marker.style.backgroundColor = marker.dataset.color; });
}
function renderMatrix(data) {
const roomTypes = data.overall.room_types.map((item) => item.room_type);
if (!roomTypes.length || !data.channels.length) {
$("#room-matrix").innerHTML = '暂无渠道与房型数据
';
return;
}
const heading = roomTypes.map((room) => `${escapeHtml(room)} | `).join("");
const rows = data.channels.map((channel) => {
const values = new Map(channel.room_types.map((room) => [room.room_type, room.rooms_sold]));
return `| ${escapeHtml(channel.worksheet)} | ${roomTypes.map((room) => `${formatInteger(values.get(room) || 0)} | `).join("")}${formatInteger(channel.totals.rooms_sold)} |
`;
}).join("");
const totals = new Map(data.overall.room_types.map((room) => [room.room_type, room.rooms_sold]));
$("#room-matrix").innerHTML = `| 渠道 | ${heading}合计 |
${rows}| 合计 | ${roomTypes.map((room) => `${formatInteger(totals.get(room) || 0)} | `).join("")}${formatInteger(data.overall.totals.rooms_sold)} |
`;
}
function renderAnalytics(data) {
state.analytics = data;
const totals = data.overall.totals;
$("#bi-rooms").textContent = formatInteger(totals.rooms_sold);
$("#bi-revenue").textContent = formatMoney(totals.total_price, true);
$("#bi-nights").textContent = formatInteger(totals.room_nights);
$("#bi-channels").textContent = formatInteger(totals.channel_count);
$("#bi-subtitle").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`;
renderCompanySales(data.channels);
renderMatrix(data);
}
function resetAnalytics(message) {
["#bi-rooms", "#bi-revenue", "#bi-nights", "#bi-channels", "#donut-total"].forEach((id) => $(id).textContent = "—");
$("#company-ranking").innerHTML = `${escapeHtml(message)}
`;
$("#donut-legend").innerHTML = "";
$("#company-donut").style.background = "#d8e0ec";
$("#room-matrix").innerHTML = `${escapeHtml(message)}
`;
}
async function loadAnalytics(showErrors = false) {
const month = $("#bi-month").value || localMonth();
try {
renderAnalytics(await api(`/api/analytics?month=${encodeURIComponent(month)}`));
} catch (error) {
resetAnalytics("该月份暂无可用看板数据");
if (showErrors) showToast(error.message, true);
}
}
function encodedFilename(value) {
const filenameBytes = new TextEncoder().encode(value);
let binary = "";
filenameBytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function clearUploadProgressTimer() {
window.clearTimeout(state.uploadProgressTimer);
state.uploadProgressTimer = null;
}
function setUploadProgress(value, label, tone = "running") {
const progress = $("#upload-progress");
const labelNode = $("#upload-progress-label");
const percentNode = $("#upload-progress-percent");
const track = $("#upload-progress-track");
const bar = $("#upload-progress-bar");
if (!progress || !labelNode || !percentNode || !track || !bar) return;
const bounded = Math.max(0, Math.min(100, Math.round(Number(value) || 0)));
state.uploadProgressValue = bounded;
progress.hidden = false;
progress.className = `upload-progress${tone ? ` is-${tone}` : ""}`;
labelNode.textContent = label;
percentNode.textContent = `${bounded}%`;
track.setAttribute("aria-valuenow", String(bounded));
track.setAttribute("aria-valuetext", `${label} ${bounded}%`);
bar.style.transform = `scaleX(${bounded / 100})`;
}
function resetUploadProgress() {
clearUploadProgressTimer();
const progress = $("#upload-progress");
if (!progress) return;
progress.hidden = true;
progress.className = "upload-progress";
state.uploadProgressValue = 0;
$("#upload-progress-label").textContent = "等待开始";
$("#upload-progress-percent").textContent = "0%";
$("#upload-progress-track").setAttribute("aria-valuenow", "0");
$("#upload-progress-track").setAttribute("aria-valuetext", "等待开始 0%");
$("#upload-progress-bar").style.transform = "scaleX(0)";
}
function startUploadProgress() {
clearUploadProgressTimer();
setUploadProgress(12, "正在上传 ARR.XML");
let stageIndex = 0;
const advance = () => {
const stage = DAILY_UPLOAD_PROGRESS_STAGES[stageIndex];
if (!stage) return;
setUploadProgress(stage.value, stage.label);
stageIndex += 1;
if (DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]) {
state.uploadProgressTimer = window.setTimeout(advance, stage.delay);
}
};
state.uploadProgressTimer = window.setTimeout(advance, 500);
}
function finishUploadProgress(success, label) {
clearUploadProgressTimer();
if (success) {
setUploadProgress(100, label, "success");
return;
}
setUploadProgress(Math.max(12, state.uploadProgressValue), label, "error");
}
async function handleUpload() {
if (!state.selectedFile || !state.health.processing_ready) return;
const file = state.selectedFile;
const button = $("#upload-button");
const fileInput = $("#xml-file");
const dropzone = $("#dropzone");
state.uploadInFlight = true;
button.disabled = true;
fileInput.disabled = true;
dropzone.classList.add("is-disabled");
button.setAttribute("aria-busy", "true");
button.textContent = "处理中…";
clearTracePoll();
state.selectedJobId = "";
state.jobTrace = null;
renderJobs(state.jobs);
renderTracePlaceholder(
"正在处理ARR.XML文件",
"正在安全上传、运行固定处理器、独立验收并提交数据库。",
"running",
"处理完成后将立即读取服务端全流程日志…",
);
setTraceLiveState("正在创建任务", "is-live");
startUploadProgress();
try {
const receipt = await api("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/xml", "X-ARR-Filename-B64": encodedFilename(file.name) },
body: await file.arrayBuffer(),
});
state.selectedFile = null;
$("#xml-file").value = "";
$("#selected-file").textContent = "尚未选择文件";
const failed = receipt.status === "failed";
finishUploadProgress(!failed, failed ? "处理失败" : "处理完成");
showToast(failed ? "ARR.XML 处理失败,请查看任务日志" : "ARR.XML 已处理并完成入库", failed);
state.jobsOffset = 0;
await loadJobs(false, false);
if (receipt.job_id) {
await selectJob(receipt.job_id);
} else {
renderTracePlaceholder("处理已结束", "任务编号暂不可用,请刷新 Daily Report。", "success");
}
} catch (error) {
finishUploadProgress(false, "处理失败");
renderTracePlaceholder("处理未能完成", error.message, "error", "请刷新 Daily Report,确认是否已登记失败任务。");
setTraceLiveState("处理失败", "is-error");
showToast(error.message, true);
} finally {
state.uploadInFlight = false;
fileInput.disabled = !state.health.processing_ready;
dropzone.classList.toggle("is-disabled", !state.health.processing_ready);
button.removeAttribute("aria-busy");
button.textContent = "开始处理";
button.disabled = !state.selectedFile || !state.health.processing_ready;
}
}
async function loadMonthsAndAnalytics() {
try {
const months = await api("/api/months");
populateMonths(months);
if (months.length) $("#bi-month").value = months[0].month_key;
} catch (_) {
populateMonths([]);
}
await loadAnalytics();
}
function setCompanySourceError(message = "") {
const node = $("#company-source-error");
node.textContent = message;
node.hidden = !message;
}
function updateCompanySourceUploadControls() {
const busy = state.companySourceUploading || state.companyReviewMutating || isCompanyReportActive();
const enabled = state.companySourceUploadReady && !busy;
const input = $("#company-excel-file");
input.disabled = !enabled;
$("#company-excel-dropzone").classList.toggle("is-disabled", !enabled);
const button = $("#company-upload-button");
button.disabled = !enabled || !state.companySourceFile;
button.setAttribute("aria-busy", String(state.companySourceUploading));
$("span", button).textContent = state.companySourceUploading ? "正在提取" : "提取并核对";
}
function clearCompanyReviewSelection() {
state.companyReviewSelectedIds.clear();
}
function syncCompanyReviewSelection(items = [], busy = false) {
const visibleIds = items
.map((item) => Number(item.item_id))
.filter((itemId) => Number.isInteger(itemId) && itemId > 0);
const visibleIdSet = new Set(visibleIds);
[...state.companyReviewSelectedIds].forEach((itemId) => {
if (!visibleIdSet.has(itemId)) state.companyReviewSelectedIds.delete(itemId);
});
const selectedCount = state.companyReviewSelectedIds.size;
const pageCheckbox = $("#company-review-select-page");
pageCheckbox.checked = visibleIds.length > 0 && selectedCount === visibleIds.length;
pageCheckbox.indeterminate = selectedCount > 0 && selectedCount < visibleIds.length;
pageCheckbox.disabled = busy || visibleIds.length === 0;
$("#company-review-selection-count").textContent = `已选 ${formatInteger(selectedCount)} 条`;
$("#company-review-delete-selected").disabled = busy || selectedCount === 0;
$$("[data-review-select]", $("#company-review-body")).forEach((checkbox) => {
const itemId = Number(checkbox.value);
const selected = state.companyReviewSelectedIds.has(itemId);
checkbox.checked = selected;
checkbox.disabled = busy;
const row = checkbox.closest("[data-review-item-id]");
row?.classList.toggle("is-selected", selected);
row?.setAttribute("aria-selected", String(selected));
});
}
function renderCompanyDraft(page) {
const panel = $("#company-review-panel");
const body = $("#company-review-body");
const empty = $("#company-review-empty");
if (!page?.summary) {
panel.hidden = true;
panel.classList.remove("is-busy");
$("#company-review-filename").textContent = "";
$("#company-review-filename").removeAttribute("title");
body.innerHTML = "";
empty.hidden = true;
clearCompanyReviewSelection();
syncCompanyReviewSelection();
return;
}
const summary = page.summary;
const items = Array.isArray(page.items) ? page.items : [];
const pagination = page.pagination || {};
const pending = Number(summary.pending_items) || 0;
const confirmed = Number(summary.confirmed_items) || 0;
const deleted = Number(summary.deleted_items) || 0;
const busy = state.companyReviewLoading || state.companyReviewMutating;
const filename = String(summary.filename || "").trim();
panel.hidden = false;
panel.classList.toggle("is-busy", busy);
$("#company-review-filename").textContent = filename || "未记录文件名";
if (filename) $("#company-review-filename").title = filename;
else $("#company-review-filename").removeAttribute("title");
$("#company-review-confirmed").textContent = formatInteger(confirmed);
$("#company-review-pending").textContent = formatInteger(pending);
$("#company-review-deleted").textContent = formatInteger(deleted);
body.innerHTML = items.map((item) => {
const isPending = item.review_status === "pending";
const itemId = Number(item.item_id);
const selected = state.companyReviewSelectedIds.has(itemId);
const tourCode = escapeHtml(item.tour_code || "暂无");
const raw = escapeHtml(item.room_type_raw || "暂无");
const roomType = escapeHtml(item.room_type || "");
const quantity = Number(item.quantity) || 1;
return `
|
${tourCode} |
${raw} |
|
|
${isPending ? "待人工确认" : "已确认"} |
|
`;
}).join("");
empty.hidden = items.length > 0;
syncCompanyReviewSelection(items, busy);
const total = Number(pagination.total) || 0;
const limit = Number(pagination.limit) || COMPANY_REVIEW_PAGE_SIZE;
const offset = Number(pagination.offset) || 0;
const pageCount = Math.max(1, Math.ceil(total / limit));
const currentPage = Math.min(pageCount, Math.floor(offset / limit) + 1);
const start = total > 0 ? offset + 1 : 0;
const end = total > 0 ? Math.min(offset + items.length, total) : 0;
$("#company-review-page-summary").textContent = total > 0
? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`
: "共 0 条";
$("#company-review-page-label").textContent = `第 ${formatInteger(currentPage)} / ${formatInteger(pageCount)} 页`;
$("#company-review-prev").disabled = busy || offset <= 0;
$("#company-review-next").disabled = busy || offset + items.length >= total;
$("#company-review-discard").disabled = busy;
$("#company-review-activate").disabled = busy || pending > 0 || confirmed < 1;
}
function selectCompanySourceFile(file) {
setCompanySourceError();
state.companySourceFile = null;
if (file) {
if (!file.name.toLowerCase().endsWith(".xlsx")) {
setCompanySourceError("请选择 .xlsx 格式的 Excel 文件");
} else if (!file.size) {
setCompanySourceError("Excel 文件为空");
} else if (file.size > state.maxUploadBytes) {
setCompanySourceError("Excel 文件超过 25 MB");
} else {
state.companySourceFile = file;
}
}
$("#company-selected-file").textContent = state.companySourceFile
? `${state.companySourceFile.name} · ${formatInteger(state.companySourceFile.size / 1024)} KB`
: "尚未选择文件";
updateCompanySourceUploadControls();
}
async function loadCompanySource(showErrors = false) {
if (!state.companySourceUploadReady) {
state.companySource = null;
updateCompanyReportControls();
return;
}
try {
state.companySource = await api("/api/company-reports/source");
setCompanySourceError();
} catch (error) {
state.companySource = null;
setCompanySourceError("当前 Excel 数据源暂时无法读取");
if (showErrors) showToast(error.message, true);
}
updateCompanyReportControls();
}
async function loadCompanyDraft(showErrors = false) {
if (!state.companySourceUploadReady) {
state.companySourceDraft = null;
clearCompanyReviewSelection();
renderCompanyDraft(null);
updateCompanyReportControls();
return;
}
state.companyReviewLoading = true;
renderCompanyDraft(state.companySourceDraft);
try {
let page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`);
if (page?.pagination?.total > 0 && !page.items?.length && state.companyReviewOffset > 0) {
state.companyReviewOffset = Math.max(0, Math.floor((page.pagination.total - 1) / COMPANY_REVIEW_PAGE_SIZE) * COMPANY_REVIEW_PAGE_SIZE);
page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`);
}
state.companySourceDraft = page;
} catch (error) {
state.companySourceDraft = null;
setCompanySourceError("当前提取结果暂时无法读取");
if (showErrors) showToast(error.message, true);
} finally {
state.companyReviewLoading = false;
}
renderCompanyDraft(state.companySourceDraft);
updateCompanySourceUploadControls();
updateCompanyReportControls();
}
async function handleCompanySourceUpload() {
if (!state.companySourceFile || !state.companySourceUploadReady || state.companySourceUploading) return;
state.companySourceUploading = true;
setCompanySourceError();
updateCompanySourceUploadControls();
updateCompanyReportControls();
try {
const receipt = await api("/api/company-reports/source", {
method: "POST",
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"X-ARR-Filename-B64": encodedFilename(state.companySourceFile.name),
},
body: await state.companySourceFile.arrayBuffer(),
});
state.companySourceDraft = receipt;
state.companyReviewOffset = 0;
clearCompanyReviewSelection();
state.companySourceFile = null;
$("#company-excel-file").value = "";
$("#company-selected-file").textContent = "尚未选择文件";
renderCompanyDraft(state.companySourceDraft);
const pending = Number(receipt?.summary?.pending_items) || 0;
showToast(pending > 0 ? `提取完成,${formatInteger(pending)} 条需要人工确认` : "提取完成,可以确认并启用");
} catch (error) {
setCompanySourceError(error.message || "Excel 文件未能完成提取");
showToast(error.message || "Excel 文件未能完成提取", true);
} finally {
state.companySourceUploading = false;
updateCompanySourceUploadControls();
updateCompanyReportControls();
}
}
async function saveCompanyReviewItem(button) {
if (!state.companySourceDraft?.summary || state.companyReviewMutating) return;
const row = button.closest("[data-review-item-id]");
const itemId = Number(row?.dataset.reviewItemId);
const roomType = $("[data-review-room]", row)?.value.trim() || "";
const quantity = Number($("[data-review-quantity]", row)?.value);
if (!roomType) {
setCompanySourceError("请填写房型后再保存");
return;
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 9999) {
setCompanySourceError("房间数量必须是 1 至 9999 的整数");
return;
}
state.companyReviewMutating = true;
setCompanySourceError();
renderCompanyDraft(state.companySourceDraft);
try {
await api(`/api/company-reports/source/draft/items/${itemId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft_id: state.companySourceDraft.summary.draft_id,
room_type: roomType,
quantity,
}),
});
state.companyReviewMutating = false;
await loadCompanyDraft();
showToast("房型记录已保存");
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanySourceError(error.message || "房型记录未能保存");
showToast(error.message || "房型记录未能保存", true);
}
}
function companyReviewConfirmIsOpen() {
return Boolean($("#company-review-confirm-dialog")?.open);
}
function setCompanyReviewConfirmBusy(busy) {
const request = state.companyReviewConfirmRequest;
const dialog = $("#company-review-confirm-dialog");
const cancel = $("#company-review-confirm-cancel");
const submit = $("#company-review-confirm-submit");
dialog.setAttribute("aria-busy", String(busy));
cancel.disabled = busy;
submit.disabled = busy;
submit.textContent = busy
? (request?.kind === "discard" ? "正在放弃" : "正在删除")
: (request?.confirmLabel || "确认删除");
}
function resetCompanyReviewConfirm(restoreFocus = true) {
const request = state.companyReviewConfirmRequest;
state.companyReviewConfirmRequest = null;
$("#company-review-confirm-dialog").setAttribute("aria-busy", "false");
$("#company-review-confirm-error").hidden = true;
if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) {
window.requestAnimationFrame(() => request.trigger.focus());
}
}
function closeCompanyReviewConfirm({ force = false, restoreFocus = true } = {}) {
if (state.companyReviewMutating && !force) return;
const dialog = $("#company-review-confirm-dialog");
const request = state.companyReviewConfirmRequest;
if (state.companyReviewConfirmRequest === request) resetCompanyReviewConfirm(restoreFocus);
if (dialog.open && typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
function openCompanyReviewConfirm(request) {
if (!state.companySourceDraft?.summary || state.companyReviewMutating || companyReviewConfirmIsOpen()) return;
const dialog = $("#company-review-confirm-dialog");
const error = $("#company-review-confirm-error");
error.textContent = "";
error.hidden = true;
if (request.kind === "discard") {
state.companyReviewConfirmRequest = {
...request,
confirmLabel: "确认放弃",
};
$("#company-review-confirm-title").textContent = "放弃本次提取";
$("#company-review-confirm-description").textContent = "确认放弃本次提取?";
$("#company-review-confirm-note").textContent = "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。";
} else {
const itemIds = [...new Set(request.itemIds || [])];
if (!itemIds.length) return;
const single = itemIds.length === 1;
state.companyReviewConfirmRequest = {
...request,
itemIds,
confirmLabel: single ? "确认删除" : `删除 ${formatInteger(itemIds.length)} 条`,
};
$("#company-review-confirm-title").textContent = "删除房型记录";
$("#company-review-confirm-description").textContent = single
? `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?`
: `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`;
$("#company-review-confirm-note").textContent = "此操作只影响本次提取结果,确认启用前原数据源不会改变。";
}
setCompanyReviewConfirmBusy(false);
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
window.requestAnimationFrame(() => $("#company-review-confirm-cancel").focus());
}
function deleteCompanyReviewItem(button) {
if (!state.companySourceDraft?.summary || state.companyReviewMutating) return;
const row = button.closest("[data-review-item-id]");
const itemId = Number(row?.dataset.reviewItemId);
if (!Number.isInteger(itemId) || itemId <= 0) return;
const tourCode = $(".company-review-tour", row)?.textContent?.trim() || "这条记录";
openCompanyReviewConfirm({ kind: "items", itemIds: [itemId], tourCode, trigger: button });
}
function deleteSelectedCompanyReviewItems(event) {
if (!state.companySourceDraft?.summary || state.companyReviewMutating) return;
const itemIds = (state.companySourceDraft.items || [])
.map((item) => Number(item.item_id))
.filter((itemId) => state.companyReviewSelectedIds.has(itemId));
if (!itemIds.length) return;
openCompanyReviewConfirm({ kind: "items", itemIds, trigger: event.currentTarget });
}
function discardCompanyReviewDraft(event) {
if (!state.companySourceDraft?.summary || state.companyReviewMutating) return;
openCompanyReviewConfirm({ kind: "discard", trigger: event.currentTarget });
}
async function confirmCompanyReviewAction() {
const request = state.companyReviewConfirmRequest;
const summary = state.companySourceDraft?.summary;
if (!request || !summary || state.companyReviewMutating) return;
state.companyReviewMutating = true;
setCompanySourceError();
setCompanyReviewConfirmBusy(true);
renderCompanyDraft(state.companySourceDraft);
try {
if (request.kind === "discard") {
await api("/api/company-reports/source/draft", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft_id: summary.draft_id }),
});
state.companySourceDraft = null;
state.companyReviewOffset = 0;
state.companyReviewMutating = false;
clearCompanyReviewSelection();
renderCompanyDraft(null);
updateCompanySourceUploadControls();
updateCompanyReportControls();
closeCompanyReviewConfirm({ force: true, restoreFocus: false });
showToast("本次提取已放弃,原数据源未改变");
return;
}
await api("/api/company-reports/source/draft/items", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft_id: summary.draft_id, item_ids: request.itemIds }),
});
const deletedCount = request.itemIds.length;
clearCompanyReviewSelection();
state.companyReviewMutating = false;
await loadCompanyDraft();
closeCompanyReviewConfirm({ force: true, restoreFocus: false });
showToast(deletedCount === 1 ? "房型记录已删除" : `已删除 ${formatInteger(deletedCount)} 条房型记录`);
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanyReviewConfirmBusy(false);
const message = error.message || (request.kind === "discard" ? "本次提取未能放弃" : "房型记录未能删除");
const errorNode = $("#company-review-confirm-error");
errorNode.textContent = message;
errorNode.hidden = false;
setCompanySourceError(message);
showToast(message, true);
}
}
async function activateCompanyReviewDraft() {
const summary = state.companySourceDraft?.summary;
if (!summary || state.companyReviewMutating || Number(summary.pending_items) > 0) return;
state.companyReviewMutating = true;
setCompanySourceError();
renderCompanyDraft(state.companySourceDraft);
try {
const source = await api("/api/company-reports/source/draft/activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft_id: summary.draft_id }),
});
state.companySource = source;
state.companySourceDraft = null;
state.companyReviewOffset = 0;
state.companyReviewMutating = false;
clearCompanyReviewSelection();
renderCompanyDraft(null);
updateCompanyReportControls();
showToast("人工核对完成,Booking 数据源已启用");
} catch (error) {
state.companyReviewMutating = false;
renderCompanyDraft(state.companySourceDraft);
setCompanySourceError(error.message || "Booking 数据源未能启用");
showToast(error.message || "Booking 数据源未能启用", true);
}
}
function companyMonthEndDay(reportMonth) {
const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || "");
if (!match) return null;
return new Date(Date.UTC(Number(match[1]), Number(match[2]), 0)).getUTCDate();
}
function companyPeriodRange(reportMonth, period) {
if (period === "01-10") return "01-10";
if (period === "11-20") return "11-20";
const monthEnd = companyMonthEndDay(reportMonth);
return monthEnd ? `21-${monthEnd}` : "21-月末";
}
function companyPeriodDisplayLabel(reportMonth, period) {
if (period === "01-10") return "C/O:01-10";
if (period === "11-20") return "C/O:11-20";
const monthEnd = companyMonthEndDay(reportMonth);
return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30";
}
function companyPeriodCountKey(reportMonth, period) {
if (period !== "21-month-end") return period;
const monthEnd = companyMonthEndDay(reportMonth);
return monthEnd ? `21-${monthEnd}` : "";
}
function companyPeriodCompleteDate(reportMonth, period) {
const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || "");
if (!match) return "";
if (period === "01-10") return `${reportMonth}-11`;
if (period === "11-20") return `${reportMonth}-21`;
if (period !== "21-month-end") return "";
let year = Number(match[1]);
let month = Number(match[2]) + 1;
if (month === 13) {
year += 1;
month = 1;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`;
}
function companyPeriodIncluded(cutoff, period) {
return COMPANY_REPORT_PERIODS.indexOf(period) <= COMPANY_REPORT_PERIODS.indexOf(cutoff);
}
function monthLabel(value) {
const match = /^(\d{4})-(\d{2})$/.exec(value || "");
return match ? `${match[1]}年${match[2]}月` : "未选择";
}
function formatDurationSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) return "暂无";
if (seconds < 60) return `${Math.max(1, Math.round(seconds))} 秒`;
return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
}
function isCompanyReportActive(job = state.companyReportCurrentJob) {
return Boolean(job && ["queued", "running"].includes(job.state));
}
function setCompanyReportError(message = "") {
const node = $("#company-report-error");
node.textContent = message;
node.hidden = !message;
}
function updateCompanyReportControls() {
const reportMonth = $("#company-report-month").value || "";
const monthValid = /^\d{4}-(0[1-9]|1[0-2])$/.test(reportMonth);
const currentMonth = localMonth();
const monthFuture = monthValid && reportMonth > currentMonth;
const reviewOpen = Boolean(state.companySourceDraft?.summary);
const sourceReady = Boolean(state.companySource) && !reviewOpen;
const active = isCompanyReportActive() || Boolean(state.companyReportSubmittingPeriod) || Boolean(state.companyReportConfirmRequest) || state.companySourceUploading || state.companyReviewMutating;
const today = localDate();
const monthInput = $("#company-report-month");
monthInput.max = currentMonth;
monthInput.disabled = active;
$$('[data-company-period]').forEach((button) => {
const period = button.dataset.companyPeriod;
const completeDate = companyPeriodCompleteDate(reportMonth, period);
const periodComplete = Boolean(completeDate) && today >= completeDate;
const range = companyPeriodRange(reportMonth, period);
const displayRange = companyPeriodDisplayLabel(reportMonth, period);
const rangeNode = $('[data-company-period-range]', button);
const releaseNode = $('[data-company-period-release]', button);
const statusNode = $('[data-company-period-state]', button);
if (rangeNode) rangeNode.textContent = displayRange;
if (releaseNode) releaseNode.textContent = completeDate ? `周期结束:${completeDate} 00:00(曼谷)` : "选择月份后显示周期结束时间";
statusNode.className = "company-period-state";
if (!monthValid) {
statusNode.textContent = "选择月份";
} else if (reviewOpen) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = "先完成核对";
} else if (!sourceReady) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = "先上传 Excel";
} else if (!state.companyReportsReady) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = "服务未就绪";
} else if (monthFuture) {
statusNode.classList.add("is-unavailable");
statusNode.textContent = "未来月份";
} else if (!periodComplete) {
statusNode.classList.add("is-in-progress");
statusNode.textContent = "周期未结束";
} else {
statusNode.classList.add("is-complete");
statusNode.textContent = "周期已结束";
}
button.disabled = active || !state.companyReportsReady || !sourceReady || !monthValid || monthFuture;
button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period));
if (!monthValid) button.title = "请先选择有效的报表月份";
else if (reviewOpen) button.title = "请先确认或放弃当前 Excel 提取结果";
else if (!sourceReady) button.title = "请先提取并启用 Excel 报表";
else if (!state.companyReportsReady) button.title = "公司渠道明细服务尚未就绪";
else if (monthFuture) button.title = "未来报表月份暂不可生成";
else if (!periodComplete) button.title = `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`;
else button.removeAttribute("title");
});
}
function companyStateLabel(value) {
return {
queued: "已排队",
running: "生成中",
succeeded: "全部成功",
partial_failure: "部分成功",
failed: "未完成",
}[value] || "未知";
}
function companyStateStyle(value) {
if (value === "succeeded") return "success";
if (value === "partial_failure") return "review";
if (value === "failed") return "failed";
return "running";
}
function companyCutoffLabel(job) {
if (job.period === "01-10") return "截至 10 日";
if (job.period === "11-20") return "截至 20 日";
return "截至月末";
}
function companyProblemLabel(code) {
return {
COMPANY_REPORT_MULTI_PRICE_REVIEW: "同房型存在不同静态价格,需人工复核",
COMPANY_REPORT_SOURCE_VERSION_MISSING: "数据源版本缺失",
COMPANY_REPORT_GROUP_CODE_MISSING: "Group Code 缺失",
COMPANY_REPORT_GROUP_CODE_NOT_FOUND: "Group Code 未找到",
COMPANY_REPORT_BOOKING_PARSE_FAILED: "预订解析异常",
COMPANY_REPORT_ROOM_ITEMS_MISSING: "房型明细缺失",
COMPANY_REPORT_STAY_DATE_INVALID: "住宿日期异常",
COMPANY_REPORT_NIGHTS_CONFLICT: "间夜数据冲突",
COMPANY_REPORT_TOTAL_PRICE_INVALID: "静态价格异常",
COMPANY_REPORT_OUTPUT_VALIDATION_FAILED: "Excel 输出校验失败",
COMPANY_REPORT_PUBLISH_FAILED: "正式表格发布失败",
COMPANY_REPORT_INTERNAL_ERROR: "处理服务异常",
COMPANY_REPORT_EXECUTION_FAILED: "任务执行未完成",
COMPANY_REPORT_JOB_INTERRUPTED: "服务重启中断任务",
}[code] || "需复核处理结果";
}
function uniqueCompanyProblems(problems) {
const seen = new Set();
return (problems || []).filter((item) => {
const key = `${item.kind || "problem"}|${item.code || ""}|${item.period || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function companyProblemsForResult(result) {
const warnings = (result?.warnings || []).map((item) => ({ ...item, kind: "warning" }));
const errors = (result?.errors || []).map((item) => ({ ...item, kind: "error" }));
return uniqueCompanyProblems([...warnings, ...errors]);
}
function companySuccessCount(job) {
return (job.company_results || []).filter((item) => item.status === "success").length;
}
function companyWarningCount(job) {
return (job.company_results || []).reduce(
(sum, item) => sum + companyProblemsForResult(item).filter((problem) => problem.kind === "warning").length,
0,
);
}
function companyPeriodCell(job, result, period) {
const range = companyPeriodRange(job.report_month, period);
if (!companyPeriodIncluded(job.period, period)) {
return `待生成 | `;
}
if (!result) {
return `${isCompanyReportActive(job) ? "等待生成" : "无结果"} | `;
}
if (result.status === "failed") {
return `未生成 | `;
}
const countKey = companyPeriodCountKey(job.report_month, period);
const count = Object.prototype.hasOwnProperty.call(result.period_row_counts || {}, countKey) ? result.period_row_counts[countKey] : null;
return `${count === null ? "未记录" : `✓ ${formatInteger(count)} 条`} | `;
}
function renderCompanyResultRows(job) {
const byCompany = new Map((job.company_results || []).map((item) => [item.company, item]));
$("#company-report-result-rows").innerHTML = COMPANY_REPORT_NAMES.map((company) => {
const result = byCompany.get(company);
const warnings = result?.warnings || [];
const rowStyle = result?.status === "failed" ? "is-failed" : warnings.length ? "is-review" : "";
let resultStatus = isCompanyReportActive(job) ? "等待生成" : "无结果";
let resultStyle = isCompanyReportActive(job) ? "running" : "failed";
if (result?.status === "success") {
resultStatus = warnings.length ? "成功 · 待复核" : "成功";
resultStyle = warnings.length ? "review" : "success";
} else if (result?.status === "failed") {
resultStatus = "失败";
resultStyle = "failed";
}
const problems = companyProblemsForResult(result);
const problemHtml = problems.length
? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "暂无")}`).join("")
: '暂无';
const download = result?.download_url?.startsWith("/api/company-reports/")
? `下载`
: `${isCompanyReportActive(job) ? "待生成" : "暂无"}`;
return `
| ${escapeHtml(company)} |
${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")}
${result ? formatInteger(result.row_count) : "暂无"} |
${escapeHtml(resultStatus)} |
${problemHtml} |
${formatDate(job.finished_at || job.created_at, true)} |
${download} |
`;
}).join("");
}
function renderCompanyReportJob(job) {
state.companyReportCurrentJob = job;
$("#company-report-job-panel").hidden = false;
const terminal = ["succeeded", "partial_failure", "failed"].includes(job.state);
const kicker = $("#company-report-job-kicker");
kicker.textContent = "TASK STATUS";
kicker.hidden = terminal;
$("#company-report-job-title").textContent = `${monthLabel(job.report_month)} · ${companyCutoffLabel(job)}`;
const message = $("#company-report-job-message");
const messageText = job.state === "failed" && !job.failure_code ? "" : (job.message || "");
message.textContent = messageText;
message.hidden = !messageText;
const status = $("#company-report-job-state");
status.className = `status-chip ${companyStateStyle(job.state)}`;
status.textContent = companyStateLabel(job.state);
$("#company-report-success-count").textContent = `${companySuccessCount(job)} / 5`;
const percent = Math.max(0, Math.min(100, Number(job.progress?.percent) || 0));
$("#company-report-progress-label").textContent = job.progress?.label || "等待生成";
$("#company-report-progress-percent").textContent = `${percent}%`;
$("#company-report-progress-track").setAttribute("aria-valuenow", String(percent));
$("#company-report-progress-bar").style.transform = `scaleX(${percent / 100})`;
$("#company-report-job-month").textContent = monthLabel(job.report_month);
$("#company-report-job-as-of").textContent = job.as_of_date || "暂无";
$("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds);
$("#company-report-warning-count").textContent = `${companyWarningCount(job)} 项`;
$("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end");
renderCompanyResultRows(job);
localStorage.setItem("arr:last-company-report-job", job.job_id);
updateCompanyReportControls();
updateCompanySourceUploadControls();
}
function renderCompanyReportHistory(jobs) {
state.companyReportJobs = jobs;
const body = $("#company-history-rows");
if (!jobs.length) {
body.innerHTML = '| 该月份还没有公司渠道明细任务 |
';
return;
}
body.innerHTML = jobs.map((job) => `
| ${formatDate(job.created_at, true)} |
${escapeHtml(job.report_month || "暂无")} |
${escapeHtml(companyCutoffLabel(job))} |
${escapeHtml(companyStateLabel(job.state))} |
${companySuccessCount(job)} / 5 |
${companyWarningCount(job)} |
|
`).join("");
$$('[data-company-job-id]', body).forEach((button) => button.addEventListener("click", () => loadCompanyReportJob(button.dataset.companyJobId, true)));
}
async function loadCompanyReportHistory(restore = false) {
if (state.companyHistoryLoading) return;
if (!state.companyReportsReady) {
state.companyReportsOffset = 0;
state.companyReportsTotal = 0;
renderCompanyReportHistory([]);
renderPagination("company", 0, 0);
return;
}
state.companyHistoryLoading = true;
renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, true);
const month = $("#company-report-month").value || localMonth();
try {
const encodedMonth = encodeURIComponent(month);
let page = readPage(await api(
`/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`,
{ returnEnvelope: true },
));
if (!page.items.length && page.total > 0 && state.companyReportsOffset >= page.total) {
state.companyReportsOffset = pageOffset(page.total, state.companyReportsOffset, -1);
page = readPage(await api(
`/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`,
{ returnEnvelope: true },
));
}
state.companyReportsTotal = page.total;
renderCompanyReportHistory(page.items);
if (restore) {
const remembered = localStorage.getItem("arr:last-company-report-job");
const target = page.items.find((job) => job.job_id === remembered) || page.items.find((job) => ["queued", "running"].includes(job.state));
if (target) await loadCompanyReportJob(target.job_id);
}
} catch (error) {
$("#company-history-rows").innerHTML = '| 任务记录暂时无法读取 |
';
if (restore) showToast(error.message, true);
} finally {
state.companyHistoryLoading = false;
renderPagination("company", state.companyReportsTotal, state.companyReportsOffset);
}
}
async function loadCompanyReportJob(jobId, scroll = false) {
if (state.companyReportPollTimer) window.clearTimeout(state.companyReportPollTimer);
try {
const job = await api(`/api/company-reports/jobs/${encodeURIComponent(jobId)}`);
renderCompanyReportJob(job);
if (scroll) $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" });
if (isCompanyReportActive(job)) {
state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 1200);
} else {
await loadCompanyReportHistory(false);
}
} catch (error) {
showToast(error.message, true);
state.companyReportPollTimer = window.setTimeout(() => loadCompanyReportJob(jobId), 2500);
}
}
function companyReportConfirmIsOpen() {
return Boolean($("#company-report-confirm-dialog")?.open);
}
function setCompanyReportConfirmBusy(busy) {
const dialog = $("#company-report-confirm-dialog");
const cancel = $("#company-report-confirm-cancel");
const submit = $("#company-report-confirm-submit");
dialog.setAttribute("aria-busy", String(busy));
cancel.disabled = busy;
submit.disabled = busy;
submit.textContent = busy ? "正在生成" : "确认生成";
}
function resetCompanyReportConfirm(restoreFocus = true) {
const request = state.companyReportConfirmRequest;
state.companyReportConfirmRequest = null;
const dialog = $("#company-report-confirm-dialog");
const error = $("#company-report-confirm-error");
dialog.setAttribute("aria-busy", "false");
error.textContent = "";
error.hidden = true;
if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) {
window.requestAnimationFrame(() => request.trigger.focus());
}
updateCompanyReportControls();
}
function closeCompanyReportConfirm({ force = false, restoreFocus = true } = {}) {
if (state.companyReportSubmittingPeriod && !force) return;
const dialog = $("#company-report-confirm-dialog");
if (state.companyReportConfirmRequest) resetCompanyReportConfirm(restoreFocus);
if (dialog.open && typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
function openCompanyReportConfirm({ period, trigger }) {
if (!state.companyReportsReady || !state.companySource || isCompanyReportActive() || state.companyReportSubmittingPeriod || companyReportConfirmIsOpen()) return;
const reportMonth = $("#company-report-month").value;
if (!reportMonth || reportMonth > localMonth()) return;
const dialog = $("#company-report-confirm-dialog");
state.companyReportConfirmRequest = { period, reportMonth, trigger };
$("#company-report-confirm-description").textContent = `${monthLabel(reportMonth)},${companyPeriodRange(reportMonth, period)}`;
const completeDate = companyPeriodCompleteDate(reportMonth, period);
const periodComplete = Boolean(completeDate) && localDate() >= completeDate;
$("#company-report-confirm-note").textContent = periodComplete
? "将一次生成五家公司的正式 Excel。"
: "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。";
setCompanyReportConfirmBusy(false);
updateCompanyReportControls();
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
window.requestAnimationFrame(() => $("#company-report-confirm-cancel").focus());
}
async function confirmCompanyReportAction() {
const request = state.companyReportConfirmRequest;
if (!request || state.companyReportSubmittingPeriod) return;
setCompanyReportError();
state.companyReportSubmittingPeriod = request.period;
setCompanyReportConfirmBusy(true);
updateCompanyReportControls();
try {
const created = await api("/api/company-reports/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ report_month: request.reportMonth, period: request.period }),
});
localStorage.setItem("arr:last-company-report-job", created.job_id);
state.companyReportsOffset = 0;
closeCompanyReportConfirm({ force: true, restoreFocus: false });
renderCompanyReportJob(created);
$("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" });
showToast("五家公司渠道明细任务已提交");
await loadCompanyReportJob(created.job_id);
} catch (error) {
const message = error.message || "公司渠道明细任务未能提交";
const errorNode = $("#company-report-confirm-error");
errorNode.textContent = message;
errorNode.hidden = false;
setCompanyReportConfirmBusy(false);
setCompanyReportError(message);
showToast(message, true);
} finally {
state.companyReportSubmittingPeriod = "";
updateCompanyReportControls();
}
}
function startCompanyReport(period, trigger) {
openCompanyReportConfirm({ period, trigger });
}
async function refreshCompanyReports() {
setCompanyReportError();
await loadHealth();
await Promise.all([loadCompanySource(true), loadCompanyDraft(true), loadCompanyReportHistory(true)]);
showToast("公司渠道明细任务已刷新");
}
async function changeHistoryPage(totalKey, offsetKey, direction, loader) {
const nextOffset = pageOffset(state[totalKey], state[offsetKey], direction);
if (nextOffset === state[offsetKey]) return;
state[offsetKey] = nextOffset;
await loader();
}
function bindEvents() {
$$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab)));
$("#xml-file").addEventListener("change", (event) => {
if (state.uploadInFlight) return;
state.selectedFile = event.target.files?.[0] || null;
if (!state.uploadInFlight) resetUploadProgress();
$("#selected-file").textContent = state.selectedFile ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` : "尚未选择文件";
$("#upload-button").disabled = !state.selectedFile || !state.health.processing_ready;
});
const dropzone = $("#dropzone");
["dragenter", "dragover"].forEach((name) => dropzone.addEventListener(name, (event) => {
event.preventDefault();
if (state.health.processing_ready) dropzone.classList.add("is-over");
}));
["dragleave", "drop"].forEach((name) => dropzone.addEventListener(name, (event) => {
event.preventDefault();
dropzone.classList.remove("is-over");
}));
dropzone.addEventListener("drop", (event) => {
if (!state.health.processing_ready || state.uploadInFlight) return;
const file = event.dataTransfer?.files?.[0];
if (!file) return;
state.selectedFile = file;
if (!state.uploadInFlight) resetUploadProgress();
$("#selected-file").textContent = `${file.name} · ${formatInteger(file.size / 1024)} KB`;
$("#upload-button").disabled = false;
});
$("#upload-button").addEventListener("click", handleUpload);
$("#company-excel-file").addEventListener("change", (event) => {
selectCompanySourceFile(event.target.files?.[0] || null);
});
const companyDropzone = $("#company-excel-dropzone");
["dragenter", "dragover"].forEach((name) => companyDropzone.addEventListener(name, (event) => {
event.preventDefault();
if (state.companySourceUploadReady && !state.companySourceUploading && !isCompanyReportActive()) {
companyDropzone.classList.add("is-over");
}
}));
["dragleave", "drop"].forEach((name) => companyDropzone.addEventListener(name, (event) => {
event.preventDefault();
companyDropzone.classList.remove("is-over");
}));
companyDropzone.addEventListener("drop", (event) => {
if (!state.companySourceUploadReady || state.companySourceUploading || isCompanyReportActive()) return;
selectCompanySourceFile(event.dataTransfer?.files?.[0] || null);
});
$("#company-upload-button").addEventListener("click", handleCompanySourceUpload);
$("#company-review-body").addEventListener("click", (event) => {
const save = event.target.closest("[data-review-save]");
if (save) {
saveCompanyReviewItem(save);
return;
}
const remove = event.target.closest("[data-review-delete]");
if (remove) deleteCompanyReviewItem(remove);
});
$("#company-review-body").addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-review-select]");
if (!checkbox || state.companyReviewMutating) return;
const itemId = Number(checkbox.value);
if (!Number.isInteger(itemId) || itemId <= 0) return;
if (checkbox.checked) state.companyReviewSelectedIds.add(itemId);
else state.companyReviewSelectedIds.delete(itemId);
syncCompanyReviewSelection(state.companySourceDraft?.items || [], false);
});
$("#company-review-select-page").addEventListener("change", (event) => {
if (state.companyReviewMutating) return;
(state.companySourceDraft?.items || []).forEach((item) => {
const itemId = Number(item.item_id);
if (!Number.isInteger(itemId) || itemId <= 0) return;
if (event.currentTarget.checked) state.companyReviewSelectedIds.add(itemId);
else state.companyReviewSelectedIds.delete(itemId);
});
syncCompanyReviewSelection(state.companySourceDraft?.items || [], false);
});
$("#company-review-delete-selected").addEventListener("click", deleteSelectedCompanyReviewItems);
$("#company-review-discard").addEventListener("click", discardCompanyReviewDraft);
$("#company-review-activate").addEventListener("click", activateCompanyReviewDraft);
$("#company-review-prev").addEventListener("click", async () => {
clearCompanyReviewSelection();
state.companyReviewOffset = Math.max(0, state.companyReviewOffset - COMPANY_REVIEW_PAGE_SIZE);
await loadCompanyDraft(true);
});
$("#company-review-next").addEventListener("click", async () => {
clearCompanyReviewSelection();
state.companyReviewOffset += COMPANY_REVIEW_PAGE_SIZE;
await loadCompanyDraft(true);
});
$("#refresh-jobs").addEventListener("click", () => loadJobs(true));
$("#jobs-prev").addEventListener("click", () =>
changeHistoryPage("jobsTotal", "jobsOffset", -1, () => loadJobs(true, false))
);
$("#jobs-next").addEventListener("click", () =>
changeHistoryPage("jobsTotal", "jobsOffset", 1, () => loadJobs(true, false))
);
$("#monthly-prev").addEventListener("click", () =>
changeHistoryPage("monthlyTotal", "monthlyOffset", -1, () => loadMonthly(true, false))
);
$("#monthly-next").addEventListener("click", () =>
changeHistoryPage("monthlyTotal", "monthlyOffset", 1, () => loadMonthly(true, false))
);
$("#company-prev").addEventListener("click", () =>
changeHistoryPage("companyReportsTotal", "companyReportsOffset", -1, () => loadCompanyReportHistory(false))
);
$("#company-next").addEventListener("click", () =>
changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false))
);
$("#jobs-body").addEventListener("click", (event) => {
if (event.target.closest("a, button")) return;
const row = event.target.closest("[data-job-id]");
if (row) selectJob(row.dataset.jobId, { showLog: true });
});
$("#jobs-body").addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
const row = event.target.closest("[data-job-id]");
if (!row) return;
event.preventDefault();
selectJob(row.dataset.jobId, { showLog: true });
});
$("#task-log-trigger").addEventListener("click", openTaskLog);
$("#close-task-log").addEventListener("click", closeTaskLog);
$("#task-log-dialog").addEventListener("close", clearTracePoll);
$("#task-log-dialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeTaskLog();
});
$("#company-review-confirm-cancel").addEventListener("click", () => closeCompanyReviewConfirm());
$("#company-review-confirm-submit").addEventListener("click", confirmCompanyReviewAction);
$("#company-review-confirm-dialog").addEventListener("cancel", (event) => {
if (state.companyReviewMutating) event.preventDefault();
});
$("#company-review-confirm-dialog").addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeCompanyReviewConfirm();
});
$("#company-review-confirm-dialog").addEventListener("close", () => {
if (state.companyReviewConfirmRequest) resetCompanyReviewConfirm();
});
$("#company-review-confirm-dialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeCompanyReviewConfirm();
});
$("#company-report-confirm-cancel").addEventListener("click", () => closeCompanyReportConfirm());
$("#company-report-confirm-submit").addEventListener("click", confirmCompanyReportAction);
$("#company-report-confirm-dialog").addEventListener("cancel", (event) => {
if (state.companyReportSubmittingPeriod) event.preventDefault();
});
$("#company-report-confirm-dialog").addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeCompanyReportConfirm();
});
$("#company-report-confirm-dialog").addEventListener("close", () => {
if (state.companyReportConfirmRequest) resetCompanyReportConfirm();
});
$("#company-report-confirm-dialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeCompanyReportConfirm();
});
$("#refresh-trace").addEventListener("click", async () => {
await loadJobTrace(true);
showToast("任务日志已刷新");
});
$("#copy-trace").addEventListener("click", copyAllTraceLogs);
$("#logout-button").addEventListener("click", logout);
$("#bi-month").addEventListener("change", () => loadAnalytics(true));
$("#refresh-company-reports").addEventListener("click", refreshCompanyReports);
$("#company-report-month").addEventListener("change", async () => {
state.companyReportCurrentJob = null;
state.companyReportsOffset = 0;
state.companyReportsTotal = 0;
$("#company-report-job-panel").hidden = true;
updateCompanyReportControls();
updateCompanySourceUploadControls();
await loadCompanyReportHistory(false);
});
$$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod, button)));
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearTracePoll();
clearMonthlyPoll();
} else {
scheduleTracePoll(250);
if ($("#panel-monthly")?.classList.contains("is-active")) loadMonthly(false, true);
}
});
window.addEventListener("hashchange", () => activateTab(location.hash.slice(1), false));
}
async function boot() {
$("#company-report-month").value = localMonth();
populateMonths([]);
bindEvents();
activateTab(location.hash.slice(1) || "daily", false);
try {
await initSession();
} catch (_) {
showToast("页面会话初始化失败", true);
return;
}
await loadHealth();
await Promise.all([
loadJobs(),
loadMonthly(),
loadMonthsAndAnalytics(),
loadCompanySource(),
loadCompanyDraft(),
loadCompanyReportHistory(location.hash.slice(1) === "usage"),
]);
}
boot();
})();