feat: Add initial Go CRM application with customer CRUD, import, authentication, and basic UI.

This commit is contained in:
hangyu.tao 2026-01-05 19:31:52 +08:00
commit 77abd6e599
16 changed files with 4128 additions and 0 deletions

84
README.md Normal file
View File

@ -0,0 +1,84 @@
# CRM Customer Management System
A web-based CRM system built with Go backend and HTML/CSS/JavaScript frontend.
## Features
1. **Customer Management**
- Create customers with fields: Customer Name, Intended Product
- Customer list with fields: Time, Customer, Version, Description, Solution, Type, Module, Status & Progress, Reporter (all editable)
- Import customers from CSV files
2. **Customer Dashboard**
- Visual charts (pie charts, line charts) for data analysis
- Filter data by date range
- View customer distribution by status, trends, product interest, and customer types
## Technology Stack
- Backend: Go
- Frontend: HTML, CSS, JavaScript
- Charts: Chart.js (via CDN)
- Data Storage: Local JSON file
## How to Run
1. Navigate to the project directory:
```bash
cd /Users/d-robotics/crm/crm-go
```
2. Run the server:
```bash
./bin/server
```
3. Open your browser and go to:
```
http://localhost:8080
```
## Usage
### Customer Management
- Click "Customer Management" in the navigation
- Add new customers using the form
- Edit existing customers by clicking "Edit" button
- Delete customers by clicking "Delete" button
- Import customers from CSV file using the import form
### Dashboard
- Click "Dashboard" in the navigation
- View various charts showing customer data
- Filter data by date range
## Data Storage
Customer data is stored in `./data/customers.json` in JSON format.
## File Structure
```
crm-go/
├── cmd/
│ └── server/
│ └── main.go # Main application entry point
├── models/
│ └── customer.go # Customer data models
├── internal/
│ ├── handlers/
│ │ └── customer_handler.go # HTTP request handlers
│ └── storage/
│ └── customer_storage.go # Data storage implementation
├── frontend/
│ ├── index.html # Main HTML page
│ ├── css/
│ └── style.css # Stylesheet
│ └── js/
│ └── main.js # Client-side JavaScript
├── data/
│ └── customers.json # Customer data storage
├── bin/
│ └── server # Compiled binary
└── go.mod # Go module file
```

17
build.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# Build script for CRM application
echo "Building CRM application..."
# Create data directory if it doesn't exist
mkdir -p /Users/d-robotics/crm/crm-go/data
# Build the application
go build -o /Users/d-robotics/crm/crm-go/bin/server /Users/d-robotics/crm/crm-go/cmd/server/main.go
if [ $? -eq 0 ]; then
echo "Build successful!"
echo "To run the application, execute: ./bin/server"
else
echo "Build failed!"
fi

113
cmd/server/main.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"crm-go/internal/handlers"
"crm-go/internal/middleware"
"crm-go/internal/storage"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
func main() {
// Initialize storage
customerStorage := storage.NewCustomerStorage("./data/customers.json")
// Initialize handlers
customerHandler := handlers.NewCustomerHandler(customerStorage)
authHandler := handlers.NewAuthHandler()
// Enable CORS manually
corsHandler := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
h.ServeHTTP(w, r)
})
}
// Auth routes
http.HandleFunc("/api/login", authHandler.Login)
// Set up routes using standard http
http.HandleFunc("/api/customers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
customerHandler.GetCustomers(w, r)
case "POST":
customerHandler.CreateCustomer(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/customers/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Handle import endpoint
if path == "/api/customers/import" {
if r.Method == "POST" {
customerHandler.ImportCustomers(w, r)
return
}
}
// Handle customer ID endpoints
if strings.HasPrefix(path, "/api/customers/") && path != "/api/customers/" {
// Extract customer ID from URL
id := strings.TrimPrefix(path, "/api/customers/")
// Remove query parameters if any
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if id != "" {
if r.Method == "GET" {
customerHandler.GetCustomerByID(w, r)
return
}
if r.Method == "PUT" {
customerHandler.UpdateCustomer(w, r)
return
}
if r.Method == "DELETE" {
customerHandler.DeleteCustomer(w, r)
return
}
}
}
http.NotFound(w, r)
})
// Serve static files for the frontend
staticDir := "./frontend"
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
// Create basic frontend directory if it doesn't exist
os.MkdirAll(staticDir, 0755)
}
// Serve static files
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./frontend"))))
// Serve index page
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join("./frontend", "index.html"))
})
// Assemble final handler chain: DefaultServeMux -> AuthMiddleware -> CorsHandler
finalHandler := middleware.AuthMiddleware(http.DefaultServeMux)
log.Println("Server starting on :8081")
log.Fatal(http.ListenAndServe(":8081", corsHandler(finalHandler)))
}

236
data/customers.json Normal file
View File

@ -0,0 +1,236 @@
[
{
"id": "24849eb6e78a42998d0b7b08b5979756",
"createdAt": "2026-01-04T12:14:26.825475+08:00",
"customerName": "2025-12-22",
"intendedProduct": "予芯",
"version": "1.9.4",
"description": "训练失败看不到错误日志",
"solution": "已解决",
"type": "功能问题",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "a10e4beaa8134473bfd80fba4b33aee7",
"createdAt": "2026-01-04T12:14:26.826755+08:00",
"customerName": "2025/12/23",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "客户端上传大文件失败",
"solution": "技术优化,修复中",
"type": "功能问题",
"module": "数据空间",
"statusProgress": "已修复",
"reporter": ""
},
{
"id": "20188f28fe1e40e589e171a542cf3a26",
"createdAt": "2026-01-04T12:14:26.827195+08:00",
"customerName": "2025/12/23",
"intendedProduct": "良业集团",
"version": "1.9.4",
"description": "数据集发布感觉操作繁琐,如果在模型工坊里直接选择需要训练的图片会更方便",
"solution": "",
"type": "反馈",
"module": "数据集,模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "b1f033d8f2b54b5cae52764f9e4eb727",
"createdAt": "2026-01-04T12:14:26.827545+08:00",
"customerName": "2025/12/25",
"intendedProduct": "斯蒂尔",
"version": "1.9.4",
"description": "1.试用账号图片生成速度比较慢(2分钟1张图)\n2.生图效果不理想",
"solution": "试用资源有限",
"type": "反馈",
"module": "数据生成",
"statusProgress": "",
"reporter": ""
},
{
"id": "ce7df9ffeab34998a040ee0af5b21789",
"createdAt": "2026-01-04T12:14:26.827889+08:00",
"customerName": "2025/12/26",
"intendedProduct": "求之",
"version": "1.9.4",
"description": "1.训练过程不太透明,以及无法提前终止模型训练\n2.训练过程无法断点继续训练;\n3.试用账号资源有限,训练时长过长",
"solution": "1、训练过程的透明化日志化和目前的易用化有一些产品设计矛盾之前主要还是在做易用化。\n接下来我们会在更透明更强的客户对过程的操控粒度上提升。\n2、训练时长的事情实在抱歉最近客户比较多我们资源有限",
"type": "反馈",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "be855bfa554e4e71abcee1cb5c614c3d",
"createdAt": "2026-01-04T12:14:26.828428+08:00",
"customerName": "2025/12/26",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "训练进程显示什么时候上线",
"solution": "训练日志可视化,我们大约需要一个月的时间可以满足上线使用",
"type": "需求",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "93924ee960434cf1b3a358321efcf5a5",
"createdAt": "2026-01-04T12:14:26.828857+08:00",
"customerName": "2025/12/26",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "(igev模型)全量训练是指从0开始 增量是在现在的ckpt上做微调是吗 ",
"solution": "全量训练:在加载开源基础的权重上进行增量训练\n增量训练在您的数据上训练的checkpoint上进行的增量训练",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "6d748e5ec8814e938509c0784902e5bc",
"createdAt": "2026-01-04T12:14:26.829476+08:00",
"customerName": "2025/12/26",
"intendedProduct": "斯蒂尔",
"version": "1.9.4",
"description": "数据生成效果改进优化方式?试用账号配置卡数",
"solution": "409024g然后生图是单卡单线程\n目前系统上挂载了2张GPU",
"type": "咨询",
"module": "数据生成",
"statusProgress": "",
"reporter": ""
},
{
"id": "74e79089d5e7477f8388ad62515704f2",
"createdAt": "2026-01-04T12:14:26.830017+08:00",
"customerName": "2025/12/26",
"intendedProduct": "良业",
"version": "1.9.4",
"description": "1、训练时需要添加测试图无法理解模型分析结果可以用训练集\n2、训练失败时需提示原因方便下一步操作。",
"solution": "1、训练过程中根据训练集的数据来更新模型参数在通过验证集来验证\n2、训练失败有日志可以查看您滚动下屏幕至右侧可以看到哈",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "f4a97d749e9843419dc74c9990e0035f",
"createdAt": "2026-01-04T12:14:26.830529+08:00",
"customerName": "2025/12/29",
"intendedProduct": "良业",
"version": "1.9.4",
"description": "1.试了几个模型,没训练出来,感觉不出效果\n",
"solution": "补充数据集",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "d8aa7b048820406d932b410ecc3fd83f",
"createdAt": "2026-01-04T12:14:26.830915+08:00",
"customerName": "2025/12/29",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "1.总轮数与拓展参数(num_steps)不一致的时候,以哪个为准结束训练",
"solution": "目前StereoNet-V2.5支持使用steps参数其他的还是以epoch为准",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "fa1a6afdc1cf47728eb3940be1a554d9",
"createdAt": "2026-01-04T12:14:26.831304+08:00",
"customerName": "2025/12/30",
"intendedProduct": "良业",
"version": "1.9.4",
"description": "户外同一个草坪测试用yolo检测640*480图片检测时间\u003c100ms分割精度\u003c10像素出错率1/10万使用x3可以吗",
"solution": "100ms可以",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "515c58d6e5184393a60462701ee97768",
"createdAt": "2026-01-04T12:14:26.831727+08:00",
"customerName": "2025/12/30",
"intendedProduct": "良业",
"version": "1.9.4",
"description": "训练任务无效果",
"solution": "数据集少了,补数后需要保存快照",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "751cbd5497a849888641270f88012b0d",
"createdAt": "2026-01-04T12:14:26.832123+08:00",
"customerName": "2025/12/30",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "现在平台上的V2.5就可以需要完成的ckpt这个预计什么时候可以更新啊",
"solution": "",
"type": "咨询",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "c754c98a22684a9c8cec940869ca7511",
"createdAt": "2026-01-04T12:14:26.832483+08:00",
"customerName": "2025-12-31",
"intendedProduct": "诺因智能",
"version": "1.9.4",
"description": "境外网站的加速,下载部分数据集时速度会很慢",
"solution": "",
"type": "反馈",
"module": "",
"statusProgress": "",
"reporter": ""
},
{
"id": "876f234b07da45138ec2d85ea4294c9b",
"createdAt": "2026-01-04T12:14:26.83287+08:00",
"customerName": "2025/12/31",
"intendedProduct": "诺因智能",
"version": "1.9.6",
"description": "模型ckpt的匹配这个可以公开镜像也可以是根据账户权限设置的私有镜像都OK但是得有",
"solution": "",
"type": "需求",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "7db1edba452d48f8b5b2e56854bbd92c",
"createdAt": "2026-01-04T12:14:26.833264+08:00",
"customerName": "2025/12/31",
"intendedProduct": "诺因智能",
"version": "1.9.7",
"description": "模型推理的指标对比按钮无法选择",
"solution": "",
"type": "需求",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "beca6e1a2791409fb750c4a1ca1c16e8",
"createdAt": "2026-01-04T12:14:26.834896+08:00",
"customerName": "2025/12/31",
"intendedProduct": "诺因智能",
"version": "1.9.8",
"description": "推理评测可以增加定量参数推理的时候能否增加一下epe衡量模型精度的指标",
"solution": "",
"type": "需求",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
}
]

1164
frontend/css/style.css Normal file

File diff suppressed because it is too large Load Diff

438
frontend/index.html Normal file
View File

@ -0,0 +1,438 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CRM客户管理系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 50 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M30.8799 0.0634766C32.1561 0.191098 35.0914 0.25518 40.1953 3.12598C46.9838 7.51557 49.4463 14.7383 49.8291 17.8008C50.297 20.8846 50.084 28.5324 45.4902 34.4531C40.8966 40.3736 33.8362 42.194 30.8799 42.3643H9.95215V31.1357C4.45541 31.1354 0 26.6803 0 21.1836C2.62861e-05 15.6869 4.45542 11.2308 9.95215 11.2305V0H30.8799V0.0634766ZM30.8799 11.3564C28.7219 11.1986 15.882 11.302 11.4531 11.3428C16.2383 12.0662 19.9062 16.1966 19.9062 21.1836C19.9062 26.1365 16.288 30.2432 11.5508 31.0078H30.8799C33.2407 30.4973 39.1744 27.7535 38.6641 20.2891C38.2557 14.3174 33.3045 11.8457 30.8799 11.3564Z"
fill="currentColor"></path>
</svg>
<span>CRM系统</span>
</div>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-section="customer">
<i class="fas fa-user-plus"></i>
<span>客户信息管理</span>
</a>
<a href="#" class="nav-item" data-section="dashboard">
<i class="fas fa-chart-line"></i>
<span>数据仪表板</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<i class="fas fa-user-circle"></i>
<span>管理员</span>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="main-content">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<button class="menu-toggle" id="menuToggle">
<i class="fas fa-bars"></i>
</button>
<h1 id="pageTitle">客户信息管理</h1>
</div>
<div class="header-right">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="customerSearchInput" placeholder="搜索客户...">
</div>
<button class="icon-btn">
<i class="fas fa-bell"></i>
</button>
<button class="icon-btn">
<i class="fas fa-cog"></i>
</button>
</div>
</header>
<!-- Content Area -->
<main class="content-area">
<!-- Customer Management Section -->
<section id="customerSection" class="content-section active">
<div class="action-bar">
<button id="addCustomerBtn" class="btn-primary">
<i class="fas fa-plus"></i>
添加客户
</button>
<button id="importBtn" class="btn-secondary">
<i class="fas fa-file-import"></i>
导入CSV
</button>
<div class="filter-group">
<label for="customerFilter">筛选客户:</label>
<select id="customerFilter" class="filter-select">
<option value="">全部客户</option>
</select>
</div>
</div>
<!-- Customer List -->
<div class="card table-card">
<div class="card-header">
<h3><i class="fas fa-list"></i> 客户进度</h3>
<div class="table-actions">
<button id="refreshCustomersBtn" class="icon-btn" title="刷新">
<i class="fas fa-sync-alt"></i>
</button>
<button id="exportCustomersBtn" class="icon-btn" title="导出">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="customerTable">
<thead>
<tr>
<th>时间</th>
<th>客户</th>
<th>版本</th>
<th>描述</th>
<th>解决方案</th>
<th>类型</th>
<th>模块</th>
<th>状态与进度</th>
<th>报告人</th>
<th>操作</th>
</tr>
</thead>
<tbody id="customerTableBody">
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination">
<div class="pagination-info">
<span id="paginationInfo">显示 0-0 共 0 条</span>
</div>
<div class="pagination-controls">
<button id="firstPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-left"></i>
</button>
<button id="prevPage" class="pagination-btn" disabled>
<i class="fas fa-angle-left"></i>
</button>
<div id="pageNumbers" class="page-numbers">
</div>
<button id="nextPage" class="pagination-btn" disabled>
<i class="fas fa-angle-right"></i>
</button>
<button id="lastPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-right"></i>
</button>
</div>
<div class="pagination-size">
<select id="pageSizeSelect">
<option value="10">10条/页</option>
<option value="20">20条/页</option>
<option value="50">50条/页</option>
<option value="100">100条/页</option>
</select>
</div>
</div>
</div>
</div>
</section>
<!-- Dashboard Section -->
<section id="dashboardSection" class="content-section">
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<h3 id="totalCustomers">0</h3>
<p>总客户数</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-user-plus"></i>
</div>
<div class="stat-info">
<h3 id="newCustomers">0</h3>
<p>本月新增</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-box"></i>
</div>
<div class="stat-info">
<h3 id="totalProducts">0</h3>
<p>产品数</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3 id="completedTasks">0</h3>
<p>已完成</p>
</div>
</div>
</div>
<div class="dashboard-filters">
<div class="filter-group">
<label>日期范围:</label>
<input type="date" id="startDate">
<span></span>
<input type="date" id="endDate">
<button id="applyFilters" class="btn-primary">
<i class="fas fa-filter"></i>
应用筛选
</button>
</div>
</div>
<div class="dashboard-grid">
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-chart-pie"></i> 数据分布</h3>
<div class="chart-controls">
<select id="chartFieldSelect" class="chart-field-select">
<option value="intendedProduct">客户</option>
<option value="type">类型</option>
<option value="module">模块</option>
<option value="reporter">报告人</option>
</select>
<input type="text" id="statusChartTitle" class="chart-title-input"
placeholder="自定义标题" value="数据分布">
</div>
</div>
<div class="card-body">
<canvas id="statusChart"></canvas>
</div>
</div>
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-chart-doughnut"></i> 客户类型</h3>
<div class="chart-controls">
<select id="typeChartFieldSelect" class="chart-field-select">
<option value="intendedProduct">客户</option>
<option value="type">类型</option>
<option value="module">模块</option>
<option value="reporter">报告人</option>
</select>
<input type="text" id="typeChartTitle" class="chart-title-input" placeholder="自定义标题"
value="客户类型">
</div>
</div>
<div class="card-body">
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
<!-- Modal for creating customer -->
<div id="createModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-user-plus"></i> 添加新客户</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="createCustomerForm">
<div class="form-row">
<div class="form-group">
<label for="customerName">日期</label>
<input type="date" id="customerName" name="customerName" required>
</div>
<div class="form-group">
<label for="intendedProduct">客户名称</label>
<input type="text" id="intendedProduct" name="intendedProduct" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="createVersion">版本</label>
<input type="text" id="createVersion" name="version">
</div>
<div class="form-group">
<label for="createType">类型</label>
<input type="text" id="createType" name="type">
</div>
</div>
<div class="form-group">
<label for="createDescription">描述</label>
<textarea id="createDescription" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="createSolution">解决方案</label>
<textarea id="createSolution" name="solution" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="createModule">模块</label>
<select id="createModule" name="module">
<option value="">请选择模块</option>
<option value="数据生成">数据生成</option>
<option value="数据集">数据集</option>
<option value="数据空间">数据空间</option>
<option value="模型工坊">模型工坊</option>
</select>
</div>
<div class="form-group">
<label for="createStatusProgress">状态与进度</label>
<input type="text" id="createStatusProgress" name="statusProgress">
</div>
</div>
<div class="form-group">
<label for="createReporter">报告人</label>
<input type="text" id="createReporter" name="reporter">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i>
创建客户
</button>
<button type="button" class="btn-secondary cancel-create">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for importing customers -->
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-file-import"></i> 导入客户</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="importFileForm" enctype="multipart/form-data">
<div class="form-group">
<label for="importFile">选择文件支持CSV和XLSX</label>
<div class="file-upload">
<i class="fas fa-cloud-upload-alt"></i>
<input type="file" id="importFile" name="file" accept=".csv,.xlsx" required>
<span id="fileName">选择文件...</span>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-upload"></i>
导入
</button>
<button type="button" class="btn-secondary cancel-import">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for editing customer -->
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-edit"></i> 编辑客户</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="editCustomerForm">
<input type="hidden" id="editCustomerId">
<div class="form-row">
<div class="form-group">
<label for="editCustomerName">日期</label>
<input type="date" id="editCustomerName" name="customerName">
</div>
<div class="form-group">
<label for="editIntendedProduct">客户名称</label>
<input type="text" id="editIntendedProduct" name="intendedProduct">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="editVersion">版本</label>
<input type="text" id="editVersion" name="version">
</div>
<div class="form-group">
<label for="editType">类型</label>
<input type="text" id="editType" name="type">
</div>
</div>
<div class="form-group">
<label for="editDescription">描述</label>
<textarea id="editDescription" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="editSolution">解决方案</label>
<textarea id="editSolution" name="solution" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="editModule">模块</label>
<select id="editModule" name="module">
<option value="">请选择模块</option>
<option value="数据生成">数据生成</option>
<option value="数据集">数据集</option>
<option value="数据空间">数据空间</option>
<option value="模型工坊">模型工坊</option>
</select>
</div>
<div class="form-group">
<label for="editStatusProgress">状态与进度</label>
<input type="text" id="editStatusProgress" name="statusProgress">
</div>
</div>
<div class="form-group">
<label for="editReporter">报告人</label>
<input type="text" id="editReporter" name="reporter">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i>
更新客户
</button>
<button type="button" class="btn-secondary cancel-edit">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/static/js/main.js"></script>
</body>
</html>

1082
frontend/js/main.js Normal file

File diff suppressed because it is too large Load Diff

161
frontend/login.html Normal file
View File

@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - CRM客户管理系统</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.login-card {
background: white;
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header i {
font-size: 3rem;
color: #FF6B35;
margin-bottom: 1rem;
}
.login-header h2 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #666;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #FF6B35;
}
.login-btn {
width: 100%;
padding: 0.75rem;
background-color: #FF6B35;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
.login-btn:hover {
background-color: #e85a2a;
}
.error-msg {
color: #e74c3c;
font-size: 0.85rem;
margin-top: 1rem;
text-align: center;
display: none;
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<svg width="48" height="48" viewBox="0 0 50 43" fill="none" xmlns="http://www.w3.org/2000/svg"
style="color: #FF6B35; margin-bottom: 1rem;">
<path
d="M30.8799 0.0634766C32.1561 0.191098 35.0914 0.25518 40.1953 3.12598C46.9838 7.51557 49.4463 14.7383 49.8291 17.8008C50.297 20.8846 50.084 28.5324 45.4902 34.4531C40.8966 40.3736 33.8362 42.194 30.8799 42.3643H9.95215V31.1357C4.45541 31.1354 0 26.6803 0 21.1836C2.62861e-05 15.6869 4.45542 11.2308 9.95215 11.2305V0H30.8799V0.0634766ZM30.8799 11.3564C28.7219 11.1986 15.882 11.302 11.4531 11.3428C16.2383 12.0662 19.9062 16.1966 19.9062 21.1836C19.9062 26.1365 16.288 30.2432 11.5508 31.0078H30.8799C33.2407 30.4973 39.1744 27.7535 38.6641 20.2891C38.2557 14.3174 33.3045 11.8457 30.8799 11.3564Z"
fill="currentColor"></path>
</svg>
<h2>CRM 系统登录</h2>
</div>
<form id="loginForm">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" required placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" required placeholder="请输入密码">
</div>
<button type="submit" class="login-btn">立即登录</button>
<div id="errorMsg" class="error-msg"></div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function (e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorMsg = document.getElementById('errorMsg');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('crmToken', data.token);
window.location.href = '/';
} else {
const text = await response.text();
errorMsg.textContent = text || '登录失败,请检查用户名和密码';
errorMsg.style.display = 'block';
}
} catch (error) {
console.error('Login error:', error);
errorMsg.textContent = '服务器连接失败,请稍后再试';
errorMsg.style.display = 'block';
}
});
// 如果已经登录,直接跳转
if (localStorage.getItem('crmToken')) {
window.location.href = '/';
}
</script>
</body>
</html>

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module crm-go
go 1.21
require github.com/xuri/excelize/v2 v2.8.0
require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/xuri/efp v0.0.0-20230802181842-ad255f2331ca // indirect
github.com/xuri/nfp v0.0.0-20230819163627-dc951e3ffe1a // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/text v0.12.0 // indirect
)

73
go.sum Normal file
View File

@ -0,0 +1,73 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/xuri/efp v0.0.0-20230802181842-ad255f2331ca h1:uvPMDVyP7PXMMioYdyPH+0O+Ta/UO1WFfNYMO3Wz0eg=
github.com/xuri/efp v0.0.0-20230802181842-ad255f2331ca/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.0 h1:Vd4Qy809fupgp1v7X+nCS/MioeQmYVVzi495UCTqB7U=
github.com/xuri/excelize/v2 v2.8.0/go.mod h1:6iA2edBTKxKbZAa7X5bDhcCg51xdOn1Ar5sfoXRGrQg=
github.com/xuri/nfp v0.0.0-20230819163627-dc951e3ffe1a h1:Mw2VNrNNNjDtw68VsEj2+st+oCSn4Uz7vZw6TbhcV1o=
github.com/xuri/nfp v0.0.0-20230819163627-dc951e3ffe1a/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
// JWTSecret 应该在实际环境中使用环境变量
var JWTSecret = []byte("crm-go-secret-key")
// LoginRequest 登录请求结构
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse 登录返回结构
type LoginResponse struct {
Token string `json:"token"`
}
// AuthHandler 身份验证处理器
type AuthHandler struct{}
// NewAuthHandler 创建身份验证处理器
func NewAuthHandler() *AuthHandler {
return &AuthHandler{}
}
// Login 处理登录请求
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "方法不允许", http.StatusMethodNotAllowed)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求参数错误", http.StatusBadRequest)
return
}
// 验证用户名和密码
if req.Username != "admin" || req.Password != "admin123" {
http.Error(w, "用户名或密码错误", http.StatusUnauthorized)
return
}
// 生成 JWT Token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": req.Username,
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24小时过期
})
tokenString, err := token.SignedString(JWTSecret)
if err != nil {
http.Error(w, "无法生成Token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{Token: tokenString})
}

View File

@ -0,0 +1,358 @@
package handlers
import (
"encoding/csv"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"crm-go/internal/storage"
"crm-go/models"
"github.com/xuri/excelize/v2"
)
type CustomerHandler struct {
storage storage.CustomerStorage
}
func NewCustomerHandler(storage storage.CustomerStorage) *CustomerHandler {
return &CustomerHandler{
storage: storage,
}
}
func (h *CustomerHandler) GetCustomers(w http.ResponseWriter, r *http.Request) {
page := 1
pageSize := 10
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if pageSizeStr := r.URL.Query().Get("pageSize"); pageSizeStr != "" {
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
pageSize = ps
}
}
customers, err := h.storage.GetAllCustomers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
total := len(customers)
totalPages := (total + pageSize - 1) / pageSize
start := (page - 1) * pageSize
end := start + pageSize
if start >= total {
customers = []models.Customer{}
} else if end > total {
customers = customers[start:]
} else {
customers = customers[start:end]
}
response := map[string]interface{}{
"customers": customers,
"total": total,
"page": page,
"pageSize": pageSize,
"totalPages": totalPages,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *CustomerHandler) GetCustomerByID(w http.ResponseWriter, r *http.Request) {
// Extract customer ID from URL path
urlPath := r.URL.Path
// The path should be /api/customers/{id}
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3] // Get the ID from the path
// Remove query parameters if any
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
customer, err := h.storage.GetCustomerByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if customer == nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(customer)
}
func (h *CustomerHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) {
var req models.CreateCustomerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
customer := models.Customer{
CustomerName: req.CustomerName,
IntendedProduct: req.IntendedProduct,
Version: req.Version,
Description: req.Description,
Solution: req.Solution,
Type: req.Type,
Module: req.Module,
StatusProgress: req.StatusProgress,
Reporter: req.Reporter,
CreatedAt: time.Now(),
}
if err := h.storage.CreateCustomer(customer); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(customer)
}
func (h *CustomerHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
// Extract customer ID from URL path
urlPath := r.URL.Path
// The path should be /api/customers/{id}
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3] // Get the ID from the path
// Remove query parameters if any
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
var req models.UpdateCustomerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.storage.UpdateCustomer(id, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *CustomerHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
// Extract customer ID from URL path
urlPath := r.URL.Path
// The path should be /api/customers/{id}
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3] // Get the ID from the path
// Remove query parameters if any
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if err := h.storage.DeleteCustomer(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *CustomerHandler) ImportCustomers(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20) // 10 MB
if err != nil {
http.Error(w, "Unable to parse form", http.StatusBadRequest)
return
}
file, handler, err := r.FormFile("file")
if err != nil {
http.Error(w, "Unable to get file", http.StatusBadRequest)
return
}
defer file.Close()
// Check file extension
filename := handler.Filename
isExcel := strings.HasSuffix(strings.ToLower(filename), ".xlsx")
importedCount := 0
duplicateCount := 0
if isExcel {
// Parse as Excel file
f, err := excelize.OpenReader(file)
if err != nil {
http.Error(w, "Unable to open Excel file", http.StatusBadRequest)
return
}
defer f.Close()
// Get the first sheet
sheets := f.GetSheetList()
if len(sheets) == 0 {
http.Error(w, "No sheets found in Excel file", http.StatusBadRequest)
return
}
// Read all rows from the first sheet
rows, err := f.GetRows(sheets[0])
if err != nil {
http.Error(w, "Unable to read Excel file", http.StatusBadRequest)
return
}
// Skip header row if exists
for i, row := range rows {
if i == 0 {
continue // Skip header row
}
if len(row) < 2 {
continue // Skip rows with insufficient data
}
customer := models.Customer{
ID: "", // Will be generated by the storage
CreatedAt: time.Now(),
CustomerName: getValue(row, 0),
IntendedProduct: getValue(row, 1),
Version: getValue(row, 2),
Description: getValue(row, 3),
Solution: getValue(row, 4),
Type: getValue(row, 5),
Module: getValue(row, 6),
StatusProgress: getValue(row, 7),
Reporter: getValue(row, 8),
}
// Check for duplicate
exists, err := h.storage.CustomerExists(customer)
if err != nil {
http.Error(w, "Error checking for duplicate customer", http.StatusInternalServerError)
return
}
if exists {
duplicateCount++
continue // Skip duplicate
}
if err := h.storage.CreateCustomer(customer); err != nil {
http.Error(w, "Error saving customer", http.StatusInternalServerError)
return
}
importedCount++
}
} else {
// Parse as CSV
content, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Unable to read file", http.StatusBadRequest)
return
}
reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll()
if err != nil {
http.Error(w, "Unable to parse CSV file", http.StatusBadRequest)
return
}
// Skip header row if exists
for i, row := range records {
if i == 0 {
continue // Skip header row
}
if len(row) < 2 {
continue // Skip rows with insufficient data
}
customer := models.Customer{
ID: "", // Will be generated by the storage
CreatedAt: time.Now(),
CustomerName: getValue(row, 0),
IntendedProduct: getValue(row, 1),
Version: getValue(row, 2),
Description: getValue(row, 3),
Solution: getValue(row, 4),
Type: getValue(row, 5),
Module: getValue(row, 6),
StatusProgress: getValue(row, 7),
Reporter: getValue(row, 8),
}
// Check for duplicate
exists, err := h.storage.CustomerExists(customer)
if err != nil {
http.Error(w, "Error checking for duplicate customer", http.StatusInternalServerError)
return
}
if exists {
duplicateCount++
continue // Skip duplicate
}
if err := h.storage.CreateCustomer(customer); err != nil {
http.Error(w, "Error saving customer", http.StatusInternalServerError)
return
}
importedCount++
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Customers imported successfully",
"importedCount": importedCount,
"duplicateCount": duplicateCount,
})
}
func getValue(row []string, index int) string {
if index < len(row) {
return row[index]
}
return ""
}

View File

@ -0,0 +1,59 @@
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"crm-go/internal/handlers"
"github.com/golang-jwt/jwt/v5"
)
// AuthMiddleware 身份验证中间件
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 登录接口和静态资源不需要认证
path := r.URL.Path
if path == "/api/login" || !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "未授权访问", http.StatusUnauthorized)
return
}
// 解析 Bearer Token
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "无效的校验格式", http.StatusUnauthorized)
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("未预期的签名方法: %v", token.Header["alg"])
}
return handlers.JWTSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "无效或过期的Token", http.StatusUnauthorized)
return
}
// 将用户信息存入 context
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
ctx := context.WithValue(r.Context(), "username", claims["username"])
next.ServeHTTP(w, r.WithContext(ctx))
return
}
http.Error(w, "解析权限出错", http.StatusUnauthorized)
})
}

View File

@ -0,0 +1,214 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"sync"
"path/filepath"
"time"
"crm-go/models"
)
type CustomerStorage interface {
GetAllCustomers() ([]models.Customer, error)
GetCustomerByID(id string) (*models.Customer, error)
CreateCustomer(customer models.Customer) error
UpdateCustomer(id string, updates models.UpdateCustomerRequest) error
DeleteCustomer(id string) error
SaveCustomers(customers []models.Customer) error
LoadCustomers() ([]models.Customer, error)
CustomerExists(customer models.Customer) (bool, error)
}
type customerStorage struct {
filePath string
mutex sync.RWMutex
}
func NewCustomerStorage(filePath string) CustomerStorage {
storage := &customerStorage{
filePath: filePath,
}
// Ensure the directory exists
dir := os.DirFS(filePath[:len(filePath)-len("/customers.json")])
_ = dir // Use the directory to ensure it exists
return storage
}
func (cs *customerStorage) GetAllCustomers() ([]models.Customer, error) {
cs.mutex.RLock()
defer cs.mutex.RUnlock()
return cs.LoadCustomers()
}
func (cs *customerStorage) GetCustomerByID(id string) (*models.Customer, error) {
cs.mutex.RLock()
defer cs.mutex.RUnlock()
customers, err := cs.LoadCustomers()
if err != nil {
return nil, err
}
for _, customer := range customers {
if customer.ID == id {
return &customer, nil
}
}
return nil, nil
}
func (cs *customerStorage) CreateCustomer(customer models.Customer) error {
cs.mutex.Lock()
defer cs.mutex.Unlock()
if customer.ID == "" {
customer.ID = generateUUID()
}
if customer.CreatedAt.IsZero() {
customer.CreatedAt = time.Now()
}
customers, err := cs.LoadCustomers()
if err != nil {
return err
}
customers = append(customers, customer)
return cs.SaveCustomers(customers)
}
func generateUUID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant
return hex.EncodeToString(bytes)
}
func (cs *customerStorage) UpdateCustomer(id string, updates models.UpdateCustomerRequest) error {
cs.mutex.Lock()
defer cs.mutex.Unlock()
customers, err := cs.LoadCustomers()
if err != nil {
return err
}
for i, customer := range customers {
if customer.ID == id {
if updates.CustomerName != nil {
customers[i].CustomerName = *updates.CustomerName
}
if updates.IntendedProduct != nil {
customers[i].IntendedProduct = *updates.IntendedProduct
}
if updates.Version != nil {
customers[i].Version = *updates.Version
}
if updates.Description != nil {
customers[i].Description = *updates.Description
}
if updates.Solution != nil {
customers[i].Solution = *updates.Solution
}
if updates.Type != nil {
customers[i].Type = *updates.Type
}
if updates.Module != nil {
customers[i].Module = *updates.Module
}
if updates.StatusProgress != nil {
customers[i].StatusProgress = *updates.StatusProgress
}
if updates.Reporter != nil {
customers[i].Reporter = *updates.Reporter
}
return cs.SaveCustomers(customers)
}
}
return nil // Customer not found, but not an error
}
func (cs *customerStorage) DeleteCustomer(id string) error {
cs.mutex.Lock()
defer cs.mutex.Unlock()
customers, err := cs.LoadCustomers()
if err != nil {
return err
}
for i, customer := range customers {
if customer.ID == id {
customers = append(customers[:i], customers[i+1:]...)
return cs.SaveCustomers(customers)
}
}
return nil // Customer not found, but not an error
}
func (cs *customerStorage) SaveCustomers(customers []models.Customer) error {
// Ensure the directory exists
dir := filepath.Dir(cs.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(customers, "", " ")
if err != nil {
return err
}
return os.WriteFile(cs.filePath, data, 0644)
}
func (cs *customerStorage) LoadCustomers() ([]models.Customer, error) {
// Check if file exists
if _, err := os.Stat(cs.filePath); os.IsNotExist(err) {
// Return empty slice if file doesn't exist
return []models.Customer{}, nil
}
data, err := os.ReadFile(cs.filePath)
if err != nil {
return nil, err
}
var customers []models.Customer
if err := json.Unmarshal(data, &customers); err != nil {
return nil, err
}
return customers, nil
}
func (cs *customerStorage) CustomerExists(customer models.Customer) (bool, error) {
cs.mutex.RLock()
defer cs.mutex.RUnlock()
customers, err := cs.LoadCustomers()
if err != nil {
return false, err
}
for _, existingCustomer := range customers {
if existingCustomer.Description == customer.Description {
return true, nil
}
}
return false, nil
}

41
models/customer.go Normal file
View File

@ -0,0 +1,41 @@
package models
import "time"
type Customer struct {
ID string `json:"id" csv:"id"`
CreatedAt time.Time `json:"createdAt" csv:"createdAt"`
CustomerName string `json:"customerName" csv:"customerName"`
IntendedProduct string `json:"intendedProduct" csv:"intendedProduct"`
Version string `json:"version" csv:"version"`
Description string `json:"description" csv:"description"`
Solution string `json:"solution" csv:"solution"`
Type string `json:"type" csv:"type"`
Module string `json:"module" csv:"module"`
StatusProgress string `json:"statusProgress" csv:"statusProgress"`
Reporter string `json:"reporter" csv:"reporter"`
}
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"`
}
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"`
}

5
sample_customers.csv Normal file
View File

@ -0,0 +1,5 @@
Customer Name,Intended Product,Version,Description,Solution,Type,Module,Status & Progress,Reporter
ABC Company,Product A,v1.0,Enterprise client,Cloud solution,Enterprise,CRM,In Progress,John Doe
XYZ Corp,Product B,v2.1,New prospect,On-premise,Prospect,Sales,Lead,Jane Smith
Tech Solutions,Product C,v1.5,Existing customer,Hybrid,Existing,Support,Closed Won,Mike Johnson
Global Inc,Product A,v2.0,International client,Cloud solution,Enterprise,CRM,Negotiation,Sarah Wilson
1 Customer Name Intended Product Version Description Solution Type Module Status & Progress Reporter
2 ABC Company Product A v1.0 Enterprise client Cloud solution Enterprise CRM In Progress John Doe
3 XYZ Corp Product B v2.1 New prospect On-premise Prospect Sales Lead Jane Smith
4 Tech Solutions Product C v1.5 Existing customer Hybrid Existing Support Closed Won Mike Johnson
5 Global Inc Product A v2.0 International client Cloud solution Enterprise CRM Negotiation Sarah Wilson