2313 lines
111 KiB
JavaScript
2313 lines
111 KiB
JavaScript
(() => {
|
||
"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("<", "<")
|
||
.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 = 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();
|
||
})();
|