diff --git a/README.md b/README.md index 8930afd..c4b2e5b 100644 --- a/README.md +++ b/README.md @@ -1,306 +1,71 @@ -# 开发者部署平台 (Developer Platform) +# 网络验证平台 -## 项目简介 +一个功能完整的软件授权验证管理平台,支持多应用管理、用户授权、卡密销售、代理分销等功能。 -开发者部署平台是一个面向开发者的软件授权和验证管理平台,提供开发者官网展示、产品功能介绍、产品定价方案、开发者后台管理系统以及代理商后台管理系统。 +## 功能特性 -## 功能模块 +- **应用管理** - 创建和管理多个应用,支持独立的验证配置 +- **用户系统** - 用户注册、登录、设备绑定、试用功能 +- **卡密系统** - 生成、销售、管理卡密,支持多种卡类型 +- **代理系统** - 多级代理分销,佣金结算 +- **云端变量** - 单值模式和流水模式,支持应用端写入 +- **版本管理** - ZIP包上传,文件索引,自动更新 +- **扩展API** - Webhook推送,API Key认证 +- **邮件系统** - 邮箱验证、密码重置、通知邮件 -### 1. 开发者官网展示 -- 平台介绍页面 -- 功能特性展示 -- 快速入门指南 -- 开发者社区入口 - -### 2. 产品功能介绍 -- 授权验证功能 -- 用户管理功能 -- 数据统计功能 -- API 接口说明 - -### 3. 产品定价方案 -- 套餐价格展示 -- 功能对比表 -- 免费试用入口 -- 在线购买流程 - -### 4. 开发者后台管理系统 -- 应用管理 -- 卡密管理 -- 用户管理 -- 设备管理 -- 版本管理 -- 云端数据管理 -- 风控管理 -- 财务管理 -- 工单系统 -- 日志记录 - -### 5. 代理商后台管理系统 -- 代理授权管理 -- 卡密权限配置 -- 余额充值 -- 佣金管理 -- 数据统计 - -## 技术架构 - -### 前端 -- **框架**: Vue 3 + TypeScript -- **构建工具**: Vite -- **UI 组件库**: shadcn-vue -- **样式**: Tailwind CSS -- **状态管理**: Pinia -- **路由**: Vue Router +## 技术栈 ### 后端 -- **语言**: Go 1.21+ -- **Web 框架**: Gin -- **数据库**: MySQL 8.0 -- **缓存**: Redis -- **ORM**: GORM +- Go 1.21+ +- Gin Web Framework +- GORM ORM +- SQLite/MySQL/PostgreSQL -## 项目结构 - -``` -developer-platform/ -├── frontend/ # 前端项目 -│ ├── src/ -│ │ ├── pages/ # 页面组件 -│ │ │ ├── developer/ # 开发者后台页面 -│ │ │ │ ├── applications/ # 应用管理 -│ │ │ │ ├── cards/ # 卡密管理 -│ │ │ │ ├── users/ # 用户管理 -│ │ │ │ ├── devices/ # 设备管理 -│ │ │ │ ├── versions/ # 版本管理 -│ │ │ │ ├── cloud-constants/ # 云端常量 -│ │ │ │ ├── cloud-variables/ # 云端变量 -│ │ │ │ ├── dynamic-code/ # 云端函数 -│ │ │ │ ├── risk-control/ # 风控管理 -│ │ │ │ ├── agent-apps/ # 代理授权 -│ │ │ │ ├── finance/ # 财务管理 -│ │ │ │ ├── tickets/ # 工单系统 -│ │ │ │ └── logs/ # 日志记录 -│ │ │ └── index.vue # 官网首页 -│ │ ├── components/ # 公共组件 -│ │ ├── router/ # 路由配置 -│ │ ├── stores/ # 状态管理 -│ │ └── services/ # API 服务 -│ └── package.json -├── backend/ # 后端项目 -│ ├── cmd/ # 应用入口 -│ ├── internal/ -│ │ ├── router/ # 路由配置 -│ │ │ ├── developer/ # 开发者路由 -│ │ │ ├── app/ # 应用 API 路由 -│ │ │ └── extension/ # 扩展路由 -│ │ ├── middleware/ # 中间件 -│ │ ├── model/ # 数据模型 -│ │ ├── service/ # 业务逻辑 -│ │ └── config/ # 配置管理 -│ └── go.mod -├── database/ # 数据库文件 -│ ├── schema.sql # 数据库结构 -│ └── seeds.sql # 初始数据 -└── docker-compose.yml # Docker 编排文件 -``` +### 前端 +- Vue 3 + TypeScript +- Vite +- Tailwind CSS +- Shadcn/UI ## 快速开始 -### 环境要求 - -- Node.js >= 22.15 -- pnpm >= 10 -- Go >= 1.21 -- MySQL >= 8.0 -- Redis >= 7.0 - -### 本地开发 - -#### 1. 克隆项目 - -```bash -git clone -cd developer-platform -``` - -#### 2. 启动后端服务 +### 后端启动 ```bash cd backend -cp config.yaml.example config.yaml -# 修改 config.yaml 中的数据库和 Redis 配置 -go mod download +go mod tidy go run cmd/main.go ``` -#### 3. 启动前端服务 +### 前端启动 ```bash cd frontend -pnpm install -pnpm dev +npm install +npm run dev ``` -#### 4. 访问应用 +## 目录结构 -- 前端地址: http://localhost:5173 -- 后端 API: http://localhost:8080 - -### Docker 部署 - -```bash -# 构建并启动所有服务 -docker-compose up -d - -# 查看服务状态 -docker-compose ps - -# 查看日志 -docker-compose logs -f - -# 停止服务 -docker-compose down ``` - -## 数据库配置 - -### 创建数据库 - -```sql -CREATE DATABASE developer_platform DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +developer-platform/ +├── backend/ # Go 后端服务 +│ ├── cmd/ # 入口文件 +│ ├── internal/ # 内部代码 +│ │ ├── database/ # 数据库 +│ │ ├── model/ # 数据模型 +│ │ ├── router/ # API路由 +│ │ └── service/ # 业务逻辑 +│ └── uploads/ # 上传文件 +├── frontend/ # Vue 前端 +│ ├── src/ +│ │ ├── pages/ # 页面 +│ │ ├── services/ # API服务 +│ │ └── components/ # 组件 +│ └── public/ +└── README.md ``` -### 导入数据结构 - -```bash -mysql -u root -p developer_platform < database/schema.sql -mysql -u root -p developer_platform < database/seeds.sql -``` - -## 环境变量 - -### 后端环境变量 - -| 变量名 | 说明 | 默认值 | -|--------|------|--------| -| APP_ENV | 运行环境 | development | -| DATABASE_HOST | 数据库地址 | localhost | -| DATABASE_PORT | 数据库端口 | 3306 | -| DATABASE_NAME | 数据库名称 | developer_platform | -| DATABASE_USERNAME | 数据库用户名 | root | -| DATABASE_PASSWORD | 数据库密码 | - | -| REDIS_HOST | Redis 地址 | localhost | -| REDIS_PORT | Redis 端口 | 6379 | - -## 部署文档 - -### 传统部署 - -1. **准备服务器** - - 操作系统: Ubuntu 20.04+ / CentOS 7+ - - 内存: 最低 2GB,推荐 4GB+ - - CPU: 最低 2 核,推荐 4 核+ - - 存储: 最低 20GB,推荐 50GB+ - -2. **安装依赖** - ```bash - # 安装 Node.js - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs - - # 安装 pnpm - npm install -g pnpm - - # 安装 Go - wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz - sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz - export PATH=$PATH:/usr/local/go/bin - - # 安装 MySQL - sudo apt-get install mysql-server - - # 安装 Redis - sudo apt-get install redis-server - ``` - -3. **部署后端** - ```bash - cd backend - go build -o developer-platform cmd/main.go - ./developer-platform - ``` - -4. **部署前端** - ```bash - cd frontend - pnpm install - pnpm build - # 使用 Nginx 托管 dist 目录 - ``` - -### Docker 部署 - -```bash -# 构建镜像 -docker build -t developer-platform-frontend ./frontend -docker build -t developer-platform-backend ./backend - -# 运行容器 -docker-compose up -d -``` - -### Kubernetes 部署 - -提供完整的 Kubernetes 部署配置文件,支持: -- Deployment 配置 -- Service 配置 -- Ingress 配置 -- ConfigMap 和 Secret 配置 -- 持久化存储配置 - -## 数据隔离说明 - -本平台与企业官网平台完全独立: -- 独立的数据库实例 -- 独立的 Redis 缓存 -- 独立的后端服务 -- 独立的前端应用 -- 无数据共享和依赖 - -## 安全性保障 - -1. **数据加密**: 敏感数据采用 AES 加密存储 -2. **身份认证**: JWT Token 认证机制 -3. **权限控制**: 基于角色的访问控制 (RBAC) -4. **SQL 注入防护**: 使用 ORM 框架防止 SQL 注入 -5. **XSS 防护**: 前端输入输出过滤 -6. **CSRF 防护**: Token 验证机制 -7. **API 密钥管理**: 安全的 API 密钥生成和管理 - -## 开发者工具 - -### SDK 支持 - -提供多语言 SDK: -- JavaScript/TypeScript SDK -- Python SDK -- Go SDK -- Java SDK - -### API 文档 - -完整的 RESTful API 文档,支持: -- OpenAPI 3.0 规范 -- Swagger UI 在线文档 -- Postman 集合 - ## 许可证 MIT License - -## 联系方式 - -- 作者: viaeon -- 邮箱: support@example.com -- 文档: https://docs.example.com diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go index 85d99e1..d99c49c 100644 --- a/backend/internal/middleware/middleware.go +++ b/backend/internal/middleware/middleware.go @@ -134,6 +134,26 @@ func DeveloperAuth() gin.HandlerFunc { } } +// AgentAuth 代理商授权中间件 +func AgentAuth() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + response.Error(c, http.StatusForbidden, "Access denied") + c.Abort() + return + } + + if role != "agent" && role != "admin" { + response.Error(c, http.StatusForbidden, "Agent access required") + c.Abort() + return + } + + c.Next() + } +} + // PackageAuth 套餐授权中间件 func PackageAuth() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 54b67de..fdcde6c 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -14,7 +14,7 @@ type User struct { Password string `gorm:"size:255" json:"-"` Avatar string `gorm:"size:255" json:"avatar"` Signature string `gorm:"size:255" json:"signature"` - Role string `gorm:"size:20;default:developer" json:"role"` + Role string `gorm:"size:20;default:admin" json:"role"` Status string `gorm:"size:20;default:active" json:"status"` DeviceID string `gorm:"size:100" json:"device_id"` LastLoginAt *time.Time `json:"last_login_at"` diff --git a/backend/internal/router/agent/agent.go b/backend/internal/router/agent/agent.go new file mode 100644 index 0000000..ec31a68 --- /dev/null +++ b/backend/internal/router/agent/agent.go @@ -0,0 +1,275 @@ +package agent + +import ( + "math/rand" + "strconv" + "time" + "verification-platform-backend/internal/database" + "verification-platform-backend/internal/model" + "verification-platform-backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func SetupAgentRoutes(r *gin.RouterGroup) { + r.GET("/stats", handleGetStats) + r.GET("/apps", handleGetApps) + r.GET("/cards", handleGetCards) + r.POST("/cards/generate", handleGenerateCards) + r.GET("/users", handleGetUsers) + r.GET("/finance", handleGetFinance) + r.GET("/profile", handleGetProfile) + r.PUT("/profile", handleUpdateProfile) +} + +func handleGetStats(c *gin.Context) { + userID := c.GetUint("user_id") + + var totalApps int64 + database.DB.Model(&model.AgentApplication{}).Where("agent_id = ?", userID).Count(&totalApps) + + var totalCards int64 + database.DB.Model(&model.Card{}).Where("agent_id = ?", userID).Count(&totalCards) + + var totalUsers int64 + var agentApps []model.AgentApplication + database.DB.Where("agent_id = ?", userID).Find(&agentApps) + appIDs := make([]uint, 0) + for _, app := range agentApps { + appIDs = append(appIDs, app.ApplicationID) + } + if len(appIDs) > 0 { + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Count(&totalUsers) + } + + var totalRevenue float64 + database.DB.Model(&model.Card{}).Where("agent_id = ?", userID).Select("COALESCE(SUM(price), 0)").Scan(&totalRevenue) + + today := time.Now().Format("2006-01-02") + var todayCards int64 + database.DB.Model(&model.Card{}).Where("agent_id = ? AND DATE(created_at) = ?", userID, today).Count(&todayCards) + + var todayRevenue float64 + database.DB.Model(&model.Card{}).Where("agent_id = ? AND DATE(created_at) = ?", userID, today).Select("COALESCE(SUM(price), 0)").Scan(&todayRevenue) + + response.Success(c, gin.H{ + "totalApps": totalApps, + "totalCards": totalCards, + "totalUsers": totalUsers, + "totalRevenue": totalRevenue, + "todayCards": todayCards, + "todayRevenue": todayRevenue, + }) +} + +func handleGetApps(c *gin.Context) { + userID := c.GetUint("user_id") + + var agentApps []model.AgentApplication + if err := database.DB.Where("agent_id = ?", userID).Preload("Application").Find(&agentApps).Error; err != nil { + response.Error(c, 500, "获取应用列表失败") + return + } + + apps := make([]gin.H, 0) + for _, aa := range agentApps { + if aa.Application.ID > 0 { + apps = append(apps, gin.H{ + "id": aa.Application.ID, + "name": aa.Application.Name, + "description": aa.Application.Description, + "status": aa.Application.Status, + "authorized_at": aa.CreatedAt, + "commission": aa.Commission, + }) + } + } + + response.Success(c, gin.H{ + "apps": apps, + }) +} + +func handleGetCards(c *gin.Context) { + userID := c.GetUint("user_id") + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "20") + + var total int64 + database.DB.Model(&model.Card{}).Where("agent_id = ?", userID).Count(&total) + + var cards []model.Card + offset := 0 + if pageInt, err := strconv.Atoi(page); err == nil && pageInt > 1 { + offset = (pageInt - 1) * 20 + } + + limit := 20 + if pageSizeInt, err := strconv.Atoi(pageSize); err == nil && pageSizeInt > 0 { + limit = pageSizeInt + } + + database.DB.Where("agent_id = ?", userID).Order("created_at DESC").Limit(limit).Offset(offset).Find(&cards) + + response.Success(c, gin.H{ + "cards": cards, + "total": total, + }) +} + +func handleGenerateCards(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + ApplicationID uint `json:"application_id"` + CardTypeID uint `json:"card_type_id"` + Quantity int `json:"quantity"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var agentApp model.AgentApplication + if err := database.DB.Where("agent_id = ? AND application_id = ?", userID, req.ApplicationID).First(&agentApp).Error; err != nil { + response.Error(c, 403, "无权操作该应用") + return + } + + cards := make([]model.Card, req.Quantity) + for i := 0; i < req.Quantity; i++ { + cards[i] = model.Card{ + ApplicationID: req.ApplicationID, + CardTypeID: req.CardTypeID, + CardKey: generateCardCode(), + CreatorID: userID, + Status: "unused", + } + } + + if err := database.DB.Create(&cards).Error; err != nil { + response.Error(c, 500, "生成卡密失败") + return + } + + response.Success(c, gin.H{ + "count": req.Quantity, + }) +} + +func generateCardCode() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 16) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} + +func handleGetUsers(c *gin.Context) { + userID := c.GetUint("user_id") + + var agentApps []model.AgentApplication + database.DB.Where("agent_id = ?", userID).Find(&agentApps) + appIDs := make([]uint, 0) + for _, app := range agentApps { + appIDs = append(appIDs, app.ApplicationID) + } + + if len(appIDs) == 0 { + response.Success(c, gin.H{ + "users": []interface{}{}, + "total": 0, + }) + return + } + + page := c.DefaultQuery("page", "1") + pageSize := c.DefaultQuery("page_size", "20") + + var total int64 + database.DB.Model(&model.AppUser{}).Where("application_id IN ?", appIDs).Count(&total) + + var users []model.AppUser + offset := 0 + if pageInt, err := strconv.Atoi(page); err == nil && pageInt > 1 { + offset = (pageInt - 1) * 20 + } + + limit := 20 + if pageSizeInt, err := strconv.Atoi(pageSize); err == nil && pageSizeInt > 0 { + limit = pageSizeInt + } + + database.DB.Where("application_id IN ?", appIDs).Order("created_at DESC").Limit(limit).Offset(offset).Find(&users) + + response.Success(c, gin.H{ + "users": users, + "total": total, + }) +} + +func handleGetFinance(c *gin.Context) { + userID := c.GetUint("user_id") + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + var records []model.RechargeRecord + database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(20).Find(&records) + + response.Success(c, gin.H{ + "balance": user.Balance, + "commission": user.Commission, + "records": records, + }) +} + +func handleGetProfile(c *gin.Context) { + userID := c.GetUint("user_id") + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + response.Success(c, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "avatar": user.Avatar, + "balance": user.Balance, + "commission": user.Commission, + "can_create_agent": user.CanCreateAgent, + }) +} + +func handleUpdateProfile(c *gin.Context) { + userID := c.GetUint("user_id") + + var req struct { + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + var user model.User + if err := database.DB.First(&user, userID).Error; err != nil { + response.Error(c, 404, "用户不存在") + return + } + + if req.Email != "" { + user.Email = &req.Email + } + database.DB.Save(&user) + + response.Success(c, nil) +} diff --git a/backend/internal/router/developer/agents.go b/backend/internal/router/developer/agents.go index 434c955..7db43a6 100644 --- a/backend/internal/router/developer/agents.go +++ b/backend/internal/router/developer/agents.go @@ -47,7 +47,7 @@ type AgentTreeNode struct { func handleGetAgents(c *gin.Context) { var users []model.User - if err := database.DB.Where("role = ?", "developer"). + if err := database.DB.Where("role = ?", "admin"). Preload("ParentAgent"). Order("created_at DESC"). Find(&users).Error; err != nil { @@ -123,7 +123,7 @@ func handleGetAgents(c *gin.Context) { func handleGetAgentsTree(c *gin.Context) { var users []model.User - if err := database.DB.Where("role = ?", "developer"). + if err := database.DB.Where("role = ?", "admin"). Preload("ParentAgent"). Order("created_at DESC"). Find(&users).Error; err != nil { @@ -239,7 +239,7 @@ func handleCreateAgent(c *gin.Context) { if req.ParentAgentID != nil { var parentAgent model.User - if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "developer").First(&parentAgent).Error; err != nil { + if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "admin").First(&parentAgent).Error; err != nil { response.Error(c, 404, "上级代理不存在") return } @@ -254,7 +254,7 @@ func handleCreateAgent(c *gin.Context) { user := model.User{ Username: req.Username, Password: string(hashedPassword), - Role: "developer", + Role: "agent", Status: "active", ParentAgentID: req.ParentAgentID, Commission: req.Commission, @@ -283,7 +283,7 @@ func handleGetAgentDetail(c *gin.Context) { agentID := c.Param("id") var user model.User - if err := database.DB.Where("id = ? AND role = ?", agentID, "developer"). + if err := database.DB.Where("id = ? AND role = ?", agentID, "admin"). Preload("ParentAgent"). First(&user).Error; err != nil { response.Error(c, 404, "代理不存在") @@ -378,7 +378,7 @@ func handleUpdateAgent(c *gin.Context) { agentID := c.Param("id") var user model.User - if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + if err := database.DB.Where("id = ? AND role = ?", agentID, "admin").First(&user).Error; err != nil { response.Error(c, 404, "代理不存在") return } @@ -402,7 +402,7 @@ func handleUpdateAgent(c *gin.Context) { return } var parentAgent model.User - if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "developer").First(&parentAgent).Error; err != nil { + if err := database.DB.Where("id = ? AND role = ?", *req.ParentAgentID, "admin").First(&parentAgent).Error; err != nil { response.Error(c, 404, "上级代理不存在") return } @@ -446,7 +446,7 @@ func handleUpdateAgentStatus(c *gin.Context) { agentID := c.Param("id") var user model.User - if err := database.DB.Where("id = ? AND role = ?", agentID, "developer").First(&user).Error; err != nil { + if err := database.DB.Where("id = ? AND role = ?", agentID, "admin").First(&user).Error; err != nil { response.Error(c, 404, "代理不存在") return } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 721db55..c101e1b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -4,6 +4,7 @@ import ( "time" "verification-platform-backend/internal/middleware" + "verification-platform-backend/internal/router/agent" "verification-platform-backend/internal/router/app" "verification-platform-backend/internal/router/developer" "verification-platform-backend/internal/router/extension" @@ -36,6 +37,13 @@ func SetupRoutes(r *gin.Engine) { developer.SetupRoutesWithoutPackage(devGroup) } + agentGroup := api.Group("/agent") + { + agentGroup.Use(middleware.JWT()) + agentGroup.Use(middleware.AgentAuth()) + agent.SetupAgentRoutes(agentGroup) + } + appGroup := api.Group("/app/:appKey") { appGroup.Use(app.AppCryptoMiddleware()) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index bed5827..b238133 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -56,12 +56,13 @@ func (s *AuthService) Login(username, password, agentPath string) (map[string]in // 返回用户信息和令牌 return map[string]interface{}{ "user": map[string]interface{}{ - "id": user.ID, - "username": user.Username, - "email": user.Email, - "avatar": user.Avatar, - "role": user.Role, - "status": user.Status, + "id": user.ID, + "username": user.Username, + "email": user.Email, + "avatar": user.Avatar, + "role": user.Role, + "status": user.Status, + "parent_agent_id": user.ParentAgentID, }, "token": token, }, nil diff --git a/frontend/src/pages/admin/agent-apps/[id]/card-types.vue b/frontend/src/pages/admin/agent-apps/[id]/card-types.vue new file mode 100644 index 0000000..11e23dd --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/[id]/card-types.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/[id]/edit.vue b/frontend/src/pages/admin/agent-apps/[id]/edit.vue new file mode 100644 index 0000000..dcd24c3 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/[id]/edit.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/[id]/recharge.vue b/frontend/src/pages/admin/agent-apps/[id]/recharge.vue new file mode 100644 index 0000000..8e847a0 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/[id]/recharge.vue @@ -0,0 +1,185 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/components/columns.ts b/frontend/src/pages/admin/agent-apps/components/columns.ts new file mode 100644 index 0000000..1a079ff --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/components/columns.ts @@ -0,0 +1,239 @@ +import type { ColumnDef } from '@tanstack/vue-table' + +import { h } from 'vue' +import { useRouter } from 'vue-router' + +import type { CombinedItem } from '@/pages/developer/agent-apps/data/schema' + +import Badge from '@/components/ui/badge/Badge.vue' +import Button from '@/components/ui/button/Button.vue' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +export function getStatusVariant(status: string, hasAuthorization: boolean) { + if (hasAuthorization) { + return status === 'active' ? 'default' : 'secondary' + } + if (status === 'pending') + return 'outline' + if (status === 'approved') + return 'default' + return 'destructive' +} + +export function getStatusText(status: string, hasAuthorization: boolean) { + if (hasAuthorization) { + return status === 'active' ? '已授权' : '已禁用' + } + const map: Record = { + pending: '待处理', + approved: '已通过', + rejected: '已拒绝', + } + return map[status] || status +} + +export function getItemTypeText(itemType: string) { + const map: Record = { + authorization: '授权记录', + request: '收到的申请', + my_request: '发出的申请', + } + return map[itemType] || itemType +} + +export function getColumns(actions: { + onApprove: (row: CombinedItem) => void + onReject: (row: CombinedItem) => void + onRemove: (row: CombinedItem) => void +}): ColumnDef[] { + return [ + { + accessorKey: 'itemType', + header: '类型', + cell: ({ row }) => { + const itemType = row.getValue('itemType') as string + const variants: Record = { + authorization: 'default', + request: 'outline', + my_request: 'secondary', + } + return h(Badge, { variant: variants[itemType] || 'default' }, () => getItemTypeText(itemType)) + }, + }, + { + accessorKey: 'agent_name', + header: '代理商/开发者', + cell: ({ row }) => { + const agentName = row.getValue('agent_name') as string + const itemType = row.original.itemType + const label = itemType === 'my_request' ? '开发者' : '代理商' + return h('div', {}, [ + h('div', { class: 'font-medium' }, agentName || '-'), + h('div', { class: 'text-xs text-muted-foreground' }, label), + ]) + }, + }, + { + accessorKey: 'app_name', + header: '应用', + cell: ({ row }) => { + const appName = row.getValue('app_name') as string + return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-' + }, + }, + { + accessorKey: 'status', + header: '状态', + cell: ({ row }) => { + const status = row.getValue('status') as string + const hasAuthorization = row.original.hasAuthorization + const variant = getStatusVariant(status, hasAuthorization) + const text = getStatusText(status, hasAuthorization) + return h(Badge, { variant }, () => text) + }, + }, + { + accessorKey: 'commission', + header: '佣金比例', + cell: ({ row }) => { + const hasAuthorization = row.original.hasAuthorization + if (!hasAuthorization) + return '-' + const commission = row.getValue('commission') as number + return `${(commission * 100).toFixed(1)}%` + }, + }, + { + accessorKey: 'discount', + header: '折扣', + cell: ({ row }) => { + const hasAuthorization = row.original.hasAuthorization + if (!hasAuthorization) + return '-' + const discount = row.getValue('discount') as number + return `${(discount * 10).toFixed(1)}折` + }, + }, + { + accessorKey: 'balance', + header: '余额', + cell: ({ row }) => { + const hasAuthorization = row.original.hasAuthorization + if (!hasAuthorization) + return '-' + const balance = row.getValue('balance') as number + return h('span', { class: 'font-medium' }, `¥${balance.toFixed(2)}`) + }, + }, + { + accessorKey: 'created_at', + header: '创建时间', + cell: ({ row }) => { + const createdAt = row.getValue('created_at') + if (!createdAt) + return '-' + try { + const date = new Date(createdAt as string) + if (Number.isNaN(date.getTime())) + return '-' + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + catch { + return '-' + } + }, + }, + { + id: 'actions', + header: () => h('span', { class: 'sr-only' }, '操作'), + cell: ({ row }) => { + const item = row.original + const router = useRouter() + + return h( + DropdownMenu, + {}, + { + default: () => [ + h(DropdownMenuTrigger, { asChild: true }, () => + h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => + h('span', { class: 'sr-only' }, '打开菜单'))), + h( + DropdownMenuContent, + { align: 'end' }, + () => { + const items = [] + + if (item.itemType === 'request' && item.status === 'pending') { + items.push( + h(DropdownMenuItem, { onClick: () => actions.onApprove(item) }, () => [ + h('span', { class: 'i-lucide-check mr-2 h-4 w-4' }), + '批准', + ]), + h(DropdownMenuItem, { onClick: () => actions.onReject(item) }, () => [ + h('span', { class: 'i-lucide-x mr-2 h-4 w-4' }), + '拒绝', + ]), + ) + } + + if (item.hasAuthorization) { + items.push( + h(DropdownMenuItem, { onClick: () => router.push(`/developer/agent-apps/${item.authorizationId}/edit`) }, () => [ + h('span', { class: 'i-lucide-edit mr-2 h-4 w-4' }), + '编辑', + ]), + h(DropdownMenuItem, { onClick: () => router.push(`/developer/agent-apps/${item.authorizationId}/card-types`) }, () => [ + h('span', { class: 'i-lucide-key mr-2 h-4 w-4' }), + '卡密权限', + ]), + h(DropdownMenuItem, { onClick: () => router.push(`/developer/agent-apps/${item.authorizationId}/recharge`) }, () => [ + h('span', { class: 'i-lucide-wallet mr-2 h-4 w-4' }), + '充值余额', + ]), + ) + } + + if (item.itemType === 'my_request' && item.status === 'pending') { + items.push( + h(DropdownMenuSeparator), + h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onRemove(item) }, () => [ + h('span', { class: 'i-lucide-trash-2 mr-2 h-4 w-4' }), + '撤回申请', + ]), + ) + } + else if (item.hasAuthorization) { + items.push( + h(DropdownMenuSeparator), + h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onRemove(item) }, () => [ + h('span', { class: 'i-lucide-trash-2 mr-2 h-4 w-4' }), + '移除授权', + ]), + ) + } + + return items + }, + ), + ], + }, + ) + }, + enableSorting: false, + enableHiding: false, + }, + ] +} diff --git a/frontend/src/pages/admin/agent-apps/components/data-table-toolbar.vue b/frontend/src/pages/admin/agent-apps/components/data-table-toolbar.vue new file mode 100644 index 0000000..3a36d77 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/components/data-table-toolbar.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/components/data-table.vue b/frontend/src/pages/admin/agent-apps/components/data-table.vue new file mode 100644 index 0000000..c6e5c27 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/components/data-table.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/data/data.ts b/frontend/src/pages/admin/agent-apps/data/data.ts new file mode 100644 index 0000000..6b1f9c9 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/data/data.ts @@ -0,0 +1,12 @@ +export const agentAppStatuses = [ + { label: '已授权', value: 'active' }, + { label: '已禁用', value: 'inactive' }, + { label: '待处理', value: 'pending' }, + { label: '已拒绝', value: 'rejected' }, +] + +export const itemTypes = [ + { label: '授权记录', value: 'authorization' }, + { label: '收到的申请', value: 'request' }, + { label: '发出的申请', value: 'my_request' }, +] diff --git a/frontend/src/pages/admin/agent-apps/data/schema.ts b/frontend/src/pages/admin/agent-apps/data/schema.ts new file mode 100644 index 0000000..776bff5 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/data/schema.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' + +export const agentAppStatusSchema = z.enum(['active', 'inactive', 'pending', 'approved', 'rejected']) + +export const agentAppSchema = z.object({ + id: z.number(), + agent_id: z.number(), + agent_name: z.string(), + application_id: z.number(), + app_name: z.string(), + status: agentAppStatusSchema, + commission: z.number(), + discount: z.number(), + balance: z.number(), + card_types: z.array(z.object({ + card_type_id: z.number(), + can_generate: z.boolean(), + })).optional(), + created_at: z.string(), + updated_at: z.string().optional(), + is_received: z.boolean().optional(), +}) + +export const agentRequestSchema = z.object({ + id: z.number(), + developer_id: z.number(), + developer_name: z.string(), + agent_id: z.number(), + agent_name: z.string(), + application_id: z.number(), + app_name: z.string(), + status: z.enum(['pending', 'approved', 'rejected']), + message: z.string().optional(), + created_at: z.string(), +}) + +export type AgentApp = z.infer +export type AgentRequest = z.infer + +export interface CombinedItem { + id: number + agent_id: number + agent_name: string + application_id: number + app_name: string + status: string + commission: number + discount: number + balance: number + card_types?: Array<{ card_type_id: number, can_generate: boolean }> + created_at: string + itemType: 'authorization' | 'request' | 'my_request' + hasAuthorization: boolean + authorizationId: number | null + is_received?: boolean +} diff --git a/frontend/src/pages/admin/agent-apps/index.vue b/frontend/src/pages/admin/agent-apps/index.vue new file mode 100644 index 0000000..5db7c4f --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/index.vue @@ -0,0 +1,391 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/invite.vue b/frontend/src/pages/admin/agent-apps/invite.vue new file mode 100644 index 0000000..b8f7911 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/invite.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/pages/admin/agent-apps/request.vue b/frontend/src/pages/admin/agent-apps/request.vue new file mode 100644 index 0000000..46ea5f5 --- /dev/null +++ b/frontend/src/pages/admin/agent-apps/request.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/src/pages/admin/agents/[id].vue b/frontend/src/pages/admin/agents/[id].vue new file mode 100644 index 0000000..0b084ac --- /dev/null +++ b/frontend/src/pages/admin/agents/[id].vue @@ -0,0 +1,370 @@ + + + diff --git a/frontend/src/pages/admin/agents/components/columns.ts b/frontend/src/pages/admin/agents/components/columns.ts new file mode 100644 index 0000000..d77218f --- /dev/null +++ b/frontend/src/pages/admin/agents/components/columns.ts @@ -0,0 +1,194 @@ +import type { ColumnDef, Row } from '@tanstack/vue-table' +import type { Composer } from 'vue-i18n' + +import { Ban, Check, MoreHorizontal, Pencil, Trash2, X } from 'lucide-vue-next' +import { h } from 'vue' + +import type { Agent } from '../data/schema' + +import Badge from '@/components/ui/badge/Badge.vue' +import Button from '@/components/ui/button/Button.vue' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +function getIndentStyle(row: Row): string { + const depth = row.depth + if (depth === 0) return '' + return `padding-left: ${depth * 24}px;` +} + +export function getColumns(actions: { + onToggleStatus: (row: Agent) => void + onEdit: (row: Agent) => void + onDelete: (row: Agent) => void +}, t: Composer['t']): ColumnDef[] { + return [ + { + accessorKey: 'username', + header: () => t('developer.agents.columns.username'), + cell: ({ row }) => { + const agent = row.original + const indentStyle = getIndentStyle(row) + return h('div', { class: 'flex items-center space-x-3', style: indentStyle }, [ + h(Avatar, { class: 'h-8 w-8' }, () => [ + h(AvatarImage, { src: agent.avatar, alt: agent.username }), + h(AvatarFallback, {}, () => agent.username?.charAt(0).toUpperCase() || 'U'), + ]), + h('div', { class: 'flex flex-col' }, [ + h('span', { class: 'font-medium' }, agent.username), + agent.email ? h('span', { class: 'text-xs text-muted-foreground' }, agent.email) : null, + ]), + ]) + }, + }, + { + accessorKey: 'status', + header: () => t('developer.agents.columns.status'), + cell: ({ row }) => { + const status = row.getValue('status') as string + const statusMap: Record = { + active: { text: t('developer.agents.status.active'), variant: 'default' }, + inactive: { text: t('developer.agents.status.inactive'), variant: 'secondary' }, + banned: { text: t('developer.agents.status.banned'), variant: 'destructive' }, + } + const { text, variant } = statusMap[status] || { text: status, variant: 'default' } + return h(Badge, { variant }, () => text) + }, + }, + { + accessorKey: 'can_create_agent', + header: () => t('developer.agents.columns.canCreateAgent'), + cell: ({ row }) => { + const canCreate = row.getValue('can_create_agent') as boolean + return h('div', { class: 'flex items-center justify-center' }, [ + canCreate + ? h(Check, { class: 'h-4 w-4 text-green-500' }) + : h(X, { class: 'h-4 w-4 text-red-500' }), + ]) + }, + }, + { + accessorKey: 'cards_count', + header: () => t('developer.agents.columns.cardsCount'), + cell: ({ row }) => { + const count = row.getValue('cards_count') as number + return h(Badge, { variant: 'outline' }, () => String(count)) + }, + }, + { + accessorKey: 'child_agents_count', + header: () => t('developer.agents.columns.childAgentsCount'), + cell: ({ row }) => { + const count = row.getValue('child_agents_count') as number + return h(Badge, { variant: 'secondary' }, () => String(count)) + }, + }, + { + accessorKey: 'balance', + header: () => t('developer.agents.columns.balance'), + cell: ({ row }) => { + const balance = row.getValue('balance') as number + return `¥${balance.toFixed(2)}` + }, + }, + { + accessorKey: 'commission', + header: () => t('developer.agents.columns.commission'), + cell: ({ row }) => { + const commission = row.getValue('commission') as number + return `${(commission * 100).toFixed(0)}%` + }, + }, + { + accessorKey: 'created_at', + header: () => t('developer.agents.columns.createdAt'), + cell: ({ row }) => { + const createdAt = row.getValue('created_at') as string + if (!createdAt) return '-' + try { + const date = new Date(createdAt) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return '-' + } + }, + }, + { + accessorKey: 'last_login_at', + header: () => t('developer.agents.columns.lastLoginAt'), + cell: ({ row }) => { + const lastLoginAt = row.getValue('last_login_at') as string + if (!lastLoginAt) return '-' + try { + const date = new Date(lastLoginAt) + if (Number.isNaN(date.getTime())) return '-' + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return '-' + } + }, + }, + { + id: 'actions', + header: () => h('span', { class: 'sr-only' }, t('common.actions')), + cell: ({ row }) => { + const agent = row.original + const isBanned = agent.status === 'banned' + + return h( + DropdownMenu, + {}, + { + default: () => [ + h(DropdownMenuTrigger, { asChild: true }, () => + h(Button, { variant: 'ghost', class: 'h-8 w-8 p-0' }, () => [ + h(MoreHorizontal, { class: 'h-4 w-4' }), + h('span', { class: 'sr-only' }, t('common.openMenu')), + ])), + h( + DropdownMenuContent, + { align: 'end' }, + () => [ + h(DropdownMenuItem, { onClick: () => actions.onEdit(agent) }, () => [ + h(Pencil, { class: 'mr-2 h-4 w-4' }), + t('common.edit'), + ]), + h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(agent) }, () => [ + h(Ban, { class: 'mr-2 h-4 w-4' }), + isBanned ? t('developer.agents.enable') : t('developer.agents.disable'), + ]), + h(DropdownMenuSeparator), + h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(agent) }, () => [ + h(Trash2, { class: 'mr-2 h-4 w-4' }), + t('common.delete'), + ]), + ], + ), + ], + }, + ) + }, + enableSorting: false, + enableHiding: false, + }, + ] +} diff --git a/frontend/src/pages/admin/agents/components/data-table.vue b/frontend/src/pages/admin/agents/components/data-table.vue new file mode 100644 index 0000000..94a4662 --- /dev/null +++ b/frontend/src/pages/admin/agents/components/data-table.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/pages/admin/agents/create.vue b/frontend/src/pages/admin/agents/create.vue new file mode 100644 index 0000000..a55edce --- /dev/null +++ b/frontend/src/pages/admin/agents/create.vue @@ -0,0 +1,326 @@ + + + diff --git a/frontend/src/pages/admin/agents/data/schema.ts b/frontend/src/pages/admin/agents/data/schema.ts new file mode 100644 index 0000000..5cb2ac7 --- /dev/null +++ b/frontend/src/pages/admin/agents/data/schema.ts @@ -0,0 +1,18 @@ +export interface Agent { + id: number + username: string + email: string + avatar: string + status: string + role: string + parent_agent_id: number | null + parent_agent_name: string + created_at: string + last_login_at: string + balance: number + commission: number + can_create_agent: boolean + cards_count: number + child_agents_count: number + children?: Agent[] +} diff --git a/frontend/src/pages/admin/agents/index.vue b/frontend/src/pages/admin/agents/index.vue new file mode 100644 index 0000000..37e2da8 --- /dev/null +++ b/frontend/src/pages/admin/agents/index.vue @@ -0,0 +1,307 @@ + + + diff --git a/frontend/src/pages/admin/announcements/[id].vue b/frontend/src/pages/admin/announcements/[id].vue new file mode 100644 index 0000000..9b49462 --- /dev/null +++ b/frontend/src/pages/admin/announcements/[id].vue @@ -0,0 +1,315 @@ + + +