feat: Add initial Go CRM application with customer CRUD, import, authentication, and basic UI.
This commit is contained in:
commit
77abd6e599
84
README.md
Normal file
84
README.md
Normal 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
17
build.sh
Executable 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
113
cmd/server/main.go
Normal 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
236
data/customers.json
Normal 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": "4090,24g,然后生图是单卡单线程\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
1164
frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
438
frontend/index.html
Normal file
438
frontend/index.html
Normal 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">×</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">×</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">×</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
1082
frontend/js/main.js
Normal file
File diff suppressed because it is too large
Load Diff
161
frontend/login.html
Normal file
161
frontend/login.html
Normal 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
17
go.mod
Normal 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
73
go.sum
Normal 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=
|
||||
66
internal/handlers/auth_handler.go
Normal file
66
internal/handlers/auth_handler.go
Normal 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})
|
||||
}
|
||||
358
internal/handlers/customer_handler.go
Normal file
358
internal/handlers/customer_handler.go
Normal 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 ""
|
||||
}
|
||||
59
internal/middleware/auth_middleware.go
Normal file
59
internal/middleware/auth_middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
214
internal/storage/customer_storage.go
Normal file
214
internal/storage/customer_storage.go
Normal 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
41
models/customer.go
Normal 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
5
sample_customers.csv
Normal 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
|
||||
|
Loading…
x
Reference in New Issue
Block a user