feat: 添加客户截图上传与展示功能,包括后端文件处理和前端界面集成。
This commit is contained in:
parent
3de6cfd48d
commit
9250803979
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
|
||||||
@ -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) {
|
http.HandleFunc("/api/customers/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
|
|
||||||
@ -340,6 +348,9 @@ func main() {
|
|||||||
// Serve static files
|
// Serve static files
|
||||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./frontend"))))
|
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
|
// Serve index page
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.ServeFile(w, r, filepath.Join("./frontend", "index.html"))
|
http.ServeFile(w, r, filepath.Join("./frontend", "index.html"))
|
||||||
|
|||||||
@ -603,6 +603,24 @@ body {
|
|||||||
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
|
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 {
|
.form-group textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
@ -716,6 +734,7 @@ th {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
@ -728,6 +747,12 @@ td {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
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 {
|
tr:hover td {
|
||||||
@ -2258,35 +2283,20 @@ tr:hover .action-cell {
|
|||||||
/* Font Awesome check icon */
|
/* Font Awesome check icon */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table refresh animation */
|
/* Table refresh animation simplified */
|
||||||
@keyframes tableRefresh {
|
|
||||||
0% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-refreshing {
|
.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 {
|
@keyframes rowHighlight {
|
||||||
0% {
|
0% {
|
||||||
background-color: rgba(255, 107, 53, 0.2);
|
background-color: rgba(255, 107, 53, 0.1);
|
||||||
transform: translateX(-5px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
transform: translateX(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2699,4 +2709,174 @@ tr:hover .action-cell {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: auto;
|
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); }
|
||||||
}
|
}
|
||||||
@ -222,6 +222,7 @@
|
|||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
<th>模块</th>
|
<th>模块</th>
|
||||||
<th>状态与进度</th>
|
<th>状态与进度</th>
|
||||||
|
<th>截图</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -529,13 +530,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="followupCustomerName">客户名称 <span style="color: red">*</span></label>
|
<label for="followupCustomerName">客户名称 <span style="color: red">*</span></label>
|
||||||
<select id="followupCustomerName" name="customerName" required>
|
<select id="followupCustomerName" name="customerName" required>
|
||||||
<option value="">请选择客户</option>
|
<option value="" disabled selected hidden>请选择客户</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="followupDealStatus">成交状态 <span style="color: red">*</span></label>
|
<label for="followupDealStatus">成交状态 <span style="color: red">*</span></label>
|
||||||
<select id="followupDealStatus" name="dealStatus" required>
|
<select id="followupDealStatus" name="dealStatus" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="未成交">未成交</option>
|
<option value="未成交">未成交</option>
|
||||||
<option value="已成交">已成交</option>
|
<option value="已成交">已成交</option>
|
||||||
</select>
|
</select>
|
||||||
@ -546,7 +547,7 @@
|
|||||||
<label for="followupCustomerLevel">客户级别 <span
|
<label for="followupCustomerLevel">客户级别 <span
|
||||||
style="color: red">*</span></label>
|
style="color: red">*</span></label>
|
||||||
<select id="followupCustomerLevel" name="customerLevel" required>
|
<select id="followupCustomerLevel" name="customerLevel" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="A (重点关注)">A (重点关注)</option>
|
<option value="A (重点关注)">A (重点关注)</option>
|
||||||
<option value="B (很有希望)">B (很有希望)</option>
|
<option value="B (很有希望)">B (很有希望)</option>
|
||||||
<option value="C (常规意向)">C (常规意向)</option>
|
<option value="C (常规意向)">C (常规意向)</option>
|
||||||
@ -640,7 +641,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="createCustomerName">客户名称 <span style="color: red">*</span></label>
|
<label for="createCustomerName">客户名称 <span style="color: red">*</span></label>
|
||||||
<select id="createCustomerName" name="customerName" required>
|
<select id="createCustomerName" name="customerName" required>
|
||||||
<option value="">请选择客户</option>
|
<option value="" disabled selected hidden>请选择客户</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -656,10 +657,11 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="createType">类型 <span style="color: red">*</span></label>
|
<label for="createType">类型 <span style="color: red">*</span></label>
|
||||||
<select id="createType" name="type" required>
|
<select id="createType" name="type" required>
|
||||||
<option value="">请选择类型</option>
|
<option value="" disabled selected hidden>请选择类型</option>
|
||||||
<option value="私有化">私有化</option>
|
<option value="反馈">反馈</option>
|
||||||
<option value="公有云">公有云</option>
|
<option value="需求">需求</option>
|
||||||
<option value="其他">其他</option>
|
<option value="咨询">咨询</option>
|
||||||
|
<option value="功能问题">功能问题</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -667,7 +669,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="createModule">模块</label>
|
<label for="createModule">模块</label>
|
||||||
<select id="createModule" name="module">
|
<select id="createModule" name="module">
|
||||||
<option value="">请选择模块</option>
|
<option value="" disabled selected hidden>请选择模块</option>
|
||||||
<option value="数据生成">数据生成</option>
|
<option value="数据生成">数据生成</option>
|
||||||
<option value="数据集">数据集</option>
|
<option value="数据集">数据集</option>
|
||||||
<option value="数据空间">数据空间</option>
|
<option value="数据空间">数据空间</option>
|
||||||
@ -682,7 +684,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="createStatusProgress">状态与进度 <span style="color: red">*</span></label>
|
<label for="createStatusProgress">状态与进度 <span style="color: red">*</span></label>
|
||||||
<select id="createStatusProgress" name="statusProgress" required>
|
<select id="createStatusProgress" name="statusProgress" required>
|
||||||
<option value="">请选择状态</option>
|
<option value="" disabled selected hidden>请选择状态</option>
|
||||||
<option value="已完成">已完成</option>
|
<option value="已完成">已完成</option>
|
||||||
<option value="进行中">进行中</option>
|
<option value="进行中">进行中</option>
|
||||||
<option value="待排期">待排期</option>
|
<option value="待排期">待排期</option>
|
||||||
@ -699,6 +701,17 @@
|
|||||||
<label for="createSolution">解决方案</label>
|
<label for="createSolution">解决方案</label>
|
||||||
<textarea id="createSolution" name="solution" rows="3" placeholder="请输入拟采取的解决方案..."></textarea>
|
<textarea id="createSolution" name="solution" rows="3" placeholder="请输入拟采取的解决方案..."></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary">
|
<button type="submit" class="btn-primary">
|
||||||
<i class="fas fa-save"></i>
|
<i class="fas fa-save"></i>
|
||||||
@ -742,9 +755,11 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editType">类型 <span style="color: red">*</span></label>
|
<label for="editType">类型 <span style="color: red">*</span></label>
|
||||||
<select id="editType" name="type" required>
|
<select id="editType" name="type" required>
|
||||||
<option value="">请选择类型</option>
|
<option value="" disabled selected hidden>请选择类型</option>
|
||||||
<option value="私有化">私有化</option>
|
<option value="反馈">反馈</option>
|
||||||
<option value="公有云">公有云</option>
|
<option value="需求">需求</option>
|
||||||
|
<option value="咨询">咨询</option>
|
||||||
|
<option value="功能问题">功能问题</option>
|
||||||
<option value="其他">其他</option>
|
<option value="其他">其他</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -753,7 +768,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editModule">模块</label>
|
<label for="editModule">模块</label>
|
||||||
<select id="editModule" name="module">
|
<select id="editModule" name="module">
|
||||||
<option value="">请选择模块</option>
|
<option value="" disabled selected hidden>请选择模块</option>
|
||||||
<option value="数据生成">数据生成</option>
|
<option value="数据生成">数据生成</option>
|
||||||
<option value="数据集">数据集</option>
|
<option value="数据集">数据集</option>
|
||||||
<option value="数据空间">数据空间</option>
|
<option value="数据空间">数据空间</option>
|
||||||
@ -768,7 +783,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editStatusProgress">状态与进度 <span style="color: red">*</span></label>
|
<label for="editStatusProgress">状态与进度 <span style="color: red">*</span></label>
|
||||||
<select id="editStatusProgress" name="statusProgress" required>
|
<select id="editStatusProgress" name="statusProgress" required>
|
||||||
<option value="">请选择状态</option>
|
<option value="" disabled selected hidden>请选择状态</option>
|
||||||
<option value="已完成">已完成</option>
|
<option value="已完成">已完成</option>
|
||||||
<option value="进行中">进行中</option>
|
<option value="进行中">进行中</option>
|
||||||
<option value="待排期">待排期</option>
|
<option value="待排期">待排期</option>
|
||||||
@ -785,6 +800,17 @@
|
|||||||
<label for="editSolution">解决方案</label>
|
<label for="editSolution">解决方案</label>
|
||||||
<textarea id="editSolution" name="solution" rows="3"></textarea>
|
<textarea id="editSolution" name="solution" rows="3"></textarea>
|
||||||
</div>
|
</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) -->
|
<!-- Trial period details (Read-only reference) -->
|
||||||
<div class="trial-details-section" style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #eee;">
|
<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">
|
<div class="form-group">
|
||||||
<label for="addFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
|
<label for="addFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
|
||||||
<select id="addFollowupCustomerName" name="customerName" required>
|
<select id="addFollowupCustomerName" name="customerName" required>
|
||||||
<option value="">请选择客户</option>
|
<option value="" disabled selected hidden>请选择客户</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
|
<label for="addFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
|
||||||
<select id="addFollowupDealStatus" name="dealStatus" required>
|
<select id="addFollowupDealStatus" name="dealStatus" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="已成交">已成交</option>
|
<option value="已成交">已成交</option>
|
||||||
<option value="未成交">未成交</option>
|
<option value="未成交">未成交</option>
|
||||||
</select>
|
</select>
|
||||||
@ -845,7 +871,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="addFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
|
<label for="addFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
|
||||||
<select id="addFollowupCustomerLevel" name="customerLevel" required>
|
<select id="addFollowupCustomerLevel" name="customerLevel" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="A">A级 (重点客户)</option>
|
<option value="A">A级 (重点客户)</option>
|
||||||
<option value="B">B级 (潜在客户)</option>
|
<option value="B">B级 (潜在客户)</option>
|
||||||
<option value="C">C级 (一般客户)</option>
|
<option value="C">C级 (一般客户)</option>
|
||||||
@ -888,13 +914,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
|
<label for="editFollowupCustomerName">客户名称 <span style="color: red">*</span></label>
|
||||||
<select id="editFollowupCustomerName" name="customerName" required>
|
<select id="editFollowupCustomerName" name="customerName" required>
|
||||||
<option value="">请选择客户</option>
|
<option value="" disabled selected hidden>请选择客户</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
|
<label for="editFollowupDealStatus">成交状态 <span style="color: red">*</span></label>
|
||||||
<select id="editFollowupDealStatus" name="dealStatus" required>
|
<select id="editFollowupDealStatus" name="dealStatus" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="已成交">已成交</option>
|
<option value="已成交">已成交</option>
|
||||||
<option value="未成交">未成交</option>
|
<option value="未成交">未成交</option>
|
||||||
</select>
|
</select>
|
||||||
@ -904,7 +930,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="editFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
|
<label for="editFollowupCustomerLevel">客户级别 <span style="color: red">*</span></label>
|
||||||
<select id="editFollowupCustomerLevel" name="customerLevel" required>
|
<select id="editFollowupCustomerLevel" name="customerLevel" required>
|
||||||
<option value="">请选择</option>
|
<option value="" disabled selected hidden>请选择</option>
|
||||||
<option value="A">A级 (重点客户)</option>
|
<option value="A">A级 (重点客户)</option>
|
||||||
<option value="B">B级 (潜在客户)</option>
|
<option value="B">B级 (潜在客户)</option>
|
||||||
<option value="C">C级 (一般客户)</option>
|
<option value="C">C级 (一般客户)</option>
|
||||||
@ -1154,6 +1180,12 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Image Viewer Modal (Lightbox) -->
|
||||||
|
<div id="imageViewerModal" class="image-viewer-modal" style="display: none">
|
||||||
|
<span class="image-viewer-close">×</span>
|
||||||
|
<img class="image-viewer-content" id="fullImage" />
|
||||||
|
<div id="imageViewerCaption"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/main.js?v=2.6"></script>
|
<script src="/static/js/main.js?v=2.6"></script>
|
||||||
|
|||||||
@ -116,6 +116,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
let customerStartDate = "";
|
let customerStartDate = "";
|
||||||
let customerEndDate = "";
|
let customerEndDate = "";
|
||||||
let customerSearchQuery = "";
|
let customerSearchQuery = "";
|
||||||
|
let createUploadedScreenshots = [];
|
||||||
|
let editUploadedScreenshots = [];
|
||||||
|
|
||||||
let cellTooltipEl = null;
|
let cellTooltipEl = null;
|
||||||
let tooltipAnchorCell = null;
|
let tooltipAnchorCell = null;
|
||||||
@ -470,7 +472,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const customerSelect = document.getElementById("createCustomerName");
|
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) {
|
if (data.customerNames && data.customerNames.length > 0) {
|
||||||
data.customerNames.forEach((customerName) => {
|
data.customerNames.forEach((customerName) => {
|
||||||
@ -630,6 +632,21 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
sidebar.classList.remove("open");
|
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
|
// Load customers from API
|
||||||
@ -962,6 +979,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
{ value: customer.type || "", name: "type" },
|
{ value: customer.type || "", name: "type" },
|
||||||
{ value: customer.module || "", name: "module" },
|
{ value: customer.module || "", name: "module" },
|
||||||
{ value: customer.statusProgress || "", name: "statusProgress" },
|
{ value: customer.statusProgress || "", name: "statusProgress" },
|
||||||
|
{ value: customer.screenshots || [], name: "screenshots" },
|
||||||
];
|
];
|
||||||
|
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
@ -973,6 +991,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
td.innerHTML = getStatusBadge(textValue);
|
td.innerHTML = getStatusBadge(textValue);
|
||||||
} else if (field.name === "customerName") {
|
} else if (field.name === "customerName") {
|
||||||
td.innerHTML = `<strong>${textValue}</strong>`;
|
td.innerHTML = `<strong>${textValue}</strong>`;
|
||||||
|
} else if (field.name === "screenshots") {
|
||||||
|
td.classList.add("screenshot-column-cell");
|
||||||
|
renderScreenshotsInCell(td, field.value);
|
||||||
} else {
|
} else {
|
||||||
td.textContent = textValue;
|
td.textContent = textValue;
|
||||||
}
|
}
|
||||||
@ -1010,6 +1031,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
checkTextOverflow();
|
checkTextOverflow();
|
||||||
|
initImageViewer();
|
||||||
|
|
||||||
document.querySelectorAll(".edit-btn").forEach((btn) => {
|
document.querySelectorAll(".edit-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
@ -1095,6 +1117,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
module: getModuleValue("createModule", "createModuleOther"),
|
module: getModuleValue("createModule", "createModuleOther"),
|
||||||
statusProgress: document.getElementById("createStatusProgress").value,
|
statusProgress: document.getElementById("createStatusProgress").value,
|
||||||
reporter: "", // Trial periods managed separately
|
reporter: "", // Trial periods managed separately
|
||||||
|
screenshots: createUploadedScreenshots,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1108,6 +1131,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
createCustomerForm.reset();
|
createCustomerForm.reset();
|
||||||
|
createUploadedScreenshots = [];
|
||||||
|
renderScreenshotPreviews("create", []);
|
||||||
// Hide module "other" input
|
// Hide module "other" input
|
||||||
const createModuleOther = document.getElementById("createModuleOther");
|
const createModuleOther = document.getElementById("createModuleOther");
|
||||||
if (createModuleOther) createModuleOther.style.display = "none";
|
if (createModuleOther) createModuleOther.style.display = "none";
|
||||||
@ -1180,6 +1205,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
document.getElementById("editStatusProgress").value =
|
document.getElementById("editStatusProgress").value =
|
||||||
customer.statusProgress || "";
|
customer.statusProgress || "";
|
||||||
|
|
||||||
|
// Screenshots
|
||||||
|
editUploadedScreenshots = customer.screenshots || [];
|
||||||
|
renderScreenshotPreviews("edit", editUploadedScreenshots);
|
||||||
|
|
||||||
// Parse trial period and fill datetime inputs
|
// Parse trial period and fill datetime inputs
|
||||||
const reporter = customer.reporter || "";
|
const reporter = customer.reporter || "";
|
||||||
if (reporter) {
|
if (reporter) {
|
||||||
@ -1247,6 +1276,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
module: getModuleValue("editModule", "editModuleOther"),
|
module: getModuleValue("editModule", "editModuleOther"),
|
||||||
statusProgress: document.getElementById("editStatusProgress").value,
|
statusProgress: document.getElementById("editStatusProgress").value,
|
||||||
reporter: "", // Trial periods managed separately
|
reporter: "", // Trial periods managed separately
|
||||||
|
screenshots: editUploadedScreenshots,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1976,7 +2006,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
const selectElement = document.getElementById(selectId);
|
const selectElement = document.getElementById(selectId);
|
||||||
if (!selectElement) return;
|
if (!selectElement) return;
|
||||||
|
|
||||||
selectElement.innerHTML = '<option value="">请选择客户</option>';
|
selectElement.innerHTML = '<option value="" disabled selected hidden>请选择客户</option>';
|
||||||
if (data.customerNames && data.customerNames.length > 0) {
|
if (data.customerNames && data.customerNames.length > 0) {
|
||||||
data.customerNames.forEach((customerName) => {
|
data.customerNames.forEach((customerName) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
@ -3144,4 +3174,179 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
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}">×</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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -143,7 +143,7 @@ function populateCustomerSelect() {
|
|||||||
const select = document.getElementById('trialCustomerSelect');
|
const select = document.getElementById('trialCustomerSelect');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
select.innerHTML = '<option value="">-- 选择已有客户或输入新客户 --</option>';
|
select.innerHTML = '<option value="" disabled selected hidden>-- 选择已有客户或输入新客户 --</option>';
|
||||||
|
|
||||||
// Get unique names from customersMap
|
// Get unique names from customersMap
|
||||||
const names = [...new Set(Object.values(customersMap))].sort();
|
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
1
go.mod
@ -7,6 +7,7 @@ toolchain go1.24.3
|
|||||||
require (
|
require (
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/xuri/excelize/v2 v2.8.0
|
github.com/xuri/excelize/v2 v2.8.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
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=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -15,6 +17,7 @@ import (
|
|||||||
"crm-go/internal/storage"
|
"crm-go/internal/storage"
|
||||||
"crm-go/models"
|
"crm-go/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/xuri/excelize/v2"
|
"github.com/xuri/excelize/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -128,6 +131,7 @@ func (h *CustomerHandler) CreateCustomer(w http.ResponseWriter, r *http.Request)
|
|||||||
Module: req.Module,
|
Module: req.Module,
|
||||||
StatusProgress: req.StatusProgress,
|
StatusProgress: req.StatusProgress,
|
||||||
Reporter: req.Reporter,
|
Reporter: req.Reporter,
|
||||||
|
Screenshots: req.Screenshots,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,6 +189,63 @@ func (h *CustomerHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request)
|
|||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
func (h *CustomerHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
// Extract customer ID from URL path
|
// Extract customer ID from URL path
|
||||||
urlPath := r.URL.Path
|
urlPath := r.URL.Path
|
||||||
|
|||||||
@ -147,6 +147,9 @@ func (cs *customerStorage) UpdateCustomer(id string, updates models.UpdateCustom
|
|||||||
if updates.Reporter != nil {
|
if updates.Reporter != nil {
|
||||||
customers[i].Reporter = *updates.Reporter
|
customers[i].Reporter = *updates.Reporter
|
||||||
}
|
}
|
||||||
|
if updates.Screenshots != nil {
|
||||||
|
customers[i].Screenshots = *updates.Screenshots
|
||||||
|
}
|
||||||
|
|
||||||
return cs.SaveCustomers(customers)
|
return cs.SaveCustomers(customers)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,105 @@ func InitDB(config DBConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("✅ Database connection established")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ func NewMySQLCustomerStorage() CustomerStorage {
|
|||||||
func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
|
func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, created_at, customer_name, intended_product, version,
|
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
|
FROM customers
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@ -38,12 +38,12 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
|
|||||||
var customers []models.Customer
|
var customers []models.Customer
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c models.Customer
|
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(
|
err := rows.Scan(
|
||||||
&c.ID, &c.CreatedAt, &c.CustomerName,
|
&c.ID, &c.CreatedAt, &c.CustomerName,
|
||||||
&intendedProduct, &version, &description,
|
&intendedProduct, &version, &description,
|
||||||
&solution, &typ, &module, &statusProgress, &reporter,
|
&solution, &typ, &module, &statusProgress, &reporter, &screenshots,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -57,6 +57,11 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
|
|||||||
c.Module = module.String
|
c.Module = module.String
|
||||||
c.StatusProgress = statusProgress.String
|
c.StatusProgress = statusProgress.String
|
||||||
c.Reporter = reporter.String
|
c.Reporter = reporter.String
|
||||||
|
if screenshots.Valid && screenshots.String != "" {
|
||||||
|
c.Screenshots = strings.Split(screenshots.String, ",")
|
||||||
|
} else {
|
||||||
|
c.Screenshots = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
customers = append(customers, c)
|
customers = append(customers, c)
|
||||||
}
|
}
|
||||||
@ -67,18 +72,18 @@ func (cs *mysqlCustomerStorage) GetAllCustomers() ([]models.Customer, error) {
|
|||||||
func (cs *mysqlCustomerStorage) GetCustomerByID(id string) (*models.Customer, error) {
|
func (cs *mysqlCustomerStorage) GetCustomerByID(id string) (*models.Customer, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, created_at, customer_name, intended_product, version,
|
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
|
FROM customers
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
var c models.Customer
|
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(
|
err := cs.db.QueryRow(query, id).Scan(
|
||||||
&c.ID, &c.CreatedAt, &c.CustomerName,
|
&c.ID, &c.CreatedAt, &c.CustomerName,
|
||||||
&intendedProduct, &version, &description,
|
&intendedProduct, &version, &description,
|
||||||
&solution, &typ, &module, &statusProgress, &reporter,
|
&solution, &typ, &module, &statusProgress, &reporter, &screenshots,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@ -96,6 +101,11 @@ func (cs *mysqlCustomerStorage) GetCustomerByID(id string) (*models.Customer, er
|
|||||||
c.Module = module.String
|
c.Module = module.String
|
||||||
c.StatusProgress = statusProgress.String
|
c.StatusProgress = statusProgress.String
|
||||||
c.Reporter = reporter.String
|
c.Reporter = reporter.String
|
||||||
|
if screenshots.Valid && screenshots.String != "" {
|
||||||
|
c.Screenshots = strings.Split(screenshots.String, ",")
|
||||||
|
} else {
|
||||||
|
c.Screenshots = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
@ -110,15 +120,17 @@ func (cs *mysqlCustomerStorage) CreateCustomer(customer models.Customer) error {
|
|||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO customers (id, created_at, customer_name, intended_product, version,
|
INSERT INTO customers (id, created_at, customer_name, intended_product, version,
|
||||||
description, solution, type, module, status_progress, reporter)
|
description, solution, type, module, status_progress, reporter, screenshots)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
screenshots := strings.Join(customer.Screenshots, ",")
|
||||||
|
|
||||||
_, err := cs.db.Exec(query,
|
_, err := cs.db.Exec(query,
|
||||||
customer.ID, customer.CreatedAt, customer.CustomerName,
|
customer.ID, customer.CreatedAt, customer.CustomerName,
|
||||||
customer.IntendedProduct, customer.Version, customer.Description,
|
customer.IntendedProduct, customer.Version, customer.Description,
|
||||||
customer.Solution, customer.Type, customer.Module,
|
customer.Solution, customer.Type, customer.Module,
|
||||||
customer.StatusProgress, customer.Reporter,
|
customer.StatusProgress, customer.Reporter, screenshots,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@ -159,19 +171,24 @@ func (cs *mysqlCustomerStorage) UpdateCustomer(id string, updates models.UpdateC
|
|||||||
if updates.Reporter != nil {
|
if updates.Reporter != nil {
|
||||||
existing.Reporter = *updates.Reporter
|
existing.Reporter = *updates.Reporter
|
||||||
}
|
}
|
||||||
|
if updates.Screenshots != nil {
|
||||||
|
existing.Screenshots = *updates.Screenshots
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE customers
|
UPDATE customers
|
||||||
SET customer_name = ?, intended_product = ?, version = ?,
|
SET customer_name = ?, intended_product = ?, version = ?,
|
||||||
description = ?, solution = ?, type = ?,
|
description = ?, solution = ?, type = ?,
|
||||||
module = ?, status_progress = ?, reporter = ?
|
module = ?, status_progress = ?, reporter = ?, screenshots = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
|
screenshots := strings.Join(existing.Screenshots, ",")
|
||||||
|
|
||||||
_, err = cs.db.Exec(query,
|
_, err = cs.db.Exec(query,
|
||||||
existing.CustomerName, existing.IntendedProduct, existing.Version,
|
existing.CustomerName, existing.IntendedProduct, existing.Version,
|
||||||
existing.Description, existing.Solution, existing.Type,
|
existing.Description, existing.Solution, existing.Type,
|
||||||
existing.Module, existing.StatusProgress, existing.Reporter,
|
existing.Module, existing.StatusProgress, existing.Reporter, screenshots,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -14,28 +14,31 @@ type Customer struct {
|
|||||||
Module string `json:"module" csv:"module"`
|
Module string `json:"module" csv:"module"`
|
||||||
StatusProgress string `json:"statusProgress" csv:"statusProgress"`
|
StatusProgress string `json:"statusProgress" csv:"statusProgress"`
|
||||||
Reporter string `json:"reporter" csv:"reporter"`
|
Reporter string `json:"reporter" csv:"reporter"`
|
||||||
|
Screenshots []string `json:"screenshots" csv:"screenshots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCustomerRequest struct {
|
type CreateCustomerRequest struct {
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
IntendedProduct string `json:"intendedProduct"`
|
IntendedProduct string `json:"intendedProduct"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Solution string `json:"solution"`
|
Solution string `json:"solution"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
StatusProgress string `json:"statusProgress"`
|
StatusProgress string `json:"statusProgress"`
|
||||||
Reporter string `json:"reporter"`
|
Reporter string `json:"reporter"`
|
||||||
|
Screenshots []string `json:"screenshots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCustomerRequest struct {
|
type UpdateCustomerRequest struct {
|
||||||
CustomerName *string `json:"customerName"`
|
CustomerName *string `json:"customerName"`
|
||||||
IntendedProduct *string `json:"intendedProduct"`
|
IntendedProduct *string `json:"intendedProduct"`
|
||||||
Version *string `json:"version"`
|
Version *string `json:"version"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Solution *string `json:"solution"`
|
Solution *string `json:"solution"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Module *string `json:"module"`
|
Module *string `json:"module"`
|
||||||
StatusProgress *string `json:"statusProgress"`
|
StatusProgress *string `json:"statusProgress"`
|
||||||
Reporter *string `json:"reporter"`
|
Reporter *string `json:"reporter"`
|
||||||
|
Screenshots *[]string `json:"screenshots"`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user