3388 lines
107 KiB
JavaScript
3388 lines
107 KiB
JavaScript
// 封装带 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.2 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 = '<option value="" disabled selected hidden>请选择客户</option>';
|
||
|
||
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 = '<option value="">全部客户</option>';
|
||
|
||
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 = '<option value="">全部类型</option>';
|
||
|
||
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 `<span class="progress-badge progress-completed"><i class="fas fa-check"></i> ${status}</span>`;
|
||
} else if (status === "进行中" || statusLower.includes("进行")) {
|
||
return `<span class="progress-badge progress-in-progress"><i class="fas fa-sync"></i> ${status}</span>`;
|
||
} else if (status === "待排期" || statusLower.includes("待")) {
|
||
return `<span class="progress-badge progress-pending"><i class="fas fa-clock"></i> ${status}</span>`;
|
||
} else if (status === "已驳回" || statusLower.includes("驳回")) {
|
||
return `<span class="progress-badge progress-rejected"><i class="fas fa-times"></i> ${status}</span>`;
|
||
} else if (status === "已上线" || statusLower.includes("上线")) {
|
||
return `<span class="progress-badge progress-launched"><i class="fas fa-rocket"></i> ${status}</span>`;
|
||
}
|
||
return `<span class="progress-badge">${status}</span>`;
|
||
}
|
||
|
||
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 = `<strong>${textValue}</strong>`;
|
||
} 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 = `
|
||
<button class="action-btn edit-btn" data-id="${customer.id}">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="${deleteClass}" data-id="${customer.id}" title="${deleteTitle}" ${deleteDisabled ? "disabled" : ""}>
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
`;
|
||
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 = '<option value="" disabled selected hidden>请选择客户</option>';
|
||
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
|
||
? '<span style="color: green;">已通知</span>'
|
||
: '<span style="color: orange;">待通知</span>';
|
||
|
||
// 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 = `
|
||
<td>${followUp.customerName || ""}</td>
|
||
<td>${followUp.dealStatus || ""}</td>
|
||
<td>${levelDisplay}</td>
|
||
<td>${followUp.industry || ""}</td>
|
||
<td>${formattedTime}</td>
|
||
<td>${notificationStatus}</td>
|
||
<td>
|
||
<button class="action-btn edit-followup-btn" data-id="${followUp.id}">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="action-btn delete-followup-btn ${!canDelete() ? "disabled" : ""}" data-id="${followUp.id}" title="${!canDelete() ? "无删除权限" : "删除"}" ${!canDelete() ? "disabled" : ""}>
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
`;
|
||
|
||
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 =
|
||
'<div class="search-empty">暂无搜索历史</div>';
|
||
return;
|
||
}
|
||
searchHistoryItems.innerHTML = searchHistory
|
||
.slice(0, 5)
|
||
.map(
|
||
(item) => `
|
||
<div class="search-history-item" data-query="${item}">
|
||
<i class="fas fa-history"></i>
|
||
<span>${item}</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.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 =
|
||
'<div class="search-empty">输入关键词开始搜索...</div>';
|
||
return;
|
||
}
|
||
|
||
searchResultsItems.innerHTML =
|
||
'<div class="search-empty"><i class="fas fa-spinner fa-spin"></i> 搜索中...</div>';
|
||
|
||
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 =
|
||
'<div class="search-empty">未找到匹配结果</div>';
|
||
} 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) => `
|
||
<div class="search-result-item" data-section="${result.section}">
|
||
<i class="${result.icon}"></i>
|
||
<div class="search-result-info">
|
||
<div class="search-result-title">${result.title}</div>
|
||
<div class="search-result-meta">${result.meta}</div>
|
||
</div>
|
||
<span class="search-result-badge ${result.type}">${result.type === "trial" ? "试用" : result.type === "progress" ? "进度" : "跟进"}</span>
|
||
</div>
|
||
`,
|
||
)
|
||
.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 =
|
||
'<div class="search-empty">搜索出错,请重试</div>';
|
||
}
|
||
}
|
||
|
||
// 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 = `
|
||
<div class="weekly-summary-empty">
|
||
<i class="fas fa-inbox"></i>
|
||
<p>本周暂无客户进度数据</p>
|
||
</div>
|
||
`;
|
||
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 += `
|
||
<div class="summary-item">
|
||
${dateDisplay}
|
||
<div class="summary-item-content combined-content">
|
||
<div class="content-row">
|
||
<span class="content-label"><i class="fas fa-clipboard-list"></i></span>
|
||
<span class="content-text">${description}</span>
|
||
</div>
|
||
<div class="content-divider"></div>
|
||
<div class="content-row">
|
||
<span class="content-label"><i class="fas fa-lightbulb"></i></span>
|
||
<span class="content-text">${solution}</span>
|
||
</div>
|
||
<div class="summary-item-meta">
|
||
${record.version ? `<span><i class="fas fa-code-branch"></i> ${escapeHtml(record.version)}</span>` : ""}
|
||
${record.type ? `<span><i class="fas fa-tag"></i> ${escapeHtml(record.type)}</span>` : ""}
|
||
${record.module ? `<span><i class="fas fa-puzzle-piece"></i> ${escapeHtml(record.module)}</span>` : ""}
|
||
${record.statusProgress ? `<span><i class="fas fa-tasks"></i> ${escapeHtml(record.statusProgress)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
card.innerHTML = `
|
||
<div class="customer-summary-header">
|
||
<div class="customer-summary-name">
|
||
<i class="fas fa-building"></i>
|
||
<span>${escapeHtml(customerName)}</span>
|
||
</div>
|
||
<div class="customer-summary-badge">
|
||
<i class="fas fa-file-alt"></i>
|
||
<span>${totalRecords} 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="customer-summary-body">
|
||
<div class="summary-items">
|
||
${combinedItems || '<div class="weekly-summary-empty" style="padding: 20px;"><p>暂无数据</p></div>'}
|
||
</div>
|
||
</div>
|
||
<div class="customer-summary-stats">
|
||
<div class="stat-item">
|
||
<i class="fas fa-tag"></i>
|
||
<span>类型: <strong>${types.join(", ") || "-"}</strong></span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<i class="fas fa-puzzle-piece"></i>
|
||
<span>模块: <strong>${modules.join(", ") || "-"}</strong></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 `
|
||
<div class="summary-item-date">
|
||
<span class="day">${month}/${day}</span>
|
||
<span>周${dayOfWeek}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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 = '<span class="no-screenshots">无</span>';
|
||
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 = [];
|
||
|
||
// Direct server upload - bypassing all client-side file reading
|
||
const formData = new FormData();
|
||
let validFileCount = 0;
|
||
|
||
for (const file of files) {
|
||
if (!file.type.startsWith("image/")) {
|
||
console.warn(`File is not an image: ${file.name}`);
|
||
continue;
|
||
}
|
||
formData.append("screenshots", file);
|
||
validFileCount++;
|
||
}
|
||
|
||
if (validFileCount === 0) {
|
||
alert("请选择有效的图片文件");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log(`[v5.2-ServerUpload] Uploading ${validFileCount} file(s) to server...`);
|
||
|
||
// Use native fetch with manual Authorization header
|
||
const token = localStorage.getItem("crmToken");
|
||
const response = await fetch("/api/upload", {
|
||
method: "POST",
|
||
headers: {
|
||
"Authorization": `Bearer ${token}`
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
const uploadedImages = result.filePaths || [];
|
||
console.log(`✅ Server successfully processed ${uploadedImages.length} image(s)`);
|
||
|
||
newBase64Images.push(...uploadedImages);
|
||
} else {
|
||
const errorText = await response.text();
|
||
console.error("❌ Server upload failed:", errorText);
|
||
alert(`图片上传失败(状态码: ${response.status})\n\n可能的原因:\n1. 图片文件过大(超过32MB)\n2. 服务器存储空间不足\n3. 网络连接不稳定\n\n建议:压缩图片后重试`);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ Upload request failed:", error);
|
||
alert(`上传请求失败: ${error.message}\n\n请检查:\n1. 服务器是否正常运行\n2. 网络连接是否正常\n3. 是否已登录`);
|
||
return;
|
||
}
|
||
|
||
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 = `
|
||
<img src="${path}" alt="Screenshot">
|
||
<span class="remove-preview" data-index="${index}">×</span>
|
||
`;
|
||
|
||
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 });
|
||
});
|
||
});
|