feat:新增客户信息跟进状态字段&优化图形样式

This commit is contained in:
hangyu.tao 2026-02-05 12:00:16 +08:00
parent 0ab17d54eb
commit af125ea184
12 changed files with 439 additions and 41 deletions

View File

@ -50,7 +50,7 @@ func main() {
// Initialize handlers // Initialize handlers
customerHandler := handlers.NewCustomerHandler(customerStorage, feishuWebhook) customerHandler := handlers.NewCustomerHandler(customerStorage, feishuWebhook)
followUpHandler := handlers.NewFollowUpHandler(followUpStorage, customerStorage, feishuWebhook) followUpHandler := handlers.NewFollowUpHandler(followUpStorage, customerStorage, trialPeriodStorage, feishuWebhook)
trialPeriodHandler := handlers.NewTrialPeriodHandler(trialPeriodStorage, customerStorage, feishuWebhook) trialPeriodHandler := handlers.NewTrialPeriodHandler(trialPeriodStorage, customerStorage, feishuWebhook)
authHandler := handlers.NewAuthHandler() authHandler := handlers.NewAuthHandler()

View File

@ -1580,6 +1580,52 @@ td.overflow-cell {
border-color: #ffd591; border-color: #ffd591;
} }
/* Deal Status Badges */
.deal-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.deal-badge i {
font-size: 0.7rem;
}
.deal-prospect {
background-color: #f0f0f0;
color: #595959;
border: 1px solid #d9d9d9;
}
.deal-trial {
background-color: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
.deal-negotiation {
background-color: #fff7e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
.deal-closed-won {
background-color: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.deal-lost {
background-color: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.status-active { .status-active {
background-color: #d4edda; background-color: #d4edda;
color: #155724; color: #155724;

View File

@ -341,7 +341,8 @@
<th>客户名称</th> <th>客户名称</th>
<th>来源</th> <th>来源</th>
<th>意向产品</th> <th>意向产品</th>
<th>状态</th> <th>跟进状态</th>
<th>试用状态</th>
<th>开始时间</th> <th>开始时间</th>
<th>结束时间</th> <th>结束时间</th>
<th>创建时间</th> <th>创建时间</th>
@ -409,7 +410,7 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon"> <div class="stat-icon">
<i class="fas fa-check-circle"></i> <i class="fas fa-handshake"></i>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<h3 id="completedTasks">0</h3> <h3 id="completedTasks">0</h3>
@ -473,6 +474,25 @@
</div> </div>
</div> </div>
<div class="dashboard-grid" style="margin-top: 20px">
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-boxes"></i> 意向产品分布</h3>
</div>
<div class="card-body">
<canvas id="productChart"></canvas>
</div>
</div>
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-tasks"></i> 跟进状态分布</h3>
</div>
<div class="card-body">
<canvas id="followupStatusChart"></canvas>
</div>
</div>
</div>
<!-- Trend Line Chart --> <!-- Trend Line Chart -->
<div class="dashboard-grid" style="margin-top: 20px"> <div class="dashboard-grid" style="margin-top: 20px">
<div class="card chart-card" style="grid-column: 1 / -1"> <div class="card chart-card" style="grid-column: 1 / -1">
@ -1032,6 +1052,16 @@
<input type="text" id="trialIntendedProductOther" name="intendedProductOther" <input type="text" id="trialIntendedProductOther" name="intendedProductOther"
placeholder="请输入其他意向产品" style="display: none; margin-top: 8px" /> placeholder="请输入其他意向产品" style="display: none; margin-top: 8px" />
</div> </div>
<div class="form-group">
<label for="trialDealStatus">跟进状态</label>
<select id="trialDealStatus" name="dealStatus">
<option value="初步接触">初步接触</option>
<option value="需求确认">需求确认</option>
<option value="商务洽谈">商务洽谈</option>
<option value="已成交">已成交</option>
<option value="已流失">已流失</option>
</select>
</div>
<div class="form-group" id="trialIsTrialGroup"> <div class="form-group" id="trialIsTrialGroup">
<label>是否试用</label> <label>是否试用</label>
<div style="display: flex; gap: 20px; align-items: center"> <div style="display: flex; gap: 20px; align-items: center">
@ -1113,6 +1143,16 @@
<input type="text" id="editTrialIntendedProductOther" name="editIntendedProductOther" <input type="text" id="editTrialIntendedProductOther" name="editIntendedProductOther"
placeholder="请输入其他意向产品" style="display: none; margin-top: 8px" /> placeholder="请输入其他意向产品" style="display: none; margin-top: 8px" />
</div> </div>
<div class="form-group">
<label for="editTrialDealStatus">跟进状态</label>
<select id="editTrialDealStatus" name="dealStatus">
<option value="初步接触">初步接触</option>
<option value="需求确认">需求确认</option>
<option value="商务洽谈">商务洽谈</option>
<option value="已成交">已成交</option>
<option value="已流失">已流失</option>
</select>
</div>
<div class="form-group" id="editTrialStartTimeGroup"> <div class="form-group" id="editTrialStartTimeGroup">
<label for="editTrialStartTime">开始时间</label> <label for="editTrialStartTime">开始时间</label>
<input type="datetime-local" id="editTrialStartTime" name="startTime" /> <input type="datetime-local" id="editTrialStartTime" name="startTime" />
@ -1188,9 +1228,9 @@
</div> </div>
<!-- Scripts --> <!-- Scripts -->
<script src="/static/js/main.js?v=5.5"></script> <script src="/static/js/main.js?v=20260205005"></script>
<script src="/static/js/trial-periods.js?v=1.3"></script> <script src="/static/js/trial-periods.js?v=20260205001"></script>
<script src="/static/js/trial-periods-page.js?v=1.9"></script> <script src="/static/js/trial-periods-page.js?v=20260205001"></script>
</body> </body>
</html> </html>

View File

@ -101,6 +101,8 @@ document.addEventListener("DOMContentLoaded", function () {
let statusChartInstance = null; let statusChartInstance = null;
let typeChartInstance = null; let typeChartInstance = null;
let trendChartInstance = null; let trendChartInstance = null;
let productChartInstance = null;
let followupStatusChartInstance = null;
// Current section tracking // Current section tracking
let currentSection = "dashboard"; let currentSection = "dashboard";
@ -1360,6 +1362,9 @@ document.addEventListener("DOMContentLoaded", function () {
// Update total customers display (数据仪表盘的总客户数从每周进度获取) // Update total customers display (数据仪表盘的总客户数从每周进度获取)
// This function will be called after loading dashboard customers // This function will be called after loading dashboard customers
updateTotalCustomersWithTrialData(trialCustomerNames); updateTotalCustomersWithTrialData(trialCustomerNames);
// Render trial period analysis charts
renderTrialAnalysisCharts(trialPeriods);
} catch (error) { } catch (error) {
console.error("Error loading trial customers for dashboard:", error); console.error("Error loading trial customers for dashboard:", error);
} }
@ -1416,23 +1421,41 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.followUps) { if (data.followUps) {
const followUpCount = data.followUps.length; const followUpCount = data.followUps.length;
document.getElementById("followUpCount").textContent = followUpCount; 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";
}
// 已成交数量从客户信息表trial_periods统计
await loadCompletedDealsCount();
}
// Load completed deals count from trial periods
async function loadCompletedDealsCount() {
try {
const response = await authenticatedFetch(
"/api/trial-periods/all",
);
const data = await response.json();
if (data.trialPeriods) {
// 计算已成交数量:统计 dealStatus 为"已成交"的唯一客户数 // 计算已成交数量:统计 dealStatus 为"已成交"的唯一客户数
const completedCustomers = new Set( const completedCustomers = new Set(
data.followUps data.trialPeriods
.filter((f) => f.dealStatus === "已成交") .filter((tp) => tp.dealStatus === "已成交")
.map((f) => f.customerName) .map((tp) => tp.customerName)
.filter((name) => name), .filter((name) => name),
).size; ).size;
document.getElementById("completedTasks").textContent = document.getElementById("completedTasks").textContent =
completedCustomers; completedCustomers;
} else { } else {
document.getElementById("followUpCount").textContent = "0";
document.getElementById("completedTasks").textContent = "0"; document.getElementById("completedTasks").textContent = "0";
} }
} catch (error) { } catch (error) {
console.error("Error loading follow-up count:", error); console.error("Error loading completed deals count:", error);
document.getElementById("followUpCount").textContent = "0";
document.getElementById("completedTasks").textContent = "0"; document.getElementById("completedTasks").textContent = "0";
} }
} }
@ -1643,6 +1666,155 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
} }
// Render trial analysis charts (Intended Product & Follow-up Status)
function renderTrialAnalysisCharts(trialPeriods) {
renderProductChart(trialPeriods);
renderFollowupStatusChart(trialPeriods);
}
function renderProductChart(trialPeriods) {
const canvas = document.getElementById("productChart");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const productCount = {};
trialPeriods.forEach((tp) => {
if (!tp.intendedProduct) return;
// Split by comma if multiple products selected
const products = tp.intendedProduct.split(",").map((p) => p.trim());
products.forEach((p) => {
if (p) {
productCount[p] = (productCount[p] || 0) + 1;
}
});
});
const labels = Object.keys(productCount);
const data = Object.values(productCount);
if (productChartInstance) {
productChartInstance.destroy();
}
productChartInstance = new Chart(ctx, {
type: "bar",
data: {
labels: labels,
datasets: [
{
label: "产品数量",
data: data,
backgroundColor: "#3b82f6",
borderRadius: 6,
maxBarThickness: 40,
},
],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => `数量: ${context.parsed.y}`,
},
},
datalabels: {
anchor: "end",
align: "top",
color: "#666",
font: { weight: "bold" },
formatter: (value) => value,
},
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
},
},
},
plugins: [ChartDataLabels],
});
}
function renderFollowupStatusChart(trialPeriods) {
const canvas = document.getElementById("followupStatusChart");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const statusCount = {};
trialPeriods.forEach((tp) => {
let status = tp.dealStatus || "初步接触";
// 数据清洗:统一新旧名称
if (status === "潜在客户") status = "初步接触";
if (status === "试用中") status = "需求确认";
if (status === "") status = "初步接触";
statusCount[status] = (statusCount[status] || 0) + 1;
});
const labels = Object.keys(statusCount);
const data = Object.values(statusCount);
if (followupStatusChartInstance) {
followupStatusChartInstance.destroy();
}
const colors = {
"已成交": "#10b981", // Emerald 500
"商务洽谈": "#f59e0b", // Amber 500
"需求确认": "#6366f1", // Indigo 500
"初步接触": "#94a3b8", // Slate 400
"已流失": "#ef4444", // Red 500
"未设置": "#cbd5e1", // Slate 300
};
const backgroundColors = labels.map((l) => colors[l] || "#9E9E9E");
followupStatusChartInstance = new Chart(ctx, {
type: "doughnut",
data: {
labels: labels,
datasets: [
{
data: data,
backgroundColor: backgroundColors,
},
],
},
options: {
responsive: true,
plugins: {
legend: {
position: "right",
labels: {
generateLabels: (chart) => {
const data = chart.data;
return data.labels.map((label, i) => ({
text: `${label}: ${data.datasets[0].data[i]}`,
fillStyle: data.datasets[0].backgroundColor[i],
index: i,
}));
},
},
},
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: 10 },
},
},
},
plugins: [ChartDataLabels],
});
}
// Render trend line chart // Render trend line chart
function renderTrendChart(customers) { function renderTrendChart(customers) {
const canvas = document.getElementById("trendChart"); const canvas = document.getElementById("trendChart");

View File

@ -553,10 +553,26 @@ function renderTrialPeriodsTable() {
return `<span class="product-badge ${colorClass}">${p}</span>`; return `<span class="product-badge ${colorClass}">${p}</span>`;
}).join(''); }).join('');
// 生成跟进状态标签
const dealStatus = period.dealStatus || '初步接触';
let dealStatusBadge = '';
if (dealStatus === '已成交') {
dealStatusBadge = '<span class="deal-badge deal-closed-won"><i class="fas fa-trophy"></i> 已成交</span>';
} else if (dealStatus === '需求确认') {
dealStatusBadge = '<span class="deal-badge deal-trial"><i class="fas fa-clipboard-check"></i> 需求确认</span>';
} else if (dealStatus === '商务洽谈') {
dealStatusBadge = '<span class="deal-badge deal-negotiation"><i class="fas fa-handshake"></i> 商务洽谈</span>';
} else if (dealStatus === '已流失') {
dealStatusBadge = '<span class="deal-badge deal-lost"><i class="fas fa-times-circle"></i> 已流失</span>';
} else {
dealStatusBadge = '<span class="deal-badge deal-prospect"><i class="fas fa-user-clock"></i> 初步接触</span>';
}
row.innerHTML = ` row.innerHTML = `
<td><strong>${customerName}</strong></td> <td><strong>${customerName}</strong></td>
<td>${source}</td> <td>${source}</td>
<td>${productBadges}</td> <td>${productBadges}</td>
<td>${dealStatusBadge}</td>
<td>${statusCell}</td> <td>${statusCell}</td>
<td>${startTimeCell}</td> <td>${startTimeCell}</td>
<td>${endTimeCell}</td> <td>${endTimeCell}</td>
@ -641,6 +657,10 @@ function openAddTrialModal() {
// Reset intended product // Reset intended product
setIntendedProductValue('trialIntendedProduct', 'trialIntendedProductOther', ''); setIntendedProductValue('trialIntendedProduct', 'trialIntendedProductOther', '');
// Reset deal status
const dealStatusEl = document.getElementById('trialDealStatus');
if (dealStatusEl) dealStatusEl.value = '初步接触';
document.querySelector('input[name="isTrial"][value="true"]').checked = true; document.querySelector('input[name="isTrial"][value="true"]').checked = true;
document.getElementById('trialStartTime').value = ''; document.getElementById('trialStartTime').value = '';
document.getElementById('trialEndTime').value = ''; document.getElementById('trialEndTime').value = '';
@ -661,6 +681,10 @@ function openEditTrialModal(periodId) {
// Set intended product // Set intended product
setIntendedProductValue('editTrialIntendedProduct', 'editTrialIntendedProductOther', period.intendedProduct || ''); setIntendedProductValue('editTrialIntendedProduct', 'editTrialIntendedProductOther', period.intendedProduct || '');
// Set deal status
const dealStatusEl = document.getElementById('editTrialDealStatus');
if (dealStatusEl) dealStatusEl.value = period.dealStatus || '初步接触';
const startDate = new Date(period.startTime); const startDate = new Date(period.startTime);
const endDate = new Date(period.endTime); const endDate = new Date(period.endTime);
@ -740,6 +764,7 @@ async function createTrialPeriodFromPage() {
const isTrial = isTrialValue === 'true'; const isTrial = isTrialValue === 'true';
const startTime = document.getElementById('trialStartTime').value; const startTime = document.getElementById('trialStartTime').value;
const endTime = document.getElementById('trialEndTime').value; const endTime = document.getElementById('trialEndTime').value;
const dealStatus = document.getElementById('trialDealStatus').value;
if (!customerName) { if (!customerName) {
alert('请选择或输入客户名称'); alert('请选择或输入客户名称');
@ -758,6 +783,7 @@ async function createTrialPeriodFromPage() {
customerName: customerName, customerName: customerName,
source: source, source: source,
intendedProduct: intendedProduct, intendedProduct: intendedProduct,
dealStatus: dealStatus,
startTime: startTime ? new Date(startTime).toISOString() : '', startTime: startTime ? new Date(startTime).toISOString() : '',
endTime: endTime ? new Date(endTime).toISOString() : '', endTime: endTime ? new Date(endTime).toISOString() : '',
isTrial: isTrial isTrial: isTrial
@ -777,11 +803,12 @@ async function createTrialPeriodFromPage() {
document.getElementById('addTrialPeriodForm').reset(); document.getElementById('addTrialPeriodForm').reset();
await loadAllTrialPeriods(); await loadAllTrialPeriods();
} else { } else {
alert('添加试用时间时出错'); const errorText = await response.text();
alert(errorText || '添加试用时间时出错');
} }
} catch (error) { } catch (error) {
console.error('Error creating trial period:', error); console.error('Error creating trial period:', error);
alert('添加试用时间时出错'); alert('网络错误,请稍后再试');
} }
} }

View File

@ -187,13 +187,14 @@ async function createTrialPeriod() {
if (response.ok) { if (response.ok) {
document.getElementById('addTrialPeriodModal').style.display = 'none'; document.getElementById('addTrialPeriodModal').style.display = 'none';
document.getElementById('addTrialPeriodForm').reset(); document.getElementById('addTrialPeriodForm').reset();
await loadTrialPeriods(customerId); loadTrialPeriods(currentCustomerId); // Reload trial periods for the current customer
} else { } else {
alert('添加试用时间时出错'); const errorText = await response.text();
alert(errorText || '创建试用期时出错');
} }
} catch (error) { } catch (error) {
console.error('Error creating trial period:', error); console.error('Error creating trial period:', error);
alert('添加试用时间时出错'); alert('网络错误,请稍后再试');
} }
} }
@ -260,7 +261,13 @@ async function updateTrialPeriod() {
const isTrialRadio = document.querySelector('input[name="editIsTrial"]:checked'); const isTrialRadio = document.querySelector('input[name="editIsTrial"]:checked');
const isTrial = isTrialRadio ? isTrialRadio.value === 'true' : true; const isTrial = isTrialRadio ? isTrialRadio.value === 'true' : true;
if (!startTime || !endTime) { // Get deal status value
const dealStatusEl = document.getElementById('editTrialDealStatus');
const dealStatus = dealStatusEl ? dealStatusEl.value : '初步接触';
// Only require start/end time if "数据闭环" is selected
const requiresTimeFields = intendedProduct.includes('数据闭环');
if (requiresTimeFields && (!startTime || !endTime)) {
alert('请填写开始时间和结束时间'); alert('请填写开始时间和结束时间');
return; return;
} }
@ -268,11 +275,18 @@ async function updateTrialPeriod() {
const formData = { const formData = {
source: source, source: source,
intendedProduct: intendedProduct, intendedProduct: intendedProduct,
startTime: new Date(startTime).toISOString(), dealStatus: dealStatus,
endTime: new Date(endTime).toISOString(),
isTrial: isTrial isTrial: isTrial
}; };
// Only include time fields if they have values
if (startTime) {
formData.startTime = new Date(startTime).toISOString();
}
if (endTime) {
formData.endTime = new Date(endTime).toISOString();
}
try { try {
const response = await authenticatedFetch(`/api/trial-periods/${periodId}`, { const response = await authenticatedFetch(`/api/trial-periods/${periodId}`, {
method: 'PUT', method: 'PUT',

View File

@ -14,16 +14,18 @@ import (
) )
type FollowUpHandler struct { type FollowUpHandler struct {
storage storage.FollowUpStorage storage storage.FollowUpStorage
customerStorage storage.CustomerStorage customerStorage storage.CustomerStorage
feishuWebhook string trialPeriodStorage storage.TrialPeriodStorage
feishuWebhook string
} }
func NewFollowUpHandler(storage storage.FollowUpStorage, customerStorage storage.CustomerStorage, feishuWebhook string) *FollowUpHandler { func NewFollowUpHandler(storage storage.FollowUpStorage, customerStorage storage.CustomerStorage, trialPeriodStorage storage.TrialPeriodStorage, feishuWebhook string) *FollowUpHandler {
return &FollowUpHandler{ return &FollowUpHandler{
storage: storage, storage: storage,
customerStorage: customerStorage, customerStorage: customerStorage,
feishuWebhook: feishuWebhook, trialPeriodStorage: trialPeriodStorage,
feishuWebhook: feishuWebhook,
} }
} }
@ -134,6 +136,11 @@ func (h *FollowUpHandler) CreateFollowUp(w http.ResponseWriter, r *http.Request)
return return
} }
// 联动更新: 当跟进记录状态为"已成交"时,自动更新客户信息表的成交状态
if req.DealStatus == "已成交" {
h.updateCustomerDealStatus(req.CustomerName, "已成交")
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(followUp) json.NewEncoder(w).Encode(followUp)
@ -160,11 +167,32 @@ func (h *FollowUpHandler) UpdateFollowUp(w http.ResponseWriter, r *http.Request)
return return
} }
// 先获取原始跟进记录,用于获取客户名称
existingFollowUp, err := h.storage.GetFollowUpByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existingFollowUp == nil {
http.Error(w, "Follow-up not found", http.StatusNotFound)
return
}
if err := h.storage.UpdateFollowUp(id, req); err != nil { if err := h.storage.UpdateFollowUp(id, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// 联动更新: 当跟进记录状态更新为"已成交"时,自动更新客户信息表的成交状态
if req.DealStatus != nil && *req.DealStatus == "已成交" {
// 优先使用请求中的客户名称,如果没有则使用原记录的客户名称
customerName := existingFollowUp.CustomerName
if req.CustomerName != nil {
customerName = *req.CustomerName
}
h.updateCustomerDealStatus(customerName, "已成交")
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@ -303,3 +331,26 @@ func (h *FollowUpHandler) GetCustomerList(w http.ResponseWriter, r *http.Request
"customerMap": customerMap, // Complete ID->Name mapping for display "customerMap": customerMap, // Complete ID->Name mapping for display
}) })
} }
// updateCustomerDealStatus 联动更新客户信息表的成交状态
func (h *FollowUpHandler) updateCustomerDealStatus(customerName string, dealStatus string) {
if h.trialPeriodStorage == nil {
return
}
// 获取所有试用记录,找到匹配客户名称的记录
trialPeriods, err := h.trialPeriodStorage.GetAllTrialPeriods()
if err != nil {
return
}
// 更新所有匹配客户名称的记录的成交状态
for _, period := range trialPeriods {
if period.CustomerName == customerName {
updateReq := models.UpdateTrialPeriodRequest{
DealStatus: &dealStatus,
}
h.trialPeriodStorage.UpdateTrialPeriod(period.ID, updateReq)
}
}
}

View File

@ -93,12 +93,20 @@ func (h *TrialPeriodHandler) CreateTrialPeriod(w http.ResponseWriter, r *http.Re
CustomerName: req.CustomerName, CustomerName: req.CustomerName,
Source: req.Source, Source: req.Source,
IntendedProduct: req.IntendedProduct, IntendedProduct: req.IntendedProduct,
DealStatus: req.DealStatus,
StartTime: startTime, StartTime: startTime,
EndTime: endTime, EndTime: endTime,
IsTrial: req.IsTrial, IsTrial: req.IsTrial,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
// Check if customer name already exists
existing, err := h.storage.GetTrialPeriodsByCustomerID(req.CustomerName)
if err == nil && len(existing) > 0 {
http.Error(w, fmt.Sprintf("客户名称 '%s' 已存在,请勿重复添加", req.CustomerName), http.StatusConflict)
return
}
createdPeriod, err := h.storage.CreateTrialPeriod(trialPeriod) createdPeriod, err := h.storage.CreateTrialPeriod(trialPeriod)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -147,6 +147,31 @@ func autoMigrate() error {
log.Printf("✅ Modified screenshots column type from %s to LONGTEXT\n", columnType.String) log.Printf("✅ Modified screenshots column type from %s to LONGTEXT\n", columnType.String)
} }
// 检查并添加 deal_status 列到 trial_periods 表
var dealStatusColumn sql.NullString
err = db.QueryRow(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'trial_periods'
AND COLUMN_NAME = 'deal_status'
`).Scan(&dealStatusColumn)
if err == sql.ErrNoRows {
// deal_status 列不存在,添加新列
_, err = db.Exec("ALTER TABLE trial_periods ADD COLUMN deal_status VARCHAR(50) DEFAULT '初步接触'")
if err != nil {
return fmt.Errorf("failed to add deal_status column: %v", err)
}
log.Println("✅ Added deal_status column to trial_periods table")
} else if err != nil {
return fmt.Errorf("failed to check deal_status column: %v", err)
}
// 数据清理:将旧的状态名称统一更新为新名称
_, _ = db.Exec("UPDATE trial_periods SET deal_status = '初步接触' WHERE deal_status = '潜在客户' OR deal_status = '' OR deal_status IS NULL")
_, _ = db.Exec("UPDATE trial_periods SET deal_status = '需求确认' WHERE deal_status = '试用中'")
log.Println("✅ Database tables migrated successfully") log.Println("✅ Database tables migrated successfully")
return nil return nil
} }

View File

@ -23,7 +23,8 @@ func NewMySQLTrialPeriodStorage() TrialPeriodStorage {
func (ts *mysqlTrialPeriodStorage) GetAllTrialPeriods() ([]models.TrialPeriod, error) { func (ts *mysqlTrialPeriodStorage) GetAllTrialPeriods() ([]models.TrialPeriod, error) {
query := ` query := `
SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product, start_time, end_time, is_trial, created_at SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product,
COALESCE(deal_status, '') as deal_status, start_time, end_time, is_trial, created_at
FROM trial_periods FROM trial_periods
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@ -40,8 +41,8 @@ func (ts *mysqlTrialPeriodStorage) GetAllTrialPeriods() ([]models.TrialPeriod, e
var isTrial int var isTrial int
err := rows.Scan( err := rows.Scan(
&tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.StartTime, &tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.DealStatus,
&tp.EndTime, &isTrial, &tp.CreatedAt, &tp.StartTime, &tp.EndTime, &isTrial, &tp.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -56,7 +57,8 @@ func (ts *mysqlTrialPeriodStorage) GetAllTrialPeriods() ([]models.TrialPeriod, e
func (ts *mysqlTrialPeriodStorage) GetTrialPeriodsByCustomerID(customerID string) ([]models.TrialPeriod, error) { func (ts *mysqlTrialPeriodStorage) GetTrialPeriodsByCustomerID(customerID string) ([]models.TrialPeriod, error) {
query := ` query := `
SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product, start_time, end_time, is_trial, created_at SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product,
COALESCE(deal_status, '') as deal_status, start_time, end_time, is_trial, created_at
FROM trial_periods FROM trial_periods
WHERE customer_name = ? WHERE customer_name = ?
ORDER BY end_time DESC ORDER BY end_time DESC
@ -74,8 +76,8 @@ func (ts *mysqlTrialPeriodStorage) GetTrialPeriodsByCustomerID(customerID string
var isTrial int var isTrial int
err := rows.Scan( err := rows.Scan(
&tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.StartTime, &tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.DealStatus,
&tp.EndTime, &isTrial, &tp.CreatedAt, &tp.StartTime, &tp.EndTime, &isTrial, &tp.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -90,7 +92,8 @@ func (ts *mysqlTrialPeriodStorage) GetTrialPeriodsByCustomerID(customerID string
func (ts *mysqlTrialPeriodStorage) GetTrialPeriodByID(id string) (*models.TrialPeriod, error) { func (ts *mysqlTrialPeriodStorage) GetTrialPeriodByID(id string) (*models.TrialPeriod, error) {
query := ` query := `
SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product, start_time, end_time, is_trial, created_at SELECT id, customer_name, COALESCE(source, '') as source, COALESCE(intended_product, '') as intended_product,
COALESCE(deal_status, '') as deal_status, start_time, end_time, is_trial, created_at
FROM trial_periods FROM trial_periods
WHERE id = ? WHERE id = ?
` `
@ -99,8 +102,8 @@ func (ts *mysqlTrialPeriodStorage) GetTrialPeriodByID(id string) (*models.TrialP
var isTrial int var isTrial int
err := ts.db.QueryRow(query, id).Scan( err := ts.db.QueryRow(query, id).Scan(
&tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.StartTime, &tp.ID, &tp.CustomerName, &tp.Source, &tp.IntendedProduct, &tp.DealStatus,
&tp.EndTime, &isTrial, &tp.CreatedAt, &tp.StartTime, &tp.EndTime, &isTrial, &tp.CreatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -123,8 +126,8 @@ func (ts *mysqlTrialPeriodStorage) CreateTrialPeriod(trialPeriod models.TrialPer
} }
query := ` query := `
INSERT INTO trial_periods (id, customer_name, source, intended_product, start_time, end_time, is_trial, created_at) INSERT INTO trial_periods (id, customer_name, source, intended_product, deal_status, start_time, end_time, is_trial, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
isTrial := 0 isTrial := 0
@ -134,7 +137,7 @@ func (ts *mysqlTrialPeriodStorage) CreateTrialPeriod(trialPeriod models.TrialPer
_, err := ts.db.Exec(query, _, err := ts.db.Exec(query,
trialPeriod.ID, trialPeriod.CustomerName, trialPeriod.Source, trialPeriod.IntendedProduct, trialPeriod.ID, trialPeriod.CustomerName, trialPeriod.Source, trialPeriod.IntendedProduct,
trialPeriod.StartTime, trialPeriod.EndTime, isTrial, trialPeriod.CreatedAt, trialPeriod.DealStatus, trialPeriod.StartTime, trialPeriod.EndTime, isTrial, trialPeriod.CreatedAt,
) )
if err != nil { if err != nil {
@ -161,6 +164,9 @@ func (ts *mysqlTrialPeriodStorage) UpdateTrialPeriod(id string, updates models.U
if updates.IntendedProduct != nil { if updates.IntendedProduct != nil {
existing.IntendedProduct = *updates.IntendedProduct existing.IntendedProduct = *updates.IntendedProduct
} }
if updates.DealStatus != nil {
existing.DealStatus = *updates.DealStatus
}
if updates.StartTime != nil { if updates.StartTime != nil {
startTime, err := time.Parse(time.RFC3339, *updates.StartTime) startTime, err := time.Parse(time.RFC3339, *updates.StartTime)
if err == nil { if err == nil {
@ -179,7 +185,7 @@ func (ts *mysqlTrialPeriodStorage) UpdateTrialPeriod(id string, updates models.U
query := ` query := `
UPDATE trial_periods UPDATE trial_periods
SET customer_name = ?, source = ?, intended_product = ?, start_time = ?, end_time = ?, is_trial = ? SET customer_name = ?, source = ?, intended_product = ?, deal_status = ?, start_time = ?, end_time = ?, is_trial = ?
WHERE id = ? WHERE id = ?
` `
@ -189,7 +195,7 @@ func (ts *mysqlTrialPeriodStorage) UpdateTrialPeriod(id string, updates models.U
} }
_, err = ts.db.Exec(query, _, err = ts.db.Exec(query,
existing.CustomerName, existing.Source, existing.IntendedProduct, existing.CustomerName, existing.Source, existing.IntendedProduct, existing.DealStatus,
existing.StartTime, existing.EndTime, isTrial, id, existing.StartTime, existing.EndTime, isTrial, id,
) )

View File

@ -139,6 +139,12 @@ func (ts *trialPeriodStorage) UpdateTrialPeriod(id string, updates models.Update
if updates.Source != nil { if updates.Source != nil {
trialPeriods[i].Source = *updates.Source trialPeriods[i].Source = *updates.Source
} }
if updates.IntendedProduct != nil {
trialPeriods[i].IntendedProduct = *updates.IntendedProduct
}
if updates.DealStatus != nil {
trialPeriods[i].DealStatus = *updates.DealStatus
}
if updates.StartTime != nil { if updates.StartTime != nil {
startTime, err := time.Parse(time.RFC3339, *updates.StartTime) startTime, err := time.Parse(time.RFC3339, *updates.StartTime)
if err == nil { if err == nil {

View File

@ -8,6 +8,7 @@ type TrialPeriod struct {
CustomerName string `json:"customerName"` // 直接存储客户名称 CustomerName string `json:"customerName"` // 直接存储客户名称
Source string `json:"source"` // 客户来源 Source string `json:"source"` // 客户来源
IntendedProduct string `json:"intendedProduct"` // 意向产品 IntendedProduct string `json:"intendedProduct"` // 意向产品
DealStatus string `json:"dealStatus"` // 成交状态
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"` EndTime time.Time `json:"endTime"`
IsTrial bool `json:"isTrial"` IsTrial bool `json:"isTrial"`
@ -19,6 +20,7 @@ type CreateTrialPeriodRequest struct {
CustomerName string `json:"customerName"` // 直接使用客户名称 CustomerName string `json:"customerName"` // 直接使用客户名称
Source string `json:"source"` // 客户来源 Source string `json:"source"` // 客户来源
IntendedProduct string `json:"intendedProduct"` // 意向产品 IntendedProduct string `json:"intendedProduct"` // 意向产品
DealStatus string `json:"dealStatus"` // 成交状态
StartTime string `json:"startTime"` StartTime string `json:"startTime"`
EndTime string `json:"endTime"` EndTime string `json:"endTime"`
IsTrial bool `json:"isTrial"` IsTrial bool `json:"isTrial"`
@ -29,6 +31,7 @@ type UpdateTrialPeriodRequest struct {
CustomerName *string `json:"customerName,omitempty"` CustomerName *string `json:"customerName,omitempty"`
Source *string `json:"source,omitempty"` // 客户来源 Source *string `json:"source,omitempty"` // 客户来源
IntendedProduct *string `json:"intendedProduct,omitempty"` // 意向产品 IntendedProduct *string `json:"intendedProduct,omitempty"` // 意向产品
DealStatus *string `json:"dealStatus,omitempty"` // 成交状态
StartTime *string `json:"startTime,omitempty"` StartTime *string `json:"startTime,omitempty"`
EndTime *string `json:"endTime,omitempty"` EndTime *string `json:"endTime,omitempty"`
IsTrial *bool `json:"isTrial,omitempty"` IsTrial *bool `json:"isTrial,omitempty"`