This commit is contained in:
hangyu.tao 2026-01-13 18:02:43 +08:00
parent 558d51abe8
commit 765dea7359
20 changed files with 4266 additions and 162 deletions

137
IMPLEMENTATION_SUMMARY.md Normal file
View File

@ -0,0 +1,137 @@
# 客户跟进功能实现总结
## 已完成的改动
### 1. 侧边栏导航
✅ 在"客户信息管理"旁边新增了"客户跟进"导航项
### 2. 客户跟进页面
✅ 创建了完整的客户跟进管理页面,包含:
#### 2.1 表单字段
- **客户名称**: 下拉框选择,数据源来自客户列表
- **成交状态**: 下拉框选择 (未成交/已成交)
- **客户级别**: 下拉框选择
- A级 - 重点客户
- B级 - 潜在客户
- C级 - 一般客户
- **客户行业**: 文本输入框
- **跟进时间**: 日期时间选择器
#### 2.2 跟进列表
- 显示所有跟进记录
- 包含分页功能
- 显示通知状态 (已通知/待通知)
- 支持删除操作
### 3. 后端实现
#### 3.1 数据模型 (`models/followup.go`)
- FollowUp 结构体
- CreateFollowUpRequest
- UpdateFollowUpRequest
#### 3.2 存储层 (`internal/storage/followup_storage.go`)
- CRUD 操作
- 获取待通知的跟进记录
- 标记通知已发送
#### 3.3 处理器 (`internal/handlers/followup_handler.go`)
- HTTP 请求处理
- 飞书 Webhook 集成
- 自动通知检查
#### 3.4 API 路由 (`cmd/server/main.go`)
- GET /api/followups - 获取跟进列表
- POST /api/followups - 创建跟进
- GET /api/followups/:id - 获取单个跟进
- PUT /api/followups/:id - 更新跟进
- DELETE /api/followups/:id - 删除跟进
- GET /api/customers/list - 获取客户列表
### 4. 飞书机器人通知
#### 4.1 通知机制
- 后台定时任务每分钟检查一次
- 当跟进时间到达时自动发送通知
- 通知内容: "@所有人 请及时跟进\"客户名称\""
- 使用飞书卡片消息格式
#### 4.2 配置方式
通过环境变量配置:
```bash
export FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx"
```
### 5. 前端实现 (`frontend/`)
#### 5.1 HTML (`index.html`)
- 新增客户跟进导航项
- 新增客户跟进页面区域
- 表单和列表展示
#### 5.2 JavaScript (`js/main.js`)
- 页面切换逻辑
- 表单提交处理
- 数据加载和渲染
- 分页控制
- 删除操作
## 技术特点
1. **完整的 CRUD 功能**: 支持创建、读取、更新、删除跟进记录
2. **自动化通知**: 后台定时检查并自动发送飞书通知
3. **用户友好**:
- 客户名称下拉选择
- 日期时间选择器
- 清晰的通知状态显示
4. **数据持久化**: 使用 JSON 文件存储
5. **分页支持**: 前后端都支持分页
6. **响应式设计**: 适配移动端和桌面端
## 数据存储
- 跟进记录存储在: `./data/followups.json`
- 客户数据存储在: `./data/customers.json`
## 使用说明
1. 点击侧边栏"客户跟进"进入页面
2. 点击"添加跟进"按钮显示表单
3. 填写所有必填字段
4. 点击"确认"创建跟进记录
5. 系统会在设定时间自动发送飞书通知
## 注意事项
1. 需要配置 `FEISHU_WEBHOOK_URL` 环境变量才能使用通知功能
2. 飞书机器人需要添加到对应的群聊中
3. 确保服务器时区设置正确
4. 每条跟进记录只会发送一次通知
## 文件清单
### 新增文件:
- `models/followup.go` - 跟进数据模型
- `internal/storage/followup_storage.go` - 跟进存储层
- `internal/handlers/followup_handler.go` - 跟进处理器
- `README_FOLLOWUP.md` - 功能说明文档
- `IMPLEMENTATION_SUMMARY.md` - 本文件
### 修改文件:
- `cmd/server/main.go` - 添加跟进路由和后台任务
- `frontend/index.html` - 添加跟进页面UI
- `frontend/js/main.js` - 添加跟进页面逻辑
## 构建和运行
```bash
# 构建
go build ./cmd/server/main.go
# 运行 (可选配置飞书 Webhook)
export FEISHU_WEBHOOK_URL="your_webhook_url"
go run ./cmd/server/main.go
```
服务器将在 http://localhost:8081 启动

85
README_FOLLOWUP.md Normal file
View File

@ -0,0 +1,85 @@
# 客户跟进功能说明
## 功能概述
新增的客户跟进功能允许您:
- 创建客户跟进记录
- 设置跟进时间
- 自动通过飞书机器人发送提醒
## 功能特性
### 1. 客户跟进表单
- **客户名称**: 从现有客户列表中选择
- **成交状态**: 未成交 / 已成交
- **客户级别**:
- A级 (重点客户)
- B级 (潜在客户)
- C级 (一般客户)
- **客户行业**: 手动输入
- **跟进时间**: 选择具体的日期和时间
### 2. 飞书通知
当到达设定的跟进时间后,系统会自动通过飞书机器人发送提醒消息。
消息格式:
```
客户跟进提醒
@所有人 请及时跟进"客户名称"
```
## 配置飞书 Webhook
### 步骤 1: 创建飞书机器人
1. 在飞书群聊中,点击右上角设置
2. 选择"群机器人" -> "添加机器人"
3. 选择"自定义机器人"
4. 设置机器人名称和描述
5. 复制生成的 Webhook URL
### 步骤 2: 配置环境变量
在启动服务器之前,设置环境变量:
```bash
export FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx"
```
或者在 `.env` 文件中添加:
```
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx
```
### 步骤 3: 启动服务器
```bash
go run ./cmd/server/main.go
```
## 通知机制
- 系统每分钟检查一次是否有需要发送的跟进提醒
- 当跟进时间到达后,自动发送飞书通知
- 通知发送后,记录状态会更新为"已通知"
- 每条跟进记录只会发送一次通知
## 数据存储
跟进记录存储在 `./data/followups.json` 文件中。
## API 端点
- `GET /api/followups` - 获取跟进列表
- `POST /api/followups` - 创建跟进记录
- `GET /api/followups/:id` - 获取单个跟进记录
- `PUT /api/followups/:id` - 更新跟进记录
- `DELETE /api/followups/:id` - 删除跟进记录
- `GET /api/customers/list` - 获取客户名称列表
## 注意事项
1. 如果未配置 `FEISHU_WEBHOOK_URL`,通知功能将不可用,但其他功能正常工作
2. 确保服务器时区设置正确,以保证通知时间准确
3. 飞书机器人需要在对应的群聊中添加才能发送消息

View File

@ -4,21 +4,113 @@ import (
"crm-go/internal/handlers"
"crm-go/internal/middleware"
"crm-go/internal/storage"
"crm-go/services"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
// Initialize storage
customerStorage := storage.NewCustomerStorage("./data/customers.json")
followUpStorage := storage.NewFollowUpStorage("./data/followups.json")
trialPeriodStorage := storage.NewTrialPeriodStorage("./data/trial_periods.json")
// Get Feishu webhook URL from environment variable
feishuWebhook := "https://open.feishu.cn/open-apis/bot/v2/hook/d75c14ad-d782-489e-8a99-81b511ee4abd"
// Initialize handlers
customerHandler := handlers.NewCustomerHandler(customerStorage)
customerHandler := handlers.NewCustomerHandler(customerStorage, feishuWebhook)
followUpHandler := handlers.NewFollowUpHandler(followUpStorage, customerStorage, feishuWebhook)
trialPeriodHandler := handlers.NewTrialPeriodHandler(trialPeriodStorage, customerStorage, feishuWebhook)
authHandler := handlers.NewAuthHandler()
// Start notification checker in background
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := followUpHandler.CheckAndSendNotifications(); err != nil {
log.Printf("Error checking notifications: %v", err)
}
}
}()
// Start trial expiry checker in background
trialChecker := services.NewTrialExpiryChecker(feishuWebhook)
go func() {
// Check immediately on startup
trialPeriods, err := trialPeriodStorage.GetAllTrialPeriods()
if err == nil {
customers, err := customerStorage.GetAllCustomers()
if err == nil {
// Create customer name map
customersMap := make(map[string]string)
for _, c := range customers {
customersMap[c.ID] = c.CustomerName
}
// Convert to services.TrialPeriod type
serviceTrialPeriods := make([]services.TrialPeriod, len(trialPeriods))
for i, tp := range trialPeriods {
serviceTrialPeriods[i] = services.TrialPeriod{
ID: tp.ID,
CustomerID: tp.CustomerID,
StartTime: tp.StartTime,
EndTime: tp.EndTime,
CreatedAt: tp.CreatedAt,
}
}
if err := trialChecker.CheckTrialPeriodsAndNotify(serviceTrialPeriods, customersMap); err != nil {
log.Printf("Error checking trial expiry: %v", err)
}
}
}
// Then check once per day at 10:00 AM
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for range ticker.C {
trialPeriods, err := trialPeriodStorage.GetAllTrialPeriods()
if err != nil {
log.Printf("Error loading trial periods for expiry check: %v", err)
continue
}
customers, err := customerStorage.GetAllCustomers()
if err != nil {
log.Printf("Error loading customers for trial check: %v", err)
continue
}
// Create customer name map
customersMap := make(map[string]string)
for _, c := range customers {
customersMap[c.ID] = c.CustomerName
}
// Convert to services.TrialPeriod type
serviceTrialPeriods := make([]services.TrialPeriod, len(trialPeriods))
for i, tp := range trialPeriods {
serviceTrialPeriods[i] = services.TrialPeriod{
ID: tp.ID,
CustomerID: tp.CustomerID,
StartTime: tp.StartTime,
EndTime: tp.EndTime,
CreatedAt: tp.CreatedAt,
}
}
if err := trialChecker.CheckTrialPeriodsAndNotify(serviceTrialPeriods, customersMap); err != nil {
log.Printf("Error checking trial expiry: %v", err)
}
}
}()
// Enable CORS manually
corsHandler := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -90,6 +182,104 @@ func main() {
http.NotFound(w, r)
})
// Follow-up routes
http.HandleFunc("/api/followups", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
followUpHandler.GetFollowUps(w, r)
case "POST":
followUpHandler.CreateFollowUp(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/followups/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Handle follow-up ID endpoints
if strings.HasPrefix(path, "/api/followups/") && path != "/api/followups/" {
// Extract follow-up ID from URL
id := strings.TrimPrefix(path, "/api/followups/")
// Remove query parameters if any
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if id != "" {
if r.Method == "GET" {
followUpHandler.GetFollowUpByID(w, r)
return
}
if r.Method == "PUT" {
followUpHandler.UpdateFollowUp(w, r)
return
}
if r.Method == "DELETE" {
followUpHandler.DeleteFollowUp(w, r)
return
}
}
}
http.NotFound(w, r)
})
// Customer list endpoint for follow-up form
http.HandleFunc("/api/customers/list", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
followUpHandler.GetCustomerList(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Trial period routes
http.HandleFunc("/api/trial-periods", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
trialPeriodHandler.GetTrialPeriodsByCustomer(w, r)
case "POST":
trialPeriodHandler.CreateTrialPeriod(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Get all trial periods
http.HandleFunc("/api/trial-periods/all", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
trialPeriodHandler.GetAllTrialPeriods(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/trial-periods/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "/api/trial-periods/") && path != "/api/trial-periods/" {
id := strings.TrimPrefix(path, "/api/trial-periods/")
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if id != "" {
if r.Method == "PUT" {
trialPeriodHandler.UpdateTrialPeriod(w, r)
return
}
if r.Method == "DELETE" {
trialPeriodHandler.DeleteTrialPeriod(w, r)
return
}
}
}
http.NotFound(w, r)
})
// Serve static files for the frontend
staticDir := "./frontend"
if _, err := os.Stat(staticDir); os.IsNotExist(err) {

View File

@ -1,7 +1,7 @@
[
{
"id": "2ae2925481d541edbd29f7342f77d7a9",
"createdAt": "2026-01-08T19:29:51.290189+08:00",
"id": "d299725547a4497cab919f51bb9a9656",
"createdAt": "2026-01-12T19:12:29.589419+08:00",
"customerName": "予芯",
"intendedProduct": "2025/12/22",
"version": "1.9.4",
@ -9,12 +9,12 @@
"solution": "已解决",
"type": "功能问题",
"module": "模型工坊",
"statusProgress": "已修复",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "90f51409cdfb412dbeb679124c40d714",
"createdAt": "2026-01-08T19:29:51.291596+08:00",
"id": "33d02d8cef4e45ddb5ca5d9d62939739",
"createdAt": "2026-01-12T19:12:29.591027+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/23",
"version": "1.9.4",
@ -22,12 +22,12 @@
"solution": "技术优化,修复中",
"type": "功能问题",
"module": "数据空间",
"statusProgress": "已修复",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "b46a3544f2f34122a44ba48a9c115010",
"createdAt": "2026-01-08T19:29:51.292431+08:00",
"id": "1e27d5a740ef4204bcda26ae62f949b3",
"createdAt": "2026-01-12T19:12:29.59177+08:00",
"customerName": "良业",
"intendedProduct": "2025/12/23",
"version": "1.9.4",
@ -39,8 +39,8 @@
"reporter": ""
},
{
"id": "326ef5f696fc4517ad971f3c18c09d3c",
"createdAt": "2026-01-08T19:29:51.293332+08:00",
"id": "5e3ce4f5c2ca4bec84242e9539c34d2b",
"createdAt": "2026-01-12T19:12:29.592405+08:00",
"customerName": "斯蒂尔",
"intendedProduct": "2025/12/25",
"version": "1.9.4",
@ -52,8 +52,8 @@
"reporter": ""
},
{
"id": "2ea5ba08d65e4161b2a6d0f7bf0fdc65",
"createdAt": "2026-01-08T19:29:51.293906+08:00",
"id": "880de2c25e1540c09408083debc28c36",
"createdAt": "2026-01-12T19:12:29.592883+08:00",
"customerName": "求之",
"intendedProduct": "2025/12/26",
"version": "1.9.4",
@ -65,8 +65,8 @@
"reporter": ""
},
{
"id": "c526ea3480054f28960643808bb53ef9",
"createdAt": "2026-01-08T19:29:51.294402+08:00",
"id": "b67ef18dd5384eb1b1b45473b110a565",
"createdAt": "2026-01-12T19:12:29.593362+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/26",
"version": "1.9.4",
@ -78,8 +78,8 @@
"reporter": ""
},
{
"id": "4efd8a041ba54782a939c4ce7081b4a3",
"createdAt": "2026-01-08T19:29:51.29499+08:00",
"id": "126b78e8321248f0ab168eb1284c745d",
"createdAt": "2026-01-12T19:12:29.593978+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/26",
"version": "1.9.4",
@ -91,8 +91,8 @@
"reporter": ""
},
{
"id": "f90a391c662c4110b757366471a25b74",
"createdAt": "2026-01-08T19:29:51.295487+08:00",
"id": "e47573d35b95439aaf63126148271885",
"createdAt": "2026-01-12T19:12:29.594386+08:00",
"customerName": "斯蒂尔",
"intendedProduct": "2025/12/26",
"version": "1.9.4",
@ -104,8 +104,8 @@
"reporter": ""
},
{
"id": "2b18c018d30145f184e52bfcb04e82b5",
"createdAt": "2026-01-08T19:29:51.295938+08:00",
"id": "299d843ea730491997c4cae0e056baae",
"createdAt": "2026-01-12T19:12:29.594896+08:00",
"customerName": "良业",
"intendedProduct": "2025/12/26",
"version": "1.9.4",
@ -117,8 +117,8 @@
"reporter": ""
},
{
"id": "e95a8e18feb84e8782aaa927c6019871",
"createdAt": "2026-01-08T19:29:51.296574+08:00",
"id": "599c4a26023e4f48908de2145e716cdc",
"createdAt": "2026-01-12T19:12:29.595451+08:00",
"customerName": "良业",
"intendedProduct": "2025/12/29",
"version": "1.9.4",
@ -130,8 +130,8 @@
"reporter": ""
},
{
"id": "7b08b842f8bb43de99b19b19c480eee3",
"createdAt": "2026-01-08T19:29:51.29745+08:00",
"id": "50dba87f8f3c4f2c9a013f302ee07816",
"createdAt": "2026-01-12T19:12:29.595922+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/29",
"version": "1.9.4",
@ -143,8 +143,8 @@
"reporter": ""
},
{
"id": "7288f08cb73b4c1ba498c28cda3bf55e",
"createdAt": "2026-01-08T19:29:51.298267+08:00",
"id": "564e214d8ea244a4a72b521cd0b42504",
"createdAt": "2026-01-12T19:12:29.596473+08:00",
"customerName": "良业",
"intendedProduct": "2025/12/30",
"version": "1.9.4",
@ -156,8 +156,8 @@
"reporter": ""
},
{
"id": "259770db2c75419f99ee0d15b226c28d",
"createdAt": "2026-01-08T19:29:51.299503+08:00",
"id": "f9a2e729845d490aa3c2e74cc61260ac",
"createdAt": "2026-01-12T19:12:29.596971+08:00",
"customerName": "良业",
"intendedProduct": "2025/12/30",
"version": "1.9.4",
@ -169,8 +169,8 @@
"reporter": ""
},
{
"id": "f62e3bcaaf884a1e9f694d5b5a609477",
"createdAt": "2026-01-08T19:29:51.300399+08:00",
"id": "5ae2913365e34f75b37dfdb4eb835081",
"createdAt": "2026-01-12T19:12:29.597439+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/30",
"version": "1.9.4",
@ -182,8 +182,8 @@
"reporter": ""
},
{
"id": "02926ba82c834267a6911a15e4b3847d",
"createdAt": "2026-01-08T19:29:51.301521+08:00",
"id": "02c7e508f59140f996aeaa5ae729edf1",
"createdAt": "2026-01-12T19:12:29.597947+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/31",
"version": "1.9.4",
@ -195,8 +195,8 @@
"reporter": ""
},
{
"id": "bb4087f2ae9940fdb7ab994b8b5f4136",
"createdAt": "2026-01-08T19:29:51.302148+08:00",
"id": "65871e42c7004a3abe1eb7e5c1d818fb",
"createdAt": "2026-01-12T19:12:29.598658+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/31",
"version": "1.9.4",
@ -208,21 +208,21 @@
"reporter": ""
},
{
"id": "d89faa6bbbb448a4bbe8b68b9cb7a5a9",
"createdAt": "2026-01-08T19:29:51.302779+08:00",
"id": "82ae50b5051a4957b160407c7ccdfb0e",
"createdAt": "2026-01-12T19:12:29.599643+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/31",
"version": "1.9.4",
"description": "模型推理的指标对比按钮无法选择",
"solution": "",
"type": "需求",
"solution": "勾选多个版本进行选择",
"type": "反馈",
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "a52d112c39ac4dadb1bb80a567feaf69",
"createdAt": "2026-01-08T19:29:51.303529+08:00",
"id": "45077caed030466dbb04b1f2cc6229b8",
"createdAt": "2026-01-12T19:12:29.600477+08:00",
"customerName": "诺因智能",
"intendedProduct": "2025/12/31",
"version": "1.9.4",
@ -232,5 +232,83 @@
"module": "模型工坊",
"statusProgress": "",
"reporter": ""
},
{
"id": "8db8d7cd971c4fb89a42a36a38d52c4f",
"createdAt": "2026-01-12T19:12:29.601807+08:00",
"customerName": "雷沃",
"intendedProduct": "2026/1/9",
"version": "1.9.4",
"description": "工作流调试出现的是黑图",
"solution": "重启comfyui服务后正常",
"type": "功能问题",
"module": "数据生成",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "f1c13410c97c44f8a530ba5429967c20",
"createdAt": "2026-01-12T19:12:29.602835+08:00",
"customerName": "杰克",
"intendedProduct": "2026-01-09",
"version": "1.9.4",
"description": "数据空间上传zip失败",
"solution": "",
"type": "功能问题",
"module": "数据空间",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "5ff1cbcf23a646f1937607c9d73c8b42",
"createdAt": "2026-01-12T19:12:29.603642+08:00",
"customerName": "雷沃",
"intendedProduct": "2026-01-12",
"version": "1.9.4",
"description": "yolo训练拓展参数mixup参数不生效",
"solution": "",
"type": "反馈",
"module": "模型工坊",
"statusProgress": "待排期",
"reporter": ""
},
{
"id": "43eaae16007948a0a57543a0d88da065",
"createdAt": "2026-01-13T16:57:24.957376+08:00",
"customerName": "雷沃",
"intendedProduct": "2026-01-13",
"version": "1.9.4",
"description": "客户想推一个镜像到咱们的仓库上传一个私有模型结果一直报错unauthorized: project lovol-trial not found ",
"solution": "重新配置镜像仓库",
"type": "咨询",
"module": "镜像管理",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "395853baa43b496b94ea0b1ef763a288",
"createdAt": "2026-01-13T17:22:11.469779+08:00",
"customerName": "有你同创",
"intendedProduct": "2026-01-07",
"version": "1.9.4",
"description": "数据空间上传数据集失败",
"solution": "回复客户先使用二进制文件上传",
"type": "咨询",
"module": "数据空间",
"statusProgress": "已完成",
"reporter": ""
},
{
"id": "51e4ad26db4e4f1f842123d3ff0d44d2",
"createdAt": "2026-01-13T17:29:17.548546+08:00",
"customerName": "杰克",
"intendedProduct": "2026-01-13",
"version": "1.9.4",
"description": "批量生成任务瑕疵图片效果不太好comfyui里通过图生图的方式还能生成比较满意的图片",
"solution": "想下如何在批量生成中添加参考图\n",
"type": "反馈",
"module": "数据生成",
"statusProgress": "进行中",
"reporter": ""
}
]

22
data/followups.json Normal file
View File

@ -0,0 +1,22 @@
[
{
"id": "77a1da52097d4b5eadebcf94dce693ab",
"createdAt": "2026-01-13T16:54:58.430813+08:00",
"customerName": "5ff1cbcf23a646f1937607c9d73c8b42",
"dealStatus": "未成交",
"customerLevel": "C",
"industry": "无",
"followUpTime": "2026-01-13T08:54:00Z",
"notificationSent": true
},
{
"id": "1ebbaed4d2834bfba0afb102be5f6e01",
"createdAt": "2026-01-13T17:17:47.753915+08:00",
"customerName": "f1c13410c97c44f8a530ba5429967c20",
"dealStatus": "未成交",
"customerLevel": "A",
"industry": "无",
"followUpTime": "2026-01-13T09:17:00Z",
"notificationSent": true
}
]

23
data/trial_periods.json Normal file
View File

@ -0,0 +1,23 @@
[
{
"id": "5ac0d86616514fd383a885f51c126f9a",
"customerId": "f1c13410c97c44f8a530ba5429967c20",
"startTime": "2026-01-08T09:18:00Z",
"endTime": "2026-01-21T10:00:00Z",
"createdAt": "2026-01-13T17:18:51.333193+08:00"
},
{
"id": "41e38867a7d94745921f8ab9985533c3",
"customerId": "43eaae16007948a0a57543a0d88da065",
"startTime": "2026-01-05T09:18:00Z",
"endTime": "2026-01-16T10:20:00Z",
"createdAt": "2026-01-13T17:19:13.229897+08:00"
},
{
"id": "2751cd4ae9f54bf5a336bc3c7e6b7b6a",
"customerId": "395853baa43b496b94ea0b1ef763a288",
"startTime": "2026-01-07T09:22:00Z",
"endTime": "2026-01-23T10:30:00Z",
"createdAt": "2026-01-13T17:22:45.877434+08:00"
}
]

View File

@ -9,9 +9,9 @@
--border-color: #e0e0e0;
--sidebar-width: 250px;
--header-height: 60px;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
* {
@ -51,7 +51,10 @@ body {
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
@ -67,6 +70,50 @@ body {
font-size: 1.8rem;
}
.sidebar-toggle {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
font-size: 1.2rem;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.3s ease;
}
.sidebar-toggle:hover {
background-color: rgba(255, 107, 53, 0.2);
color: var(--white);
}
/* Collapsed sidebar */
.sidebar.collapsed {
width: 70px;
}
.sidebar.collapsed .logo span,
.sidebar.collapsed .nav-item span,
.sidebar.collapsed .user-info span {
display: none;
}
.sidebar.collapsed .sidebar-toggle i {
transform: rotate(180deg);
}
.sidebar.collapsed .logo {
justify-content: center;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 15px;
}
.sidebar.collapsed .nav-item i {
margin-right: 0;
}
.sidebar-nav {
flex: 1;
padding: 20px 0;
@ -77,7 +124,7 @@ body {
display: flex;
align-items: center;
padding: 15px 25px;
color: rgba(255,255,255,0.7);
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: all 0.3s ease;
border-left: 3px solid transparent;
@ -85,13 +132,13 @@ body {
}
.nav-item:hover {
background-color: rgba(255,107,53,0.1);
background-color: rgba(255, 107, 53, 0.1);
color: var(--white);
border-left-color: var(--primary-orange);
}
.nav-item.active {
background-color: rgba(255,107,53,0.2);
background-color: rgba(255, 107, 53, 0.2);
color: var(--white);
border-left-color: var(--primary-orange);
}
@ -104,14 +151,14 @@ body {
.sidebar-footer {
padding: 20px;
border-top: 1px solid rgba(255,255,255,0.1);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255,255,255,0.8);
color: rgba(255, 255, 255, 0.8);
}
.user-info i {
@ -128,6 +175,10 @@ body {
transition: margin-left 0.3s ease;
}
.sidebar.collapsed~.main-content {
margin-left: 70px;
}
/* Top Header */
.top-header {
background-color: var(--white);
@ -352,24 +403,36 @@ body {
/* Action Bar */
.action-bar {
display: flex;
gap: 15px;
margin-bottom: 15px;
align-items: center;
flex-wrap: wrap;
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 15px;
margin-bottom: 25px;
align-items: center;
flex-wrap: wrap;
padding: 15px;
background-color: var(--light-gray);
border-radius: 8px;
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.filter-group label {
font-size: 0.9rem;
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
.filter-select {
@ -381,7 +444,8 @@ body {
background-color: var(--white);
cursor: pointer;
transition: all 0.3s ease;
min-width: 200px;
min-width: 150px;
max-width: 200px;
}
.filter-select:hover {
@ -394,6 +458,29 @@ body {
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
.filter-input {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.9rem;
color: var(--dark-gray);
background-color: var(--white);
cursor: pointer;
transition: all 0.3s ease;
min-width: 130px;
max-width: 160px;
}
.filter-input:hover {
border-color: var(--primary-orange);
}
.filter-input:focus {
outline: none;
border-color: var(--primary-orange);
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
/* Buttons */
.btn-primary {
background-color: var(--primary-orange);
@ -474,7 +561,7 @@ body {
.form-group textarea:focus {
outline: none;
border-color: var(--primary-orange);
box-shadow: 0 0 0 3px rgba(255,107,53,0.1);
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
.form-group textarea {
@ -503,7 +590,7 @@ body {
.file-upload:hover {
border-color: var(--primary-orange);
background-color: rgba(255,107,53,0.05);
background-color: rgba(255, 107, 53, 0.05);
}
.file-upload i {
@ -552,7 +639,7 @@ td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9rem;
max-width: 200px;
max-width: 250px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -561,7 +648,7 @@ td {
}
tr:hover td {
background-color: rgba(255,107,53,0.05);
background-color: rgba(255, 107, 53, 0.05);
}
/* Tooltip styles */
@ -586,7 +673,7 @@ td.has-overflow:hover::before {
overflow: auto;
word-wrap: break-word;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
margin-bottom: 8px;
text-align: left;
line-height: 1.5;
@ -603,7 +690,7 @@ td.has-overflow:hover::after {
border-top-color: #555;
margin-bottom: 0;
z-index: 1000;
filter: drop-shadow(0 -2px 2px rgba(0,0,0,0.2));
filter: drop-shadow(0 -2px 2px rgba(0, 0, 0, 0.2));
}
@keyframes tooltipFadeIn {
@ -611,6 +698,7 @@ td.has-overflow:hover::after {
opacity: 0;
transform: translateX(-50%) translateY(-4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-8px);
@ -636,7 +724,7 @@ td.overflow-cell {
overflow: auto;
overflow-wrap: anywhere;
pointer-events: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.1);
line-height: 1.5;
}
@ -721,7 +809,7 @@ td.overflow-cell {
.stat-icon {
width: 60px;
height: 60px;
background-color: rgba(255,107,53,0.1);
background-color: rgba(255, 107, 53, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
@ -746,6 +834,37 @@ td.overflow-cell {
font-weight: 500;
}
/* Highlighted stat card for follow-up count */
.stat-card-highlight {
background: linear-gradient(135deg, #FF6B35 0%, #F28C28 100%);
border: 2px solid #FF6B35;
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.3);
}
.stat-card-highlight:hover {
box-shadow: 0 12px 28px rgba(255, 107, 53, 0.4);
transform: translateY(-8px);
}
.stat-card-highlight .stat-icon {
background-color: rgba(255, 255, 255, 0.2);
}
.stat-card-highlight .stat-icon i {
color: var(--white);
}
.stat-card-highlight .stat-info h3 {
color: var(--white);
font-size: 2.5rem;
}
.stat-card-highlight .stat-info p {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
font-size: 1rem;
}
/* Dashboard Filters */
.dashboard-filters {
background-color: var(--white);
@ -802,7 +921,7 @@ td.overflow-cell {
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
background-color: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease;
}
@ -810,6 +929,7 @@ td.overflow-cell {
from {
opacity: 0;
}
to {
opacity: 1;
}
@ -830,6 +950,7 @@ td.overflow-cell {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
@ -873,39 +994,144 @@ td.overflow-cell {
}
#editModal .modal-content {
width: calc(100% - 40px);
max-width: 920px;
width: min(calc(100vw - var(--sidebar-width) - 80px), 800px);
max-width: 800px;
max-height: 92vh;
margin: 3vh auto;
display: flex;
flex-direction: column;
}
.sidebar.collapsed~.main-content #editModal .modal-content {
width: min(calc(100vw - 70px - 80px), 800px);
}
#editModal .modal-body {
flex: 1;
overflow: auto;
}
#editModal .form-row {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
#createModal .modal-content {
width: calc(100% - 40px);
max-width: 920px;
width: min(calc(100vw - var(--sidebar-width) - 80px), 800px);
max-width: 800px;
max-height: 92vh;
margin: 3vh auto;
display: flex;
flex-direction: column;
}
.sidebar.collapsed~.main-content #createModal .modal-content {
width: min(calc(100vw - 70px - 80px), 800px);
}
#createModal .modal-body {
flex: 1;
overflow: auto;
}
#createModal .form-row {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
/* Trial Periods Section */
.trial-periods-section {
margin-bottom: 25px;
padding: 20px;
background-color: var(--light-gray);
border-radius: 8px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-header h4 {
color: var(--dark-gray);
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.section-header h4 i {
color: var(--primary-orange);
}
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
.trial-periods-list {
background-color: var(--white);
border-radius: 6px;
overflow: hidden;
}
.trial-periods-table {
width: 100%;
border-collapse: collapse;
}
.trial-periods-table thead {
background-color: var(--light-gray);
}
.trial-periods-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: var(--dark-gray);
font-size: 0.9rem;
border-bottom: 2px solid var(--border-color);
}
.trial-periods-table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9rem;
}
.trial-periods-table tbody tr:hover {
background-color: var(--light-gray);
}
.trial-action-btns {
display: flex;
gap: 8px;
}
.trial-action-btns .action-btn {
padding: 4px 8px;
font-size: 0.85rem;
}
.no-data-message {
padding: 30px;
text-align: center;
color: var(--medium-gray);
font-size: 0.95rem;
}
.divider {
height: 1px;
background-color: var(--border-color);
margin: 25px 0;
}
/* Trial Period Modals */
#addTrialPeriodModal .modal-content,
#editTrialPeriodModal .modal-content {
max-width: 500px;
}
/* Responsive Design */
@ -954,6 +1180,7 @@ td.overflow-cell {
.action-bar {
flex-direction: column;
align-items: stretch;
}
.action-bar button {
@ -961,11 +1188,24 @@ td.overflow-cell {
justify-content: center;
}
.filter-group {
width: 100%;
justify-content: space-between;
}
.filter-select,
.filter-input {
flex: 1;
min-width: 0;
max-width: none;
}
table {
font-size: 0.8rem;
}
th, td {
th,
td {
padding: 10px;
}

View File

@ -8,12 +8,13 @@
<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>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<aside class="sidebar" id="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">
@ -23,12 +24,23 @@
</svg>
<span>CRM系统</span>
</div>
<button class="sidebar-toggle" id="sidebarToggle" title="收起侧边栏">
<i class="fas fa-chevron-left"></i>
</button>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-section="customer">
<i class="fas fa-user-plus"></i>
<span>客户信息管理</span>
<span>客户信息</span>
</a>
<a href="#" class="nav-item" data-section="trialPeriods">
<i class="fas fa-clock"></i>
<span>客户试用时间</span>
</a>
<a href="#" class="nav-item" data-section="followup">
<i class="fas fa-tasks"></i>
<span>客户跟进</span>
</a>
<a href="#" class="nav-item" data-section="dashboard">
<i class="fas fa-chart-line"></i>
@ -52,7 +64,7 @@
<button class="menu-toggle" id="menuToggle">
<i class="fas fa-bars"></i>
</button>
<h1 id="pageTitle">客户信息管理</h1>
<h1 id="pageTitle">客户信息</h1>
</div>
<div class="header-right">
<div class="search-box">
@ -81,12 +93,40 @@
<i class="fas fa-file-import"></i>
导入CSV
</button>
</div>
<div class="filter-bar">
<div class="filter-group">
<label for="customerFilter">筛选客户:</label>
<select id="customerFilter" class="filter-select">
<option value="">全部客户</option>
</select>
</div>
<div class="filter-group">
<label for="typeFilter">筛选类型:</label>
<select id="typeFilter" class="filter-select">
<option value="">全部类型</option>
</select>
</div>
<div class="filter-group">
<label for="statusProgressFilter">状态与进度:</label>
<select id="statusProgressFilter" class="filter-select">
<option value="">全部状态</option>
<option value="已完成">已完成</option>
<option value="进行中">进行中</option>
<option value="待排期">待排期</option>
<option value="已驳回">已驳回</option>
<option value="已上线">已上线</option>
</select>
</div>
<div class="filter-group">
<label for="customerStartDate">开始日期:</label>
<input type="date" id="customerStartDate" class="filter-input">
</div>
<div class="filter-group">
<label for="customerEndDate">结束日期:</label>
<input type="date" id="customerEndDate" class="filter-input">
</div>
</div>
<!-- Customer List -->
@ -108,14 +148,13 @@
<thead>
<tr>
<th>客户</th>
<th>咨询时间</th>
<th>版本</th>
<th>描述</th>
<th>解决方案</th>
<th>类型</th>
<th>模块</th>
<th>状态与进度</th>
<th>报告人</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
@ -158,6 +197,67 @@
</div>
</section>
<!-- Trial Periods Section -->
<section id="trialPeriodsSection" class="content-section">
<div class="section-title">
<h2><i class="fas fa-clock"></i> 客户试用时间</h2>
<button id="addTrialBtn" class="btn-primary">
<i class="fas fa-plus"></i>
添加试用时间
</button>
</div>
<div class="trial-periods-container">
<div class="table-container">
<table>
<thead>
<tr>
<th>客户名称</th>
<th>开始时间</th>
<th>结束时间</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="trialPeriodsBody">
<!-- Trial periods will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination for trial periods -->
<div class="pagination">
<div class="pagination-info">
<span id="trialPaginationInfo">显示 0-0 共 0 条</span>
</div>
<div class="pagination-controls">
<button id="trialFirstPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-left"></i>
</button>
<button id="trialPrevPage" class="pagination-btn" disabled>
<i class="fas fa-angle-left"></i>
</button>
<div id="trialPageNumbers" class="page-numbers">
</div>
<button id="trialNextPage" class="pagination-btn" disabled>
<i class="fas fa-angle-right"></i>
</button>
<button id="trialLastPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-right"></i>
</button>
</div>
<div class="pagination-size">
<select id="trialPageSizeSelect">
<option value="10">10条/页</option>
<option value="20">20条/页</option>
<option value="50">50条/页</option>
<option value="100">100条/页</option>
</select>
</div>
</div>
</div>
</section>
<!-- Dashboard Section -->
<section id="dashboardSection" class="content-section">
<div class="dashboard-stats">
@ -197,6 +297,15 @@
<p>已完成</p>
</div>
</div>
<div class="stat-card stat-card-highlight">
<div class="stat-icon">
<i class="fas fa-user-check"></i>
</div>
<div class="stat-info">
<h3 id="followUpCount">0</h3>
<p>跟进客户次数</p>
</div>
</div>
</div>
<div class="dashboard-filters">
@ -221,7 +330,6 @@
<option value="customerName">客户</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="数据分布">
@ -239,7 +347,6 @@
<option value="customerName">客户</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="客户类型">
@ -250,6 +357,156 @@
</div>
</div>
</div>
<!-- Trend Line Chart -->
<div class="dashboard-grid" style="margin-top: 20px;">
<div class="card chart-card" style="grid-column: 1 / -1;">
<div class="card-header">
<h3><i class="fas fa-chart-line"></i> 时间趋势分析</h3>
<div class="chart-controls">
<select id="trendTypeSelect" class="chart-field-select">
<option value="customer">客户</option>
<option value="demand">需求</option>
<option value="issue">问题</option>
<option value="all">全部</option>
</select>
</div>
</div>
<div class="card-body">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
</section>
<!-- Follow-up Section -->
<section id="followupSection" class="content-section">
<div class="action-bar">
<button id="addFollowUpBtn" class="btn-primary">
<i class="fas fa-plus"></i>
添加跟进
</button>
</div>
<!-- Follow-up Form -->
<div class="card" id="followupFormCard" style="display: none; margin-bottom: 20px;">
<div class="card-header">
<h3><i class="fas fa-plus-circle"></i> 新增客户跟进</h3>
</div>
<div class="card-body">
<form id="followupForm">
<div class="form-row">
<div class="form-group">
<label for="followupCustomerName">客户名称 <span
style="color: red;">*</span></label>
<select id="followupCustomerName" name="customerName" required>
<option value="">请选择客户</option>
</select>
</div>
<div class="form-group">
<label for="followupDealStatus">成交状态 <span style="color: red;">*</span></label>
<select id="followupDealStatus" name="dealStatus" required>
<option value="">请选择</option>
<option value="未成交">未成交</option>
<option value="已成交">已成交</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="followupCustomerLevel">客户级别 <span
style="color: red;">*</span></label>
<select id="followupCustomerLevel" name="customerLevel" required>
<option value="">请选择</option>
<option value="A">A级 (重点客户)</option>
<option value="B">B级 (潜在客户)</option>
<option value="C">C级 (一般客户)</option>
</select>
</div>
<div class="form-group">
<label for="followupIndustry">客户行业 <span style="color: red;">*</span></label>
<input type="text" id="followupIndustry" name="industry" required>
</div>
</div>
<div class="form-group">
<label for="followupTime">跟进时间 <span style="color: red;">*</span></label>
<input type="datetime-local" id="followupTime" name="followUpTime" required>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i>
确认
</button>
<button type="button" class="btn-secondary" id="cancelFollowupBtn">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
<!-- Follow-up List -->
<div class="card table-card">
<div class="card-header">
<h3><i class="fas fa-list"></i> 客户跟进列表</h3>
<div class="table-actions">
<button id="refreshFollowupsBtn" class="icon-btn" title="刷新">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="followupTable">
<thead>
<tr>
<th>客户名称</th>
<th>成交状态</th>
<th>客户级别</th>
<th>客户行业</th>
<th>跟进时间</th>
<th>通知状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="followupTableBody">
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination">
<div class="pagination-info">
<span id="followupPaginationInfo">显示 0-0 共 0 条</span>
</div>
<div class="pagination-controls">
<button id="followupFirstPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-left"></i>
</button>
<button id="followupPrevPage" class="pagination-btn" disabled>
<i class="fas fa-angle-left"></i>
</button>
<div id="followupPageNumbers" class="page-numbers">
</div>
<button id="followupNextPage" class="pagination-btn" disabled>
<i class="fas fa-angle-right"></i>
</button>
<button id="followupLastPage" class="pagination-btn" disabled>
<i class="fas fa-angle-double-right"></i>
</button>
</div>
<div class="pagination-size">
<select id="followupPageSizeSelect">
<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>
</main>
</div>
@ -266,7 +523,7 @@
<form id="createCustomerForm">
<div class="form-row">
<div class="form-group">
<label for="createIntendedProduct">日期</label>
<label for="createIntendedProduct">咨询时间</label>
<input type="date" id="createIntendedProduct" name="intendedProduct">
</div>
<div class="form-group">
@ -301,17 +558,22 @@
<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">
<select id="createStatusProgress" name="statusProgress">
<option value="">请选择状态</option>
<option value="已完成">已完成</option>
<option value="进行中">进行中</option>
<option value="待排期">待排期</option>
<option value="已驳回">已驳回</option>
<option value="已上线">已上线</option>
</select>
</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>
@ -367,11 +629,12 @@
<span class="close">&times;</span>
</div>
<div class="modal-body">
<!-- Customer Form -->
<form id="editCustomerForm">
<input type="hidden" id="editCustomerId">
<div class="form-row">
<div class="form-group">
<label for="editIntendedProduct">日期</label>
<label for="editIntendedProduct">咨询时间</label>
<input type="date" id="editIntendedProduct" name="intendedProduct">
</div>
<div class="form-group">
@ -406,17 +669,22 @@
<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">
<select id="editStatusProgress" name="statusProgress">
<option value="">请选择状态</option>
<option value="已完成">已完成</option>
<option value="进行中">进行中</option>
<option value="待排期">待排期</option>
<option value="已驳回">已驳回</option>
<option value="已上线">已上线</option>
</select>
</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>
@ -432,6 +700,80 @@
</div>
</div>
<!-- Modal for adding trial period -->
<div id="addTrialPeriodModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-clock"></i> 添加试用时间</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="addTrialPeriodForm">
<div class="form-group">
<label for="trialCustomerSelect">客户名称</label>
<select id="trialCustomerSelect" name="customerId" required>
<option value="">请选择客户</option>
<!-- Customer options will be loaded here -->
</select>
</div>
<div class="form-group">
<label for="trialStartTime">开始时间</label>
<input type="datetime-local" id="trialStartTime" name="startTime" required>
</div>
<div class="form-group">
<label for="trialEndTime">结束时间</label>
<input type="datetime-local" id="trialEndTime" name="endTime" required>
</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-trial">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for editing trial period -->
<div id="editTrialPeriodModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-clock"></i> 编辑试用时间</h3>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<form id="editTrialPeriodForm">
<input type="hidden" id="editTrialPeriodId">
<div class="form-group">
<label for="editTrialStartTime">开始时间</label>
<input type="datetime-local" id="editTrialStartTime" name="startTime" required>
</div>
<div class="form-group">
<label for="editTrialEndTime">结束时间</label>
<input type="datetime-local" id="editTrialEndTime" name="endTime" required>
</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-trial">
<i class="fas fa-times"></i>
取消
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/static/js/trial-periods.js"></script>
<script src="/static/js/trial-periods-page.js"></script>
<script src="/static/js/main.js"></script>
</body>

View File

@ -1,13 +1,5 @@
document.addEventListener('DOMContentLoaded', function () {
// 登录守卫
const token = localStorage.getItem('crmToken');
if (!token && !window.location.pathname.endsWith('login.html')) {
window.location.href = '/static/login.html';
return;
}
// 封装带 Token 的 fetch
async function authenticatedFetch(url, options = {}) {
// 封装带 Token 的 fetch (全局函数)
async function authenticatedFetch(url, options = {}) {
const token = localStorage.getItem('crmToken');
const headers = {
...options.headers,
@ -23,6 +15,14 @@ document.addEventListener('DOMContentLoaded', function () {
}
return response;
}
document.addEventListener('DOMContentLoaded', function () {
// 登录守卫
const token = localStorage.getItem('crmToken');
if (!token && !window.location.pathname.endsWith('login.html')) {
window.location.href = '/static/login.html';
return;
}
// Navigation
@ -56,6 +56,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Chart instances
let statusChartInstance = null;
let typeChartInstance = null;
let trendChartInstance = null;
// Current section tracking
let currentSection = 'customer';
@ -67,6 +68,10 @@ document.addEventListener('DOMContentLoaded', function () {
let totalItems = 0;
let selectedCustomerFilter = '';
let selectedTypeFilter = '';
let selectedStatusProgressFilter = '';
let customerStartDate = '';
let customerEndDate = '';
let customerSearchQuery = '';
let cellTooltipEl = null;
@ -222,6 +227,22 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
// Sidebar toggle functionality
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', function () {
sidebar.classList.toggle('collapsed');
// Update button title
if (sidebar.classList.contains('collapsed')) {
sidebarToggle.title = '展开侧边栏';
} else {
sidebarToggle.title = '收起侧边栏';
}
});
}
// Add Customer button
addCustomerBtn.addEventListener('click', function () {
createModal.style.display = 'block';
@ -288,6 +309,46 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Type filter change event
const typeFilter = document.getElementById('typeFilter');
if (typeFilter) {
typeFilter.addEventListener('change', function () {
selectedTypeFilter = this.value;
currentPage = 1;
applyAllCustomerFilters();
});
}
// Customer date range filter events
const customerStartDateInput = document.getElementById('customerStartDate');
const customerEndDateInput = document.getElementById('customerEndDate');
if (customerStartDateInput) {
customerStartDateInput.addEventListener('change', function () {
customerStartDate = this.value;
currentPage = 1;
applyAllCustomerFilters();
});
}
if (customerEndDateInput) {
customerEndDateInput.addEventListener('change', function () {
customerEndDate = this.value;
currentPage = 1;
applyAllCustomerFilters();
});
}
// Status progress filter change event
const statusProgressFilter = document.getElementById('statusProgressFilter');
if (statusProgressFilter) {
statusProgressFilter.addEventListener('change', function () {
selectedStatusProgressFilter = this.value;
currentPage = 1;
applyAllCustomerFilters();
});
}
// Apply date filter for dashboard
document.getElementById('applyFilters').addEventListener('click', function () {
const startDate = document.getElementById('startDate').value;
@ -310,20 +371,64 @@ document.addEventListener('DOMContentLoaded', function () {
item.classList.remove('active');
});
// Hide all sections first
customerSection.classList.remove('active');
dashboardSection.classList.remove('active');
if (document.getElementById('followupSection')) {
document.getElementById('followupSection').classList.remove('active');
}
if (document.getElementById('trialPeriodsSection')) {
document.getElementById('trialPeriodsSection').classList.remove('active');
}
if (section === 'customer') {
customerSection.classList.add('active');
dashboardSection.classList.remove('active');
document.querySelector('[data-section="customer"]').classList.add('active');
pageTitle.textContent = '客户管理';
currentSection = 'customer';
loadCustomers();
} else if (section === 'trialPeriods') {
console.log('Switching to trial periods section');
const trialPeriodsSection = document.getElementById('trialPeriodsSection');
if (trialPeriodsSection) {
trialPeriodsSection.classList.add('active');
}
document.querySelector('[data-section="trialPeriods"]').classList.add('active');
pageTitle.textContent = '客户试用时间';
currentSection = 'trialPeriods';
console.log('loadCustomersForDropdown exists?', typeof loadCustomersForDropdown);
if (typeof loadCustomersForDropdown === 'function') {
console.log('Calling loadCustomersForDropdown');
loadCustomersForDropdown();
} else {
console.error('loadCustomersForDropdown is not a function!');
}
console.log('loadAllTrialPeriods exists?', typeof loadAllTrialPeriods);
if (typeof loadAllTrialPeriods === 'function') {
console.log('Calling loadAllTrialPeriods');
loadAllTrialPeriods();
} else {
console.error('loadAllTrialPeriods is not a function!');
}
} else if (section === 'dashboard') {
customerSection.classList.remove('active');
dashboardSection.classList.add('active');
document.querySelector('[data-section="dashboard"]').classList.add('active');
pageTitle.textContent = '数据仪表板';
currentSection = 'dashboard';
loadDashboardData();
} else if (section === 'followup') {
const followupSection = document.getElementById('followupSection');
if (followupSection) {
followupSection.classList.add('active');
}
document.querySelector('[data-section="followup"]').classList.add('active');
pageTitle.textContent = '客户跟进';
currentSection = 'followup';
if (typeof loadFollowUps === 'function') {
loadFollowUps();
}
}
// Close sidebar on mobile after navigation
@ -341,6 +446,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (data.customers) {
allCustomers = data.customers;
populateCustomerFilter();
populateTypeFilter();
applyAllCustomerFilters();
}
} catch (error) {
@ -367,6 +473,26 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
// Populate type filter dropdown
function populateTypeFilter() {
const typeFilterElement = document.getElementById('typeFilter');
if (!typeFilterElement) return;
const uniqueTypes = [...new Set(allCustomers.map(c => c.type).filter(t => t))];
typeFilterElement.innerHTML = '<option value="">全部类型</option>';
uniqueTypes.forEach(type => {
const option = document.createElement('option');
option.value = type;
option.textContent = type;
typeFilterElement.appendChild(option);
});
if (selectedTypeFilter) {
typeFilterElement.value = selectedTypeFilter;
}
}
// Filter customers by selected customer
function filterCustomers(selectedCustomer) {
selectedCustomerFilter = selectedCustomer;
@ -408,6 +534,28 @@ document.addEventListener('DOMContentLoaded', function () {
next = next.filter(c => c.customerName === selectedCustomerFilter);
}
if (selectedTypeFilter) {
next = next.filter(c => c.type === selectedTypeFilter);
}
if (customerStartDate) {
next = next.filter(c => {
const date = normalizeDateValue(c.intendedProduct);
return date && date >= customerStartDate;
});
}
if (customerEndDate) {
next = next.filter(c => {
const date = normalizeDateValue(c.intendedProduct);
return date && date <= customerEndDate;
});
}
if (selectedStatusProgressFilter) {
next = next.filter(c => c.statusProgress === selectedStatusProgressFilter);
}
if (customerSearchQuery) {
next = next.filter(c => customerMatchesQuery(c, customerSearchQuery));
}
@ -439,28 +587,26 @@ document.addEventListener('DOMContentLoaded', function () {
function exportCustomersToCsv(customers) {
const header = [
'客户',
'咨询时间',
'版本',
'描述',
'解决方案',
'类型',
'模块',
'状态与进度',
'报告人',
'时间'
'状态与进度'
].map(toCsvCell).join(',');
const lines = customers.map(c => {
const date = normalizeDateValue(c.intendedProduct) || (c.intendedProduct || '');
const cells = [
c.customerName || '',
date,
c.version || '',
c.description || '',
c.solution || '',
c.type || '',
c.module || '',
c.statusProgress || '',
c.reporter || '',
date
c.statusProgress || ''
];
return cells.map(toCsvCell).join(',');
});
@ -520,6 +666,7 @@ document.addEventListener('DOMContentLoaded', function () {
updateDashboardStats(dashboardCustomers);
renderStatusChart(dashboardCustomers);
renderTypeChart(dashboardCustomers);
renderTrendChart(dashboardCustomers);
} else {
console.error('No customers in dashboard data');
}
@ -551,6 +698,7 @@ document.addEventListener('DOMContentLoaded', function () {
updateDashboardStats(filteredData);
renderStatusChart(filteredData);
renderTypeChart(filteredData);
renderTrendChart(filteredData);
}
// Render customer table
@ -562,16 +710,16 @@ document.addEventListener('DOMContentLoaded', function () {
const date = customer.intendedProduct || '';
const fields = [
{ value: customer.customerName || '', name: 'customerName' },
{ value: date, name: 'date' },
{ value: customer.version || '', name: 'version' },
{ value: customer.description || '', name: 'description' },
{ value: customer.solution || '', name: 'solution' },
{ value: customer.type || '', name: 'type' },
{ value: customer.module || '', name: 'module' },
{ value: customer.statusProgress || '', name: 'statusProgress' },
{ value: customer.reporter || '', name: 'reporter' },
{ value: date, name: 'date' }
{ value: customer.statusProgress || '', name: 'statusProgress' }
];
fields.forEach(field => {
@ -685,7 +833,7 @@ document.addEventListener('DOMContentLoaded', function () {
type: document.getElementById('createType').value,
module: document.getElementById('createModule').value,
statusProgress: document.getElementById('createStatusProgress').value,
reporter: document.getElementById('createReporter').value
reporter: '' // Trial periods managed separately
};
try {
@ -763,7 +911,33 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('editType').value = customer.type || '';
document.getElementById('editModule').value = customer.module || '';
document.getElementById('editStatusProgress').value = customer.statusProgress || '';
document.getElementById('editReporter').value = customer.reporter || '';
// Parse trial period and fill datetime inputs
const reporter = customer.reporter || '';
if (reporter) {
// Split by ~ or
const parts = reporter.split(/[~]/);
if (parts.length === 2) {
const parseDateTime = (str) => {
// Parse format like "2026/1/5 10:00" or "2026-1-5 10:00"
const cleaned = str.trim();
const match = cleaned.match(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
if (match) {
const [_, year, month, day, hours, minutes] = match;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
return '';
};
document.getElementById('editTrialStart').value = parseDateTime(parts[0]);
document.getElementById('editTrialEnd').value = parseDateTime(parts[1]);
}
}
// Load trial periods for this customer
if (typeof loadTrialPeriods === 'function') {
await loadTrialPeriods(customer.id);
}
editModal.style.display = 'block';
} catch (error) {
@ -798,7 +972,7 @@ document.addEventListener('DOMContentLoaded', function () {
type: document.getElementById('editType').value,
module: document.getElementById('editModule').value,
statusProgress: document.getElementById('editStatusProgress').value,
reporter: document.getElementById('editReporter').value
reporter: '' // Trial periods managed separately
};
try {
@ -850,6 +1024,7 @@ document.addEventListener('DOMContentLoaded', function () {
async function loadDashboardData() {
console.log('loadDashboardData called');
await loadAllCustomers();
await loadFollowUpCount();
}
// Update dashboard statistics
@ -884,6 +1059,24 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('completedTasks').textContent = completed;
}
// Load follow-up count for dashboard
async function loadFollowUpCount() {
try {
const response = await authenticatedFetch('/api/followups?page=1&pageSize=1000');
const data = await response.json();
if (data.followUps) {
const followUpCount = data.followUps.length;
document.getElementById('followUpCount').textContent = followUpCount;
} else {
document.getElementById('followUpCount').textContent = '0';
}
} catch (error) {
console.error('Error loading follow-up count:', error);
document.getElementById('followUpCount').textContent = '0';
}
}
function renderStatusChart(customers) {
const ctx = document.getElementById('statusChart').getContext('2d');
const selectedField = document.getElementById('chartFieldSelect').value;
@ -935,6 +1128,29 @@ document.addEventListener('DOMContentLoaded', function () {
size: 16,
weight: 'bold'
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
},
datalabels: {
formatter: (value, ctx) => {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%';
},
color: '#fff',
font: {
weight: 'bold',
size: 14
}
}
}
}
@ -1001,6 +1217,29 @@ document.addEventListener('DOMContentLoaded', function () {
size: 16,
weight: 'bold'
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
},
datalabels: {
formatter: (value, ctx) => {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%';
},
color: '#fff',
font: {
weight: 'bold',
size: 14
}
}
}
}
@ -1009,6 +1248,224 @@ document.addEventListener('DOMContentLoaded', function () {
console.log('Type chart created successfully');
}
// Render trend line chart
function renderTrendChart(customers) {
const canvas = document.getElementById('trendChart');
if (!canvas) {
console.error('trendChart canvas not found');
return;
}
const ctx = canvas.getContext('2d');
const trendType = document.getElementById('trendTypeSelect')?.value || 'customer';
if (trendChartInstance) {
trendChartInstance.destroy();
}
// Group data by date
const dateMap = {};
customers.forEach(customer => {
const dateStr = normalizeDateValue(customer.intendedProduct);
if (!dateStr) return;
if (!dateMap[dateStr]) {
dateMap[dateStr] = {
customers: new Set(),
demands: 0,
issues: 0
};
}
// Count customers
if (customer.customerName) {
dateMap[dateStr].customers.add(customer.customerName);
}
// Count demands and issues based on type
const type = (customer.type || '').toLowerCase();
if (type.includes('需求')) {
dateMap[dateStr].demands++;
}
if (type.includes('问题') || type.includes('功能问题')) {
dateMap[dateStr].issues++;
}
});
// Sort dates
const sortedDates = Object.keys(dateMap).sort();
// Prepare datasets based on selected trend type
const datasets = [];
if (trendType === 'customer') {
datasets.push({
label: '客户数',
data: sortedDates.map(date => dateMap[date].customers.size),
borderColor: '#FF6B35',
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#FF6B35',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: true
});
} else if (trendType === 'demand') {
datasets.push({
label: '需求数',
data: sortedDates.map(date => dateMap[date].demands),
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#4CAF50',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: true
});
} else if (trendType === 'issue') {
datasets.push({
label: '问题数',
data: sortedDates.map(date => dateMap[date].issues),
borderColor: '#F28C28',
backgroundColor: 'rgba(242, 140, 40, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#F28C28',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: true
});
} else if (trendType === 'all') {
datasets.push(
{
label: '客户数',
data: sortedDates.map(date => dateMap[date].customers.size),
borderColor: '#FF6B35',
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#FF6B35',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: false
},
{
label: '需求数',
data: sortedDates.map(date => dateMap[date].demands),
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#4CAF50',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: false
},
{
label: '问题数',
data: sortedDates.map(date => dateMap[date].issues),
borderColor: '#F28C28',
backgroundColor: 'rgba(242, 140, 40, 0.1)',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#F28C28',
pointBorderColor: '#fff',
pointBorderWidth: 2,
tension: 0.4,
fill: false
}
);
}
trendChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: sortedDates,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'top'
},
title: {
display: true,
text: '时间趋势分析',
font: {
size: 16,
weight: 'bold'
}
},
tooltip: {
mode: 'index',
intersect: false
},
datalabels: {
display: true,
align: 'top',
anchor: 'end',
formatter: (value) => value,
color: '#333',
font: {
weight: 'bold',
size: 11
},
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 4,
padding: 4
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: '日期'
},
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
display: true,
title: {
display: true,
text: '数量'
},
beginAtZero: true,
ticks: {
stepSize: 1
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
console.log('Trend chart created successfully');
}
// Initialize the app
loadCustomers().then(() => {
loadAllCustomers();
@ -1063,6 +1520,16 @@ document.addEventListener('DOMContentLoaded', function () {
applyDateFilter(startDate, endDate);
});
// Trend type select event listener
const trendTypeSelect = document.getElementById('trendTypeSelect');
if (trendTypeSelect) {
trendTypeSelect.addEventListener('change', function () {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
applyDateFilter(startDate, endDate);
});
}
// 登出功能
const logoutBtn = document.createElement('button');
logoutBtn.className = 'icon-btn';
@ -1079,4 +1546,287 @@ document.addEventListener('DOMContentLoaded', function () {
if (headerRight) {
headerRight.appendChild(logoutBtn);
}
// ========== Follow-up Management ==========
const followupSection = document.getElementById('followupSection');
const addFollowUpBtn = document.getElementById('addFollowUpBtn');
const followupFormCard = document.getElementById('followupFormCard');
const followupForm = document.getElementById('followupForm');
const cancelFollowupBtn = document.getElementById('cancelFollowupBtn');
const followupTableBody = document.getElementById('followupTableBody');
const refreshFollowupsBtn = document.getElementById('refreshFollowupsBtn');
const followupCustomerNameSelect = document.getElementById('followupCustomerName');
let allFollowUps = [];
let followupCurrentPage = 1;
let followupPageSize = 10;
let followupTotalPages = 1;
let followupTotalItems = 0;
// Show/hide follow-up form
if (addFollowUpBtn) {
addFollowUpBtn.addEventListener('click', async function () {
followupFormCard.style.display = 'block';
await loadCustomerListForFollowup();
});
}
if (cancelFollowupBtn) {
cancelFollowupBtn.addEventListener('click', function () {
followupFormCard.style.display = 'none';
followupForm.reset();
});
}
// Load customer list for follow-up dropdown
async function loadCustomerListForFollowup() {
try {
const response = await authenticatedFetch('/api/customers/list');
const data = await response.json();
followupCustomerNameSelect.innerHTML = '<option value="">请选择客户</option>';
if (data.customers && data.customers.length > 0) {
data.customers.forEach(customer => {
const option = document.createElement('option');
option.value = customer.id;
option.textContent = customer.customerName;
followupCustomerNameSelect.appendChild(option);
});
}
} catch (error) {
console.error('Error loading customer list:', error);
}
}
// Create follow-up
if (followupForm) {
followupForm.addEventListener('submit', async function (e) {
e.preventDefault();
const followUpTimeValue = document.getElementById('followupTime').value;
const followUpTimeISO = new Date(followUpTimeValue).toISOString();
// Get customer name from the selected option's text
const customerSelect = document.getElementById('followupCustomerName');
const selectedOption = customerSelect.options[customerSelect.selectedIndex];
const customerName = selectedOption ? selectedOption.textContent : '';
const formData = {
customerName: customerName,
dealStatus: document.getElementById('followupDealStatus').value,
customerLevel: document.getElementById('followupCustomerLevel').value,
industry: document.getElementById('followupIndustry').value,
followUpTime: followUpTimeISO
};
try {
const response = await authenticatedFetch('/api/followups', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
followupForm.reset();
followupFormCard.style.display = 'none';
loadFollowUps();
alert('跟进记录创建成功!');
} else {
alert('创建跟进记录时出错');
}
} catch (error) {
console.error('Error creating follow-up:', error);
alert('创建跟进记录时出错');
}
});
}
// Load follow-ups
async function loadFollowUps() {
try {
const response = await authenticatedFetch(`/api/followups?page=${followupCurrentPage}&pageSize=${followupPageSize}`);
const data = await response.json();
if (data.followUps) {
allFollowUps = data.followUps;
followupTotalItems = data.total || 0;
followupTotalPages = data.totalPages || 1;
renderFollowUpTable(allFollowUps);
updateFollowupPaginationControls();
}
} catch (error) {
console.error('Error loading follow-ups:', error);
}
}
// Render follow-up table
function renderFollowUpTable(followUps) {
followupTableBody.innerHTML = '';
followUps.forEach(followUp => {
const row = document.createElement('tr');
// Format follow-up time
const followUpTime = new Date(followUp.followUpTime);
const formattedTime = followUpTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// Notification status
const notificationStatus = followUp.notificationSent ?
'<span style="color: green;">已通知</span>' :
'<span style="color: orange;">待通知</span>';
// Customer level display
let levelDisplay = followUp.customerLevel;
if (followUp.customerLevel === 'A') {
levelDisplay = 'A级 (重点客户)';
} else if (followUp.customerLevel === 'B') {
levelDisplay = 'B级 (潜在客户)';
} else if (followUp.customerLevel === 'C') {
levelDisplay = 'C级 (一般客户)';
}
row.innerHTML = `
<td>${followUp.customerName || ''}</td>
<td>${followUp.dealStatus || ''}</td>
<td>${levelDisplay}</td>
<td>${followUp.industry || ''}</td>
<td>${formattedTime}</td>
<td>${notificationStatus}</td>
<td>
<button class="action-btn delete-followup-btn" data-id="${followUp.id}">
<i class="fas fa-trash"></i>
</button>
</td>
`;
followupTableBody.appendChild(row);
});
// Add delete event listeners
document.querySelectorAll('.delete-followup-btn').forEach(btn => {
btn.addEventListener('click', function () {
const followUpId = this.getAttribute('data-id');
deleteFollowUp(followUpId);
});
});
}
// Delete follow-up
async function deleteFollowUp(followUpId) {
if (!confirm('确定要删除这条跟进记录吗?')) {
return;
}
try {
const response = await authenticatedFetch(`/api/followups/${followUpId}`, {
method: 'DELETE'
});
if (response.ok) {
loadFollowUps();
alert('跟进记录删除成功!');
} else {
alert('删除跟进记录时出错');
}
} catch (error) {
console.error('Error deleting follow-up:', error);
alert('删除跟进记录时出错');
}
}
// Update follow-up pagination controls
function updateFollowupPaginationControls() {
const startItem = followupTotalItems === 0 ? 0 : (followupCurrentPage - 1) * followupPageSize + 1;
const endItem = Math.min(followupCurrentPage * followupPageSize, followupTotalItems);
document.getElementById('followupPaginationInfo').textContent =
`显示 ${startItem}-${endItem}${followupTotalItems}`;
document.getElementById('followupFirstPage').disabled = followupCurrentPage === 1;
document.getElementById('followupPrevPage').disabled = followupCurrentPage === 1;
document.getElementById('followupNextPage').disabled = followupCurrentPage === followupTotalPages;
document.getElementById('followupLastPage').disabled = followupCurrentPage === followupTotalPages;
const pageNumbers = document.getElementById('followupPageNumbers');
pageNumbers.innerHTML = '';
const maxVisiblePages = 5;
let startPage = Math.max(1, followupCurrentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(followupTotalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageBtn = document.createElement('button');
pageBtn.className = `page-number ${i === followupCurrentPage ? 'active' : ''}`;
pageBtn.textContent = i;
pageBtn.addEventListener('click', () => {
followupCurrentPage = i;
loadFollowUps();
});
pageNumbers.appendChild(pageBtn);
}
}
// Follow-up pagination event listeners
if (document.getElementById('followupFirstPage')) {
document.getElementById('followupFirstPage').addEventListener('click', () => {
if (followupCurrentPage > 1) {
followupCurrentPage = 1;
loadFollowUps();
}
});
}
if (document.getElementById('followupPrevPage')) {
document.getElementById('followupPrevPage').addEventListener('click', () => {
if (followupCurrentPage > 1) {
followupCurrentPage--;
loadFollowUps();
}
});
}
if (document.getElementById('followupNextPage')) {
document.getElementById('followupNextPage').addEventListener('click', () => {
if (followupCurrentPage < followupTotalPages) {
followupCurrentPage++;
loadFollowUps();
}
});
}
if (document.getElementById('followupLastPage')) {
document.getElementById('followupLastPage').addEventListener('click', () => {
if (followupCurrentPage < followupTotalPages) {
followupCurrentPage = followupTotalPages;
loadFollowUps();
}
});
}
if (document.getElementById('followupPageSizeSelect')) {
document.getElementById('followupPageSizeSelect').addEventListener('change', (e) => {
followupPageSize = parseInt(e.target.value);
followupCurrentPage = 1;
loadFollowUps();
});
}
// Refresh follow-ups button
if (refreshFollowupsBtn) {
refreshFollowupsBtn.addEventListener('click', () => {
loadFollowUps();
});
}
});

View File

@ -0,0 +1,410 @@
// Trial Periods Page Management
// This file handles the standalone trial periods page
let trialPeriodsData = [];
let trialCurrentPage = 1;
let trialPageSize = 10;
let trialTotalItems = 0;
let trialTotalPages = 0;
let customersMap = {}; // Map of customer ID to customer name
// Initialize trial periods page
function initTrialPeriodsPage() {
const addTrialBtn = document.getElementById('addTrialBtn');
const trialPeriodsSection = document.getElementById('trialPeriodsSection');
if (!trialPeriodsSection) return;
// Add trial button click
if (addTrialBtn) {
addTrialBtn.addEventListener('click', function () {
openAddTrialModal();
});
}
// Pagination controls
document.getElementById('trialFirstPage')?.addEventListener('click', () => {
trialCurrentPage = 1;
loadAllTrialPeriods();
});
document.getElementById('trialPrevPage')?.addEventListener('click', () => {
if (trialCurrentPage > 1) {
trialCurrentPage--;
loadAllTrialPeriods();
}
});
document.getElementById('trialNextPage')?.addEventListener('click', () => {
if (trialCurrentPage < trialTotalPages) {
trialCurrentPage++;
loadAllTrialPeriods();
}
});
document.getElementById('trialLastPage')?.addEventListener('click', () => {
trialCurrentPage = trialTotalPages;
loadAllTrialPeriods();
});
document.getElementById('trialPageSizeSelect')?.addEventListener('change', function () {
trialPageSize = parseInt(this.value);
trialCurrentPage = 1;
loadAllTrialPeriods();
});
// Load customers for dropdown
loadCustomersForDropdown();
}
// Load all customers for dropdown
async function loadCustomersForDropdown() {
try {
console.log('Loading customers for dropdown...');
const response = await authenticatedFetch('/api/customers/list');
if (!response) {
console.error('No response from API');
return;
}
const data = await response.json();
console.log('Customers data:', data);
const customers = data.customers || [];
console.log('Number of customers:', customers.length);
customersMap = {};
const select = document.getElementById('trialCustomerSelect');
if (!select) {
console.error('trialCustomerSelect element not found');
return;
}
// Keep the first option (请选择客户)
select.innerHTML = '<option value="">请选择客户</option>';
customers.forEach(customer => {
customersMap[customer.id] = customer.customerName;
const option = document.createElement('option');
option.value = customer.id;
option.textContent = customer.customerName;
select.appendChild(option);
});
console.log('Customers loaded successfully. Total:', customers.length);
} catch (error) {
console.error('Error loading customers:', error);
}
}
// Load all trial periods
async function loadAllTrialPeriods() {
try {
const response = await authenticatedFetch('/api/trial-periods/all');
const data = await response.json();
trialPeriodsData = data.trialPeriods || [];
trialTotalItems = trialPeriodsData.length;
trialTotalPages = Math.ceil(trialTotalItems / trialPageSize);
renderTrialPeriodsTable();
updateTrialPagination();
} catch (error) {
console.error('Error loading trial periods:', error);
trialPeriodsData = [];
renderTrialPeriodsTable();
}
}
// Render expiry warning cards
function renderExpiryWarnings() {
const warningsContainer = document.getElementById('trialExpiryWarnings');
if (!warningsContainer) return;
warningsContainer.innerHTML = '';
const now = new Date();
now.setHours(0, 0, 0, 0); // Set to start of today
const warnings = [];
trialPeriodsData.forEach(period => {
const endTime = new Date(period.endTime);
endTime.setHours(0, 0, 0, 0); // Set to start of day
const daysUntilExpiry = Math.ceil((endTime - now) / (1000 * 60 * 60 * 24));
const customerName = customersMap[period.customerId] || period.customerId;
if (daysUntilExpiry >= 0 && daysUntilExpiry <= 3) {
warnings.push({
customerName,
endTime: period.endTime,
daysUntilExpiry,
period
});
}
});
// Sort by days until expiry (most urgent first)
warnings.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
warnings.forEach(warning => {
const card = document.createElement('div');
let warningClass = 'warning-soon';
let iconClass = 'fa-info-circle';
let title = '试用即将到期';
let message = '';
if (warning.daysUntilExpiry === 0) {
warningClass = 'warning-today';
iconClass = 'fa-exclamation-triangle';
title = '试用今日到期';
message = `<strong>${warning.customerName}</strong> 客户的试用期将于<span class="expiry-warning-time">今天</span>到期,请及时跟进!`;
} else if (warning.daysUntilExpiry === 1) {
warningClass = 'warning-tomorrow';
iconClass = 'fa-exclamation-circle';
title = '试用明日到期';
message = `<strong>${warning.customerName}</strong> 客户的试用期将于<span class="expiry-warning-time">明天</span>到期,请及时跟进!`;
} else {
warningClass = 'warning-soon';
iconClass = 'fa-info-circle';
title = '试用即将到期';
message = `<strong>${warning.customerName}</strong> 客户的试用期将于<span class="expiry-warning-time">${warning.daysUntilExpiry}天后</span>到期,请及时跟进!`;
}
const formattedEndTime = formatDateTime(warning.endTime);
message += `<br><small>试用结束时间:${formattedEndTime}</small>`;
card.className = `expiry-warning-card ${warningClass}`;
card.innerHTML = `
<div class="expiry-warning-icon">
<i class="fas ${iconClass}"></i>
</div>
<div class="expiry-warning-content">
<div class="expiry-warning-title">${title}</div>
<div class="expiry-warning-message">${message}</div>
</div>
<button class="expiry-warning-close" onclick="this.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
`;
warningsContainer.appendChild(card);
});
}
// Render trial periods table
function renderTrialPeriodsTable() {
const tbody = document.getElementById('trialPeriodsBody');
if (!tbody) return;
tbody.innerHTML = '';
if (trialPeriodsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="5" style="text-align: center; padding: 30px; color: #999;">暂无试用时间记录</td>';
tbody.appendChild(row);
return;
}
// Paginate data
const startIndex = (trialCurrentPage - 1) * trialPageSize;
const endIndex = Math.min(startIndex + trialPageSize, trialPeriodsData.length);
const pageData = trialPeriodsData.slice(startIndex, endIndex);
pageData.forEach(period => {
const row = document.createElement('tr');
const customerName = customersMap[period.customerId] || period.customerId;
const startTime = formatDateTime(period.startTime);
const endTime = formatDateTime(period.endTime);
const createdAt = formatDateTime(period.createdAt);
row.innerHTML = `
<td>${customerName}</td>
<td>${startTime}</td>
<td>${endTime}</td>
<td>${createdAt}</td>
<td>
<button class="action-btn edit-btn" data-id="${period.id}" title="编辑">
<i class="fas fa-edit"></i>
</button>
<button class="action-btn delete-btn" data-id="${period.id}" title="删除">
<i class="fas fa-trash"></i>
</button>
</td>
`;
tbody.appendChild(row);
});
// Add event listeners
tbody.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', function () {
const periodId = this.getAttribute('data-id');
openEditTrialModal(periodId);
});
});
tbody.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function () {
const periodId = this.getAttribute('data-id');
deleteTrialPeriodFromPage(periodId);
});
});
}
// Update trial pagination
function updateTrialPagination() {
const startItem = trialTotalItems === 0 ? 0 : (trialCurrentPage - 1) * trialPageSize + 1;
const endItem = Math.min(trialCurrentPage * trialPageSize, trialTotalItems);
document.getElementById('trialPaginationInfo').textContent =
`显示 ${startItem}-${endItem}${trialTotalItems}`;
document.getElementById('trialFirstPage').disabled = trialCurrentPage === 1;
document.getElementById('trialPrevPage').disabled = trialCurrentPage === 1;
document.getElementById('trialNextPage').disabled = trialCurrentPage === trialTotalPages;
document.getElementById('trialLastPage').disabled = trialCurrentPage === trialTotalPages;
// Update page numbers
const pageNumbers = document.getElementById('trialPageNumbers');
pageNumbers.innerHTML = '';
const maxVisiblePages = 5;
let startPage = Math.max(1, trialCurrentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(trialTotalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageBtn = document.createElement('button');
pageBtn.className = 'page-number';
if (i === trialCurrentPage) {
pageBtn.classList.add('active');
}
pageBtn.textContent = i;
pageBtn.addEventListener('click', () => {
trialCurrentPage = i;
loadAllTrialPeriods();
});
pageNumbers.appendChild(pageBtn);
}
}
// Open add trial modal
function openAddTrialModal() {
console.log('Opening add trial modal');
// Load customers first
loadCustomersForDropdown().then(() => {
console.log('Customers loaded, opening modal');
document.getElementById('trialCustomerSelect').value = '';
document.getElementById('trialStartTime').value = '';
document.getElementById('trialEndTime').value = '';
document.getElementById('addTrialPeriodModal').style.display = 'block';
});
}
// Open edit trial modal
function openEditTrialModal(periodId) {
const period = trialPeriodsData.find(p => p.id === periodId);
if (!period) return;
document.getElementById('editTrialPeriodId').value = period.id;
const startDate = new Date(period.startTime);
const endDate = new Date(period.endTime);
document.getElementById('editTrialStartTime').value = formatDateTimeLocal(startDate);
document.getElementById('editTrialEndTime').value = formatDateTimeLocal(endDate);
document.getElementById('editTrialPeriodModal').style.display = 'block';
}
// Delete trial period from page
async function deleteTrialPeriodFromPage(periodId) {
if (!confirm('确定要删除这个试用时间记录吗?')) {
return;
}
try {
const response = await authenticatedFetch(`/api/trial-periods/${periodId}`, {
method: 'DELETE'
});
if (response.ok) {
await loadAllTrialPeriods();
alert('试用时间删除成功!');
} else {
alert('删除试用时间时出错');
}
} catch (error) {
console.error('Error deleting trial period:', error);
alert('删除试用时间时出错');
}
}
// Create trial period from page
async function createTrialPeriodFromPage() {
const customerId = document.getElementById('trialCustomerSelect').value;
const startTime = document.getElementById('trialStartTime').value;
const endTime = document.getElementById('trialEndTime').value;
if (!customerId) {
alert('请选择客户');
return;
}
if (!startTime || !endTime) {
alert('请填写开始时间和结束时间');
return;
}
const formData = {
customerId: customerId,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString()
};
try {
const response = await authenticatedFetch('/api/trial-periods', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
document.getElementById('addTrialPeriodModal').style.display = 'none';
document.getElementById('addTrialPeriodForm').reset();
await loadAllTrialPeriods();
alert('试用时间添加成功!');
} else {
alert('添加试用时间时出错');
}
} catch (error) {
console.error('Error creating trial period:', error);
alert('添加试用时间时出错');
}
}
// Initialize when switching to trial periods section
document.addEventListener('DOMContentLoaded', function () {
initTrialPeriodsPage();
// Override the form submit for add trial period
const addForm = document.getElementById('addTrialPeriodForm');
if (addForm) {
addForm.addEventListener('submit', async function (e) {
e.preventDefault();
await createTrialPeriodFromPage();
});
}
});

View File

@ -0,0 +1,293 @@
// Trial Period Management JavaScript
// This file contains all trial period related functionality
// Global variables for trial periods
let currentCustomerId = null;
let currentTrialPeriods = [];
// Initialize trial period modals and event listeners
function initTrialPeriodManagement() {
const addTrialPeriodModal = document.getElementById('addTrialPeriodModal');
const editTrialPeriodModal = document.getElementById('editTrialPeriodModal');
const addTrialPeriodBtn = document.getElementById('addTrialPeriodBtn');
const addTrialPeriodForm = document.getElementById('addTrialPeriodForm');
const editTrialPeriodForm = document.getElementById('editTrialPeriodForm');
// Add trial period button click
if (addTrialPeriodBtn) {
addTrialPeriodBtn.addEventListener('click', function () {
document.getElementById('trialCustomerId').value = currentCustomerId;
addTrialPeriodModal.style.display = 'block';
});
}
// Close modals
const closeButtons = document.querySelectorAll('.close');
closeButtons.forEach(btn => {
btn.addEventListener('click', function () {
const modal = this.closest('.modal');
if (modal) {
modal.style.display = 'none';
}
});
});
// Cancel buttons
document.querySelector('.cancel-trial')?.addEventListener('click', function () {
addTrialPeriodModal.style.display = 'none';
});
document.querySelector('.cancel-edit-trial')?.addEventListener('click', function () {
editTrialPeriodModal.style.display = 'none';
});
// Add trial period form submit
if (addTrialPeriodForm) {
addTrialPeriodForm.addEventListener('submit', async function (e) {
e.preventDefault();
await createTrialPeriod();
});
}
// Edit trial period form submit
if (editTrialPeriodForm) {
editTrialPeriodForm.addEventListener('submit', async function (e) {
e.preventDefault();
await updateTrialPeriod();
});
}
// Click outside modal to close
window.addEventListener('click', function (e) {
if (e.target === addTrialPeriodModal) {
addTrialPeriodModal.style.display = 'none';
}
if (e.target === editTrialPeriodModal) {
editTrialPeriodModal.style.display = 'none';
}
});
}
// Load trial periods for a customer
async function loadTrialPeriods(customerId) {
currentCustomerId = customerId;
try {
const response = await authenticatedFetch(`/api/trial-periods?customerId=${customerId}`);
const data = await response.json();
currentTrialPeriods = data.trialPeriods || [];
renderTrialPeriods();
} catch (error) {
console.error('Error loading trial periods:', error);
currentTrialPeriods = [];
renderTrialPeriods();
}
}
// Render trial periods table
function renderTrialPeriods() {
const tbody = document.getElementById('trialPeriodsTableBody');
const noDataMessage = document.getElementById('noTrialPeriodsMessage');
const table = document.querySelector('.trial-periods-table');
if (!tbody) return;
tbody.innerHTML = '';
if (currentTrialPeriods.length === 0) {
table.style.display = 'none';
noDataMessage.style.display = 'block';
return;
}
table.style.display = 'table';
noDataMessage.style.display = 'none';
currentTrialPeriods.forEach(period => {
const row = document.createElement('tr');
const startTime = formatDateTime(period.startTime);
const endTime = formatDateTime(period.endTime);
row.innerHTML = `
<td>${startTime}</td>
<td>${endTime}</td>
<td>
<div class="trial-action-btns">
<button class="action-btn edit-btn" data-id="${period.id}" title="编辑">
<i class="fas fa-edit"></i>
</button>
<button class="action-btn delete-btn" data-id="${period.id}" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// Add event listeners to edit and delete buttons
tbody.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', function () {
const periodId = this.getAttribute('data-id');
openEditTrialPeriodModal(periodId);
});
});
tbody.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function () {
const periodId = this.getAttribute('data-id');
deleteTrialPeriod(periodId);
});
});
}
// Format datetime for display
function formatDateTime(dateTimeStr) {
if (!dateTimeStr) return '';
const date = new Date(dateTimeStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
}
// Create trial period
async function createTrialPeriod() {
const customerId = document.getElementById('trialCustomerId').value;
const startTime = document.getElementById('trialStartTime').value;
const endTime = document.getElementById('trialEndTime').value;
if (!startTime || !endTime) {
alert('请填写开始时间和结束时间');
return;
}
const formData = {
customerId: customerId,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString()
};
try {
const response = await authenticatedFetch('/api/trial-periods', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
document.getElementById('addTrialPeriodModal').style.display = 'none';
document.getElementById('addTrialPeriodForm').reset();
await loadTrialPeriods(customerId);
alert('试用时间添加成功!');
} else {
alert('添加试用时间时出错');
}
} catch (error) {
console.error('Error creating trial period:', error);
alert('添加试用时间时出错');
}
}
// Open edit trial period modal
function openEditTrialPeriodModal(periodId) {
const period = currentTrialPeriods.find(p => p.id === periodId);
if (!period) return;
document.getElementById('editTrialPeriodId').value = period.id;
// Convert ISO string to datetime-local format
const startDate = new Date(period.startTime);
const endDate = new Date(period.endTime);
document.getElementById('editTrialStartTime').value = formatDateTimeLocal(startDate);
document.getElementById('editTrialEndTime').value = formatDateTimeLocal(endDate);
document.getElementById('editTrialPeriodModal').style.display = 'block';
}
// Format date to datetime-local input format
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Update trial period
async function updateTrialPeriod() {
const periodId = document.getElementById('editTrialPeriodId').value;
const startTime = document.getElementById('editTrialStartTime').value;
const endTime = document.getElementById('editTrialEndTime').value;
if (!startTime || !endTime) {
alert('请填写开始时间和结束时间');
return;
}
const formData = {
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString()
};
try {
const response = await authenticatedFetch(`/api/trial-periods/${periodId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
document.getElementById('editTrialPeriodModal').style.display = 'none';
await loadTrialPeriods(currentCustomerId);
alert('试用时间更新成功!');
} else {
alert('更新试用时间时出错');
}
} catch (error) {
console.error('Error updating trial period:', error);
alert('更新试用时间时出错');
}
}
// Delete trial period
async function deleteTrialPeriod(periodId) {
if (!confirm('确定要删除这个试用时间记录吗?')) {
return;
}
try {
const response = await authenticatedFetch(`/api/trial-periods/${periodId}`, {
method: 'DELETE'
});
if (response.ok) {
await loadTrialPeriods(currentCustomerId);
alert('试用时间删除成功!');
} else {
alert('删除试用时间时出错');
}
} catch (error) {
console.error('Error deleting trial period:', error);
alert('删除试用时间时出错');
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function () {
initTrialPeriodManagement();
});

View File

@ -1,9 +1,12 @@
package handlers
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@ -17,11 +20,13 @@ import (
type CustomerHandler struct {
storage storage.CustomerStorage
feishuWebhook string
}
func NewCustomerHandler(storage storage.CustomerStorage) *CustomerHandler {
func NewCustomerHandler(storage storage.CustomerStorage, feishuWebhook string) *CustomerHandler {
return &CustomerHandler{
storage: storage,
feishuWebhook: feishuWebhook,
}
}
@ -160,11 +165,23 @@ func (h *CustomerHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request)
return
}
// Get customer info for notification
customer, err := h.storage.GetCustomerByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := h.storage.UpdateCustomer(id, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Send Feishu notification if reporter (trial period) is updated
if req.Reporter != nil && *req.Reporter != "" && h.feishuWebhook != "" && customer != nil {
go h.sendTrialNotification(customer.CustomerName, *req.Reporter)
}
w.WriteHeader(http.StatusOK)
}
@ -252,14 +269,14 @@ func (h *CustomerHandler) ImportCustomers(w http.ResponseWriter, r *http.Request
ID: "", // Will be generated by the storage
CreatedAt: time.Now(),
CustomerName: getValue(row, 0),
Version: getValue(row, 1),
Description: getValue(row, 2),
Solution: getValue(row, 3),
Type: getValue(row, 4),
Module: getValue(row, 5),
StatusProgress: getValue(row, 6),
Reporter: getValue(row, 7),
IntendedProduct: getValue(row, 8),
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: "",
}
// Check for duplicate
@ -310,14 +327,14 @@ func (h *CustomerHandler) ImportCustomers(w http.ResponseWriter, r *http.Request
ID: "", // Will be generated by the storage
CreatedAt: time.Now(),
CustomerName: getValue(row, 0),
Version: getValue(row, 1),
Description: getValue(row, 2),
Solution: getValue(row, 3),
Type: getValue(row, 4),
Module: getValue(row, 5),
StatusProgress: getValue(row, 6),
Reporter: getValue(row, 7),
IntendedProduct: getValue(row, 8),
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: "",
}
// Check for duplicate
@ -356,3 +373,37 @@ func getValue(row []string, index int) string {
}
return ""
}
// sendTrialNotification sends a Feishu notification when trial period is set/updated
func (h *CustomerHandler) sendTrialNotification(customerName, trialPeriod string) {
if h.feishuWebhook == "" {
return
}
message := map[string]interface{}{
"msg_type": "text",
"content": map[string]string{
"text": fmt.Sprintf("温馨提示:%s客户于明日试用到期请及时跟进\n试用时间%s", customerName, trialPeriod),
},
}
jsonData, err := json.Marshal(message)
if err != nil {
log.Printf("Failed to marshal Feishu message: %v", err)
return
}
resp, err := http.Post(h.feishuWebhook, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("Failed to send Feishu notification: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("Feishu API returned status: %d", resp.StatusCode)
return
}
log.Printf("Sent trial notification for customer: %s", customerName)
}

View File

@ -0,0 +1,307 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"crm-go/internal/storage"
"crm-go/models"
)
type FollowUpHandler struct {
storage storage.FollowUpStorage
customerStorage storage.CustomerStorage
feishuWebhook string
}
func NewFollowUpHandler(storage storage.FollowUpStorage, customerStorage storage.CustomerStorage, feishuWebhook string) *FollowUpHandler {
return &FollowUpHandler{
storage: storage,
customerStorage: customerStorage,
feishuWebhook: feishuWebhook,
}
}
func (h *FollowUpHandler) GetFollowUps(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
}
}
followUps, err := h.storage.GetAllFollowUps()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
total := len(followUps)
totalPages := (total + pageSize - 1) / pageSize
start := (page - 1) * pageSize
end := start + pageSize
if start >= total {
followUps = []models.FollowUp{}
} else if end > total {
followUps = followUps[start:]
} else {
followUps = followUps[start:end]
}
response := map[string]interface{}{
"followUps": followUps,
"total": total,
"page": page,
"pageSize": pageSize,
"totalPages": totalPages,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (h *FollowUpHandler) GetFollowUpByID(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3]
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
followUp, err := h.storage.GetFollowUpByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if followUp == nil {
http.Error(w, "Follow-up not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(followUp)
}
func (h *FollowUpHandler) CreateFollowUp(w http.ResponseWriter, r *http.Request) {
var req models.CreateFollowUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Parse follow-up time
followUpTime, err := time.Parse(time.RFC3339, req.FollowUpTime)
if err != nil {
http.Error(w, "Invalid follow-up time format", http.StatusBadRequest)
return
}
followUp := models.FollowUp{
CustomerName: req.CustomerName,
DealStatus: req.DealStatus,
CustomerLevel: req.CustomerLevel,
Industry: req.Industry,
FollowUpTime: followUpTime,
CreatedAt: time.Now(),
NotificationSent: false,
}
if err := h.storage.CreateFollowUp(followUp); 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(followUp)
}
func (h *FollowUpHandler) UpdateFollowUp(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3]
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
var req models.UpdateFollowUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := h.storage.UpdateFollowUp(id, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *FollowUpHandler) DeleteFollowUp(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3]
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if err := h.storage.DeleteFollowUp(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// SendFeishuNotification sends a notification to Feishu
func (h *FollowUpHandler) SendFeishuNotification(customerName string) error {
if h.feishuWebhook == "" {
return fmt.Errorf("Feishu webhook URL not configured")
}
// Create Feishu card message
message := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]interface{}{
"tag": "plain_text",
"content": "客户跟进提醒",
},
"template": "blue",
},
"elements": []map[string]interface{}{
{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("<at id=all></at> 请及时跟进 \"%s\"", customerName),
},
},
},
},
}
jsonData, err := json.Marshal(message)
if err != nil {
return err
}
resp, err := http.Post(h.feishuWebhook, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Feishu API returned status code: %d", resp.StatusCode)
}
return nil
}
// CheckAndSendNotifications checks for pending notifications and sends them
func (h *FollowUpHandler) CheckAndSendNotifications() error {
pendingFollowUps, err := h.storage.GetPendingNotifications()
if err != nil {
return err
}
for _, followUp := range pendingFollowUps {
// Send Feishu notification
if err := h.SendFeishuNotification(followUp.CustomerName); err != nil {
fmt.Printf("Error sending notification for follow-up %s: %v\n", followUp.ID, err)
continue
}
// Mark notification as sent
if err := h.storage.MarkNotificationSent(followUp.ID); err != nil {
fmt.Printf("Error marking notification as sent for follow-up %s: %v\n", followUp.ID, err)
}
}
return nil
}
// GetCustomerList returns a list of customers with id and name
func (h *FollowUpHandler) GetCustomerList(w http.ResponseWriter, r *http.Request) {
customers, err := h.customerStorage.GetAllCustomers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Printf("DEBUG: Total customers from storage: %d\n", len(customers))
// Create a list of customer objects with id and name
type CustomerInfo struct {
ID string `json:"id"`
CustomerName string `json:"customerName"`
}
// Use a map to deduplicate customers by name
customerMap := make(map[string]CustomerInfo)
for _, customer := range customers {
if customer.CustomerName != "" {
// Only keep the first occurrence of each customer name
if _, exists := customerMap[customer.CustomerName]; !exists {
customerMap[customer.CustomerName] = CustomerInfo{
ID: customer.ID,
CustomerName: customer.CustomerName,
}
fmt.Printf("DEBUG: Added customer: ID=%s, Name=%s\n", customer.ID, customer.CustomerName)
}
}
}
// Convert map to slice
var customerList []CustomerInfo
for _, customer := range customerMap {
customerList = append(customerList, customer)
}
fmt.Printf("DEBUG: Total unique customer list items: %d\n", len(customerList))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customers": customerList,
})
}

View File

@ -0,0 +1,329 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"crm-go/internal/storage"
"crm-go/models"
)
type TrialPeriodHandler struct {
storage storage.TrialPeriodStorage
customerStorage storage.CustomerStorage
feishuWebhook string
}
func NewTrialPeriodHandler(storage storage.TrialPeriodStorage, customerStorage storage.CustomerStorage, feishuWebhook string) *TrialPeriodHandler {
return &TrialPeriodHandler{
storage: storage,
customerStorage: customerStorage,
feishuWebhook: feishuWebhook,
}
}
// GetTrialPeriodsByCustomer returns all trial periods for a specific customer
func (h *TrialPeriodHandler) GetTrialPeriodsByCustomer(w http.ResponseWriter, r *http.Request) {
customerID := r.URL.Query().Get("customerId")
if customerID == "" {
http.Error(w, "Customer ID is required", http.StatusBadRequest)
return
}
trialPeriods, err := h.storage.GetTrialPeriodsByCustomerID(customerID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"trialPeriods": trialPeriods,
})
}
// GetAllTrialPeriods returns all trial periods
func (h *TrialPeriodHandler) GetAllTrialPeriods(w http.ResponseWriter, r *http.Request) {
trialPeriods, err := h.storage.GetAllTrialPeriods()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"trialPeriods": trialPeriods,
})
}
// CreateTrialPeriod creates a new trial period
func (h *TrialPeriodHandler) CreateTrialPeriod(w http.ResponseWriter, r *http.Request) {
var req models.CreateTrialPeriodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Parse start and end times
startTime, err := time.Parse(time.RFC3339, req.StartTime)
if err != nil {
http.Error(w, "Invalid start time format", http.StatusBadRequest)
return
}
endTime, err := time.Parse(time.RFC3339, req.EndTime)
if err != nil {
http.Error(w, "Invalid end time format", http.StatusBadRequest)
return
}
trialPeriod := models.TrialPeriod{
CustomerID: req.CustomerID,
StartTime: startTime,
EndTime: endTime,
CreatedAt: time.Now(),
}
createdPeriod, err := h.storage.CreateTrialPeriod(trialPeriod)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Send Feishu notification
if h.feishuWebhook != "" {
go h.sendTrialNotification(req.CustomerID, startTime, endTime)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(createdPeriod)
}
// UpdateTrialPeriod updates an existing trial period
func (h *TrialPeriodHandler) UpdateTrialPeriod(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3]
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
var req models.UpdateTrialPeriodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get existing trial period for notification
existingPeriod, err := h.storage.GetTrialPeriodByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := h.storage.UpdateTrialPeriod(id, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Send Feishu notification if updated
if existingPeriod != nil && h.feishuWebhook != "" {
var startTime, endTime time.Time
if req.StartTime != nil {
startTime, _ = time.Parse(time.RFC3339, *req.StartTime)
} else {
startTime = existingPeriod.StartTime
}
if req.EndTime != nil {
endTime, _ = time.Parse(time.RFC3339, *req.EndTime)
} else {
endTime = existingPeriod.EndTime
}
go h.sendTrialNotification(existingPeriod.CustomerID, startTime, endTime)
}
w.WriteHeader(http.StatusOK)
}
// DeleteTrialPeriod deletes a trial period
func (h *TrialPeriodHandler) DeleteTrialPeriod(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
pathParts := strings.Split(urlPath, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid URL format", http.StatusBadRequest)
return
}
id := pathParts[3]
if idx := strings.Index(id, "?"); idx != -1 {
id = id[:idx]
}
if err := h.storage.DeleteTrialPeriod(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// sendTrialNotification sends a rich Feishu card notification
func (h *TrialPeriodHandler) sendTrialNotification(customerID string, startTime, endTime time.Time) {
if h.feishuWebhook == "" {
return
}
// Calculate days until expiry
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endDateOnly := time.Date(endTime.Year(), endTime.Month(), endTime.Day(), 0, 0, 0, 0, endTime.Location())
daysUntilExpiry := int(endDateOnly.Sub(today).Hours() / 24)
// Only send notification if expiring within 3 days
if daysUntilExpiry < 0 || daysUntilExpiry > 3 {
return
}
// Get customer name
customerName := customerID
customer, err := h.customerStorage.GetCustomerByID(customerID)
if err == nil && customer != nil {
customerName = customer.CustomerName
}
// Determine urgency level and message based on days until expiry
var title, urgencyLevel, urgencyColor, expiryText string
switch daysUntilExpiry {
case 0:
title = "🔴 试用今日到期提醒"
urgencyLevel = "紧急"
urgencyColor = "red"
expiryText = "今天"
case 1:
title = "🟠 试用明日到期提醒"
urgencyLevel = "重要"
urgencyColor = "orange"
expiryText = "明天"
case 2:
title = "🟡 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "2天后"
case 3:
title = "🔵 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "3天后"
default:
title = "📅 试用到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = fmt.Sprintf("%d天后", daysUntilExpiry)
}
// Format trial period
trialPeriod := fmt.Sprintf("%d/%d/%d %02d:%02d%d/%d/%d %02d:%02d",
startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(),
endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute())
// Create rich card message
message := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]interface{}{
"tag": "plain_text",
"content": title,
},
"template": urgencyColor,
},
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**客户名称**\n%s", customerName),
},
},
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**紧急程度**\n%s", urgencyLevel),
},
},
},
},
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": false,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**试用时间**\n%s", trialPeriod),
},
},
},
},
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("⏰ 该客户的试用期将于 **%s** 到期,请及时跟进!", expiryText),
},
},
map[string]interface{}{
"tag": "hr",
},
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "plain_text",
"content": fmt.Sprintf("发送时间:%s", time.Now().Format("2006-01-02 15:04:05")),
},
},
},
},
},
}
jsonData, err := json.Marshal(message)
if err != nil {
log.Printf("Failed to marshal Feishu message: %v", err)
return
}
log.Printf("DEBUG: Sending Feishu message from trial_period_handler: %s", string(jsonData))
resp, err := http.Post(h.feishuWebhook, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("Failed to send Feishu notification: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("Feishu API returned status: %d", resp.StatusCode)
return
}
log.Printf("Sent trial notification for customer: %s (expires in %d days)", customerID, daysUntilExpiry)
}

View File

@ -0,0 +1,242 @@
package storage
import (
"crm-go/models"
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
type FollowUpStorage interface {
GetAllFollowUps() ([]models.FollowUp, error)
GetFollowUpByID(id string) (*models.FollowUp, error)
CreateFollowUp(followUp models.FollowUp) error
UpdateFollowUp(id string, updates models.UpdateFollowUpRequest) error
DeleteFollowUp(id string) error
SaveFollowUps(followUps []models.FollowUp) error
LoadFollowUps() ([]models.FollowUp, error)
GetPendingNotifications() ([]models.FollowUp, error)
MarkNotificationSent(id string) error
}
type followUpStorage struct {
filePath string
mutex sync.RWMutex
}
func NewFollowUpStorage(filePath string) FollowUpStorage {
storage := &followUpStorage{
filePath: filePath,
}
return storage
}
func (fs *followUpStorage) GetAllFollowUps() ([]models.FollowUp, error) {
fs.mutex.RLock()
defer fs.mutex.RUnlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return nil, err
}
// Sort by FollowUpTime in descending order (newest first)
for i := 0; i < len(followUps)-1; i++ {
for j := i + 1; j < len(followUps); j++ {
if followUps[i].FollowUpTime.Before(followUps[j].FollowUpTime) {
followUps[i], followUps[j] = followUps[j], followUps[i]
}
}
}
return followUps, nil
}
func (fs *followUpStorage) GetFollowUpByID(id string) (*models.FollowUp, error) {
fs.mutex.RLock()
defer fs.mutex.RUnlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return nil, err
}
for _, followUp := range followUps {
if followUp.ID == id {
return &followUp, nil
}
}
return nil, nil
}
func (fs *followUpStorage) CreateFollowUp(followUp models.FollowUp) error {
fs.mutex.Lock()
defer fs.mutex.Unlock()
if followUp.ID == "" {
followUp.ID = generateFollowUpUUID()
}
if followUp.CreatedAt.IsZero() {
followUp.CreatedAt = time.Now()
}
followUps, err := fs.LoadFollowUps()
if err != nil {
return err
}
followUps = append(followUps, followUp)
return fs.SaveFollowUps(followUps)
}
func generateFollowUpUUID() 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 (fs *followUpStorage) UpdateFollowUp(id string, updates models.UpdateFollowUpRequest) error {
fs.mutex.Lock()
defer fs.mutex.Unlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return err
}
for i, followUp := range followUps {
if followUp.ID == id {
if updates.CustomerName != nil {
followUps[i].CustomerName = *updates.CustomerName
}
if updates.DealStatus != nil {
followUps[i].DealStatus = *updates.DealStatus
}
if updates.CustomerLevel != nil {
followUps[i].CustomerLevel = *updates.CustomerLevel
}
if updates.Industry != nil {
followUps[i].Industry = *updates.Industry
}
if updates.FollowUpTime != nil {
// Parse the time string
t, err := time.Parse(time.RFC3339, *updates.FollowUpTime)
if err == nil {
followUps[i].FollowUpTime = t
}
}
if updates.NotificationSent != nil {
followUps[i].NotificationSent = *updates.NotificationSent
}
return fs.SaveFollowUps(followUps)
}
}
return nil // FollowUp not found, but not an error
}
func (fs *followUpStorage) DeleteFollowUp(id string) error {
fs.mutex.Lock()
defer fs.mutex.Unlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return err
}
for i, followUp := range followUps {
if followUp.ID == id {
followUps = append(followUps[:i], followUps[i+1:]...)
return fs.SaveFollowUps(followUps)
}
}
return nil // FollowUp not found, but not an error
}
func (fs *followUpStorage) SaveFollowUps(followUps []models.FollowUp) error {
// Ensure the directory exists
dir := filepath.Dir(fs.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(followUps, "", " ")
if err != nil {
return err
}
return os.WriteFile(fs.filePath, data, 0644)
}
func (fs *followUpStorage) LoadFollowUps() ([]models.FollowUp, error) {
// Check if file exists
if _, err := os.Stat(fs.filePath); os.IsNotExist(err) {
// Return empty slice if file doesn't exist
return []models.FollowUp{}, nil
}
data, err := os.ReadFile(fs.filePath)
if err != nil {
return nil, err
}
var followUps []models.FollowUp
if err := json.Unmarshal(data, &followUps); err != nil {
return nil, err
}
return followUps, nil
}
func (fs *followUpStorage) GetPendingNotifications() ([]models.FollowUp, error) {
fs.mutex.RLock()
defer fs.mutex.RUnlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return nil, err
}
now := time.Now()
var pending []models.FollowUp
for _, followUp := range followUps {
// Check if follow-up time has passed and notification hasn't been sent
if !followUp.NotificationSent && followUp.FollowUpTime.Before(now) {
pending = append(pending, followUp)
}
}
return pending, nil
}
func (fs *followUpStorage) MarkNotificationSent(id string) error {
fs.mutex.Lock()
defer fs.mutex.Unlock()
followUps, err := fs.LoadFollowUps()
if err != nil {
return err
}
for i, followUp := range followUps {
if followUp.ID == id {
followUps[i].NotificationSent = true
return fs.SaveFollowUps(followUps)
}
}
return nil
}

View File

@ -0,0 +1,216 @@
package storage
import (
"crm-go/models"
"crypto/rand"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
type TrialPeriodStorage interface {
GetAllTrialPeriods() ([]models.TrialPeriod, error)
GetTrialPeriodsByCustomerID(customerID string) ([]models.TrialPeriod, error)
GetTrialPeriodByID(id string) (*models.TrialPeriod, error)
CreateTrialPeriod(trialPeriod models.TrialPeriod) (*models.TrialPeriod, error)
UpdateTrialPeriod(id string, updates models.UpdateTrialPeriodRequest) error
DeleteTrialPeriod(id string) error
}
type trialPeriodStorage struct {
filePath string
mutex sync.RWMutex
}
func NewTrialPeriodStorage(filePath string) TrialPeriodStorage {
storage := &trialPeriodStorage{
filePath: filePath,
}
return storage
}
func (ts *trialPeriodStorage) GetAllTrialPeriods() ([]models.TrialPeriod, error) {
ts.mutex.RLock()
defer ts.mutex.RUnlock()
trialPeriods, err := ts.loadTrialPeriods()
if err != nil {
return nil, err
}
// Sort by CreatedAt in descending order (newest first)
for i := 0; i < len(trialPeriods)-1; i++ {
for j := i + 1; j < len(trialPeriods); j++ {
if trialPeriods[i].CreatedAt.Before(trialPeriods[j].CreatedAt) {
trialPeriods[i], trialPeriods[j] = trialPeriods[j], trialPeriods[i]
}
}
}
return trialPeriods, nil
}
func (ts *trialPeriodStorage) GetTrialPeriodsByCustomerID(customerID string) ([]models.TrialPeriod, error) {
ts.mutex.RLock()
defer ts.mutex.RUnlock()
allPeriods, err := ts.loadTrialPeriods()
if err != nil {
return nil, err
}
var customerPeriods []models.TrialPeriod
for _, period := range allPeriods {
if period.CustomerID == customerID {
customerPeriods = append(customerPeriods, period)
}
}
// Sort by EndTime in descending order (latest first)
for i := 0; i < len(customerPeriods)-1; i++ {
for j := i + 1; j < len(customerPeriods); j++ {
if customerPeriods[i].EndTime.Before(customerPeriods[j].EndTime) {
customerPeriods[i], customerPeriods[j] = customerPeriods[j], customerPeriods[i]
}
}
}
return customerPeriods, nil
}
func (ts *trialPeriodStorage) GetTrialPeriodByID(id string) (*models.TrialPeriod, error) {
ts.mutex.RLock()
defer ts.mutex.RUnlock()
trialPeriods, err := ts.loadTrialPeriods()
if err != nil {
return nil, err
}
for _, period := range trialPeriods {
if period.ID == id {
return &period, nil
}
}
return nil, nil
}
func (ts *trialPeriodStorage) CreateTrialPeriod(trialPeriod models.TrialPeriod) (*models.TrialPeriod, error) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if trialPeriod.ID == "" {
trialPeriod.ID = generateTrialPeriodUUID()
}
if trialPeriod.CreatedAt.IsZero() {
trialPeriod.CreatedAt = time.Now()
}
trialPeriods, err := ts.loadTrialPeriods()
if err != nil {
return nil, err
}
trialPeriods = append(trialPeriods, trialPeriod)
if err := ts.saveTrialPeriods(trialPeriods); err != nil {
return nil, err
}
return &trialPeriod, nil
}
func (ts *trialPeriodStorage) UpdateTrialPeriod(id string, updates models.UpdateTrialPeriodRequest) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
trialPeriods, err := ts.loadTrialPeriods()
if err != nil {
return err
}
for i, period := range trialPeriods {
if period.ID == id {
if updates.StartTime != nil {
startTime, err := time.Parse(time.RFC3339, *updates.StartTime)
if err == nil {
trialPeriods[i].StartTime = startTime
}
}
if updates.EndTime != nil {
endTime, err := time.Parse(time.RFC3339, *updates.EndTime)
if err == nil {
trialPeriods[i].EndTime = endTime
}
}
return ts.saveTrialPeriods(trialPeriods)
}
}
return nil
}
func (ts *trialPeriodStorage) DeleteTrialPeriod(id string) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
trialPeriods, err := ts.loadTrialPeriods()
if err != nil {
return err
}
for i, period := range trialPeriods {
if period.ID == id {
trialPeriods = append(trialPeriods[:i], trialPeriods[i+1:]...)
return ts.saveTrialPeriods(trialPeriods)
}
}
return nil
}
func (ts *trialPeriodStorage) saveTrialPeriods(trialPeriods []models.TrialPeriod) error {
dir := filepath.Dir(ts.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(trialPeriods, "", " ")
if err != nil {
return err
}
return os.WriteFile(ts.filePath, data, 0644)
}
func (ts *trialPeriodStorage) loadTrialPeriods() ([]models.TrialPeriod, error) {
if _, err := os.Stat(ts.filePath); os.IsNotExist(err) {
return []models.TrialPeriod{}, nil
}
data, err := os.ReadFile(ts.filePath)
if err != nil {
return nil, err
}
var trialPeriods []models.TrialPeriod
if err := json.Unmarshal(data, &trialPeriods); err != nil {
return nil, err
}
return trialPeriods, nil
}
func generateTrialPeriodUUID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return hex.EncodeToString(bytes)
}

34
models/followup.go Normal file
View File

@ -0,0 +1,34 @@
package models
import "time"
// FollowUp represents a customer follow-up record
type FollowUp struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
CustomerName string `json:"customerName"`
DealStatus string `json:"dealStatus"` // 未成交, 已成交
CustomerLevel string `json:"customerLevel"` // A, B, C
Industry string `json:"industry"`
FollowUpTime time.Time `json:"followUpTime"`
NotificationSent bool `json:"notificationSent"`
}
// CreateFollowUpRequest represents the request to create a follow-up
type CreateFollowUpRequest struct {
CustomerName string `json:"customerName"`
DealStatus string `json:"dealStatus"`
CustomerLevel string `json:"customerLevel"`
Industry string `json:"industry"`
FollowUpTime string `json:"followUpTime"`
}
// UpdateFollowUpRequest represents the request to update a follow-up
type UpdateFollowUpRequest struct {
CustomerName *string `json:"customerName"`
DealStatus *string `json:"dealStatus"`
CustomerLevel *string `json:"customerLevel"`
Industry *string `json:"industry"`
FollowUpTime *string `json:"followUpTime"`
NotificationSent *bool `json:"notificationSent"`
}

25
models/trial_period.go Normal file
View File

@ -0,0 +1,25 @@
package models
import "time"
// TrialPeriod represents a trial period for a customer
type TrialPeriod struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
CreatedAt time.Time `json:"createdAt"`
}
// CreateTrialPeriodRequest represents the request to create a trial period
type CreateTrialPeriodRequest struct {
CustomerID string `json:"customerId"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
// UpdateTrialPeriodRequest represents the request to update a trial period
type UpdateTrialPeriodRequest struct {
StartTime *string `json:"startTime,omitempty"`
EndTime *string `json:"endTime,omitempty"`
}

21
server_debug.log Normal file
View File

@ -0,0 +1,21 @@
2026/01/13 15:57:33 Server starting on :8081
2026/01/13 15:57:33 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n予芯","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:562026/1/16 07:56","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **3天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:33","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🔵 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:34 Sent trial expiry notification for customer: 予芯 (expires in 3 days)
2026/01/13 15:57:34 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n斯蒂尔","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:532026/1/15 07:53","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **2天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:34","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🟡 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:34 Sent trial expiry notification for customer: 斯蒂尔 (expires in 2 days)
2026/01/13 15:57:34 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n予芯","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n重要","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:512026/1/14 07:51","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **明天** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:34","tag":"plain_text"}],"tag":"note"}],"header":{"template":"orange","title":{"content":"🟠 试用明日到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:34 Sent trial expiry notification for customer: 予芯 (expires in 1 days)
2026/01/13 15:57:34 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n求之","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:232026/1/16 07:23","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **3天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:34","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🔵 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:35 Sent trial expiry notification for customer: 求之 (expires in 3 days)
2026/01/13 15:57:35 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n重要","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:222026/1/14 07:22","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **明天** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:35","tag":"plain_text"}],"tag":"note"}],"header":{"template":"orange","title":{"content":"🟠 试用明日到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:35 Sent trial expiry notification for customer: 雷沃 (expires in 1 days)
2026/01/13 15:57:35 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:212026/1/16 07:21","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **3天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:35","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🔵 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:36 Sent trial expiry notification for customer: 雷沃 (expires in 3 days)
2026/01/13 15:57:36 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 07:102026/1/15 07:10","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **2天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:36","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🟡 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:36 Sent trial expiry notification for customer: 雷沃 (expires in 2 days)
2026/01/13 15:57:36 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n提醒","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 03:412026/1/16 03:41","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **3天后** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:36","tag":"plain_text"}],"tag":"note"}],"header":{"template":"blue","title":{"content":"🔵 试用即将到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:36 Sent trial expiry notification for customer: 雷沃 (expires in 3 days)
2026/01/13 15:57:36 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n重要","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 03:412026/1/14 03:41","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **明天** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:36","tag":"plain_text"}],"tag":"note"}],"header":{"template":"orange","title":{"content":"🟠 试用明日到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:37 Sent trial expiry notification for customer: 雷沃 (expires in 1 days)
2026/01/13 15:57:37 DEBUG: Sending Feishu message: {"card":{"elements":[{"fields":[{"is_short":true,"text":{"content":"**客户名称**\n雷沃","tag":"lark_md"}},{"is_short":true,"text":{"content":"**紧急程度**\n紧急","tag":"lark_md"}}],"tag":"div"},{"fields":[{"is_short":false,"text":{"content":"**试用时间**\n2026/1/13 03:402026/1/13 03:41","tag":"lark_md"}}],"tag":"div"},{"tag":"div","text":{"content":"⏰ 该客户的试用期将于 **今天** 到期,请及时跟进!","tag":"lark_md"}},{"tag":"hr"},{"elements":[{"content":"发送时间2026-01-13 15:57:37","tag":"plain_text"}],"tag":"note"}],"header":{"template":"red","title":{"content":"🔴 试用今日到期提醒","tag":"plain_text"}}},"msg_type":"interactive"}
2026/01/13 15:57:37 Sent trial expiry notification for customer: 雷沃 (expires in 0 days)

309
services/trial_expiry.go Normal file
View File

@ -0,0 +1,309 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
)
// TrialExpiryChecker checks for trial expiry and sends notifications
type TrialExpiryChecker struct {
feishuWebhook string
}
// NewTrialExpiryChecker creates a new trial expiry checker
func NewTrialExpiryChecker(feishuWebhook string) *TrialExpiryChecker {
return &TrialExpiryChecker{
feishuWebhook: feishuWebhook,
}
}
// Customer represents a customer record
type Customer struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
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"` // Now used for trial period
}
// TrialPeriod represents a trial period record
type TrialPeriod struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
CreatedAt time.Time `json:"createdAt"`
}
// ParseTrialPeriod parses the trial period string
// Supports formats like: "2026/1/52026/1/16", "2026/1/5~2026/1/16", "2026-1-52026-1-16"
func ParseTrialPeriod(trialStr string) (startDate, endDate time.Time, err error) {
if trialStr == "" {
return time.Time{}, time.Time{}, fmt.Errorf("empty trial period")
}
// Replace different separators with standard ~
trialStr = strings.ReplaceAll(trialStr, "", "~")
trialStr = strings.ReplaceAll(trialStr, " ", "")
// Split by ~
parts := strings.Split(trialStr, "~")
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid trial period format")
}
// Parse dates - support multiple formats
layouts := []string{
"2006/1/2",
"2006-1-2",
"2006/01/02",
"2006-01-02",
}
var parseErr error
for _, layout := range layouts {
startDate, parseErr = time.Parse(layout, strings.TrimSpace(parts[0]))
if parseErr == nil {
break
}
}
if parseErr != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse start date: %v", parseErr)
}
for _, layout := range layouts {
endDate, parseErr = time.Parse(layout, strings.TrimSpace(parts[1]))
if parseErr == nil {
break
}
}
if parseErr != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse end date: %v", parseErr)
}
return startDate, endDate, nil
}
// CheckAndNotify checks for customers whose trial expires soon and sends notifications
func (t *TrialExpiryChecker) CheckAndNotify(customers []Customer) error {
if t.feishuWebhook == "" {
log.Println("Feishu webhook not configured, skipping trial expiry notifications")
return nil
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, customer := range customers {
if customer.Reporter == "" {
continue
}
_, endDate, err := ParseTrialPeriod(customer.Reporter)
if err != nil {
// Skip invalid trial periods
continue
}
// Check if trial expires within 3 days
endDateOnly := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
daysUntilExpiry := int(endDateOnly.Sub(today).Hours() / 24)
// Send notification for trials expiring today, tomorrow, or within 3 days
if daysUntilExpiry >= 0 && daysUntilExpiry <= 3 {
err := t.sendExpiryNotification(customer.CustomerName, customer.Reporter, daysUntilExpiry)
if err != nil {
log.Printf("Failed to send expiry notification for %s: %v", customer.CustomerName, err)
} else {
log.Printf("Sent trial expiry notification for customer: %s (expires in %d days)", customer.CustomerName, daysUntilExpiry)
}
}
}
return nil
}
// CheckTrialPeriodsAndNotify checks trial periods and sends notifications
func (t *TrialExpiryChecker) CheckTrialPeriodsAndNotify(trialPeriods []TrialPeriod, customersMap map[string]string) error {
if t.feishuWebhook == "" {
log.Println("Feishu webhook not configured, skipping trial expiry notifications")
return nil
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, period := range trialPeriods {
endDateOnly := time.Date(period.EndTime.Year(), period.EndTime.Month(), period.EndTime.Day(), 0, 0, 0, 0, period.EndTime.Location())
daysUntilExpiry := int(endDateOnly.Sub(today).Hours() / 24)
// Send notification for trials expiring today, tomorrow, or within 3 days
if daysUntilExpiry >= 0 && daysUntilExpiry <= 3 {
customerName := customersMap[period.CustomerID]
if customerName == "" {
customerName = period.CustomerID
}
trialPeriodStr := fmt.Sprintf("%s%s",
period.StartTime.Format("2006/1/2 15:04"),
period.EndTime.Format("2006/1/2 15:04"))
err := t.sendExpiryNotification(customerName, trialPeriodStr, daysUntilExpiry)
if err != nil {
log.Printf("Failed to send expiry notification for %s: %v", customerName, err)
} else {
log.Printf("Sent trial expiry notification for customer: %s (expires in %d days)", customerName, daysUntilExpiry)
}
}
}
return nil
}
// sendExpiryNotification sends a rich Feishu card notification for trial expiry
func (t *TrialExpiryChecker) sendExpiryNotification(customerName, trialPeriod string, daysUntilExpiry int) error {
var title, urgencyLevel, urgencyColor, expiryText string
// Determine urgency level and message based on days until expiry
switch daysUntilExpiry {
case 0:
title = "🔴 试用今日到期提醒"
urgencyLevel = "紧急"
urgencyColor = "red"
expiryText = "今天"
case 1:
title = "🟠 试用明日到期提醒"
urgencyLevel = "重要"
urgencyColor = "orange"
expiryText = "明天"
case 2:
title = "🟡 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "2天后"
case 3:
title = "🔵 试用即将到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = "3天后"
default:
title = "📅 试用到期提醒"
urgencyLevel = "提醒"
urgencyColor = "blue"
expiryText = fmt.Sprintf("%d天后", daysUntilExpiry)
}
// Create rich card message
message := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"header": map[string]interface{}{
"title": map[string]interface{}{
"tag": "plain_text",
"content": title,
},
"template": urgencyColor,
},
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**客户名称**\n%s", customerName),
},
},
map[string]interface{}{
"is_short": true,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**紧急程度**\n%s", urgencyLevel),
},
},
},
},
map[string]interface{}{
"tag": "div",
"fields": []interface{}{
map[string]interface{}{
"is_short": false,
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("**试用时间**\n%s", trialPeriod),
},
},
},
},
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": fmt.Sprintf("⏰ 该客户的试用期将于 **%s** 到期,请及时跟进!", expiryText),
},
},
map[string]interface{}{
"tag": "hr",
},
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "plain_text",
"content": fmt.Sprintf("发送时间:%s", time.Now().Format("2006-01-02 15:04:05")),
},
},
},
},
},
}
jsonData, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %v", err)
}
// Debug: Print the JSON being sent
log.Printf("DEBUG: Sending Feishu message: %s", string(jsonData))
resp, err := http.Post(t.feishuWebhook, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("feishu API returned status: %d", resp.StatusCode)
}
return nil
}
// ExtractEndDate extracts the end date from trial period string for display
func ExtractEndDate(trialStr string) string {
if trialStr == "" {
return ""
}
// Use regex to extract the end date
re := regexp.MustCompile(`[~]\s*(.+)$`)
matches := re.FindStringSubmatch(trialStr)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return trialStr
}