crm/frontend/js/main.js

3148 lines
100 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 封装带 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 () {
// 登录守卫
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 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="">请选择客户</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");
}
}
// 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" },
];
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 {
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();
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
};
try {
const response = await authenticatedFetch("/api/customers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (response.ok) {
createCustomerForm.reset();
// 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 || "";
// 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
};
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 || "customer";
if (trendChartInstance) {
trendChartInstance.destroy();
}
// Group data by date
const dateMap = {};
customers.forEach((customer) => {
const dateStr = normalizeDateValue(customer.intendedProduct);
if (!dateStr) return;
if (!dateMap[dateStr]) {
dateMap[dateStr] = {
customers: new Set(),
demands: 0,
issues: 0,
};
}
// Count customers
if (customer.customerName) {
dateMap[dateStr].customers.add(customer.customerName);
}
// Count demands and issues based on type
const type = (customer.type || "").toLowerCase();
if (type.includes("需求")) {
dateMap[dateStr].demands++;
}
if (type.includes("问题") || type.includes("功能问题")) {
dateMap[dateStr].issues++;
}
});
// Sort dates
const sortedDates = Object.keys(dateMap).sort();
// Prepare datasets based on selected trend type
const datasets = [];
if (trendType === "customer") {
datasets.push({
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: true,
});
} else if (trendType === "demand") {
datasets.push({
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: true,
});
} else if (trendType === "issue") {
datasets.push({
label: "问题数",
data: sortedDates.map((date) => dateMap[date].issues),
borderColor: "#F28C28",
backgroundColor: "rgba(242, 140, 40, 0.1)",
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: "#F28C28",
pointBorderColor: "#fff",
pointBorderWidth: 2,
tension: 0.4,
fill: true,
});
} else if (trendType === "all") {
datasets.push(
{
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: false,
},
{
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: false,
},
{
label: "问题数",
data: sortedDates.map((date) => dateMap[date].issues),
borderColor: "#F28C28",
backgroundColor: "rgba(242, 140, 40, 0.1)",
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: "#F28C28",
pointBorderColor: "#fff",
pointBorderWidth: 2,
tension: 0.4,
fill: false,
},
);
}
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);
});
}
// ========== 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="">请选择客户</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);
});
});