diff --git a/cmd/server/main.go b/cmd/server/main.go
index 4602312..ef42e5c 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -40,51 +40,21 @@ func main() {
}()
// Start trial expiry checker in background
+ // Workday schedule: Monday-Friday, 11:00 AM and 5:00 PM
trialChecker := services.NewTrialExpiryChecker(feishuWebhook)
go func() {
- // Check immediately on startup
- trialPeriods, err := trialPeriodStorage.GetAllTrialPeriods()
- if err == nil {
- customers, err := customerStorage.GetAllCustomers()
- if err == nil {
- // Create customer name map
- customersMap := make(map[string]string)
- for _, c := range customers {
- customersMap[c.ID] = c.CustomerName
- }
-
- // Convert to services.TrialPeriod type
- serviceTrialPeriods := make([]services.TrialPeriod, len(trialPeriods))
- for i, tp := range trialPeriods {
- serviceTrialPeriods[i] = services.TrialPeriod{
- ID: tp.ID,
- CustomerName: tp.CustomerName,
- StartTime: tp.StartTime,
- EndTime: tp.EndTime,
- CreatedAt: tp.CreatedAt,
- }
- }
-
- if err := trialChecker.CheckTrialPeriodsAndNotify(serviceTrialPeriods, customersMap); err != nil {
- log.Printf("Error checking trial expiry: %v", err)
- }
- }
- }
-
- // Then check once per day at 10:00 AM
- ticker := time.NewTicker(24 * time.Hour)
- defer ticker.Stop()
- for range ticker.C {
+ // Helper function to check and send trial expiry notifications
+ checkTrialExpiry := func() {
trialPeriods, err := trialPeriodStorage.GetAllTrialPeriods()
if err != nil {
log.Printf("Error loading trial periods for expiry check: %v", err)
- continue
+ return
}
customers, err := customerStorage.GetAllCustomers()
if err != nil {
log.Printf("Error loading customers for trial check: %v", err)
- continue
+ return
}
// Create customer name map
@@ -109,6 +79,51 @@ func main() {
log.Printf("Error checking trial expiry: %v", err)
}
}
+
+ // Helper function to check if it's a workday (Monday-Friday)
+ isWorkday := func(t time.Time) bool {
+ weekday := t.Weekday()
+ return weekday >= time.Monday && weekday <= time.Friday
+ }
+
+ // Helper function to check if current time matches notification time (11:00 or 17:00)
+ isNotificationTime := func(t time.Time) bool {
+ hour := t.Hour()
+ minute := t.Minute()
+ // Check for 11:00 AM or 5:00 PM (17:00)
+ return (hour == 11 || hour == 17) && minute == 0
+ }
+
+ // Track last notification time to prevent duplicate sends
+ var lastNotificationTime time.Time
+
+ // Check immediately on startup (only on workdays)
+ now := time.Now()
+ if isWorkday(now) {
+ log.Println("Trial expiry checker: Running initial check on startup...")
+ checkTrialExpiry()
+ lastNotificationTime = now
+ }
+
+ // Check every minute for scheduled notification times
+ // Workdays: Monday-Friday, 11:00 AM and 5:00 PM
+ ticker := time.NewTicker(1 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ now := time.Now()
+
+ // Only send on workdays at 11:00 AM or 5:00 PM
+ if isWorkday(now) && isNotificationTime(now) {
+ // Ensure we don't send duplicate notifications for the same time slot
+ if lastNotificationTime.Hour() != now.Hour() ||
+ lastNotificationTime.Day() != now.Day() ||
+ lastNotificationTime.Month() != now.Month() {
+ log.Printf("Trial expiry checker: Sending scheduled notification at %s", now.Format("2006-01-02 15:04"))
+ checkTrialExpiry()
+ lastNotificationTime = now
+ }
+ }
+ }
}()
// Enable CORS manually
diff --git a/frontend/index.html b/frontend/index.html
index 456a4a9..bf062ce 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -358,7 +358,7 @@
@@ -873,6 +873,126 @@
+
+
+
+
+
+
+
diff --git a/frontend/js/main.js b/frontend/js/main.js
index b59930f..c69160c 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -1140,15 +1140,9 @@ document.addEventListener('DOMContentLoaded', function () {
}).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;
+ // 已成交数量将在 loadFollowUpCount 中更新
}
// Load follow-up count for dashboard
@@ -1160,12 +1154,23 @@ document.addEventListener('DOMContentLoaded', function () {
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';
}
}
@@ -1665,14 +1670,45 @@ document.addEventListener('DOMContentLoaded', function () {
let followupTotalPages = 1;
let followupTotalItems = 0;
- // Show/hide follow-up form
+ // 弹窗相关元素
+ 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 () {
- followupFormCard.style.display = 'block';
- await loadCustomerListForFollowup();
+ 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';
@@ -1680,19 +1716,22 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
- // Load customer list for follow-up dropdown from trial periods
- async function loadCustomerListForFollowup() {
+ // 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();
- followupCustomerNameSelect.innerHTML = '';
+ const selectElement = document.getElementById(selectId);
+ if (!selectElement) return;
+
+ selectElement.innerHTML = '';
if (data.customerNames && data.customerNames.length > 0) {
data.customerNames.forEach(customerName => {
const option = document.createElement('option');
option.value = customerName;
option.textContent = customerName;
- followupCustomerNameSelect.appendChild(option);
+ selectElement.appendChild(option);
});
}
} catch (error) {
@@ -1803,6 +1842,9 @@ document.addEventListener('DOMContentLoaded', function () {
${formattedTime} |
${notificationStatus} |
+
@@ -1812,6 +1854,14 @@ document.addEventListener('DOMContentLoaded', function () {
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
document.querySelectorAll('.delete-followup-btn').forEach(btn => {
btn.addEventListener('click', function () {
@@ -1844,6 +1894,128 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
+ // 添加跟进弹窗表单提交
+ 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();
+ alert('跟进记录创建成功!');
+ } 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();
+ alert('跟进记录更新成功!');
+ } 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;
diff --git a/frontend/js/trial-periods-page.js b/frontend/js/trial-periods-page.js
index 21c8873..7dc6eb9 100644
--- a/frontend/js/trial-periods-page.js
+++ b/frontend/js/trial-periods-page.js
@@ -296,11 +296,13 @@ function renderTrialPeriodsTable() {
// 直接使用 customerName 字段
const customerName = period.customerName || '未知客户';
- // 计算状态和到期天数
+ // 计算状态和到期天数(使用日期比较,忽略具体时间)
const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // 今天 00:00:00
const endDate = new Date(period.endTime);
+ const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); // 结束日期 00:00:00
const startDate = new Date(period.startTime);
- const daysUntilExpiry = Math.ceil((endDate - now) / (1000 * 60 * 60 * 24));
+ const daysUntilExpiry = Math.round((endDateOnly - today) / (1000 * 60 * 60 * 24));
const isTrial = (period.isTrial !== undefined && period.isTrial !== null) ? period.isTrial : false;
// 生成状态badge
@@ -308,7 +310,7 @@ function renderTrialPeriodsTable() {
if (!isTrial) {
statusBadge = ' 非试用';
} else if (daysUntilExpiry < 0) {
- statusBadge = ' 已过期';
+ statusBadge = ' 已到期';
} else if (daysUntilExpiry === 0) {
statusBadge = ' 今日到期';
} else if (daysUntilExpiry <= 3) {
|