// 封装带 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; } 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(); function normalizeDateValue(dateValue) { const raw = String(dateValue || '').trim(); if (!raw) return ''; const cleaned = raw.replace(/\./g, '-').replace(/\//g, '-'); const parts = cleaned.split('-').filter(Boolean); if (parts.length < 3) return ''; const year = parts[0].padStart(4, '0'); const month = parts[1].padStart(2, '0'); const day = parts[2].padStart(2, '0'); return `${year}-${month}-${day}`; } // Navigation event listeners navItems.forEach(item => { item.addEventListener('click', function (e) { e.preventDefault(); const section = this.getAttribute('data-section'); switchSection(section); }); }); // Menu toggle for mobile menuToggle.addEventListener('click', function () { sidebar.classList.toggle('open'); }); // Close sidebar when clicking outside on mobile document.addEventListener('click', function (e) { if (window.innerWidth <= 768) { if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) { sidebar.classList.remove('open'); } } }); // Sidebar toggle functionality const sidebarToggle = document.getElementById('sidebarToggle'); if (sidebarToggle && sidebar) { sidebarToggle.addEventListener('click', function () { sidebar.classList.toggle('collapsed'); // Update button title if (sidebar.classList.contains('collapsed')) { sidebarToggle.title = '展开侧边栏'; } else { sidebarToggle.title = '收起侧边栏'; } }); } // Add Customer button addCustomerBtn.addEventListener('click', async function () { await loadTrialCustomerListForCreate(); createModal.style.display = 'block'; }); // Import button importBtn.addEventListener('click', function () { importModal.style.display = 'block'; }); // Close create modal createModal.querySelector('.close').addEventListener('click', function () { createModal.style.display = 'none'; }); createModal.querySelector('.cancel-create').addEventListener('click', function () { createModal.style.display = 'none'; }); // Close import modal importModal.querySelector('.close').addEventListener('click', function () { importModal.style.display = 'none'; }); importModal.querySelector('.cancel-import').addEventListener('click', function () { importModal.style.display = 'none'; }); // Close edit modal editModal.querySelector('.close').addEventListener('click', function () { editModal.style.display = 'none'; }); editModal.querySelector('.cancel-edit').addEventListener('click', function () { editModal.style.display = 'none'; }); // File name display const importFile = document.getElementById('importFile'); const fileName = document.getElementById('fileName'); importFile.addEventListener('change', function () { if (this.files.length > 0) { fileName.textContent = this.files[0].name; } else { fileName.textContent = '选择文件...'; } }); // Customer filter change event customerFilter.addEventListener('change', function () { selectedCustomerFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); if (customerSearchInput) { customerSearchInput.addEventListener('input', function () { customerSearchQuery = (this.value || '').trim(); if (currentSection === 'customer') { currentPage = 1; applyAllCustomerFilters(); } }); } // Load trial customer list for create customer dropdown async function loadTrialCustomerListForCreate() { try { const response = await authenticatedFetch('/api/trial-customers/list'); const data = await response.json(); const customerSelect = document.getElementById('createCustomerName'); customerSelect.innerHTML = ''; if (data.customerNames && data.customerNames.length > 0) { data.customerNames.forEach(customerName => { const option = document.createElement('option'); option.value = customerName; option.textContent = customerName; customerSelect.appendChild(option); }); } } catch (error) { console.error('Error loading trial customer list:', error); } } // Type filter change event const typeFilter = document.getElementById('typeFilter'); if (typeFilter) { typeFilter.addEventListener('change', function () { selectedTypeFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Customer date range filter events const customerStartDateInput = document.getElementById('customerStartDate'); const customerEndDateInput = document.getElementById('customerEndDate'); if (customerStartDateInput) { customerStartDateInput.addEventListener('change', function () { customerStartDate = this.value; currentPage = 1; applyAllCustomerFilters(); }); } if (customerEndDateInput) { customerEndDateInput.addEventListener('change', function () { customerEndDate = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Status progress filter change event const statusProgressFilter = document.getElementById('statusProgressFilter'); if (statusProgressFilter) { statusProgressFilter.addEventListener('change', function () { selectedStatusProgressFilter = this.value; currentPage = 1; applyAllCustomerFilters(); }); } // Apply date filter for dashboard document.getElementById('applyFilters').addEventListener('click', function () { const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; applyDateFilter(startDate, endDate); }); // Chart title change events document.getElementById('statusChartTitle').addEventListener('input', function () { applyDateFilter(document.getElementById('startDate').value, document.getElementById('endDate').value); }); document.getElementById('typeChartTitle').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') { console.log('Switching to trial periods section'); 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') { console.log('Loading customers first, then trial periods'); loadCustomersForDropdown().then(() => { console.log('Customers loaded, now loading trial periods'); 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() { console.log('Populating filter, allCustomers:', allCustomers); const uniqueCustomers = [...new Set(allCustomers.map(c => c.customerName).filter(c => c))]; console.log('Unique customers:', uniqueCustomers); customerFilter.innerHTML = ''; uniqueCustomers.forEach(customer => { const option = document.createElement('option'); option.value = customer; option.textContent = customer; customerFilter.appendChild(option); }); if (selectedCustomerFilter) { customerFilter.value = selectedCustomerFilter; } } // Populate type filter dropdown function populateTypeFilter() { const typeFilterElement = document.getElementById('typeFilter'); if (!typeFilterElement) return; const uniqueTypes = [...new Set(allCustomers.map(c => c.type).filter(t => t))]; typeFilterElement.innerHTML = ''; uniqueTypes.forEach(type => { const option = document.createElement('option'); option.value = type; option.textContent = type; typeFilterElement.appendChild(option); }); if (selectedTypeFilter) { typeFilterElement.value = selectedTypeFilter; } } // Filter customers by selected customer function filterCustomers(selectedCustomer) { selectedCustomerFilter = selectedCustomer; currentPage = 1; applyAllCustomerFilters(); } function customerMatchesQuery(customer, query) { if (!query) return true; const fields = [ customer.customerName, customer.intendedProduct, customer.version, customer.description, customer.solution, customer.type, customer.module, customer.statusProgress, customer.reporter ]; const haystack = fields .map(v => String(v || '')) .join(' ') .toLowerCase(); const normalizedHaystack = haystack.replace(/[\/.]/g, '-'); const needle = query.toLowerCase(); const normalizedNeedle = needle.replace(/[\/.]/g, '-'); return normalizedHaystack.includes(normalizedNeedle); } function applyAllCustomerFilters() { let next = [...allCustomers]; if (selectedCustomerFilter) { next = next.filter(c => c.customerName === selectedCustomerFilter); } if (selectedTypeFilter) { next = next.filter(c => c.type === selectedTypeFilter); } if (customerStartDate) { next = next.filter(c => { const date = normalizeDateValue(c.intendedProduct); return date && date >= customerStartDate; }); } if (customerEndDate) { next = next.filter(c => { const date = normalizeDateValue(c.intendedProduct); return date && date <= customerEndDate; }); } if (selectedStatusProgressFilter) { next = next.filter(c => c.statusProgress === selectedStatusProgressFilter); } if (customerSearchQuery) { next = next.filter(c => customerMatchesQuery(c, customerSearchQuery)); } filteredCustomers = next; applyCustomerFilter(); } function toCsvCell(value) { const raw = String(value ?? ''); const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const escaped = normalized.replace(/"/g, '""'); return `"${escaped}"`; } function downloadCsv(filename, rows) { const content = ['\uFEFF' + rows[0], ...rows.slice(1)].join('\r\n'); const blob = new Blob([content], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function exportCustomersToCsv(customers) { const header = [ '客户', '咨询时间', '版本', '描述', '解决方案', '类型', '模块', '状态与进度' ].map(toCsvCell).join(','); const lines = customers.map(c => { const date = normalizeDateValue(c.intendedProduct) || (c.intendedProduct || ''); const cells = [ c.customerName || '', date, c.version || '', c.description || '', c.solution || '', c.type || '', c.module || '', c.statusProgress || '' ]; return cells.map(toCsvCell).join(','); }); const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); const filename = `客户列表_${y}${m}${d}_${hh}${mm}${ss}.csv`; downloadCsv(filename, [header, ...lines]); } if (refreshCustomersBtn) { refreshCustomersBtn.addEventListener('click', () => { if (currentSection === 'customer') { loadCustomers(); } }); } 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() { console.log('loadAllCustomers called'); try { const response = await authenticatedFetch('/api/customers?page=1&pageSize=1000'); const data = await response.json(); console.log('Dashboard data received:', data); if (data.customers) { dashboardCustomers = data.customers; console.log('Dashboard customers set:', dashboardCustomers.length, 'customers'); updateDashboardStats(dashboardCustomers); renderStatusChart(dashboardCustomers); renderTypeChart(dashboardCustomers); renderTrendChart(dashboardCustomers); } else { console.error('No customers in dashboard data'); } } catch (error) { console.error('Error loading all customers:', error); } } // Apply date filter for dashboard function applyDateFilter(startDate, endDate) { let filteredData = dashboardCustomers; if (startDate) { filteredData = filteredData.filter(c => { if (!c.intendedProduct) return false; const date = normalizeDateValue(c.intendedProduct); return date && date >= startDate; }); } if (endDate) { filteredData = filteredData.filter(c => { if (!c.intendedProduct) return false; const date = normalizeDateValue(c.intendedProduct); return date && date <= endDate; }); } updateDashboardStats(filteredData); renderStatusChart(filteredData); renderTypeChart(filteredData); renderTrendChart(filteredData); } // Render customer table function renderCustomerTable(customers) { customerTableBody.innerHTML = ''; // Helper function to generate status badge function getStatusBadge(status) { if (!status) return ''; const statusLower = status.toLowerCase(); if (status === '已完成' || statusLower.includes('完成') || statusLower.includes('complete')) { return ` ${status}`; } else if (status === '进行中' || statusLower.includes('进行')) { return ` ${status}`; } else if (status === '待排期' || statusLower.includes('待')) { return ` ${status}`; } else if (status === '已驳回' || statusLower.includes('驳回')) { return ` ${status}`; } else if (status === '已上线' || statusLower.includes('上线')) { return ` ${status}`; } return `${status}`; } customers.forEach(customer => { const row = document.createElement('tr'); row.classList.add('customer-row'); const date = customer.intendedProduct || ''; const fields = [ { value: customer.customerName || '', name: 'customerName' }, { value: date, name: 'date' }, { value: customer.version || '', name: 'version' }, { value: customer.description || '', name: 'description' }, { value: customer.solution || '', name: 'solution' }, { value: customer.type || '', name: 'type' }, { value: customer.module || '', name: 'module' }, { value: customer.statusProgress || '', name: 'statusProgress' } ]; fields.forEach(field => { const td = document.createElement('td'); const textValue = String(field.value ?? ''); // Use badge for statusProgress field if (field.name === 'statusProgress') { td.innerHTML = getStatusBadge(textValue); } else if (field.name === 'customerName') { td.innerHTML = `${textValue}`; } else { 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'); actionTd.innerHTML = ` `; 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').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: document.getElementById('createModule').value, 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(); createModal.style.display = 'none'; loadCustomers(); alert('客户创建成功!'); } 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 || ''; document.getElementById('editModule').value = 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: document.getElementById('editModule').value, 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(); alert('客户更新成功!'); } 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(); alert('客户删除成功!'); } else { alert('删除客户时出错'); } } catch (error) { console.error('Error deleting customer:', error); alert('删除客户时出错'); } } // Dashboard functionality async function loadDashboardData() { console.log('loadDashboardData called'); 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 const trialCustomerNames = new Set(); trialPeriods.forEach(period => { // Check if customerId is in map, otherwise use customerId directly as name const customerName = customersMap[period.customerId] || period.customerId; if (customerName) { trialCustomerNames.add(customerName); } }); // Update total customers to include trial customers updateTotalCustomersWithTrialData(trialCustomerNames); } catch (error) { console.error('Error loading trial customers for dashboard:', error); } } // Update total customers count including trial customers function updateTotalCustomersWithTrialData(trialCustomerNames) { // Get current dashboard customers const progressCustomerNames = new Set(dashboardCustomers.map(c => c.customerName).filter(c => c)); // Merge both sets const allCustomerNames = new Set([...progressCustomerNames, ...trialCustomerNames]); // Update the total customers display document.getElementById('totalCustomers').textContent = allCustomerNames.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; const completed = new Set( customers.filter(c => c.statusProgress && (c.statusProgress.includes('已修复') || c.statusProgress.includes('完成') || c.statusProgress.toLowerCase().includes('complete')) ).map(c => c.customerName).filter(c => c) ).size; document.getElementById('totalCustomers').textContent = totalCustomers; document.getElementById('newCustomers').textContent = newCustomers; document.getElementById('completedTasks').textContent = completed; } // 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; } else { document.getElementById('followUpCount').textContent = '0'; } } catch (error) { console.error('Error loading follow-up count:', error); document.getElementById('followUpCount').textContent = '0'; } } function renderStatusChart(customers) { const ctx = document.getElementById('statusChart').getContext('2d'); const selectedField = document.getElementById('chartFieldSelect').value; const chartTitle = document.getElementById('statusChartTitle').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: true, 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) { console.log('renderTypeChart called with customers:', customers); const canvas = document.getElementById('typeChart'); console.log('typeChart canvas element:', canvas); if (!canvas) { console.error('typeChart canvas not found'); return; } const ctx = canvas.getContext('2d'); const chartTitle = document.getElementById('typeChartTitle').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); console.log('Type chart labels:', labels); console.log('Type chart data:', data); 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: true, 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 } } } } }); console.log('Type chart created successfully'); } // 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 } } }); console.log('Trend chart created successfully'); } // 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; // Show/hide follow-up form if (addFollowUpBtn) { addFollowUpBtn.addEventListener('click', async function () { followupFormCard.style.display = 'block'; await loadCustomerListForFollowup(); }); } if (cancelFollowupBtn) { cancelFollowupBtn.addEventListener('click', function () { followupFormCard.style.display = 'none'; followupForm.reset(); }); } // Load customer list for follow-up dropdown from trial periods async function loadCustomerListForFollowup() { try { const response = await authenticatedFetch('/api/trial-customers/list'); const data = await response.json(); followupCustomerNameSelect.innerHTML = ''; if (data.customerNames && data.customerNames.length > 0) { data.customerNames.forEach(customerName => { const option = document.createElement('option'); option.value = customerName; option.textContent = customerName; followupCustomerNameSelect.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(); alert('跟进记录创建成功!'); } else { alert('创建跟进记录时出错'); } } catch (error) { console.error('Error creating follow-up:', error); alert('创建跟进记录时出错'); } }); } // Load follow-ups async function loadFollowUps() { try { const response = await authenticatedFetch(`/api/followups?page=${followupCurrentPage}&pageSize=${followupPageSize}`); const data = await response.json(); if (data.followUps) { allFollowUps = data.followUps; followupTotalItems = data.total || 0; followupTotalPages = data.totalPages || 1; renderFollowUpTable(allFollowUps); updateFollowupPaginationControls(); } } catch (error) { console.error('Error loading follow-ups:', error); } } // Render follow-up table function renderFollowUpTable(followUps) { followupTableBody.innerHTML = ''; followUps.forEach(followUp => { const row = document.createElement('tr'); // Format follow-up time const followUpTime = new Date(followUp.followUpTime); const formattedTime = followUpTime.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); // Notification status const notificationStatus = followUp.notificationSent ? '已通知' : '待通知'; // Customer level display let levelDisplay = followUp.customerLevel; if (followUp.customerLevel === 'A') { levelDisplay = 'A级 (重点客户)'; } else if (followUp.customerLevel === 'B') { levelDisplay = 'B级 (潜在客户)'; } else if (followUp.customerLevel === 'C') { levelDisplay = 'C级 (一般客户)'; } row.innerHTML = `