feat_crm
This commit is contained in:
parent
558d51abe8
commit
765dea7359
137
IMPLEMENTATION_SUMMARY.md
Normal file
137
IMPLEMENTATION_SUMMARY.md
Normal 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
85
README_FOLLOWUP.md
Normal 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. 飞书机器人需要在对应的群聊中添加才能发送消息
|
||||
@ -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) {
|
||||
|
||||
@ -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
22
data/followups.json
Normal 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
23
data/trial_periods.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@ -52,6 +52,9 @@ body {
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
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;
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -611,6 +698,7 @@ td.has-overflow:hover::after {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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">×</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">×</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">×</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>
|
||||
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
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
|
||||
// 封装带 Token 的 fetch (全局函数)
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('crmToken');
|
||||
const headers = {
|
||||
@ -25,6 +17,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
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
const customerSection = document.getElementById('customerSection');
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
410
frontend/js/trial-periods-page.js
Normal file
410
frontend/js/trial-periods-page.js
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
293
frontend/js/trial-periods.js
Normal file
293
frontend/js/trial-periods.js
Normal 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();
|
||||
});
|
||||
@ -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)
|
||||
}
|
||||
|
||||
307
internal/handlers/followup_handler.go
Normal file
307
internal/handlers/followup_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
329
internal/handlers/trial_period_handler.go
Normal file
329
internal/handlers/trial_period_handler.go
Normal 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)
|
||||
}
|
||||
242
internal/storage/followup_storage.go
Normal file
242
internal/storage/followup_storage.go
Normal 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
|
||||
}
|
||||
216
internal/storage/trial_period_storage.go
Normal file
216
internal/storage/trial_period_storage.go
Normal 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
34
models/followup.go
Normal 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
25
models/trial_period.go
Normal 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
21
server_debug.log
Normal 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:56~2026/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:53~2026/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:51~2026/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:23~2026/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:22~2026/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:21~2026/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:10~2026/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:41~2026/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:41~2026/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:40~2026/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
309
services/trial_expiry.go
Normal 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/5~2026/1/16", "2026/1/5~2026/1/16", "2026-1-5~2026-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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user