// 封装带 Token 的 fetch (全局函数) async function authenticatedFetch(url, options = {}) { const token = localStorage.getItem("crmToken"); const headers = { ...options.headers, Authorization: `Bearer ${token}`, }; const response = await fetch(url, { ...options, headers }); if (response.status === 401) { localStorage.removeItem("crmToken"); window.location.href = "/static/login.html"; return; } return response; } // 解析 JWT Token 获取用户信息 function parseJwtToken(token) { try { if (!token) return null; const base64Url = token.split(".")[1]; const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const jsonPayload = decodeURIComponent( atob(base64) .split("") .map(function (c) { return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); }) .join(""), ); return JSON.parse(jsonPayload); } catch (e) { console.error("Error parsing JWT token:", e); return null; } } // 获取当前用户角色 function getUserRole() { const token = localStorage.getItem("crmToken"); const payload = parseJwtToken(token); return payload ? payload.role : null; } // 获取当前用户名 function getUsername() { const token = localStorage.getItem("crmToken"); const payload = parseJwtToken(token); return payload ? payload.username : null; } // 检查当前用户是否可以删除数据 // admin 用户 (role: viewer) 不能删除 // administrator 用户 (role: admin) 可以删除 function canDelete() { const role = getUserRole(); return role === "admin"; } document.addEventListener("DOMContentLoaded", function () { console.log("CRM System Main JS v5.1 Loaded - " + new Date().toLocaleString()); // 登录守卫 const token = localStorage.getItem("crmToken"); if (!token && !window.location.pathname.endsWith("login.html")) { window.location.href = "/static/login.html"; return; } // Navigation const navItems = document.querySelectorAll(".nav-item"); const customerSection = document.getElementById("customerSection"); const dashboardSection = document.getElementById("dashboardSection"); const pageTitle = document.getElementById("pageTitle"); // Elements const createCustomerForm = document.getElementById("createCustomerForm"); const importFileForm = document.getElementById("importFileForm"); const customerTableBody = document.getElementById("customerTableBody"); const addCustomerBtn = document.getElementById("addCustomerBtn"); const importBtn = document.getElementById("importBtn"); const createModal = document.getElementById("createModal"); const importModal = document.getElementById("importModal"); const editModal = document.getElementById("editModal"); const editCustomerForm = document.getElementById("editCustomerForm"); const menuToggle = document.getElementById("menuToggle"); const sidebar = document.querySelector(".sidebar"); const customerFilter = document.getElementById("customerFilter"); const customerSearchInput = document.getElementById("customerSearchInput"); const contentArea = document.querySelector(".content-area"); const refreshCustomersBtn = document.getElementById("refreshCustomersBtn"); const exportCustomersBtn = document.getElementById("exportCustomersBtn"); let allCustomers = []; let filteredCustomers = []; let dashboardCustomers = []; // Chart instances let statusChartInstance = null; let typeChartInstance = null; let trendChartInstance = null; // Current section tracking let currentSection = "dashboard"; // Pagination state let currentPage = 1; let pageSize = 10; let totalPages = 1; let totalItems = 0; let selectedCustomerFilter = ""; let selectedTypeFilter = ""; let selectedStatusProgressFilter = ""; let customerStartDate = ""; let customerEndDate = ""; let customerSearchQuery = ""; let createUploadedScreenshots = []; let editUploadedScreenshots = []; let cellTooltipEl = null; let tooltipAnchorCell = null; let tooltipHideTimer = null; function ensureCellTooltip() { if (cellTooltipEl) return cellTooltipEl; cellTooltipEl = document.createElement("div"); cellTooltipEl.className = "cell-tooltip"; cellTooltipEl.style.display = "none"; document.body.appendChild(cellTooltipEl); cellTooltipEl.addEventListener("mouseenter", () => { if (tooltipHideTimer) { clearTimeout(tooltipHideTimer); tooltipHideTimer = null; } }); cellTooltipEl.addEventListener("mouseleave", (e) => { const nextTarget = e.relatedTarget; if ( tooltipAnchorCell && nextTarget && tooltipAnchorCell.contains(nextTarget) ) return; hideCellTooltip(0); }); return cellTooltipEl; } function hideCellTooltip(delayMs = 120) { if (tooltipHideTimer) clearTimeout(tooltipHideTimer); tooltipHideTimer = setTimeout(() => { if (!cellTooltipEl) return; cellTooltipEl.style.display = "none"; cellTooltipEl.textContent = ""; cellTooltipEl.removeAttribute("data-placement"); tooltipAnchorCell = null; }, delayMs); } function positionCellTooltipForCell(cell) { if (!cellTooltipEl) return; const rect = cell.getBoundingClientRect(); const viewportPadding = 12; const offset = 10; cellTooltipEl.style.left = "0px"; cellTooltipEl.style.top = "0px"; cellTooltipEl.style.visibility = "hidden"; cellTooltipEl.style.display = "block"; const tipRect = cellTooltipEl.getBoundingClientRect(); const tipWidth = tipRect.width; const tipHeight = tipRect.height; const canShowAbove = rect.top >= tipHeight + offset + viewportPadding; const placement = canShowAbove ? "top" : "bottom"; cellTooltipEl.setAttribute("data-placement", placement); const leftUnclamped = rect.left + rect.width / 2 - tipWidth / 2; const left = Math.max( viewportPadding, Math.min(leftUnclamped, window.innerWidth - viewportPadding - tipWidth), ); const top = placement === "top" ? rect.top - tipHeight - offset : Math.min( rect.bottom + offset, window.innerHeight - viewportPadding - tipHeight, ); cellTooltipEl.style.left = `${Math.round(left)}px`; cellTooltipEl.style.top = `${Math.round(top)}px`; cellTooltipEl.style.visibility = "visible"; } function showCellTooltipForCell(cell) { const rawText = (cell.getAttribute("data-tooltip") || "").trim(); if (!rawText) return; const tip = ensureCellTooltip(); tip.textContent = rawText; tooltipAnchorCell = cell; if (tooltipHideTimer) { clearTimeout(tooltipHideTimer); tooltipHideTimer = null; } positionCellTooltipForCell(cell); } function bindCellTooltipEvents() { customerTableBody.addEventListener("mouseover", (e) => { const cell = e.target && e.target.closest ? e.target.closest("td.has-overflow") : null; if (!cell || !customerTableBody.contains(cell)) return; if ( tooltipAnchorCell === cell && cellTooltipEl && cellTooltipEl.style.display === "block" ) return; showCellTooltipForCell(cell); }); customerTableBody.addEventListener("mouseout", (e) => { const fromCell = e.target && e.target.closest ? e.target.closest("td.has-overflow") : null; if (!fromCell) return; const nextTarget = e.relatedTarget; if (cellTooltipEl && nextTarget && cellTooltipEl.contains(nextTarget)) return; if (tooltipAnchorCell === fromCell) hideCellTooltip(120); }); const reposition = () => { if ( !cellTooltipEl || cellTooltipEl.style.display !== "block" || !tooltipAnchorCell ) return; positionCellTooltipForCell(tooltipAnchorCell); }; window.addEventListener("resize", reposition); window.addEventListener("scroll", reposition, true); if (contentArea) contentArea.addEventListener("scroll", reposition, { passive: true }); } bindCellTooltipEvents(); // Setup module dropdown with "other" option handling function setupModuleDropdown(selectId, otherInputId) { const select = document.getElementById(selectId); const otherInput = document.getElementById(otherInputId); if (!select || !otherInput) return; select.addEventListener("change", function () { if (this.value === "其他") { otherInput.style.display = "block"; otherInput.focus(); } else { otherInput.style.display = "none"; otherInput.value = ""; } }); } // Get module value (handles "other" option) function getModuleValue(selectId, otherInputId) { const select = document.getElementById(selectId); const otherInput = document.getElementById(otherInputId); if (!select) return ""; if (select.value === "其他" && otherInput && otherInput.value.trim()) { return otherInput.value.trim(); } return select.value; } // Set module value in form (handles "other" option) function setModuleValue(selectId, otherInputId, value) { const select = document.getElementById(selectId); const otherInput = document.getElementById(otherInputId); if (!select) return; // Check if value matches one of the predefined options const predefinedOptions = [ "数据生成", "数据集", "数据空间", "模型工坊", "模型广场", "镜像管理", "", ]; if (predefinedOptions.includes(value)) { select.value = value; if (otherInput) { otherInput.style.display = "none"; otherInput.value = ""; } } else if (value) { // Custom value - select "other" and fill input select.value = "其他"; if (otherInput) { otherInput.style.display = "block"; otherInput.value = value; } } else { select.value = ""; if (otherInput) { otherInput.style.display = "none"; otherInput.value = ""; } } } // Initialize module dropdowns setupModuleDropdown("createModule", "createModuleOther"); setupModuleDropdown("editModule", "editModuleOther"); function normalizeDateValue(dateValue) { const raw = String(dateValue || "").trim(); if (!raw) return ""; const cleaned = raw.replace(/\./g, "-").replace(/\//g, "-"); const parts = cleaned.split("-").filter(Boolean); if (parts.length < 3) return ""; const year = parts[0].padStart(4, "0"); const month = parts[1].padStart(2, "0"); const day = parts[2].padStart(2, "0"); return `${year}-${month}-${day}`; } // Navigation event listeners navItems.forEach((item) => { item.addEventListener("click", function (e) { e.preventDefault(); const section = this.getAttribute("data-section"); switchSection(section); }); }); // Menu toggle for mobile menuToggle.addEventListener("click", function () { sidebar.classList.toggle("open"); }); // Close sidebar when clicking outside on mobile document.addEventListener("click", function (e) { if (window.innerWidth <= 768) { if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) { sidebar.classList.remove("open"); } } }); // Sidebar toggle functionality const sidebarToggle = document.getElementById("sidebarToggle"); if (sidebarToggle && sidebar) { sidebarToggle.addEventListener("click", function () { sidebar.classList.toggle("collapsed"); // Update button title if (sidebar.classList.contains("collapsed")) { sidebarToggle.title = "展开侧边栏"; } else { sidebarToggle.title = "收起侧边栏"; } }); } // Add Customer button addCustomerBtn.addEventListener("click", async function () { await loadTrialCustomerListForCreate(); createModal.style.display = "block"; }); // Import button importBtn.addEventListener("click", function () { importModal.style.display = "block"; }); // Close create modal createModal.querySelector(".close").addEventListener("click", function () { createModal.style.display = "none"; }); createModal .querySelector(".cancel-create") .addEventListener("click", function () { createModal.style.display = "none"; }); // Close import modal importModal.querySelector(".close").addEventListener("click", function () { importModal.style.display = "none"; }); importModal .querySelector(".cancel-import") .addEventListener("click", function () { importModal.style.display = "none"; }); // Close edit modal editModal.querySelector(".close").addEventListener("click", function () { editModal.style.display = "none"; }); editModal .querySelector(".cancel-edit") .addEventListener("click", function () { editModal.style.display = "none"; }); // File name display const importFile = document.getElementById("importFile"); const fileName = document.getElementById("fileName"); importFile.addEventListener("change", function () { if (this.files.length > 0) { fileName.textContent = this.files[0].name; } else { fileName.textContent = "选择文件..."; } }); // Customer filter change event customerFilter.addEventListener("change", function () { selectedCustomerFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); if (customerSearchInput) { customerSearchInput.addEventListener("input", function () { customerSearchQuery = (this.value || "").trim(); if (currentSection === "customer") { currentPage = 1; applyAllCustomerFilters(); } }); } // Load trial customer list for create customer dropdown async function loadTrialCustomerListForCreate() { try { const response = await authenticatedFetch("/api/trial-customers/list"); const data = await response.json(); const customerSelect = document.getElementById("createCustomerName"); customerSelect.innerHTML = ''; if (data.customerNames && data.customerNames.length > 0) { data.customerNames.forEach((customerName) => { const option = document.createElement("option"); option.value = customerName; option.textContent = customerName; customerSelect.appendChild(option); }); } } catch (error) { console.error("Error loading trial customer list:", error); } } // Type filter change event const typeFilter = document.getElementById("typeFilter"); if (typeFilter) { typeFilter.addEventListener("change", function () { selectedTypeFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Customer date range filter events const customerStartDateInput = document.getElementById("customerStartDate"); const customerEndDateInput = document.getElementById("customerEndDate"); if (customerStartDateInput) { customerStartDateInput.addEventListener("change", function () { customerStartDate = this.value; currentPage = 1; applyAllCustomerFilters(); }); } if (customerEndDateInput) { customerEndDateInput.addEventListener("change", function () { customerEndDate = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Status progress filter change event const statusProgressFilter = document.getElementById("statusProgressFilter"); if (statusProgressFilter) { statusProgressFilter.addEventListener("change", function () { selectedStatusProgressFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Apply date filter for dashboard document .getElementById("applyFilters") .addEventListener("click", function () { const startDate = document.getElementById("startDate").value; const endDate = document.getElementById("endDate").value; applyDateFilter(startDate, endDate); }); // Chart title change events - 添加空值检查防止元素不存在时报错 const statusChartTitleEl = document.getElementById("statusChartTitle"); if (statusChartTitleEl) { statusChartTitleEl.addEventListener("input", function () { applyDateFilter( document.getElementById("startDate").value, document.getElementById("endDate").value, ); }); } const typeChartTitleEl = document.getElementById("typeChartTitle"); if (typeChartTitleEl) { typeChartTitleEl.addEventListener("input", function () { applyDateFilter( document.getElementById("startDate").value, document.getElementById("endDate").value, ); }); } // Switch between sections function switchSection(section) { navItems.forEach((item) => { item.classList.remove("active"); }); // Hide all sections first customerSection.classList.remove("active"); dashboardSection.classList.remove("active"); if (document.getElementById("followupSection")) { document.getElementById("followupSection").classList.remove("active"); } if (document.getElementById("trialPeriodsSection")) { document.getElementById("trialPeriodsSection").classList.remove("active"); } if (section === "customer") { customerSection.classList.add("active"); document .querySelector('[data-section="customer"]') .classList.add("active"); pageTitle.textContent = "每周客户进度"; currentSection = "customer"; loadCustomers(); } else if (section === "trialPeriods") { const trialPeriodsSection = document.getElementById( "trialPeriodsSection", ); if (trialPeriodsSection) { trialPeriodsSection.classList.add("active"); } document .querySelector('[data-section="trialPeriods"]') .classList.add("active"); pageTitle.textContent = "客户信息"; currentSection = "trialPeriods"; // Load customers first, then trial periods if ( typeof loadCustomersForDropdown === "function" && typeof loadAllTrialPeriods === "function" ) { loadCustomersForDropdown().then(() => { loadAllTrialPeriods(); }); } else { console.error("Required functions not available!"); } } else if (section === "dashboard") { dashboardSection.classList.add("active"); document .querySelector('[data-section="dashboard"]') .classList.add("active"); pageTitle.textContent = "数据仪表板"; currentSection = "dashboard"; loadDashboardData(); } else if (section === "followup") { const followupSection = document.getElementById("followupSection"); if (followupSection) { followupSection.classList.add("active"); } document .querySelector('[data-section="followup"]') .classList.add("active"); pageTitle.textContent = "客户跟进"; currentSection = "followup"; if (typeof loadFollowUps === "function") { loadFollowUps(); } } // Close sidebar on mobile after navigation if (window.innerWidth <= 768) { sidebar.classList.remove("open"); } // Re-trigger animate-up animation for visible cards setTimeout(() => { const activeSection = document.querySelector('.content-section.active'); if (activeSection) { const animatedCards = activeSection.querySelectorAll('.animate-up'); animatedCards.forEach(card => { // Remove and re-add animation class to trigger animation card.style.animation = 'none'; // Force reflow card.offsetHeight; card.style.animation = ''; }); } }, 10); } // Load customers from API async function loadCustomers() { try { const response = await authenticatedFetch( "/api/customers?page=1&pageSize=1000", ); const data = await response.json(); if (data.customers) { allCustomers = data.customers; populateCustomerFilter(); populateTypeFilter(); applyAllCustomerFilters(); } } catch (error) { console.error("Error loading customers:", error); } } // Populate customer filter dropdown function populateCustomerFilter() { const uniqueCustomers = [ ...new Set(allCustomers.map((c) => c.customerName).filter((c) => c)), ]; customerFilter.innerHTML = ''; uniqueCustomers.forEach((customer) => { const option = document.createElement("option"); option.value = customer; option.textContent = customer; customerFilter.appendChild(option); }); if (selectedCustomerFilter) { customerFilter.value = selectedCustomerFilter; } } // Populate type filter dropdown function populateTypeFilter() { const typeFilterElement = document.getElementById("typeFilter"); if (!typeFilterElement) return; const uniqueTypes = [ ...new Set(allCustomers.map((c) => c.type).filter((t) => t)), ]; typeFilterElement.innerHTML = ''; uniqueTypes.forEach((type) => { const option = document.createElement("option"); option.value = type; option.textContent = type; typeFilterElement.appendChild(option); }); if (selectedTypeFilter) { typeFilterElement.value = selectedTypeFilter; } } // Filter customers by selected customer function filterCustomers(selectedCustomer) { selectedCustomerFilter = selectedCustomer; currentPage = 1; applyAllCustomerFilters(); } function customerMatchesQuery(customer, query) { if (!query) return true; const fields = [ customer.customerName, customer.intendedProduct, customer.version, customer.description, customer.solution, customer.type, customer.module, customer.statusProgress, customer.reporter, ]; const haystack = fields .map((v) => String(v || "")) .join(" ") .toLowerCase(); const normalizedHaystack = haystack.replace(/[\/.]/g, "-"); const needle = query.toLowerCase(); const normalizedNeedle = needle.replace(/[\/.]/g, "-"); return normalizedHaystack.includes(normalizedNeedle); } function applyAllCustomerFilters() { let next = [...allCustomers]; if (selectedCustomerFilter) { next = next.filter((c) => c.customerName === selectedCustomerFilter); } if (selectedTypeFilter) { next = next.filter((c) => c.type === selectedTypeFilter); } if (customerStartDate) { next = next.filter((c) => { const date = normalizeDateValue(c.intendedProduct); return date && date >= customerStartDate; }); } if (customerEndDate) { next = next.filter((c) => { const date = normalizeDateValue(c.intendedProduct); return date && date <= customerEndDate; }); } if (selectedStatusProgressFilter) { next = next.filter( (c) => c.statusProgress === selectedStatusProgressFilter, ); } if (customerSearchQuery) { next = next.filter((c) => customerMatchesQuery(c, customerSearchQuery)); } filteredCustomers = next; applyCustomerFilter(); } function toCsvCell(value) { const raw = String(value ?? ""); const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const escaped = normalized.replace(/"/g, '""'); return `"${escaped}"`; } function downloadCsv(filename, rows) { const content = ["\uFEFF" + rows[0], ...rows.slice(1)].join("\r\n"); const blob = new Blob([content], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function exportCustomersToCsv(customers) { const header = [ "客户", "咨询时间", "版本", "描述", "解决方案", "类型", "模块", "状态与进度", ] .map(toCsvCell) .join(","); const lines = customers.map((c) => { const date = normalizeDateValue(c.intendedProduct) || c.intendedProduct || ""; const cells = [ c.customerName || "", date, c.version || "", c.description || "", c.solution || "", c.type || "", c.module || "", c.statusProgress || "", ]; return cells.map(toCsvCell).join(","); }); const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); const hh = String(now.getHours()).padStart(2, "0"); const mm = String(now.getMinutes()).padStart(2, "0"); const ss = String(now.getSeconds()).padStart(2, "0"); const filename = `客户列表_${y}${m}${d}_${hh}${mm}${ss}.csv`; downloadCsv(filename, [header, ...lines]); } if (refreshCustomersBtn) { refreshCustomersBtn.addEventListener("click", async () => { // Add refreshing animation refreshCustomersBtn.classList.add("refreshing"); const table = document.getElementById("customerTable"); try { await loadCustomers(); // Show success feedback briefly refreshCustomersBtn.classList.remove("refreshing"); refreshCustomersBtn.classList.add("refresh-success"); // Add table refresh animation if (table) { table.classList.add("table-refreshing"); setTimeout(() => { table.classList.remove("table-refreshing"); }, 500); } setTimeout(() => { refreshCustomersBtn.classList.remove("refresh-success"); }, 1000); } catch (error) { refreshCustomersBtn.classList.remove("refreshing"); } }); } if (exportCustomersBtn) { exportCustomersBtn.addEventListener("click", () => { if (currentSection !== "customer") return; exportCustomersToCsv(filteredCustomers); }); } // Apply customer filter and render table with pagination function applyCustomerFilter() { totalItems = filteredCustomers.length; totalPages = Math.ceil(totalItems / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedCustomers = filteredCustomers.slice(startIndex, endIndex); renderCustomerTable(paginatedCustomers); updatePaginationControls(); } // Load all customers for dashboard async function loadAllCustomers() { try { const response = await authenticatedFetch( "/api/customers?page=1&pageSize=1000", ); const data = await response.json(); if (data.customers) { dashboardCustomers = data.customers; updateDashboardStats(dashboardCustomers); renderStatusChart(dashboardCustomers); renderTypeChart(dashboardCustomers); renderTrendChart(dashboardCustomers); } else { console.error("No customers in dashboard data"); } } catch (error) { console.error("Error loading all customers:", error); } } // Apply date filter for dashboard function applyDateFilter(startDate, endDate) { let filteredData = dashboardCustomers; if (startDate) { filteredData = filteredData.filter((c) => { if (!c.intendedProduct) return false; const date = normalizeDateValue(c.intendedProduct); return date && date >= startDate; }); } if (endDate) { filteredData = filteredData.filter((c) => { if (!c.intendedProduct) return false; const date = normalizeDateValue(c.intendedProduct); return date && date <= endDate; }); } updateDashboardStats(filteredData); renderStatusChart(filteredData); renderTypeChart(filteredData); renderTrendChart(filteredData); } // Render customer table function renderCustomerTable(customers) { customerTableBody.innerHTML = ""; // Helper function to generate status badge function getStatusBadge(status) { if (!status) return ""; const statusLower = status.toLowerCase(); if ( status === "已完成" || statusLower.includes("完成") || statusLower.includes("complete") ) { return ` ${status}`; } else if (status === "进行中" || statusLower.includes("进行")) { return ` ${status}`; } else if (status === "待排期" || statusLower.includes("待")) { return ` ${status}`; } else if (status === "已驳回" || statusLower.includes("驳回")) { return ` ${status}`; } else if (status === "已上线" || statusLower.includes("上线")) { return ` ${status}`; } return `${status}`; } customers.forEach((customer) => { const row = document.createElement("tr"); row.classList.add("customer-row"); const date = customer.intendedProduct || ""; const fields = [ { value: customer.customerName || "", name: "customerName" }, { value: date, name: "date" }, { value: customer.version || "", name: "version" }, { value: customer.description || "", name: "description" }, { value: customer.solution || "", name: "solution" }, { value: customer.type || "", name: "type" }, { value: customer.module || "", name: "module" }, { value: customer.statusProgress || "", name: "statusProgress" }, { value: customer.screenshots || [], name: "screenshots" }, ]; fields.forEach((field) => { const td = document.createElement("td"); const textValue = String(field.value ?? ""); // Use badge for statusProgress field if (field.name === "statusProgress") { td.innerHTML = getStatusBadge(textValue); } else if (field.name === "customerName") { td.innerHTML = `${textValue}`; } else if (field.name === "screenshots") { td.classList.add("screenshot-column-cell"); renderScreenshotsInCell(td, field.value); } else { td.textContent = textValue; } td.setAttribute("data-tooltip", textValue); if (field.name === "description" || field.name === "solution") { td.classList.add("overflow-cell"); } row.appendChild(td); }); const actionTd = document.createElement("td"); actionTd.classList.add("action-cell"); // 根据用户权限决定删除按钮是否可用 const deleteDisabled = !canDelete(); const deleteClass = deleteDisabled ? "action-btn delete-btn disabled" : "action-btn delete-btn"; const deleteTitle = deleteDisabled ? "无删除权限" : "删除"; actionTd.innerHTML = ` `; row.appendChild(actionTd); customerTableBody.appendChild(row); }); checkTextOverflow(); initImageViewer(); document.querySelectorAll(".edit-btn").forEach((btn) => { btn.addEventListener("click", function () { const customerId = this.getAttribute("data-id"); openEditModal(customerId); }); }); document.querySelectorAll(".delete-btn:not(.disabled)").forEach((btn) => { btn.addEventListener("click", function () { const customerId = this.getAttribute("data-id"); deleteCustomer(customerId); }); }); } function checkTextOverflow() { // Use requestAnimationFrame to ensure DOM is fully rendered before checking overflow requestAnimationFrame(() => { const cells = customerTableBody.querySelectorAll("td[data-tooltip]"); cells.forEach((cell) => { const cellText = (cell.getAttribute("data-tooltip") || "").trim(); const shouldAlwaysShowTooltip = cell.classList.contains("overflow-cell") && cellText.length > 0; const hasOverflow = cell.scrollWidth > cell.clientWidth; if (shouldAlwaysShowTooltip || hasOverflow) { cell.classList.add("has-overflow"); } else { cell.classList.remove("has-overflow"); } }); }); } // Update pagination controls function updatePaginationControls() { const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; const endItem = Math.min(currentPage * pageSize, totalItems); document.getElementById("paginationInfo").textContent = `显示 ${startItem}-${endItem} 共 ${totalItems} 条`; document.getElementById("firstPage").disabled = currentPage === 1; document.getElementById("prevPage").disabled = currentPage === 1; document.getElementById("nextPage").disabled = currentPage === totalPages; document.getElementById("lastPage").disabled = currentPage === totalPages; const pageNumbers = document.getElementById("pageNumbers"); pageNumbers.innerHTML = ""; const maxVisiblePages = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); if (endPage - startPage + 1 < maxVisiblePages) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement("button"); pageBtn.className = `page-number ${i === currentPage ? "active" : ""}`; pageBtn.textContent = i; pageBtn.addEventListener("click", () => { currentPage = i; loadCustomers(); }); pageNumbers.appendChild(pageBtn); } } // Create customer createCustomerForm.addEventListener("submit", async function (e) { e.preventDefault(); const formData = { customerName: document.getElementById("createCustomerName").value, intendedProduct: document.getElementById("createIntendedProduct").value, version: document.getElementById("createVersion").value, description: document.getElementById("createDescription").value, solution: document.getElementById("createSolution").value, type: document.getElementById("createType").value, module: getModuleValue("createModule", "createModuleOther"), statusProgress: document.getElementById("createStatusProgress").value, reporter: "", // Trial periods managed separately screenshots: createUploadedScreenshots, }; try { const response = await authenticatedFetch("/api/customers", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }); if (response.ok) { createCustomerForm.reset(); createUploadedScreenshots = []; renderScreenshotPreviews("create", []); // Hide module "other" input const createModuleOther = document.getElementById("createModuleOther"); if (createModuleOther) createModuleOther.style.display = "none"; createModal.style.display = "none"; loadCustomers(); } else { alert("创建客户时出错"); } } catch (error) { console.error("Error creating customer:", error); alert("创建客户时出错"); } }); // Import customers importFileForm.addEventListener("submit", async function (e) { e.preventDefault(); const formData = new FormData(); const fileInput = document.getElementById("importFile"); if (!fileInput.files.length) { alert("请选择要导入的文件"); return; } formData.append("file", fileInput.files[0]); try { const response = await authenticatedFetch("/api/customers/import", { method: "POST", body: formData, }); if (response.ok) { const result = await response.json(); const message = `客户导入成功!\n导入数量: ${result.importedCount}\n重复数量: ${result.duplicateCount}`; alert(message); importFileForm.reset(); fileName.textContent = "选择文件..."; importModal.style.display = "none"; loadCustomers(); } else { alert("导入客户时出错"); } } catch (error) { console.error("Error importing customers:", error); alert("导入客户时出错"); } }); // Open edit modal async function openEditModal(customerId) { try { const response = await authenticatedFetch(`/api/customers/${customerId}`); const customer = await response.json(); document.getElementById("editCustomerId").value = customer.id; document.getElementById("editCustomerName").value = customer.customerName || ""; document.getElementById("editIntendedProduct").value = normalizeDateValue( customer.intendedProduct, ); document.getElementById("editVersion").value = customer.version || ""; document.getElementById("editDescription").value = customer.description || ""; document.getElementById("editSolution").value = customer.solution || ""; document.getElementById("editType").value = customer.type || ""; setModuleValue("editModule", "editModuleOther", customer.module || ""); document.getElementById("editStatusProgress").value = customer.statusProgress || ""; // Screenshots editUploadedScreenshots = customer.screenshots || []; renderScreenshotPreviews("edit", editUploadedScreenshots); // Parse trial period and fill datetime inputs const reporter = customer.reporter || ""; if (reporter) { // Split by ~ or ~ const parts = reporter.split(/[~~]/); if (parts.length === 2) { const parseDateTime = (str) => { // Parse format like "2026/1/5 10:00" or "2026-1-5 10:00" const cleaned = str.trim(); const match = cleaned.match( /(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/, ); if (match) { const [_, year, month, day, hours, minutes] = match; return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; } return ""; }; document.getElementById("editTrialStart").value = parseDateTime( parts[0], ); document.getElementById("editTrialEnd").value = parseDateTime( parts[1], ); } } // Load trial periods for this customer if (typeof loadTrialPeriods === "function") { await loadTrialPeriods(customer.id); } editModal.style.display = "block"; } catch (error) { console.error("Error loading customer for edit:", error); } } window.addEventListener("click", function (e) { if (e.target === createModal) { createModal.style.display = "none"; } if (e.target === importModal) { importModal.style.display = "none"; } if (e.target === editModal) { editModal.style.display = "none"; } }); // Update customer editCustomerForm.addEventListener("submit", async function (e) { e.preventDefault(); const customerId = document.getElementById("editCustomerId").value; const formData = { customerName: document.getElementById("editCustomerName").value, intendedProduct: document.getElementById("editIntendedProduct").value, version: document.getElementById("editVersion").value, description: document.getElementById("editDescription").value, solution: document.getElementById("editSolution").value, type: document.getElementById("editType").value, module: getModuleValue("editModule", "editModuleOther"), statusProgress: document.getElementById("editStatusProgress").value, reporter: "", // Trial periods managed separately screenshots: editUploadedScreenshots, }; try { const response = await authenticatedFetch( `/api/customers/${customerId}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }, ); if (response.ok) { editModal.style.display = "none"; loadCustomers(); } else { alert("更新客户时出错"); } } catch (error) { console.error("Error updating customer:", error); alert("更新客户时出错"); } }); // Delete customer async function deleteCustomer(customerId) { if (!confirm("确定要删除这个客户吗?")) { return; } try { const response = await authenticatedFetch( `/api/customers/${customerId}`, { method: "DELETE", }, ); if (response.ok) { loadCustomers(); } else { alert("删除客户时出错"); } } catch (error) { console.error("Error deleting customer:", error); alert("删除客户时出错"); } } // Dashboard functionality async function loadDashboardData() { await loadAllCustomers(); await loadTrialCustomersForDashboard(); await loadFollowUpCount(); } // Load trial customers for dashboard statistics async function loadTrialCustomersForDashboard() { try { const response = await authenticatedFetch("/api/trial-periods/all"); const data = await response.json(); const trialPeriods = data.trialPeriods || []; // Get customers map to resolve names const customersResponse = await authenticatedFetch("/api/customers/list"); const customersData = await customersResponse.json(); const customersMap = customersData.customerMap || {}; // Collect unique trial customer names - directly use customerName field const trialCustomerNames = new Set(); trialPeriods.forEach((period) => { // 直接使用 customerName 字段 if (period.customerName) { trialCustomerNames.add(period.customerName); } }); // Update total customers display (数据仪表盘的总客户数从每周进度获取) // This function will be called after loading dashboard customers updateTotalCustomersWithTrialData(trialCustomerNames); } catch (error) { console.error("Error loading trial customers for dashboard:", error); } } // Update total customers count - 数据仪表盘的总客户数取的是每周进度表里的客户数量 function updateTotalCustomersWithTrialData(trialCustomerNames) { // Get current dashboard customers (from customers.json / 每周进度) const progressCustomerNames = new Set( dashboardCustomers.map((c) => c.customerName).filter((c) => c), ); // 数据仪表盘的总客户数取的是每周进度表里的客户数量,支持去重 document.getElementById("totalCustomers").textContent = progressCustomerNames.size; } // Update dashboard statistics function updateDashboardStats(customers) { const totalCustomers = new Set( customers.map((c) => c.customerName).filter((c) => c), ).size; const now = new Date(); const currentMonth = String(now.getMonth() + 1).padStart(2, "0"); const currentYear = now.getFullYear(); const newCustomers = new Set( customers .filter((customer) => { if (!customer.customerName) return false; const normalized = normalizeDateValue(customer.intendedProduct); if (!normalized) return false; const year = parseInt(normalized.slice(0, 4), 10); const month = normalized.slice(5, 7); return year === currentYear && month === currentMonth; }) .map((c) => c.customerName) .filter((c) => c), ).size; document.getElementById("totalCustomers").textContent = totalCustomers; document.getElementById("newCustomers").textContent = newCustomers; // 已成交数量将在 loadFollowUpCount 中更新 } // Load follow-up count for dashboard async function loadFollowUpCount() { try { const response = await authenticatedFetch( "/api/followups?page=1&pageSize=1000", ); const data = await response.json(); if (data.followUps) { const followUpCount = data.followUps.length; document.getElementById("followUpCount").textContent = followUpCount; // 计算已成交数量:统计 dealStatus 为"已成交"的唯一客户数 const completedCustomers = new Set( data.followUps .filter((f) => f.dealStatus === "已成交") .map((f) => f.customerName) .filter((name) => name), ).size; document.getElementById("completedTasks").textContent = completedCustomers; } else { document.getElementById("followUpCount").textContent = "0"; document.getElementById("completedTasks").textContent = "0"; } } catch (error) { console.error("Error loading follow-up count:", error); document.getElementById("followUpCount").textContent = "0"; document.getElementById("completedTasks").textContent = "0"; } } function renderStatusChart(customers) { const ctx = document.getElementById("statusChart").getContext("2d"); const selectedField = document.getElementById("chartFieldSelect").value; const statusChartTitleEl = document.getElementById("statusChartTitle"); const chartTitle = statusChartTitleEl ? statusChartTitleEl.value : "模块分布"; const fieldCount = {}; customers.forEach((customer) => { const value = customer[selectedField] || "未设置"; fieldCount[value] = (fieldCount[value] || 0) + 1; }); const labels = Object.keys(fieldCount); const data = Object.values(fieldCount); if (statusChartInstance) { statusChartInstance.destroy(); } statusChartInstance = new Chart(ctx, { type: "pie", data: { labels: labels, datasets: [ { data: data, backgroundColor: [ "#FF6B35", "#F28C28", "#333", "#4CAF50", "#2196F3", "#FFC107", "#9C27B0", "#00BCD4", "#8BC34A", "#FF5722", ], }, ], }, options: { responsive: true, plugins: { legend: { position: "left", labels: { generateLabels: function (chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { return data.labels.map((label, i) => { const value = data.datasets[0].data[i]; return { text: `${label}: ${value}`, fillStyle: data.datasets[0].backgroundColor[i], hidden: false, index: i, }; }); } return []; }, }, }, title: { display: false, text: chartTitle, font: { size: 16, weight: "bold", }, }, tooltip: { callbacks: { label: function (context) { const label = context.label || ""; const value = context.parsed; const total = context.dataset.data.reduce((a, b) => a + b, 0); const percentage = ((value / total) * 100).toFixed(1); return `${label}: ${value} (${percentage}%)`; }, }, }, datalabels: { formatter: (value, ctx) => { const total = ctx.dataset.data.reduce((a, b) => a + b, 0); const percentage = ((value / total) * 100).toFixed(1); // 显示具体数值和百分比 return `${value}\n(${percentage}%)`; }, color: "#fff", font: { weight: "bold", size: 12, }, }, }, }, }); } // Render customer type chart function renderTypeChart(customers) { const canvas = document.getElementById("typeChart"); if (!canvas) { console.error("typeChart canvas not found"); return; } const ctx = canvas.getContext("2d"); const typeChartTitleEl = document.getElementById("typeChartTitle"); const chartTitle = typeChartTitleEl ? typeChartTitleEl.value : "类型分布"; if (typeChartInstance) { typeChartInstance.destroy(); } const selectedField = document.getElementById("typeChartFieldSelect").value; const typeCount = {}; customers.forEach((customer) => { const type = customer[selectedField] || "未设置"; typeCount[type] = (typeCount[type] || 0) + 1; }); const labels = Object.keys(typeCount); const data = Object.values(typeCount); typeChartInstance = new Chart(ctx, { type: "doughnut", data: { labels: labels, datasets: [ { data: data, backgroundColor: [ "#333", "#FF6B35", "#F28C28", "#4CAF50", "#2196F3", "#FFC107", ], }, ], }, options: { responsive: true, plugins: { legend: { position: "left", labels: { generateLabels: function (chart) { const data = chart.data; if (data.labels.length && data.datasets.length) { return data.labels.map((label, i) => { const value = data.datasets[0].data[i]; return { text: `${label}: ${value}`, fillStyle: data.datasets[0].backgroundColor[i], hidden: false, index: i, }; }); } return []; }, }, }, title: { display: false, text: chartTitle, font: { size: 16, weight: "bold", }, }, tooltip: { callbacks: { label: function (context) { const label = context.label || ""; const value = context.parsed; const total = context.dataset.data.reduce((a, b) => a + b, 0); const percentage = ((value / total) * 100).toFixed(1); return `${label}: ${value} (${percentage}%)`; }, }, }, datalabels: { formatter: (value, ctx) => { const total = ctx.dataset.data.reduce((a, b) => a + b, 0); const percentage = ((value / total) * 100).toFixed(1); // 显示具体数值和百分比 return `${value}\n(${percentage}%)`; }, color: "#fff", font: { weight: "bold", size: 12, }, }, }, }, }); } // Render trend line chart function renderTrendChart(customers) { const canvas = document.getElementById("trendChart"); if (!canvas) { console.error("trendChart canvas not found"); return; } const ctx = canvas.getContext("2d"); const trendType = document.getElementById("trendTypeSelect")?.value || "all"; const dateGrain = document.getElementById("trendChartFieldSelect")?.value || "day"; if (trendChartInstance) { trendChartInstance.destroy(); } // Group data by date const dateMap = {}; customers.forEach((customer) => { const dateValue = normalizeDateValue(customer.intendedProduct); if (!dateValue) return; let dateKey = dateValue; if (dateGrain === "month") { dateKey = dateValue.substring(0, 7); // YYYY-MM } else if (dateGrain === "week") { // Find Monday of that week const d = new Date(dateValue); const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); const monday = new Date(d.setDate(diff)); dateKey = monday.toISOString().split("T")[0] + " (周)"; } if (!dateMap[dateKey]) { dateMap[dateKey] = { customers: new Set(), demands: 0, issues: 0, }; } // Count customers if (customer.customerName) { dateMap[dateKey].customers.add(customer.customerName); } // Count demands and issues based on type const type = (customer.type || "").toLowerCase(); if (type.includes("需求")) { dateMap[dateKey].demands++; } if ( type.includes("问题") || type.includes("功能问题") || type.includes("反馈") ) { dateMap[dateKey].issues++; } }); // Sort dates const sortedDates = Object.keys(dateMap).sort(); // Prepare datasets based on selected trend type const datasets = []; const customerDataset = { label: "客户数", data: sortedDates.map((date) => dateMap[date].customers.size), borderColor: "#FF6B35", backgroundColor: "rgba(255, 107, 53, 0.1)", borderWidth: 3, pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: "#FF6B35", pointBorderColor: "#fff", pointBorderWidth: 2, tension: 0.4, fill: trendType === "customer", }; const demandDataset = { label: "需求数", data: sortedDates.map((date) => dateMap[date].demands), borderColor: "#4CAF50", backgroundColor: "rgba(76, 175, 80, 0.1)", borderWidth: 3, pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: "#4CAF50", pointBorderColor: "#fff", pointBorderWidth: 2, tension: 0.4, fill: trendType === "demand", }; const issueDataset = { label: "反馈数", data: sortedDates.map((date) => dateMap[date].issues), borderColor: "#2196F3", backgroundColor: "rgba(33, 150, 243, 0.1)", borderWidth: 3, pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: "#2196F3", pointBorderColor: "#fff", pointBorderWidth: 2, tension: 0.4, fill: trendType === "issue", }; if (trendType === "customer") { datasets.push(customerDataset); } else if (trendType === "demand") { datasets.push(demandDataset); } else if (trendType === "issue") { datasets.push(issueDataset); } else { // all datasets.push(customerDataset, demandDataset, issueDataset); } trendChartInstance = new Chart(ctx, { type: "line", data: { labels: sortedDates, datasets: datasets, }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { position: "top", }, title: { display: true, text: "时间趋势分析", font: { size: 16, weight: "bold", }, }, tooltip: { mode: "index", intersect: false, }, datalabels: { display: true, align: "top", anchor: "end", formatter: (value) => value, color: "#333", font: { weight: "bold", size: 11, }, backgroundColor: "rgba(255, 255, 255, 0.8)", borderRadius: 4, padding: 4, }, }, scales: { x: { display: true, title: { display: true, text: "日期", }, ticks: { maxRotation: 45, minRotation: 45, }, }, y: { display: true, title: { display: true, text: "数量", }, beginAtZero: true, ticks: { stepSize: 1, }, }, }, interaction: { mode: "nearest", axis: "x", intersect: false, }, }, }); } // Initialize the app loadCustomers().then(() => { loadDashboardData(); // Changed from loadAllCustomers() to include trial customers }); // Pagination event listeners document.getElementById("firstPage").addEventListener("click", () => { if (currentPage > 1) { currentPage = 1; applyCustomerFilter(); } }); document.getElementById("prevPage").addEventListener("click", () => { if (currentPage > 1) { currentPage--; applyCustomerFilter(); } }); document.getElementById("nextPage").addEventListener("click", () => { if (currentPage < totalPages) { currentPage++; applyCustomerFilter(); } }); document.getElementById("lastPage").addEventListener("click", () => { if (currentPage < totalPages) { currentPage = totalPages; applyCustomerFilter(); } }); document.getElementById("pageSizeSelect").addEventListener("change", (e) => { pageSize = parseInt(e.target.value); currentPage = 1; loadCustomers(); }); // Chart field select event listener document .getElementById("chartFieldSelect") .addEventListener("change", function () { const startDate = document.getElementById("startDate").value; const endDate = document.getElementById("endDate").value; applyDateFilter(startDate, endDate); }); // Type chart field select event listener document .getElementById("typeChartFieldSelect") .addEventListener("change", function () { const startDate = document.getElementById("startDate").value; const endDate = document.getElementById("endDate").value; applyDateFilter(startDate, endDate); }); // Trend type select event listener const trendTypeSelect = document.getElementById("trendTypeSelect"); if (trendTypeSelect) { trendTypeSelect.addEventListener("change", function () { const startDate = document.getElementById("startDate").value; const endDate = document.getElementById("endDate").value; applyDateFilter(startDate, endDate); }); } const trendChartFieldSelect = document.getElementById("trendChartFieldSelect"); if (trendChartFieldSelect) { trendChartFieldSelect.addEventListener("change", function () { const startDate = document.getElementById("startDate").value; const endDate = document.getElementById("endDate").value; applyDateFilter(startDate, endDate); }); } // ========== Follow-up Management ========== const followupSection = document.getElementById("followupSection"); const addFollowUpBtn = document.getElementById("addFollowUpBtn"); const followupFormCard = document.getElementById("followupFormCard"); const followupForm = document.getElementById("followupForm"); const cancelFollowupBtn = document.getElementById("cancelFollowupBtn"); const followupTableBody = document.getElementById("followupTableBody"); const refreshFollowupsBtn = document.getElementById("refreshFollowupsBtn"); const followupCustomerNameSelect = document.getElementById( "followupCustomerName", ); let allFollowUps = []; let followupCurrentPage = 1; let followupPageSize = 10; let followupTotalPages = 1; let followupTotalItems = 0; // 弹窗相关元素 const addFollowupModal = document.getElementById("addFollowupModal"); const addFollowupModalForm = document.getElementById("addFollowupModalForm"); const editFollowupModal = document.getElementById("editFollowupModal"); const editFollowupForm = document.getElementById("editFollowupForm"); // 点击添加跟进按钮 - 弹出弹窗 if (addFollowUpBtn) { addFollowUpBtn.addEventListener("click", async function () { await loadCustomerListForFollowupModal(); addFollowupModal.style.display = "block"; }); } // 取消添加跟进弹窗 if (addFollowupModal) { addFollowupModal.querySelector(".close").addEventListener("click", () => { addFollowupModal.style.display = "none"; addFollowupModalForm.reset(); }); addFollowupModal .querySelector(".cancel-add-followup") .addEventListener("click", () => { addFollowupModal.style.display = "none"; addFollowupModalForm.reset(); }); } // 取消编辑跟进弹窗 if (editFollowupModal) { editFollowupModal.querySelector(".close").addEventListener("click", () => { editFollowupModal.style.display = "none"; editFollowupForm.reset(); }); editFollowupModal .querySelector(".cancel-edit-followup") .addEventListener("click", () => { editFollowupModal.style.display = "none"; editFollowupForm.reset(); }); } // 旧表单相关逻辑保留兼容 if (cancelFollowupBtn) { cancelFollowupBtn.addEventListener("click", function () { followupFormCard.style.display = "none"; followupForm.reset(); }); } // Load customer list for follow-up dropdown from trial periods (for modal) async function loadCustomerListForFollowupModal( selectId = "addFollowupCustomerName", ) { try { const response = await authenticatedFetch("/api/trial-customers/list"); const data = await response.json(); const selectElement = document.getElementById(selectId); if (!selectElement) return; selectElement.innerHTML = ''; if (data.customerNames && data.customerNames.length > 0) { data.customerNames.forEach((customerName) => { const option = document.createElement("option"); option.value = customerName; option.textContent = customerName; selectElement.appendChild(option); }); } } catch (error) { console.error("Error loading customer list:", error); } } // Create follow-up if (followupForm) { followupForm.addEventListener("submit", async function (e) { e.preventDefault(); const followUpTimeValue = document.getElementById("followupTime").value; const followUpTimeISO = new Date(followUpTimeValue).toISOString(); // Get customer name from the selected option's text const customerSelect = document.getElementById("followupCustomerName"); const selectedOption = customerSelect.options[customerSelect.selectedIndex]; const customerName = selectedOption ? selectedOption.textContent : ""; const formData = { customerName: customerName, dealStatus: document.getElementById("followupDealStatus").value, customerLevel: document.getElementById("followupCustomerLevel").value, industry: document.getElementById("followupIndustry").value, followUpTime: followUpTimeISO, }; try { const response = await authenticatedFetch("/api/followups", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }); if (response.ok) { followupForm.reset(); followupFormCard.style.display = "none"; loadFollowUps(); } else { alert("创建跟进记录时出错"); } } catch (error) { console.error("Error creating follow-up:", error); alert("创建跟进记录时出错"); } }); } // Load follow-ups async function loadFollowUps() { try { const response = await authenticatedFetch( `/api/followups?page=${followupCurrentPage}&pageSize=${followupPageSize}`, ); const data = await response.json(); if (data.followUps) { allFollowUps = data.followUps; followupTotalItems = data.total || 0; followupTotalPages = data.totalPages || 1; renderFollowUpTable(allFollowUps); updateFollowupPaginationControls(); } } catch (error) { console.error("Error loading follow-ups:", error); } } // Render follow-up table function renderFollowUpTable(followUps) { followupTableBody.innerHTML = ""; followUps.forEach((followUp) => { const row = document.createElement("tr"); // Format follow-up time const followUpTime = new Date(followUp.followUpTime); const formattedTime = followUpTime.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); // Notification status const notificationStatus = followUp.notificationSent ? '已通知' : '待通知'; // Customer level display let levelDisplay = followUp.customerLevel; if (followUp.customerLevel === "A") { levelDisplay = "A级 (重点客户)"; } else if (followUp.customerLevel === "B") { levelDisplay = "B级 (潜在客户)"; } else if (followUp.customerLevel === "C") { levelDisplay = "C级 (一般客户)"; } row.innerHTML = ` ${followUp.customerName || ""} ${followUp.dealStatus || ""} ${levelDisplay} ${followUp.industry || ""} ${formattedTime} ${notificationStatus} `; followupTableBody.appendChild(row); }); // Add edit event listeners document.querySelectorAll(".edit-followup-btn").forEach((btn) => { btn.addEventListener("click", async function () { const followUpId = this.getAttribute("data-id"); await openEditFollowupModal(followUpId); }); }); // Add delete event listeners (only for users with delete permission) document .querySelectorAll(".delete-followup-btn:not(.disabled)") .forEach((btn) => { btn.addEventListener("click", function () { const followUpId = this.getAttribute("data-id"); deleteFollowUp(followUpId); }); }); } // Delete follow-up async function deleteFollowUp(followUpId) { if (!confirm("确定要删除这条跟进记录吗?")) { return; } try { const response = await authenticatedFetch( `/api/followups/${followUpId}`, { method: "DELETE", }, ); if (response.ok) { loadFollowUps(); } else { alert("删除跟进记录时出错"); } } catch (error) { console.error("Error deleting follow-up:", error); alert("删除跟进记录时出错"); } } // 添加跟进弹窗表单提交 if (addFollowupModalForm) { addFollowupModalForm.addEventListener("submit", async function (e) { e.preventDefault(); const followUpTimeValue = document.getElementById("addFollowupTime").value; const followUpTimeISO = new Date(followUpTimeValue).toISOString(); const customerSelect = document.getElementById("addFollowupCustomerName"); const selectedOption = customerSelect.options[customerSelect.selectedIndex]; const customerName = selectedOption ? selectedOption.textContent : ""; const formData = { customerName: customerName, dealStatus: document.getElementById("addFollowupDealStatus").value, customerLevel: document.getElementById("addFollowupCustomerLevel") .value, industry: document.getElementById("addFollowupIndustry").value, followUpTime: followUpTimeISO, }; try { const response = await authenticatedFetch("/api/followups", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }); if (response.ok) { addFollowupModalForm.reset(); addFollowupModal.style.display = "none"; loadFollowUps(); } else { alert("创建跟进记录时出错"); } } catch (error) { console.error("Error creating follow-up:", error); alert("创建跟进记录时出错"); } }); } // 打开编辑跟进弹窗 async function openEditFollowupModal(followUpId) { // 从缓存中查找跟进记录 const followUp = allFollowUps.find((f) => f.id === followUpId); if (!followUp) { alert("未找到跟进记录"); return; } // 加载客户列表到编辑弹窗 await loadCustomerListForFollowupModal("editFollowupCustomerName"); // 填充表单 document.getElementById("editFollowupId").value = followUp.id; document.getElementById("editFollowupCustomerName").value = followUp.customerName || ""; document.getElementById("editFollowupDealStatus").value = followUp.dealStatus || ""; document.getElementById("editFollowupCustomerLevel").value = followUp.customerLevel || ""; document.getElementById("editFollowupIndustry").value = followUp.industry || ""; // 格式化时间为 datetime-local 格式 if (followUp.followUpTime) { const date = new Date(followUp.followUpTime); const localDateTime = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") + "T" + String(date.getHours()).padStart(2, "0") + ":" + String(date.getMinutes()).padStart(2, "0"); document.getElementById("editFollowupTime").value = localDateTime; } editFollowupModal.style.display = "block"; } // 编辑跟进表单提交 if (editFollowupForm) { editFollowupForm.addEventListener("submit", async function (e) { e.preventDefault(); const followUpId = document.getElementById("editFollowupId").value; const followUpTimeValue = document.getElementById("editFollowupTime").value; const followUpTimeISO = new Date(followUpTimeValue).toISOString(); const customerSelect = document.getElementById( "editFollowupCustomerName", ); const selectedOption = customerSelect.options[customerSelect.selectedIndex]; const customerName = selectedOption ? selectedOption.textContent : ""; const formData = { customerName: customerName, dealStatus: document.getElementById("editFollowupDealStatus").value, customerLevel: document.getElementById("editFollowupCustomerLevel") .value, industry: document.getElementById("editFollowupIndustry").value, followUpTime: followUpTimeISO, }; try { const response = await authenticatedFetch( `/api/followups/${followUpId}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }, ); if (response.ok) { editFollowupForm.reset(); editFollowupModal.style.display = "none"; loadFollowUps(); } else { alert("更新跟进记录时出错"); } } catch (error) { console.error("Error updating follow-up:", error); alert("更新跟进记录时出错"); } }); } // Update follow-up pagination controls function updateFollowupPaginationControls() { const startItem = followupTotalItems === 0 ? 0 : (followupCurrentPage - 1) * followupPageSize + 1; const endItem = Math.min( followupCurrentPage * followupPageSize, followupTotalItems, ); document.getElementById("followupPaginationInfo").textContent = `显示 ${startItem}-${endItem} 共 ${followupTotalItems} 条`; document.getElementById("followupFirstPage").disabled = followupCurrentPage === 1; document.getElementById("followupPrevPage").disabled = followupCurrentPage === 1; document.getElementById("followupNextPage").disabled = followupCurrentPage === followupTotalPages; document.getElementById("followupLastPage").disabled = followupCurrentPage === followupTotalPages; const pageNumbers = document.getElementById("followupPageNumbers"); pageNumbers.innerHTML = ""; const maxVisiblePages = 5; let startPage = Math.max( 1, followupCurrentPage - Math.floor(maxVisiblePages / 2), ); let endPage = Math.min(followupTotalPages, startPage + maxVisiblePages - 1); if (endPage - startPage + 1 < maxVisiblePages) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement("button"); pageBtn.className = `page-number ${i === followupCurrentPage ? "active" : ""}`; pageBtn.textContent = i; pageBtn.addEventListener("click", () => { followupCurrentPage = i; loadFollowUps(); }); pageNumbers.appendChild(pageBtn); } } // Follow-up pagination event listeners if (document.getElementById("followupFirstPage")) { document .getElementById("followupFirstPage") .addEventListener("click", () => { if (followupCurrentPage > 1) { followupCurrentPage = 1; loadFollowUps(); } }); } if (document.getElementById("followupPrevPage")) { document .getElementById("followupPrevPage") .addEventListener("click", () => { if (followupCurrentPage > 1) { followupCurrentPage--; loadFollowUps(); } }); } if (document.getElementById("followupNextPage")) { document .getElementById("followupNextPage") .addEventListener("click", () => { if (followupCurrentPage < followupTotalPages) { followupCurrentPage++; loadFollowUps(); } }); } if (document.getElementById("followupLastPage")) { document .getElementById("followupLastPage") .addEventListener("click", () => { if (followupCurrentPage < followupTotalPages) { followupCurrentPage = followupTotalPages; loadFollowUps(); } }); } if (document.getElementById("followupPageSizeSelect")) { document .getElementById("followupPageSizeSelect") .addEventListener("change", (e) => { followupPageSize = parseInt(e.target.value); followupCurrentPage = 1; loadFollowUps(); }); } // Refresh follow-ups button if (refreshFollowupsBtn) { refreshFollowupsBtn.addEventListener("click", async () => { // Add refreshing animation refreshFollowupsBtn.classList.add("refreshing"); const table = document.getElementById("followupTable"); try { await loadFollowUps(); // Show success feedback briefly refreshFollowupsBtn.classList.remove("refreshing"); refreshFollowupsBtn.classList.add("refresh-success"); // Add table refresh animation if (table) { table.classList.add("table-refreshing"); setTimeout(() => { table.classList.remove("table-refreshing"); }, 500); } setTimeout(() => { refreshFollowupsBtn.classList.remove("refresh-success"); }, 1000); } catch (error) { refreshFollowupsBtn.classList.remove("refreshing"); } }); } // ========================================== // Global Search Functionality // ========================================== const globalSearchInput = document.getElementById("globalSearchInput"); const searchResultsDropdown = document.getElementById( "searchResultsDropdown", ); const searchResultsItems = document.getElementById("searchResultsItems"); const searchHistoryItems = document.getElementById("searchHistoryItems"); let searchHistory = JSON.parse( localStorage.getItem("crmSearchHistory") || "[]", ); let searchDebounceTimer = null; // Render search history function renderSearchHistory() { if (searchHistory.length === 0) { searchHistoryItems.innerHTML = '
暂无搜索历史
'; return; } searchHistoryItems.innerHTML = searchHistory .slice(0, 5) .map( (item) => `
${item}
`, ) .join(""); // Add click handlers for history items searchHistoryItems .querySelectorAll(".search-history-item") .forEach((el) => { el.addEventListener("click", () => { globalSearchInput.value = el.dataset.query; performGlobalSearch(el.dataset.query); }); }); } // Save to search history function saveToSearchHistory(query) { if (!query.trim()) return; searchHistory = [query, ...searchHistory.filter((h) => h !== query)].slice( 0, 10, ); localStorage.setItem("crmSearchHistory", JSON.stringify(searchHistory)); renderSearchHistory(); } // Perform global search async function performGlobalSearch(query) { if (!query.trim()) { searchResultsItems.innerHTML = '
输入关键词开始搜索...
'; return; } searchResultsItems.innerHTML = '
搜索中...
'; try { const results = []; // Search in trial periods try { const trialResponse = await authenticatedFetch( "/api/trial-periods/all", ); if (trialResponse.ok) { const trialData = await trialResponse.json(); const trialPeriods = trialData.trialPeriods || []; // Get customers map const customersResponse = await authenticatedFetch( "/api/customers/list", ); const customersData = customersResponse.ok ? await customersResponse.json() : {}; const customersMap = customersData.customerMap || {}; // Search trial periods trialPeriods.forEach((period) => { const customerName = customersMap[period.customerId] || period.customerId || ""; if ( customerName && customerName.toLowerCase().includes(query.toLowerCase()) ) { results.push({ type: "trial", title: customerName, meta: `试用时间: ${period.startTime ? period.startTime.split("T")[0] : ""} ~ ${period.endTime ? period.endTime.split("T")[0] : ""}`, icon: "fas fa-clock", section: "trialPeriods", }); } }); } } catch (trialError) { console.warn("Trial periods search error:", trialError); } // Search in customers (weekly progress) - use local allCustomers if available try { if (typeof allCustomers !== "undefined" && allCustomers.length > 0) { allCustomers.forEach((customer) => { const searchFields = [ customer.customerName || "", customer.description || "", customer.solution || "", customer.module || "", ] .join(" ") .toLowerCase(); if (searchFields.includes(query.toLowerCase())) { results.push({ type: "progress", title: customer.customerName || "未知客户", meta: customer.description ? customer.description.substring(0, 50) + "..." : "", icon: "fas fa-user-plus", section: "customer", }); } }); } else { const progressResponse = await authenticatedFetch( "/api/customers?page=1&pageSize=100", ); if (progressResponse.ok) { const progressData = await progressResponse.json(); const customers = progressData.customers || []; customers.forEach((customer) => { const searchFields = [ customer.customerName || "", customer.description || "", customer.solution || "", customer.module || "", ] .join(" ") .toLowerCase(); if (searchFields.includes(query.toLowerCase())) { results.push({ type: "progress", title: customer.customerName || "未知客户", meta: customer.description ? customer.description.substring(0, 50) + "..." : "", icon: "fas fa-user-plus", section: "customer", }); } }); } } } catch (progressError) { console.warn("Progress search error:", progressError); } // Search in followups try { const followupResponse = await authenticatedFetch("/api/followups"); if (followupResponse.ok) { const followupData = await followupResponse.json(); const followups = followupData.followups || []; followups.forEach((followup) => { if ( followup.customerName && followup.customerName.toLowerCase().includes(query.toLowerCase()) ) { results.push({ type: "followup", title: followup.customerName, meta: `状态: ${followup.dealStatus || "未知"} | 级别: ${followup.customerLevel || "未知"}`, icon: "fas fa-tasks", section: "followup", }); } }); } } catch (followupError) { console.warn("Followup search error:", followupError); } // Render results if (results.length === 0) { searchResultsItems.innerHTML = '
未找到匹配结果
'; } else { // Deduplicate by title const seen = new Set(); const uniqueResults = results.filter((r) => { const key = r.title + r.type; if (seen.has(key)) return false; seen.add(key); return true; }); searchResultsItems.innerHTML = uniqueResults .slice(0, 10) .map( (result) => `
${result.title}
${result.meta}
${result.type === "trial" ? "试用" : result.type === "progress" ? "进度" : "跟进"}
`, ) .join(""); // Add click handlers searchResultsItems .querySelectorAll(".search-result-item") .forEach((el) => { el.addEventListener("click", () => { saveToSearchHistory(query); switchSection(el.dataset.section); searchResultsDropdown.style.display = "none"; globalSearchInput.value = ""; }); }); } } catch (error) { console.error("Search error:", error); searchResultsItems.innerHTML = '
搜索出错,请重试
'; } } // Search input event handlers if (globalSearchInput) { globalSearchInput.addEventListener("focus", () => { renderSearchHistory(); searchResultsDropdown.style.display = "block"; }); globalSearchInput.addEventListener("input", (e) => { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { performGlobalSearch(e.target.value); }, 300); }); globalSearchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") { searchResultsDropdown.style.display = "none"; globalSearchInput.blur(); } if (e.key === "Enter") { saveToSearchHistory(globalSearchInput.value); } }); // Close dropdown when clicking outside document.addEventListener("click", (e) => { if (!e.target.closest(".global-search-container")) { searchResultsDropdown.style.display = "none"; } }); // Keyboard shortcut (Cmd+K / Ctrl+K) document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); globalSearchInput.focus(); } }); } // ========================================== // Header Quick Add Button // ========================================== const headerQuickAddBtn = document.getElementById("headerQuickAddBtn"); const headerQuickAddDropdown = document.getElementById( "headerQuickAddDropdown", ); if (headerQuickAddBtn) { headerQuickAddBtn.addEventListener("click", (e) => { e.stopPropagation(); const isVisible = headerQuickAddDropdown.style.display === "block"; headerQuickAddDropdown.style.display = isVisible ? "none" : "block"; }); // Quick add menu item handlers document.querySelectorAll(".quick-add-menu-item").forEach((item) => { item.addEventListener("click", () => { const action = item.dataset.action; headerQuickAddDropdown.style.display = "none"; if (action === "trial") { switchSection("trialPeriods"); setTimeout(() => { const addTrialBtn = document.getElementById("addTrialBtn"); if (addTrialBtn) addTrialBtn.click(); }, 100); } else if (action === "customer") { switchSection("customer"); setTimeout(() => { const addCustomerBtn = document.getElementById("addCustomerBtn"); if (addCustomerBtn) addCustomerBtn.click(); }, 100); } else if (action === "followup") { switchSection("followup"); setTimeout(() => { const addFollowUpBtn = document.getElementById("addFollowUpBtn"); if (addFollowUpBtn) addFollowUpBtn.click(); }, 100); } }); }); // Close dropdown when clicking outside document.addEventListener("click", (e) => { if (!e.target.closest(".quick-add-dropdown-container")) { headerQuickAddDropdown.style.display = "none"; } }); } // ========================================== // Logout Button // ========================================== const headerLogoutBtn = document.getElementById("logoutBtn"); if (headerLogoutBtn) { headerLogoutBtn.addEventListener("click", () => { if (confirm("确定要退出登录吗?")) { localStorage.removeItem("crmToken"); localStorage.removeItem("crmSearchHistory"); window.location.href = "/static/login.html"; } }); } // ========================================== // Customer Progress Tabs // ========================================== const progressListTab = document.getElementById("progressListTab"); const weeklySummaryTab = document.getElementById("weeklySummaryTab"); const progressListView = document.getElementById("progressListView"); const weeklySummaryView = document.getElementById("weeklySummaryView"); // Week offset for navigation (0 = current week, -1 = last week, etc.) let weekOffset = 0; // Tab switching function switchCustomerTab(tabName) { // Update tab buttons document.querySelectorAll(".customer-tab").forEach((tab) => { tab.classList.remove("active"); }); // Update tab content document.querySelectorAll(".tab-content").forEach((content) => { content.classList.remove("active"); }); // Get the table actions (refresh & export buttons) const tableActions = document.querySelector( "#customerSection .card-header .table-actions", ); if (tabName === "progress-list") { progressListTab?.classList.add("active"); progressListView?.classList.add("active"); // Show refresh and export buttons for progress list if (tableActions) tableActions.style.display = "flex"; } else if (tabName === "weekly-summary") { weeklySummaryTab?.classList.add("active"); weeklySummaryView?.classList.add("active"); // Hide refresh and export buttons for weekly summary if (tableActions) tableActions.style.display = "none"; // Load weekly summary when switching to this tab weekOffset = 0; loadWeeklySummary(); } } // Tab click handlers progressListTab?.addEventListener("click", () => switchCustomerTab("progress-list"), ); weeklySummaryTab?.addEventListener("click", () => switchCustomerTab("weekly-summary"), ); // Week navigation handlers document.getElementById("prevWeekBtn")?.addEventListener("click", () => { weekOffset--; loadWeeklySummary(); }); document.getElementById("nextWeekBtn")?.addEventListener("click", () => { weekOffset++; loadWeeklySummary(); }); document.getElementById("currentWeekBtn")?.addEventListener("click", () => { weekOffset = 0; loadWeeklySummary(); }); // Get week date range function getWeekRange(offset = 0) { const now = new Date(); const currentDay = now.getDay(); const diff = currentDay === 0 ? 6 : currentDay - 1; // Adjust for Monday start const monday = new Date(now); monday.setDate(now.getDate() - diff + offset * 7); monday.setHours(0, 0, 0, 0); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); sunday.setHours(23, 59, 59, 999); return { start: monday, end: sunday }; } // Format date for display function formatWeekDate(date) { const month = date.getMonth() + 1; const day = date.getDate(); return `${month}月${day}日`; } // Format date to YYYY-MM-DD for comparison function formatDateForCompare(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } // Parse date string from customer data function parseCustomerDate(dateStr) { if (!dateStr) return null; const cleaned = String(dateStr) .trim() .replace(/\./g, "-") .replace(/\//g, "-"); const parts = cleaned.split("-").filter(Boolean); if (parts.length < 3) return null; return new Date( parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), ); } // Load and render weekly summary function loadWeeklySummary() { const weekRange = getWeekRange(weekOffset); // Update week range display const weekRangeEl = document.getElementById("currentWeekRange"); if (weekRangeEl) { const weekLabel = weekOffset === 0 ? "本周" : weekOffset === -1 ? "上周" : `${Math.abs(weekOffset)}周${weekOffset < 0 ? "前" : "后"}`; weekRangeEl.textContent = `${weekLabel}: ${formatWeekDate(weekRange.start)} - ${formatWeekDate(weekRange.end)}`; } // Filter customers by week const weekStart = formatDateForCompare(weekRange.start); const weekEnd = formatDateForCompare(weekRange.end); const weeklyCustomers = allCustomers.filter((customer) => { const customerDate = parseCustomerDate(customer.intendedProduct); if (!customerDate) return false; const dateStr = formatDateForCompare(customerDate); return dateStr >= weekStart && dateStr <= weekEnd; }); // Group by customer name const customerGroups = {}; weeklyCustomers.forEach((customer) => { const name = customer.customerName || "未知客户"; if (!customerGroups[name]) { customerGroups[name] = []; } customerGroups[name].push(customer); }); // Render weekly summary renderWeeklySummary(customerGroups); } // Render weekly summary cards function renderWeeklySummary(customerGroups) { const container = document.getElementById("weeklySummaryContainer"); if (!container) return; container.innerHTML = ""; const customerNames = Object.keys(customerGroups); if (customerNames.length === 0) { container.innerHTML = `

本周暂无客户进度数据

`; return; } // Sort customer names customerNames.sort(); customerNames.forEach((customerName) => { const records = customerGroups[customerName]; const card = createCustomerSummaryCard(customerName, records); container.appendChild(card); }); } // Create customer summary card function createCustomerSummaryCard(customerName, records) { const card = document.createElement("div"); card.className = "customer-summary-card"; // Sort records by date records.sort((a, b) => { const dateA = parseCustomerDate(a.intendedProduct) || new Date(0); const dateB = parseCustomerDate(b.intendedProduct) || new Date(0); return dateB - dateA; }); // Count statistics const totalRecords = records.length; // Get unique types and modules const types = [...new Set(records.map((r) => r.type).filter(Boolean))]; const modules = [...new Set(records.map((r) => r.module).filter(Boolean))]; // Build combined items (description + solution in one row) let combinedItems = ""; records.forEach((record) => { const date = parseCustomerDate(record.intendedProduct); const dateDisplay = date ? formatItemDate(date) : ""; const description = record.description && record.description.trim() ? escapeHtml(record.description) : "-"; const solution = record.solution && record.solution.trim() ? escapeHtml(record.solution) : "-"; combinedItems += `
${dateDisplay}
${description}
${solution}
${record.version ? ` ${escapeHtml(record.version)}` : ""} ${record.type ? ` ${escapeHtml(record.type)}` : ""} ${record.module ? ` ${escapeHtml(record.module)}` : ""} ${record.statusProgress ? ` ${escapeHtml(record.statusProgress)}` : ""}
`; }); card.innerHTML = `
${escapeHtml(customerName)}
${totalRecords} 条记录
${combinedItems || '

暂无数据

'}
类型: ${types.join(", ") || "-"}
模块: ${modules.join(", ") || "-"}
`; return card; } // Format date for summary item function formatItemDate(date) { const dayNames = ["日", "一", "二", "三", "四", "五", "六"]; const month = date.getMonth() + 1; const day = date.getDate(); const dayOfWeek = dayNames[date.getDay()]; return `
${month}/${day} 周${dayOfWeek}
`; } // Escape HTML to prevent XSS function escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // Export weekly summary document .getElementById("exportWeeklySummaryBtn") ?.addEventListener("click", () => { const weekRange = getWeekRange(weekOffset); const weekStart = formatDateForCompare(weekRange.start); const weekEnd = formatDateForCompare(weekRange.end); const weeklyCustomers = allCustomers.filter((customer) => { const customerDate = parseCustomerDate(customer.intendedProduct); if (!customerDate) return false; const dateStr = formatDateForCompare(customerDate); return dateStr >= weekStart && dateStr <= weekEnd; }); // Group by customer name const customerGroups = {}; weeklyCustomers.forEach((customer) => { const name = customer.customerName || "未知客户"; if (!customerGroups[name]) { customerGroups[name] = []; } customerGroups[name].push(customer); }); // Build export content let content = `周报汇总 (${formatWeekDate(weekRange.start)} - ${formatWeekDate(weekRange.end)})\n`; content += "=".repeat(60) + "\n\n"; Object.keys(customerGroups) .sort() .forEach((customerName) => { const records = customerGroups[customerName]; content += `【${customerName}】\n`; content += "-".repeat(40) + "\n"; content += "\n▶ 问题描述:\n"; records.forEach((record) => { if (record.description && record.description.trim()) { const date = parseCustomerDate(record.intendedProduct); const dateStr = date ? `${date.getMonth() + 1}/${date.getDate()}` : ""; content += ` [${dateStr}] ${record.description}\n`; if (record.version) content += ` 版本: ${record.version}\n`; if (record.type) content += ` 类型: ${record.type}\n`; if (record.statusProgress) content += ` 状态: ${record.statusProgress}\n`; } }); content += "\n▶ 解决方案:\n"; records.forEach((record) => { if (record.solution && record.solution.trim()) { const date = parseCustomerDate(record.intendedProduct); const dateStr = date ? `${date.getMonth() + 1}/${date.getDate()}` : ""; content += ` [${dateStr}] ${record.solution}\n`; } }); content += "\n"; }); // Download as text file const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `周报汇总_${formatDateForCompare(weekRange.start)}_${formatDateForCompare(weekRange.end)}.txt`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }); // ========== Screenshot & Image Viewer Logic ========== function renderScreenshotsInCell(td, screenshots) { td.innerHTML = ""; if (!screenshots || screenshots.length === 0) { td.innerHTML = ''; return; } const wrapper = document.createElement("div"); wrapper.className = "screenshot-wrapper"; screenshots.forEach((path) => { const img = document.createElement("img"); img.src = path; img.className = "screenshot-thumbnail"; img.onclick = (e) => { e.stopPropagation(); const viewer = document.getElementById("imageViewerModal"); const fullImg = document.getElementById("fullImage"); if (viewer && fullImg) { fullImg.src = path; viewer.style.display = "flex"; } }; wrapper.appendChild(img); }); td.appendChild(wrapper); } function initImageViewer() { const viewer = document.getElementById("imageViewerModal"); const closeBtn = document.querySelector(".image-viewer-close"); if (!viewer || !closeBtn) return; closeBtn.onclick = () => { viewer.style.display = "none"; }; viewer.onclick = (e) => { if (e.target === viewer) { viewer.style.display = "none"; } }; } // File upload handling for modals function handleScreenshotUpload(type) { const input = document.getElementById( type === "create" ? "createScreenshots" : "editScreenshots", ); const previewList = document.getElementById( type === "create" ? "createScreenshotPreview" : "editScreenshotPreview", ); if (!input || !previewList) return; input.addEventListener("change", async function () { if (!this.files.length) return; const files = Array.from(this.files); if (files.length === 0) return; const newBase64Images = []; // Canvas-based conversion - most compatible method for restricted environments const fileToBase64Canvas = (file) => { return new Promise((resolve, reject) => { // Create object URL from file const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { try { // Create canvas and draw image const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); // Convert to base64 const base64 = canvas.toDataURL(file.type || 'image/png'); // Clean up URL.revokeObjectURL(url); resolve(base64); } catch (error) { URL.revokeObjectURL(url); reject(error); } }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load image')); }; img.src = url; }); }; for (const file of files) { if (!file.type.startsWith("image/")) { console.warn(`File is not an image: ${file.name}`); continue; } try { console.log(`[v5.1-Canvas] Converting ${file.name} (${file.size} bytes)...`); const base64 = await fileToBase64Canvas(file); newBase64Images.push(base64); console.log(`✅ Successfully converted ${file.name} using Canvas method`); } catch (error) { console.error(`❌ Canvas method failed for ${file.name}:`, error); alert(`无法处理文件 "${file.name}"。\n\n可能的原因:\n1. 图片格式不受支持\n2. 图片文件损坏\n3. 浏览器内存不足\n\n建议:\n• 尝试使用较小的图片\n• 转换为标准格式(JPG/PNG)\n• 刷新页面后重试`); } } if (newBase64Images.length > 0) { if (type === "create") { createUploadedScreenshots = [ ...createUploadedScreenshots, ...newBase64Images, ]; renderScreenshotPreviews("create", createUploadedScreenshots); } else { editUploadedScreenshots = [ ...editUploadedScreenshots, ...newBase64Images, ]; renderScreenshotPreviews("edit", editUploadedScreenshots); } } // 清除 input,以便再次选择同一张图片 this.value = ""; }); } function renderScreenshotPreviews(type, screenshots) { const previewList = document.getElementById( type === "create" ? "createScreenshotPreview" : "editScreenshotPreview", ); if (!previewList) return; previewList.innerHTML = ""; screenshots.forEach((path, index) => { const item = document.createElement("div"); item.className = "preview-item"; item.innerHTML = ` Screenshot × `; item.querySelector(".remove-preview").onclick = () => { if (type === "create") { createUploadedScreenshots.splice(index, 1); renderScreenshotPreviews("create", createUploadedScreenshots); } else { editUploadedScreenshots.splice(index, 1); renderScreenshotPreviews("edit", editUploadedScreenshots); } }; previewList.appendChild(item); }); } // Initialize upload handlers handleScreenshotUpload("create"); handleScreenshotUpload("edit"); // ========== Select Placeholder Color Control ========== // Function to update select color based on value function updateSelectColor(select) { if (!select.value || select.value === "") { select.classList.add("placeholder-active"); } else { select.classList.remove("placeholder-active"); } } // Apply to all select elements const allSelects = document.querySelectorAll("select"); allSelects.forEach((select) => { // Set initial color updateSelectColor(select); // Update color on change select.addEventListener("change", function () { updateSelectColor(this); }); // Also update on input (for some browsers) select.addEventListener("input", function () { updateSelectColor(this); }); }); // Re-apply when modals open (for dynamically populated selects) const observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { if (mutation.type === "attributes" && mutation.attributeName === "style") { const target = mutation.target; if (target.classList.contains("modal") && target.style.display === "block") { setTimeout(() => { target.querySelectorAll("select").forEach(updateSelectColor); }, 100); } } }); }); // Observe all modals document.querySelectorAll(".modal").forEach((modal) => { observer.observe(modal, { attributes: true }); }); });