feat: 添加客户截图上传与展示功能,包括后端文件处理和前端界面集成。

This commit is contained in:
zulifeng 2026-01-29 19:26:35 +08:00
parent 3de6cfd48d
commit 9250803979
15 changed files with 725 additions and 73 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
vendor/
# Go workspace file
go.work
# IDE specific files
.idea
.vscode
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
# Build
bin/
dist/
# Environment variables
/apps/**/.env

View File

@ -183,6 +183,14 @@ func main() {
}
})
http.HandleFunc("/api/upload", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
customerHandler.UploadScreenshots(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/customers/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@ -340,6 +348,9 @@ func main() {
// Serve static files
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./frontend"))))
// Serve uploaded files
http.Handle("/static/uploads/", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("./frontend/uploads"))))
// Serve index page
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join("./frontend", "index.html"))

View File

@ -603,6 +603,24 @@ body {
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
/* Select placeholder styling - Simple and direct approach */
select {
color: var(--dark-gray);
}
select option {
color: var(--dark-gray);
}
select option[disabled] {
color: #999;
}
/* This will be controlled by JavaScript for placeholder state */
select.placeholder-active {
color: #999 !important;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
@ -716,6 +734,7 @@ th {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
vertical-align: middle;
}
td {
@ -728,6 +747,12 @@ td {
text-overflow: ellipsis;
cursor: pointer;
position: relative;
vertical-align: middle;
height: inherit; /* Ensure td takes full height of tr */
}
#customerTable td {
display: table-cell; /* Force correct display mode */
}
tr:hover td {
@ -2258,35 +2283,20 @@ tr:hover .action-cell {
/* Font Awesome check icon */
}
/* Table refresh animation */
@keyframes tableRefresh {
0% {
opacity: 0.3;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
/* Table refresh animation simplified */
.table-refreshing {
animation: tableRefresh 0.5s ease-out;
opacity: 0.8;
transition: opacity 0.3s ease;
}
/* Row highlight on refresh */
/* Row highlight on refresh - removed transform */
@keyframes rowHighlight {
0% {
background-color: rgba(255, 107, 53, 0.2);
transform: translateX(-5px);
background-color: rgba(255, 107, 53, 0.1);
}
100% {
background-color: transparent;
transform: translateX(0);
}
}
@ -2699,4 +2709,174 @@ tr:hover .action-cell {
gap: 8px;
min-width: auto;
}
}
/* Screenshot Preview & List Styles */
.screenshot-wrapper {
display: flex;
gap: 5px;
align-items: center;
max-width: 100%;
overflow-x: auto;
padding: 4px 0;
scrollbar-width: thin;
min-height: 24px;
}
.screenshot-column-cell {
min-width: 120px;
}
.screenshot-wrapper::-webkit-scrollbar {
height: 4px;
}
.screenshot-wrapper::-webkit-scrollbar-thumb {
background: rgba(255, 107, 53, 0.3);
border-radius: 10px;
}
.screenshot-thumbnail {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
cursor: pointer;
border: 1px solid var(--border-color);
transition: transform 0.2s ease;
}
.screenshot-thumbnail:hover {
transform: scale(1.1);
border-color: var(--primary-orange);
}
.no-screenshots {
color: #ccc;
font-size: 0.8rem;
}
/* Upload Container */
.screenshot-upload-container {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: #fafafa;
}
.screenshot-preview-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
min-height: 50px;
}
.preview-item {
position: relative;
width: 80px;
height: 80px;
}
.preview-item img {
width: 100%;
height: 100%;
border-radius: 6px;
object-fit: cover;
border: 1px solid var(--border-color);
}
.remove-preview {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.screenshot-upload-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: var(--white);
border: 1px dashed var(--primary-orange);
border-radius: 6px;
color: var(--primary-orange);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
width: fit-content;
}
.screenshot-upload-btn:hover {
background-color: rgba(255, 107, 53, 0.05);
transform: translateY(-1px);
}
/* Lightbox / Image Viewer */
.image-viewer-modal {
position: fixed;
z-index: 5000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
}
.image-viewer-content {
max-width: 90%;
max-height: 80%;
border-radius: 4px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
.image-viewer-close {
position: absolute;
top: 20px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
cursor: pointer;
transition: 0.3s;
}
.image-viewer-close:hover {
color: var(--primary-orange);
}
#imageViewerCaption {
margin: 15px;
color: #ccc;
font-size: 1rem;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes zoomIn {
from { transform: scale(0.9); }
to { transform: scale(1); }
}

View File

@ -222,6 +222,7 @@
<th>类型</th>
<th>模块</th>
<th>状态与进度</th>
<th>截图</th>
<th>操作</th>
</tr>
</thead>
@ -529,13 +530,13 @@
<div class="form-group">
<label for="followupCustomerName">客户名称 <span style="color: red">*</span></label>
<select id="followupCustomerName" name="customerName" required>
<option value="">请选择客户</option>
<option value="" disabled selected hidden>请选择客户</option>
</select>
</div>
<div class="form-group">
<label for="followupDealStatus">成交状态 <span style="color: red">*</span></label>
<select id="followupDealStatus" name="dealStatus" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="未成交">未成交</option>
<option value="已成交">已成交</option>
</select>
@ -546,7 +547,7 @@
<label for="followupCustomerLevel">客户级别 <span
style="color: red">*</span></label>
<select id="followupCustomerLevel" name="customerLevel" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="A (重点关注)">A (重点关注)</option>
<option value="B (很有希望)">B (很有希望)</option>
<option value="C (常规意向)">C (常规意向)</option>
@ -640,7 +641,7 @@
<div class="form-group">
<label for="createCustomerName">客户名称 <span style="color: red">*</span></label>
<select id="createCustomerName" name="customerName" required>
<option value="">请选择客户</option>
<option value="" disabled selected hidden>请选择客户</option>
</select>
</div>
<div class="form-group">
@ -656,10 +657,11 @@
<div class="form-group">
<label for="createType">类型 <span style="color: red">*</span></label>
<select id="createType" name="type" required>
<option value="">请选择类型</option>
<option value="私有化">私有化</option>
<option value="公有云">公有云</option>
<option value="其他">其他</option>
<option value="" disabled selected hidden>请选择类型</option>
<option value="反馈">反馈</option>
<option value="需求">需求</option>
<option value="咨询">咨询</option>
<option value="功能问题">功能问题</option>
</select>
</div>
</div>
@ -667,7 +669,7 @@
<div class="form-group">
<label for="createModule">模块</label>
<select id="createModule" name="module">
<option value="">请选择模块</option>
<option value="" disabled selected hidden>请选择模块</option>
<option value="数据生成">数据生成</option>
<option value="数据集">数据集</option>
<option value="数据空间">数据空间</option>
@ -682,7 +684,7 @@
<div class="form-group">
<label for="createStatusProgress">状态与进度 <span style="color: red">*</span></label>
<select id="createStatusProgress" name="statusProgress" required>
<option value="">请选择状态</option>
<option value="" disabled selected hidden>请选择状态</option>
<option value="已完成">已完成</option>
<option value="进行中">进行中</option>
<option value="待排期">待排期</option>
@ -699,6 +701,17 @@
<label for="createSolution">解决方案</label>
<textarea id="createSolution" name="solution" rows="3" placeholder="请输入拟采取的解决方案..."></textarea>
</div>
<div class="form-group">
<label>截图 (支持多张)</label>
<div class="screenshot-upload-container">
<div id="createScreenshotPreview" class="screenshot-preview-list"></div>
<label class="screenshot-upload-btn">
<i class="fas fa-camera"></i>
<span>上传截图</span>
<input type="file" id="createScreenshots" accept="image/*" multiple hidden />
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i>
@ -742,9 +755,11 @@
<div class="form-group">
<label for="editType">类型 <span style="color: red">*</span></label>
<select id="editType" name="type" required>
<option value="">请选择类型</option>
<option value="私有化">私有化</option>
<option value="公有云">公有云</option>
<option value="" disabled selected hidden>请选择类型</option>
<option value="反馈">反馈</option>
<option value="需求">需求</option>
<option value="咨询">咨询</option>
<option value="功能问题">功能问题</option>
<option value="其他">其他</option>
</select>
</div>
@ -753,7 +768,7 @@
<div class="form-group">
<label for="editModule">模块</label>
<select id="editModule" name="module">
<option value="">请选择模块</option>
<option value="" disabled selected hidden>请选择模块</option>
<option value="数据生成">数据生成</option>
<option value="数据集">数据集</option>
<option value="数据空间">数据空间</option>
@ -768,7 +783,7 @@
<div class="form-group">
<label for="editStatusProgress">状态与进度 <span style="color: red">*</span></label>
<select id="editStatusProgress" name="statusProgress" required>
<option value="">请选择状态</option>
<option value="" disabled selected hidden>请选择状态</option>
<option value="已完成">已完成</option>
<option value="进行中">进行中</option>
<option value="待排期">待排期</option>
@ -785,6 +800,17 @@
<label for="editSolution">解决方案</label>
<textarea id="editSolution" name="solution" rows="3"></textarea>
</div>
<div class="form-group">
<label>截图 (支持多张)</label>
<div class="screenshot-upload-container">
<div id="editScreenshotPreview" class="screenshot-preview-list"></div>
<label class="screenshot-upload-btn">
<i class="fas fa-camera"></i>
<span>上传截图</span>
<input type="file" id="editScreenshots" accept="image/*" multiple hidden />
</label>
</div>
</div>
<!-- Trial period details (Read-only reference) -->
<div class="trial-details-section" style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #eee;">
@ -829,13 +855,13 @@
<div class="form-group">
<label for="addFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
<select id="addFollowupCustomerName" name="customerName" required>
<option value="">请选择客户</option>
<option value="" disabled selected hidden>请选择客户</option>
</select>
</div>
<div class="form-group">
<label for="addFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
<select id="addFollowupDealStatus" name="dealStatus" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="已成交">已成交</option>
<option value="未成交">未成交</option>
</select>
@ -845,7 +871,7 @@
<div class="form-group">
<label for="addFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
<select id="addFollowupCustomerLevel" name="customerLevel" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="A">A级 (重点客户)</option>
<option value="B">B级 (潜在客户)</option>
<option value="C">C级 (一般客户)</option>
@ -888,13 +914,13 @@
<div class="form-group">
<label for="editFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
<select id="editFollowupCustomerName" name="customerName" required>
<option value="">请选择客户</option>
<option value="" disabled selected hidden>请选择客户</option>
</select>
</div>
<div class="form-group">
<label for="editFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
<select id="editFollowupDealStatus" name="dealStatus" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="已成交">已成交</option>
<option value="未成交">未成交</option>
</select>
@ -904,7 +930,7 @@
<div class="form-group">
<label for="editFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
<select id="editFollowupCustomerLevel" name="customerLevel" required>
<option value="">请选择</option>
<option value="" disabled selected hidden>请选择</option>
<option value="A">A级 (重点客户)</option>
<option value="B">B级 (潜在客户)</option>
<option value="C">C级 (一般客户)</option>
@ -1154,6 +1180,12 @@
</form>
</div>
</div>
<!-- Image Viewer Modal (Lightbox) -->
<div id="imageViewerModal" class="image-viewer-modal" style="display: none">
<span class="image-viewer-close">&times;</span>
<img class="image-viewer-content" id="fullImage" />
<div id="imageViewerCaption"></div>
</div>
<!-- Scripts -->
<script src="/static/js/main.js?v=2.6"></script>

View File

@ -116,6 +116,8 @@ document.addEventListener("DOMContentLoaded", function () {
let customerStartDate = "";
let customerEndDate = "";
let customerSearchQuery = "";
let createUploadedScreenshots = [];
let editUploadedScreenshots = [];
let cellTooltipEl = null;
let tooltipAnchorCell = null;
@ -470,7 +472,7 @@ document.addEventListener("DOMContentLoaded", function () {
const data = await response.json();
const customerSelect = document.getElementById("createCustomerName");
customerSelect.innerHTML = '<option value="">请选择客户</option>';
customerSelect.innerHTML = '<option value="" disabled selected hidden>请选择客户</option>';
if (data.customerNames && data.customerNames.length > 0) {
data.customerNames.forEach((customerName) => {
@ -630,6 +632,21 @@ document.addEventListener("DOMContentLoaded", function () {
if (window.innerWidth <= 768) {
sidebar.classList.remove("open");
}
// Re-trigger animate-up animation for visible cards
setTimeout(() => {
const activeSection = document.querySelector('.content-section.active');
if (activeSection) {
const animatedCards = activeSection.querySelectorAll('.animate-up');
animatedCards.forEach(card => {
// Remove and re-add animation class to trigger animation
card.style.animation = 'none';
// Force reflow
card.offsetHeight;
card.style.animation = '';
});
}
}, 10);
}
// Load customers from API
@ -962,6 +979,7 @@ document.addEventListener("DOMContentLoaded", function () {
{ value: customer.type || "", name: "type" },
{ value: customer.module || "", name: "module" },
{ value: customer.statusProgress || "", name: "statusProgress" },
{ value: customer.screenshots || [], name: "screenshots" },
];
fields.forEach((field) => {
@ -973,6 +991,9 @@ document.addEventListener("DOMContentLoaded", function () {
td.innerHTML = getStatusBadge(textValue);
} else if (field.name === "customerName") {
td.innerHTML = `<strong>${textValue}</strong>`;
} else if (field.name === "screenshots") {
td.classList.add("screenshot-column-cell");
renderScreenshotsInCell(td, field.value);
} else {
td.textContent = textValue;
}
@ -1010,6 +1031,7 @@ document.addEventListener("DOMContentLoaded", function () {
});
checkTextOverflow();
initImageViewer();
document.querySelectorAll(".edit-btn").forEach((btn) => {
btn.addEventListener("click", function () {
@ -1095,6 +1117,7 @@ document.addEventListener("DOMContentLoaded", function () {
module: getModuleValue("createModule", "createModuleOther"),
statusProgress: document.getElementById("createStatusProgress").value,
reporter: "", // Trial periods managed separately
screenshots: createUploadedScreenshots,
};
try {
@ -1108,6 +1131,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (response.ok) {
createCustomerForm.reset();
createUploadedScreenshots = [];
renderScreenshotPreviews("create", []);
// Hide module "other" input
const createModuleOther = document.getElementById("createModuleOther");
if (createModuleOther) createModuleOther.style.display = "none";
@ -1180,6 +1205,10 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("editStatusProgress").value =
customer.statusProgress || "";
// Screenshots
editUploadedScreenshots = customer.screenshots || [];
renderScreenshotPreviews("edit", editUploadedScreenshots);
// Parse trial period and fill datetime inputs
const reporter = customer.reporter || "";
if (reporter) {
@ -1247,6 +1276,7 @@ document.addEventListener("DOMContentLoaded", function () {
module: getModuleValue("editModule", "editModuleOther"),
statusProgress: document.getElementById("editStatusProgress").value,
reporter: "", // Trial periods managed separately
screenshots: editUploadedScreenshots,
};
try {
@ -1976,7 +2006,7 @@ document.addEventListener("DOMContentLoaded", function () {
const selectElement = document.getElementById(selectId);
if (!selectElement) return;
selectElement.innerHTML = '<option value="">请选择客户</option>';
selectElement.innerHTML = '<option value="" disabled selected hidden>请选择客户</option>';
if (data.customerNames && data.customerNames.length > 0) {
data.customerNames.forEach((customerName) => {
const option = document.createElement("option");
@ -3144,4 +3174,179 @@ document.addEventListener("DOMContentLoaded", function () {
a.remove();
URL.revokeObjectURL(url);
});
// ========== Screenshot & Image Viewer Logic ==========
function renderScreenshotsInCell(td, screenshots) {
td.innerHTML = "";
if (!screenshots || screenshots.length === 0) {
td.innerHTML = '<span class="no-screenshots">无</span>';
return;
}
const wrapper = document.createElement("div");
wrapper.className = "screenshot-wrapper";
screenshots.forEach((path) => {
const img = document.createElement("img");
img.src = path;
img.className = "screenshot-thumbnail";
img.onclick = (e) => {
e.stopPropagation();
const viewer = document.getElementById("imageViewerModal");
const fullImg = document.getElementById("fullImage");
if (viewer && fullImg) {
fullImg.src = path;
viewer.style.display = "flex";
}
};
wrapper.appendChild(img);
});
td.appendChild(wrapper);
}
function initImageViewer() {
const viewer = document.getElementById("imageViewerModal");
const closeBtn = document.querySelector(".image-viewer-close");
if (!viewer || !closeBtn) return;
closeBtn.onclick = () => {
viewer.style.display = "none";
};
viewer.onclick = (e) => {
if (e.target === viewer) {
viewer.style.display = "none";
}
};
}
// File upload handling for modals
function handleScreenshotUpload(type) {
const input = document.getElementById(
type === "create" ? "createScreenshots" : "editScreenshots",
);
const previewList = document.getElementById(
type === "create" ? "createScreenshotPreview" : "editScreenshotPreview",
);
if (!input || !previewList) return;
input.addEventListener("change", async function () {
if (!this.files.length) return;
const formData = new FormData();
for (const file of this.files) {
formData.append("screenshots", file);
}
try {
const response = await authenticatedFetch("/api/upload", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
const filePaths = data.filePaths || [];
if (type === "create") {
createUploadedScreenshots = [
...createUploadedScreenshots,
...filePaths,
];
renderScreenshotPreviews("create", createUploadedScreenshots);
} else {
editUploadedScreenshots = [...editUploadedScreenshots, ...filePaths];
renderScreenshotPreviews("edit", editUploadedScreenshots);
}
}
} catch (error) {
console.error("Error uploading screenshots:", error);
}
});
}
function renderScreenshotPreviews(type, screenshots) {
const previewList = document.getElementById(
type === "create" ? "createScreenshotPreview" : "editScreenshotPreview",
);
if (!previewList) return;
previewList.innerHTML = "";
screenshots.forEach((path, index) => {
const item = document.createElement("div");
item.className = "preview-item";
item.innerHTML = `
<img src="${path}" alt="Screenshot">
<span class="remove-preview" data-index="${index}">&times;</span>
`;
item.querySelector(".remove-preview").onclick = () => {
if (type === "create") {
createUploadedScreenshots.splice(index, 1);
renderScreenshotPreviews("create", createUploadedScreenshots);
} else {
editUploadedScreenshots.splice(index, 1);
renderScreenshotPreviews("edit", editUploadedScreenshots);
}
};
previewList.appendChild(item);
});
}
// Initialize upload handlers
handleScreenshotUpload("create");
handleScreenshotUpload("edit");
// ========== Select Placeholder Color Control ==========
// Function to update select color based on value
function updateSelectColor(select) {
if (!select.value || select.value === "") {
select.classList.add("placeholder-active");
} else {
select.classList.remove("placeholder-active");
}
}
// Apply to all select elements
const allSelects = document.querySelectorAll("select");
allSelects.forEach((select) => {
// Set initial color
updateSelectColor(select);
// Update color on change
select.addEventListener("change", function () {
updateSelectColor(this);
});
// Also update on input (for some browsers)
select.addEventListener("input", function () {
updateSelectColor(this);
});
});
// Re-apply when modals open (for dynamically populated selects)
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === "attributes" && mutation.attributeName === "style") {
const target = mutation.target;
if (target.classList.contains("modal") && target.style.display === "block") {
setTimeout(() => {
target.querySelectorAll("select").forEach(updateSelectColor);
}, 100);
}
}
});
});
// Observe all modals
document.querySelectorAll(".modal").forEach((modal) => {
observer.observe(modal, { attributes: true });
});
});

View File

@ -143,7 +143,7 @@ function populateCustomerSelect() {
const select = document.getElementById('trialCustomerSelect');
if (!select) return;
select.innerHTML = '<option value="">-- 选择已有客户或输入新客户 --</option>';
select.innerHTML = '<option value="" disabled selected hidden>-- 选择已有客户或输入新客户 --</option>';
// Get unique names from customersMap
const names = [...new Set(Object.values(customersMap))].sort();

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

1
go.mod
View File

@ -7,6 +7,7 @@ toolchain go1.24.3
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/xuri/excelize/v2 v2.8.0
)

2
go.sum
View File

@ -7,6 +7,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@ -8,6 +8,8 @@ import (
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -15,6 +17,7 @@ import (
"crm-go/internal/storage"
"crm-go/models"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
@ -128,6 +131,7 @@ func (h *CustomerHandler) CreateCustomer(w http.ResponseWriter, r *http.Request)
Module: req.Module,
StatusProgress: req.StatusProgress,
Reporter: req.Reporter,
Screenshots: req.Screenshots,
CreatedAt: time.Now(),
}
@ -185,6 +189,63 @@ func (h *CustomerHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}
func (h *CustomerHandler) UploadScreenshots(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
files := r.MultipartForm.File["screenshots"]
if len(files) == 0 {
http.Error(w, "No files uploaded", http.StatusBadRequest)
return
}
// Ensure upload directory exists
uploadDir := "./frontend/uploads/screenshots"
if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var filePaths []string
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
continue
}
defer file.Close()
// Generate unique filename
ext := filepath.Ext(fileHeader.Filename)
newFilename := uuid.New().String() + ext
filePath := filepath.Join(uploadDir, newFilename)
// Create file on disk
dst, err := os.Create(filePath)
if err != nil {
continue
}
defer dst.Close()
// Copy file content
if _, err := io.Copy(dst, file); err != nil {
continue
}
// Save relative path for frontend
relativePath := "/static/uploads/screenshots/" + newFilename
filePaths = append(filePaths, relativePath)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"filePaths": filePaths,
})
}
func (h *CustomerHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
// Extract customer ID from URL path
urlPath := r.URL.Path

View File

@ -147,6 +147,9 @@ func (cs *customerStorage) UpdateCustomer(id string, updates models.UpdateCustom
if updates.Reporter != nil {
customers[i].Reporter = *updates.Reporter
}
if updates.Screenshots != nil {
customers[i].Screenshots = *updates.Screenshots
}
return cs.SaveCustomers(customers)
}

View File

@ -43,6 +43,105 @@ func InitDB(config DBConfig) error {
}
log.Println("✅ Database connection established")
// 自动迁移表结构
if err := autoMigrate(); err != nil {
return fmt.Errorf("failed to auto migrate: %v", err)
}
return nil
}
// autoMigrate 自动创建或更新表结构
func autoMigrate() error {
// 创建 customers 表
createCustomersTable := `
CREATE TABLE IF NOT EXISTS customers (
id VARCHAR(64) PRIMARY KEY,
created_at DATETIME NOT NULL,
customer_name VARCHAR(255) NOT NULL,
intended_product VARCHAR(255),
version VARCHAR(100),
description TEXT,
solution TEXT,
type VARCHAR(100),
module VARCHAR(100),
status_progress VARCHAR(100),
reporter VARCHAR(255),
screenshots TEXT,
INDEX idx_customer_name (customer_name),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`
if _, err := db.Exec(createCustomersTable); err != nil {
return fmt.Errorf("failed to create customers table: %v", err)
}
// 创建 followups 表
createFollowupsTable := `
CREATE TABLE IF NOT EXISTS followups (
id VARCHAR(64) PRIMARY KEY,
created_at DATETIME NOT NULL,
customer_name VARCHAR(255) NOT NULL,
deal_status VARCHAR(50),
customer_level VARCHAR(50),
industry VARCHAR(100),
follow_up_time DATETIME,
notification_sent BOOLEAN DEFAULT FALSE,
INDEX idx_customer_name (customer_name),
INDEX idx_follow_up_time (follow_up_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`
if _, err := db.Exec(createFollowupsTable); err != nil {
return fmt.Errorf("failed to create followups table: %v", err)
}
// 创建 trial_periods 表
createTrialPeriodsTable := `
CREATE TABLE IF NOT EXISTS trial_periods (
id VARCHAR(64) PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL,
source VARCHAR(100),
intended_product VARCHAR(255),
start_time DATETIME,
end_time DATETIME,
is_trial BOOLEAN DEFAULT TRUE,
created_at DATETIME NOT NULL,
INDEX idx_customer_name (customer_name),
INDEX idx_end_time (end_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`
if _, err := db.Exec(createTrialPeriodsTable); err != nil {
return fmt.Errorf("failed to create trial_periods table: %v", err)
}
// 检查并添加 screenshots 列(如果不存在)
// MySQL 不支持 IF NOT EXISTS所以我们需要先检查列是否存在
var columnExists int
err := db.QueryRow(`
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'customers'
AND COLUMN_NAME = 'screenshots'
`).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check screenshots column: %v", err)
}
if columnExists == 0 {
_, err = db.Exec("ALTER TABLE customers ADD COLUMN screenshots TEXT")
if err != nil {
return fmt.Errorf("failed to add screenshots column: %v", err)
}
log.Println("✅ Added screenshots column to customers table")
}
log.Println("✅ Database tables migrated successfully")
return nil
}

View File

@ -24,7 +24,7 @@ func NewMySQLCustomerStorage() CustomerStorage {
func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
query := `
SELECT id, created_at, customer_name, intended_product, version,
description, solution, type, module, status_progress, reporter
description, solution, type, module, status_progress, reporter, screenshots
FROM customers
ORDER BY created_at DESC
`
@ -38,12 +38,12 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
var customers []models.Customer
for rows.Next() {
var c models.Customer
var intendedProduct, version, description, solution, typ, module, statusProgress, reporter sql.NullString
var intendedProduct, version, description, solution, typ, module, statusProgress, reporter, screenshots sql.NullString
err := rows.Scan(
&c.ID, &c.CreatedAt, &c.CustomerName,
&intendedProduct, &version, &description,
&solution, &typ, &module, &statusProgress, &reporter,
&solution, &typ, &module, &statusProgress, &reporter, &screenshots,
)
if err != nil {
return nil, err
@ -57,6 +57,11 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
c.Module = module.String
c.StatusProgress = statusProgress.String
c.Reporter = reporter.String
if screenshots.Valid && screenshots.String != "" {
c.Screenshots = strings.Split(screenshots.String, ",")
} else {
c.Screenshots = []string{}
}
customers = append(customers, c)
}
@ -67,18 +72,18 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
func (cs *mysqlCustomerStorage) GetCustomerByID(id string) (*models.Customer, error) {
query := `
SELECT id, created_at, customer_name, intended_product, version,
description, solution, type, module, status_progress, reporter
description, solution, type, module, status_progress, reporter, screenshots
FROM customers
WHERE id = ?
`
var c models.Customer
var intendedProduct, version, description, solution, typ, module, statusProgress, reporter sql.NullString
var intendedProduct, version, description, solution, typ, module, statusProgress, reporter, screenshots sql.NullString
err := cs.db.QueryRow(query, id).Scan(
&c.ID, &c.CreatedAt, &c.CustomerName,
&intendedProduct, &version, &description,
&solution, &typ, &module, &statusProgress, &reporter,
&solution, &typ, &module, &statusProgress, &reporter, &screenshots,
)
if err == sql.ErrNoRows {
@ -96,6 +101,11 @@ func (cs *mysqlCustomerStorage) GetCustomerByID(id string) (*models.Customer, er
c.Module = module.String
c.StatusProgress = statusProgress.String
c.Reporter = reporter.String
if screenshots.Valid && screenshots.String != "" {
c.Screenshots = strings.Split(screenshots.String, ",")
} else {
c.Screenshots = []string{}
}
return &c, nil
}
@ -110,15 +120,17 @@ func (cs *mysqlCustomerStorage) CreateCustomer(customer models.Customer) error {
query := `
INSERT INTO customers (id, created_at, customer_name, intended_product, version,
description, solution, type, module, status_progress, reporter)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
description, solution, type, module, status_progress, reporter, screenshots)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
screenshots := strings.Join(customer.Screenshots, ",")
_, err := cs.db.Exec(query,
customer.ID, customer.CreatedAt, customer.CustomerName,
customer.IntendedProduct, customer.Version, customer.Description,
customer.Solution, customer.Type, customer.Module,
customer.StatusProgress, customer.Reporter,
customer.StatusProgress, customer.Reporter, screenshots,
)
return err
@ -159,19 +171,24 @@ func (cs *mysqlCustomerStorage) UpdateCustomer(id string, updates models.UpdateC
if updates.Reporter != nil {
existing.Reporter = *updates.Reporter
}
if updates.Screenshots != nil {
existing.Screenshots = *updates.Screenshots
}
query := `
UPDATE customers
SET customer_name = ?, intended_product = ?, version = ?,
description = ?, solution = ?, type = ?,
module = ?, status_progress = ?, reporter = ?
module = ?, status_progress = ?, reporter = ?, screenshots = ?
WHERE id = ?
`
screenshots := strings.Join(existing.Screenshots, ",")
_, err = cs.db.Exec(query,
existing.CustomerName, existing.IntendedProduct, existing.Version,
existing.Description, existing.Solution, existing.Type,
existing.Module, existing.StatusProgress, existing.Reporter,
existing.Module, existing.StatusProgress, existing.Reporter, screenshots,
id,
)

View File

@ -14,28 +14,31 @@ type Customer struct {
Module string `json:"module" csv:"module"`
StatusProgress string `json:"statusProgress" csv:"statusProgress"`
Reporter string `json:"reporter" csv:"reporter"`
Screenshots []string `json:"screenshots" csv:"screenshots"`
}
type CreateCustomerRequest struct {
CustomerName string `json:"customerName"`
IntendedProduct string `json:"intendedProduct"`
Version string `json:"version"`
Description string `json:"description"`
Solution string `json:"solution"`
Type string `json:"type"`
Module string `json:"module"`
StatusProgress string `json:"statusProgress"`
Reporter string `json:"reporter"`
CustomerName string `json:"customerName"`
IntendedProduct string `json:"intendedProduct"`
Version string `json:"version"`
Description string `json:"description"`
Solution string `json:"solution"`
Type string `json:"type"`
Module string `json:"module"`
StatusProgress string `json:"statusProgress"`
Reporter string `json:"reporter"`
Screenshots []string `json:"screenshots"`
}
type UpdateCustomerRequest struct {
CustomerName *string `json:"customerName"`
IntendedProduct *string `json:"intendedProduct"`
Version *string `json:"version"`
Description *string `json:"description"`
Solution *string `json:"solution"`
Type *string `json:"type"`
Module *string `json:"module"`
StatusProgress *string `json:"statusProgress"`
Reporter *string `json:"reporter"`
CustomerName *string `json:"customerName"`
IntendedProduct *string `json:"intendedProduct"`
Version *string `json:"version"`
Description *string `json:"description"`
Solution *string `json:"solution"`
Type *string `json:"type"`
Module *string `json:"module"`
StatusProgress *string `json:"statusProgress"`
Reporter *string `json:"reporter"`
Screenshots *[]string `json:"screenshots"`
}