更新开发者平台:代理商管理、版本管理、UI优化
This commit is contained in:
@@ -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 <repository-url>
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
interface CardType {
|
||||
id: number
|
||||
name: string
|
||||
billing_type: string
|
||||
price: number
|
||||
}
|
||||
|
||||
interface CardTypePermission {
|
||||
card_type_id: number
|
||||
name: string
|
||||
billing_type: string
|
||||
price: number
|
||||
can_generate: boolean
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const agentAppName = ref('')
|
||||
const cardTypeForm = ref<CardTypePermission[]>([])
|
||||
|
||||
function getBillingTypeText(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
subscription: '订阅',
|
||||
time: '计时',
|
||||
point: '点卡',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const [agentAppRes, cardTypesRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/dev/card-types`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
])
|
||||
|
||||
const [agentAppData, cardTypesData] = await Promise.all([
|
||||
agentAppRes.json(),
|
||||
cardTypesRes.json(),
|
||||
])
|
||||
|
||||
if (agentAppData.code === 200 && agentAppData.agent_app) {
|
||||
agentAppName.value = `${agentAppData.agent_app.agent_name} - ${agentAppData.agent_app.app_name}`
|
||||
const existingPerms = agentAppData.agent_app.card_types || []
|
||||
|
||||
const allCardTypes = cardTypesData.card_types || []
|
||||
cardTypeForm.value = allCardTypes.map((ct: CardType) => {
|
||||
const existing = existingPerms.find((p: any) => p.card_type_id === ct.id)
|
||||
return {
|
||||
card_type_id: ct.id,
|
||||
name: ct.name,
|
||||
billing_type: ct.billing_type,
|
||||
price: ct.price,
|
||||
can_generate: existing ? existing.can_generate : false,
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
toast.error('获取授权信息失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
toast.error('获取数据失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
cardTypeForm.value.forEach(ct => ct.can_generate = true)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
cardTypeForm.value.forEach(ct => ct.can_generate = false)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}/card-types`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
card_types: cardTypeForm.value.map(ct => ({
|
||||
card_type_id: ct.card_type_id,
|
||||
can_generate: ct.can_generate,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="卡密权限配置"
|
||||
description="配置代理商可生成的卡密类型"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>授权信息</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
{{ agentAppName }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<UiButton variant="outline" size="sm" @click="selectAll">
|
||||
<Icon icon="lucide:check-check" class="mr-2 h-4 w-4" />
|
||||
全选
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="clearAll">
|
||||
<Icon icon="lucide:x" class="mr-2 h-4 w-4" />
|
||||
清空
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-0">
|
||||
<UiTable>
|
||||
<UiTableHeader>
|
||||
<UiTableRow>
|
||||
<UiTableHead class="w-12" />
|
||||
<UiTableHead>卡类名称</UiTableHead>
|
||||
<UiTableHead>计费类型</UiTableHead>
|
||||
<UiTableHead>价格</UiTableHead>
|
||||
</UiTableRow>
|
||||
</UiTableHeader>
|
||||
<UiTableBody>
|
||||
<UiTableRow
|
||||
v-for="cardType in cardTypeForm"
|
||||
:key="cardType.card_type_id"
|
||||
:class="cardType.can_generate ? 'bg-accent/50' : ''"
|
||||
class="cursor-pointer"
|
||||
@click="cardType.can_generate = !cardType.can_generate"
|
||||
>
|
||||
<UiTableCell>
|
||||
<UiCheckbox
|
||||
:checked="cardType.can_generate"
|
||||
@update:checked="cardType.can_generate = $event"
|
||||
@click.stop
|
||||
/>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="font-medium">
|
||||
{{ cardType.name }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge variant="outline">
|
||||
{{ getBillingTypeText(cardType.billing_type) }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
¥{{ cardType.price }}
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
</UiTableBody>
|
||||
</UiTable>
|
||||
</UiCardContent>
|
||||
<UiCardFooter class="flex justify-end gap-2">
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saving" @click="handleSubmit">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { AgentApp } from '@/pages/developer/agent-apps/data/schema'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const agentApp = ref<AgentApp | null>(null)
|
||||
|
||||
const form = ref({
|
||||
commission: 0.1,
|
||||
discount: 1.0,
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
async function fetchAgentApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200 && data.agent_app) {
|
||||
agentApp.value = data.agent_app
|
||||
form.value = {
|
||||
commission: data.agent_app.commission,
|
||||
discount: data.agent_app.discount,
|
||||
status: data.agent_app.status,
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error('获取授权信息失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取授权信息失败:', error)
|
||||
toast.error('获取授权信息失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (form.value.commission < 0 || form.value.commission > 1) {
|
||||
toast.error('佣金比例必须在0-1之间')
|
||||
return
|
||||
}
|
||||
if (form.value.discount < 0 || form.value.discount > 1) {
|
||||
toast.error('折扣必须在0-1之间')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgentApp()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑授权"
|
||||
description="修改代理商授权信息"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="agentApp" class="max-w-2xl">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>授权信息</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
代理商: {{ agentApp.agent_name }} | 应用: {{ agentApp.app_name }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="status">
|
||||
状态
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.status">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择状态" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="active">
|
||||
已授权
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="inactive">
|
||||
已禁用
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="commission">
|
||||
佣金比例
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiInput
|
||||
id="commission"
|
||||
v-model.number="form.commission"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground w-20">
|
||||
{{ (form.commission * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
代理商获得的佣金比例,范围0-1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="discount">
|
||||
折扣
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiInput
|
||||
id="discount"
|
||||
v-model.number="form.discount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground w-20">
|
||||
{{ (form.discount * 10).toFixed(1) }}折
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
代理商购买卡密的折扣,范围0-1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>当前余额</UiLabel>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ agentApp.balance.toFixed(2) }}
|
||||
</div>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="router.push(`/developer/agent-apps/${route.params.id}/recharge`)"
|
||||
>
|
||||
<Icon icon="lucide:wallet" class="mr-2 h-4 w-4" />
|
||||
充值余额
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
<UiCardFooter class="flex justify-end gap-2">
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saving" @click="handleSubmit">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const currentBalance = ref(0)
|
||||
const agentAppName = ref('')
|
||||
const rechargeAmount = ref(0)
|
||||
|
||||
async function fetchAgentApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200 && data.agent_app) {
|
||||
currentBalance.value = data.agent_app.balance
|
||||
agentAppName.value = `${data.agent_app.agent_name} - ${data.agent_app.app_name}`
|
||||
}
|
||||
else {
|
||||
toast.error('获取授权信息失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取授权信息失败:', error)
|
||||
toast.error('获取授权信息失败')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (rechargeAmount.value <= 0) {
|
||||
toast.error('请输入有效的充值金额')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
balance: currentBalance.value + rechargeAmount.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('充值成功')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '充值失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('充值失败:', error)
|
||||
toast.error('充值失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
|
||||
const quickAmounts = [100, 500, 1000, 5000]
|
||||
|
||||
function setAmount(amount: number) {
|
||||
rechargeAmount.value = amount
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgentApp()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="充值余额"
|
||||
description="为代理商充值余额"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-2xl">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>充值信息</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
{{ agentAppName }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>当前余额</UiLabel>
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
¥{{ currentBalance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="amount">
|
||||
充值金额
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="amount"
|
||||
v-model.number="rechargeAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="请输入充值金额"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UiButton
|
||||
v-for="amount in quickAmounts"
|
||||
:key="amount"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="setAmount(amount)"
|
||||
>
|
||||
¥{{ amount }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<div v-if="rechargeAmount > 0" class="p-4 bg-muted rounded-lg">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>当前余额</span>
|
||||
<span>¥{{ currentBalance.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm mt-1">
|
||||
<span>充值金额</span>
|
||||
<span class="text-green-600">+¥{{ rechargeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<UiSeparator class="my-2" />
|
||||
<div class="flex justify-between font-medium">
|
||||
<span>充值后余额</span>
|
||||
<span>¥{{ (currentBalance + rechargeAmount).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
<UiCardFooter class="flex justify-end gap-2">
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saving || rechargeAmount <= 0" @click="handleSubmit">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
确认充值
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -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<string, string> = {
|
||||
pending: '待处理',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
export function getItemTypeText(itemType: string) {
|
||||
const map: Record<string, string> = {
|
||||
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<CombinedItem>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'itemType',
|
||||
header: '类型',
|
||||
cell: ({ row }) => {
|
||||
const itemType = row.getValue('itemType') as string
|
||||
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { CombinedItem } from '@/pages/developer/agent-apps/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<CombinedItem>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
placeholder="搜索代理商/开发者..."
|
||||
:model-value="(table.getColumn('agent_name')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[200px]"
|
||||
@input="table.getColumn('agent_name')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
重置
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { CombinedItem } from '@/pages/admin/agent-apps/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/agent-apps/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/agent-apps/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<CombinedItem>, 'columns'> & {
|
||||
onApprove: (row: CombinedItem) => void
|
||||
onReject: (row: CombinedItem) => void
|
||||
onRemove: (row: CombinedItem) => void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<CombinedItem>,
|
||||
...getColumns({
|
||||
onApprove: props.onApprove,
|
||||
onReject: props.onReject,
|
||||
onRemove: props.onRemove,
|
||||
}),
|
||||
])
|
||||
|
||||
const table = generateVueTable<CombinedItem>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.agentApps.select',
|
||||
itemType: 'developer.agentApps.columns.type',
|
||||
agent_name: 'developer.agentApps.columns.agentName',
|
||||
app_name: 'developer.agentApps.columns.appName',
|
||||
status: 'developer.agentApps.columns.status',
|
||||
created_at: 'developer.agentApps.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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<typeof agentAppSchema>
|
||||
export type AgentRequest = z.infer<typeof agentRequestSchema>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { AgentApp, AgentRequest, CombinedItem } from '@/pages/developer/agent-apps/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/developer/agent-apps/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const agentApps = ref<AgentApp[]>([])
|
||||
const requests = ref<AgentRequest[]>([])
|
||||
const myRequests = ref<AgentRequest[]>([])
|
||||
const tableRef = ref()
|
||||
const showRejectDialog = ref(false)
|
||||
const showRemoveDialog = ref(false)
|
||||
const showBatchRejectDialog = ref(false)
|
||||
const showBatchRemoveDialog = ref(false)
|
||||
const rejectingItem = ref<CombinedItem | null>(null)
|
||||
const removingItem = ref<CombinedItem | null>(null)
|
||||
const batchRejectIds = ref<(string | number)[]>([])
|
||||
const batchRemoveIds = ref<(string | number)[]>([])
|
||||
|
||||
const activeCount = computed(() => agentApps.value.filter(app => app.status === 'active').length)
|
||||
const pendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length + myRequests.value.filter(r => r.status === 'pending').length)
|
||||
const totalBalance = computed(() => agentApps.value.reduce((sum, app) => sum + app.balance, 0))
|
||||
|
||||
const allItems = computed<CombinedItem[]>(() => {
|
||||
const items = agentApps.value.map(item => ({
|
||||
...item,
|
||||
itemType: 'authorization' as const,
|
||||
status: item.status,
|
||||
created_at: item.created_at,
|
||||
hasAuthorization: item.status === 'active' || item.status === 'inactive',
|
||||
authorizationId: item.id,
|
||||
}))
|
||||
|
||||
const requestItems = requests.value
|
||||
.filter(item => item.status === 'pending')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
agent_id: item.agent_id,
|
||||
agent_name: item.agent_name,
|
||||
application_id: item.application_id,
|
||||
app_name: item.app_name,
|
||||
status: item.status,
|
||||
commission: 0,
|
||||
discount: 1,
|
||||
balance: 0,
|
||||
card_types: [],
|
||||
created_at: item.created_at,
|
||||
itemType: 'request' as const,
|
||||
hasAuthorization: false,
|
||||
authorizationId: null,
|
||||
}))
|
||||
|
||||
const myRequestItems = myRequests.value
|
||||
.filter(item => item.status === 'pending')
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
agent_id: item.developer_id,
|
||||
agent_name: item.developer_name,
|
||||
application_id: item.application_id,
|
||||
app_name: item.app_name,
|
||||
status: item.status,
|
||||
commission: 0,
|
||||
discount: 1,
|
||||
balance: 0,
|
||||
card_types: [],
|
||||
created_at: item.created_at,
|
||||
itemType: 'my_request' as const,
|
||||
hasAuthorization: false,
|
||||
authorizationId: null,
|
||||
}))
|
||||
|
||||
return [...items, ...requestItems, ...myRequestItems].sort((a, b) => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [appsData, requestsData, myRequestsData] = await Promise.all([
|
||||
api.get<{ agent_apps: AgentApp[] }>('/dev/agent-apps'),
|
||||
api.get<{ requests: AgentRequest[] }>('/dev/agent-apps/requests'),
|
||||
api.get<{ requests: AgentRequest[] }>('/dev/agent-apps/my-requests'),
|
||||
])
|
||||
|
||||
agentApps.value = appsData?.agent_apps || []
|
||||
requests.value = requestsData?.requests || []
|
||||
myRequests.value = myRequestsData?.requests || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
toast.error(t('common.fetchFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToRequest() {
|
||||
router.push('/developer/agent-apps/request')
|
||||
}
|
||||
|
||||
function goToInvite() {
|
||||
router.push('/developer/agent-apps/invite')
|
||||
}
|
||||
|
||||
async function approveRequest(item: CombinedItem) {
|
||||
try {
|
||||
await api.post(`/dev/agent-apps/requests/${item.id}/approve`)
|
||||
toast.success(t('developer.agentApps.approveSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批准申请失败:', error)
|
||||
toast.error(error.message || t('developer.agentApps.approveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectRequest() {
|
||||
if (!rejectingItem.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.post(`/dev/agent-apps/requests/${rejectingItem.value.id}/reject`, { reason: '' })
|
||||
toast.success(t('developer.agentApps.rejectSuccess'))
|
||||
showRejectDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('拒绝申请失败:', error)
|
||||
toast.error(error.message || t('developer.agentApps.rejectFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReject(item: CombinedItem) {
|
||||
rejectingItem.value = item
|
||||
showRejectDialog.value = true
|
||||
}
|
||||
|
||||
async function removeItem() {
|
||||
if (!removingItem.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const endpoint = removingItem.value.itemType === 'my_request'
|
||||
? `/dev/agent-apps/my-requests/${removingItem.value.id}`
|
||||
: `/dev/agent-apps/${removingItem.value.authorizationId}`
|
||||
|
||||
await api.delete(endpoint)
|
||||
toast.success(t('common.success'))
|
||||
showRemoveDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('操作失败:', error)
|
||||
toast.error(error.message || t('common.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(item: CombinedItem) {
|
||||
removingItem.value = item
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
async function batchApprove(ids: (string | number)[]) {
|
||||
try {
|
||||
await api.post('/dev/agent-apps/requests/batch-approve', { ids })
|
||||
toast.success(t('developer.agentApps.batchApproveSuccess'))
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量批准失败:', error)
|
||||
toast.error(error.message || t('developer.agentApps.batchApproveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function batchReject() {
|
||||
if (batchRejectIds.value.length === 0)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.post('/dev/agent-apps/requests/batch-reject', { ids: batchRejectIds.value, reason: '' })
|
||||
toast.success(t('developer.agentApps.batchRejectSuccess'))
|
||||
showBatchRejectDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量拒绝失败:', error)
|
||||
toast.error(error.message || t('developer.agentApps.batchRejectFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchReject(ids: (string | number)[]) {
|
||||
batchRejectIds.value = ids
|
||||
showBatchRejectDialog.value = true
|
||||
}
|
||||
|
||||
async function batchRemove() {
|
||||
if (batchRemoveIds.value.length === 0)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete('/dev/agent-apps/batch', { ids: batchRemoveIds.value })
|
||||
toast.success(t('developer.agentApps.batchRemoveSuccess'))
|
||||
showBatchRemoveDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量移除失败:', error)
|
||||
toast.error(error.message || t('developer.agentApps.batchRemoveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchRemove(ids: (string | number)[]) {
|
||||
batchRemoveIds.value = ids
|
||||
showBatchRemoveDialog.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.agentApps.title')"
|
||||
:description="t('developer.agentApps.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goToRequest">
|
||||
<Icon icon="lucide:send" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agentApps.requestAuth') }}
|
||||
</UiButton>
|
||||
<UiButton @click="goToInvite">
|
||||
<Icon icon="lucide:user-plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agentApps.inviteAuth') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agentApps.totalAuth') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:share-2" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ agentApps.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agentApps.activeAuth') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agentApps.pendingRequests') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ pendingCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agentApps.agentBalance') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:wallet" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ totalBalance.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="allItems"
|
||||
:on-approve="approveRequest"
|
||||
:on-reject="confirmReject"
|
||||
:on-remove="confirmRemove"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<BulkActions
|
||||
v-if="tableRef?.table"
|
||||
:table="tableRef.table"
|
||||
:entity-name="t('developer.agentApps.auth')"
|
||||
>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="batchApprove(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
|
||||
>
|
||||
<Icon icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agentApps.batchApprove') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="confirmBatchReject(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
|
||||
>
|
||||
<Icon icon="lucide:x" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agentApps.batchReject') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="confirmBatchRemove(tableRef.table.getSelectedRowModel().rows.map((r: any) => r.original.id))"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agentApps.batchRemove') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="showRejectDialog"
|
||||
:title="t('developer.agentApps.reject')"
|
||||
:description="t('developer.agentApps.rejectConfirm')"
|
||||
confirm-text="确定"
|
||||
@confirm="rejectRequest"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="showRemoveDialog"
|
||||
:title="t('developer.agentApps.remove')"
|
||||
:description="removingItem?.itemType === 'my_request' ? t('developer.agentApps.withdrawConfirm') : t('developer.agentApps.removeConfirm')"
|
||||
confirm-text="确定"
|
||||
@confirm="removeItem"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="showBatchRejectDialog"
|
||||
:title="t('developer.agentApps.batchReject')"
|
||||
:description="t('developer.agentApps.batchRejectConfirm', { count: batchRejectIds.length })"
|
||||
confirm-text="确定"
|
||||
@confirm="batchReject"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="showBatchRemoveDialog"
|
||||
:title="t('developer.agentApps.batchRemove')"
|
||||
:description="t('developer.agentApps.batchRemoveConfirm', { count: batchRemoveIds.length })"
|
||||
confirm-text="确定"
|
||||
@confirm="batchRemove"
|
||||
/>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Array<{ id: number, name: string }>>([])
|
||||
|
||||
const form = ref({
|
||||
agent_id: '',
|
||||
application_id: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
applications.value = data.applications || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.agent_id) {
|
||||
toast.error('请输入代理商ID')
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agent_id: Number(form.value.agent_id),
|
||||
application_id: Number(form.value.application_id),
|
||||
message: form.value.message,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('邀请已发送')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送邀请失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送邀请失败:', error)
|
||||
toast.error('发送邀请失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="邀请授权"
|
||||
description="邀请代理商成为授权方"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>邀请信息</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
填写以下信息邀请代理商成为授权方
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="agent_id">
|
||||
代理商ID
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="agent_id"
|
||||
v-model="form.agent_id"
|
||||
type="number"
|
||||
placeholder="请输入代理商ID"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请向代理商索取其ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application_id">
|
||||
选择应用
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择应用" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="app in applications"
|
||||
:key="app.id"
|
||||
:value="String(app.id)"
|
||||
>
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="message">
|
||||
邀请说明
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
placeholder="请输入邀请说明(可选)"
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
<UiCardFooter class="flex justify-end gap-2">
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saving" @click="handleSubmit">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
发送邀请
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Array<{ id: number, name: string }>>([])
|
||||
|
||||
const form = ref({
|
||||
developer_id: '',
|
||||
application_id: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
applications.value = data.applications || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.developer_id) {
|
||||
toast.error('请输入开发者ID')
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/agent-apps/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
developer_id: Number(form.value.developer_id),
|
||||
application_id: Number(form.value.application_id),
|
||||
message: form.value.message,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('申请已提交')
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '提交申请失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('提交申请失败:', error)
|
||||
toast.error('提交申请失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/developer/agent-apps')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="申请授权"
|
||||
description="向开发者申请应用授权"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
<Icon icon="lucide:arrow-left" class="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>申请信息</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
填写以下信息向开发者申请应用授权
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="developer_id">
|
||||
开发者ID
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="developer_id"
|
||||
v-model="form.developer_id"
|
||||
type="number"
|
||||
placeholder="请输入开发者ID"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请向开发者索取其ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application_id">
|
||||
选择应用
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="请选择应用" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="app in applications"
|
||||
:key="app.id"
|
||||
:value="String(app.id)"
|
||||
>
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="message">
|
||||
申请说明
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
placeholder="请输入申请说明(可选)"
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
<UiCardFooter class="flex justify-end gap-2">
|
||||
<UiButton variant="outline" @click="goBack">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saving" @click="handleSubmit">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
提交申请
|
||||
</UiButton>
|
||||
</UiCardFooter>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Agent {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar: string
|
||||
status: string
|
||||
parent_agent_id: number | null
|
||||
parent_agent_name: string
|
||||
commission: number
|
||||
can_create_agent: boolean
|
||||
balance: number
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const agents = ref<Agent[]>([])
|
||||
|
||||
const agentId = computed(() => route.params.id as string)
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
parent_agent_id: '',
|
||||
commission: 0,
|
||||
can_create_agent: false,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
async function fetchAgent() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<Agent>(`/dev/agents/${agentId.value}`)
|
||||
if (data) {
|
||||
form.value.username = data.username || ''
|
||||
form.value.email = data.email || ''
|
||||
form.value.password = ''
|
||||
form.value.parent_agent_id = data.parent_agent_id ? String(data.parent_agent_id) : 'none'
|
||||
form.value.commission = data.commission || 0
|
||||
form.value.can_create_agent = data.can_create_agent === true
|
||||
form.value.balance = data.balance || 0
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取代理信息失败:', error)
|
||||
toast.error(t('developer.agents.edit.fetchFailed'))
|
||||
router.push('/developer/agents')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const data = await api.get<{ agents?: Agent[], total: number }>('/dev/agents')
|
||||
agents.value = (data?.agents || []).filter(a => String(a.id) !== agentId.value)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedParentAgent = computed(() => {
|
||||
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
|
||||
return agents.value.find(a => String(a.id) === form.value.parent_agent_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.username) {
|
||||
toast.error(t('developer.agents.create.usernameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.password && form.value.password.length < 6) {
|
||||
toast.error(t('developer.agents.create.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
commission: form.value.commission,
|
||||
can_create_agent: form.value.can_create_agent,
|
||||
balance: form.value.balance,
|
||||
}
|
||||
|
||||
if (form.value.email) {
|
||||
payload.email = form.value.email
|
||||
}
|
||||
|
||||
if (form.value.password) {
|
||||
payload.password = form.value.password
|
||||
}
|
||||
|
||||
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
|
||||
payload.parent_agent_id = parseInt(form.value.parent_agent_id)
|
||||
} else {
|
||||
payload.parent_agent_id = null
|
||||
}
|
||||
|
||||
await api.put(`/dev/agents/${agentId.value}`, payload)
|
||||
toast.success(t('developer.agents.edit.saveSuccess'))
|
||||
router.push('/developer/agents')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('保存代理失败:', error)
|
||||
toast.error(error.message || t('developer.agents.edit.saveFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgent()
|
||||
fetchAgents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.agents.edit.title')"
|
||||
:description="t('developer.agents.edit.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.agents.title'), href: '/developer/agents' },
|
||||
{ title: t('developer.agents.edit.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:user" class="size-5" />
|
||||
{{ t('developer.agents.create.basicInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.agents.create.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="username">
|
||||
{{ t('developer.agents.form.username') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
disabled
|
||||
class="bg-muted"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.edit.usernameHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="email">
|
||||
{{ t('developer.agents.form.email') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
:placeholder="t('developer.agents.form.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password">
|
||||
{{ t('developer.agents.form.password') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:placeholder="t('developer.agents.edit.passwordPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.edit.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.agents.create.agentConfig') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.agents.create.agentConfigDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="parent_agent">
|
||||
{{ t('developer.agents.form.parentAgent') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.parent_agent_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.agents.form.selectParentAgent')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('developer.agents.form.noParent') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:value="String(agent.id)"
|
||||
>
|
||||
{{ agent.username }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.create.parentAgentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="commission">
|
||||
{{ t('developer.agents.form.commission') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiNumberField
|
||||
v-model="form.commission"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
class="flex-1 max-w-[200px]"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.form.commissionPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="balance">
|
||||
{{ t('developer.agents.form.balance') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiNumberField
|
||||
v-model="form.balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="flex-1 max-w-[200px]"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">¥</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.form.balancePlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>{{ t('developer.agents.form.canCreateAgent') }}</UiLabel>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.create.canCreateAgentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 注意:UiSwitch 必须使用 v-model,不能使用 v-model:checked -->
|
||||
<UiSwitch v-model="form.can_create_agent" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:receipt" class="size-5" />
|
||||
{{ t('developer.agents.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.username') }}</span>
|
||||
<span>{{ form.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.canCreateAgent') }}</span>
|
||||
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.email') }}</span>
|
||||
<span>{{ form.email || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.parentAgent') }}</span>
|
||||
<span>{{ selectedParentAgent?.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.commission') }}</span>
|
||||
<span>{{ (form.commission * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.balance') }}</span>
|
||||
<span>¥{{ form.balance.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agents.edit.save') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.agents.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -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<Agent>): 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<Agent>[] {
|
||||
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<string, { text: string, variant: any }> = {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { Agent } from '../data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from './columns'
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Agent>, 'columns'> & {
|
||||
searchFilter?: string
|
||||
viewMode?: 'tree' | 'list'
|
||||
onToggleStatus: (row: Agent) => void
|
||||
onEdit: (row: Agent) => void
|
||||
onDelete: (row: Agent) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'toggleView': []
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Agent>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Agent>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
getSubRows: props.viewMode === 'tree' ? (row: Agent) => row.children || [] : undefined,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.agents.select',
|
||||
username: 'developer.agents.columns.username',
|
||||
status: 'developer.agents.columns.status',
|
||||
can_create_agent: 'developer.agents.columns.canCreateAgent',
|
||||
cards_count: 'developer.agents.columns.cardsCount',
|
||||
child_agents_count: 'developer.agents.columns.childAgentsCount',
|
||||
balance: 'developer.agents.columns.balance',
|
||||
commission: 'developer.agents.columns.commission',
|
||||
created_at: 'developer.agents.columns.createdAt',
|
||||
last_login_at: 'developer.agents.columns.lastLoginAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative">
|
||||
<Icon icon="lucide:search" class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<UiInput
|
||||
:model-value="searchFilter"
|
||||
type="search"
|
||||
:placeholder="t('developer.agents.searchPlaceholder')"
|
||||
class="h-9 w-[250px] pl-8"
|
||||
@update:model-value="emit('update:searchFilter', $event)"
|
||||
/>
|
||||
</div>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="emit('toggleView')">
|
||||
<Icon :icon="viewMode === 'tree' ? 'lucide:list' : 'lucide:git-branch'" class="mr-2 h-4 w-4" />
|
||||
{{ viewMode === 'tree' ? t('developer.agents.listView') : t('developer.agents.treeView') }}
|
||||
</UiButton>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Agent {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const agents = ref<Agent[]>([])
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
parent_agent_id: '',
|
||||
commission: 0,
|
||||
can_create_agent: false,
|
||||
balance: 0,
|
||||
})
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const data = await api.get<{ agents?: Agent[], total: number }>('/dev/agents')
|
||||
agents.value = data?.agents || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedParentAgent = computed(() => {
|
||||
if (form.value.parent_agent_id) {
|
||||
return agents.value.find(a => String(a.id) === form.value.parent_agent_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.username) {
|
||||
toast.error(t('developer.agents.create.usernameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.password) {
|
||||
toast.error(t('developer.agents.create.passwordRequired'))
|
||||
return
|
||||
}
|
||||
if (form.value.password.length < 6) {
|
||||
toast.error(t('developer.agents.create.passwordMinLength'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
username: form.value.username,
|
||||
password: form.value.password,
|
||||
commission: form.value.commission,
|
||||
can_create_agent: form.value.can_create_agent,
|
||||
balance: form.value.balance,
|
||||
}
|
||||
|
||||
if (form.value.email) {
|
||||
payload.email = form.value.email
|
||||
}
|
||||
|
||||
if (form.value.parent_agent_id && form.value.parent_agent_id !== 'none') {
|
||||
payload.parent_agent_id = parseInt(form.value.parent_agent_id)
|
||||
}
|
||||
|
||||
await api.post('/dev/agents', payload)
|
||||
toast.success(t('developer.agents.create.createSuccess'))
|
||||
router.push('/developer/agents')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建代理失败:', error)
|
||||
toast.error(error.message || t('developer.agents.create.createFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.agents.create.title')"
|
||||
:description="t('developer.agents.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.agents.title'), href: '/developer/agents' },
|
||||
{ title: t('developer.agents.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:user-plus" class="size-5" />
|
||||
{{ t('developer.agents.create.basicInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.agents.create.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="username">
|
||||
{{ t('developer.agents.form.username') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
:placeholder="t('developer.agents.form.usernamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="email">
|
||||
{{ t('developer.agents.form.email') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
:placeholder="t('developer.agents.form.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="password">
|
||||
{{ t('developer.agents.form.password') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:placeholder="t('developer.agents.form.passwordPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.create.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.agents.create.agentConfig') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.agents.create.agentConfigDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="parent_agent">
|
||||
{{ t('developer.agents.form.parentAgent') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.parent_agent_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.agents.form.selectParentAgent')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('developer.agents.form.noParent') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:value="String(agent.id)"
|
||||
>
|
||||
{{ agent.username }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.create.parentAgentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="commission">
|
||||
{{ t('developer.agents.form.commission') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiNumberField
|
||||
v-model="form.commission"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
class="flex-1 max-w-[200px]"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.form.commissionPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="balance">
|
||||
{{ t('developer.agents.form.balance') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiNumberField
|
||||
v-model="form.balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="flex-1 max-w-[200px]"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">¥</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.form.balancePlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>{{ t('developer.agents.form.canCreateAgent') }}</UiLabel>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.agents.create.canCreateAgentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 注意:UiSwitch 必须使用 v-model,不能使用 v-model:checked -->
|
||||
<UiSwitch v-model="form.can_create_agent" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:receipt" class="size-5" />
|
||||
{{ t('developer.agents.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.username') }}</span>
|
||||
<span>{{ form.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.email') }}</span>
|
||||
<span>{{ form.email || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.canCreateAgent') }}</span>
|
||||
<span>{{ form.can_create_agent ? t('common.yes') : t('common.no') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.parentAgent') }}</span>
|
||||
<span>{{ selectedParentAgent?.username || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.commission') }}</span>
|
||||
<span>{{ (form.commission * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.agents.form.balance') }}</span>
|
||||
<span>¥{{ form.balance.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.username || !form.password"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:user-plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agents.form.create') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.agents.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Agent } from './data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from './components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const agents = ref<Agent[]>([])
|
||||
const tableRef = ref()
|
||||
const searchFilter = ref('')
|
||||
const statusFilter = ref<string>('')
|
||||
const viewMode = ref<'tree' | 'list'>('tree')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Agent | null>(null)
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
let result = agents.value
|
||||
|
||||
if (statusFilter.value) {
|
||||
const filterByStatus = (items: Agent[]): Agent[] => {
|
||||
return items.reduce((acc: Agent[], item) => {
|
||||
const matches = item.status === statusFilter.value
|
||||
const filteredChildren = item.children ? filterByStatus(item.children) : []
|
||||
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
acc.push({
|
||||
...item,
|
||||
children: filteredChildren.length > 0 ? filteredChildren : item.children,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
result = filterByStatus(result)
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
const search = searchFilter.value.toLowerCase()
|
||||
const filterTree = (items: Agent[]): Agent[] => {
|
||||
return items.reduce((acc: Agent[], item) => {
|
||||
const matchesSearch =
|
||||
item.username?.toLowerCase().includes(search) ||
|
||||
item.email?.toLowerCase().includes(search) ||
|
||||
item.parent_agent_name?.toLowerCase().includes(search)
|
||||
|
||||
const filteredChildren = item.children ? filterTree(item.children) : []
|
||||
|
||||
if (matchesSearch || filteredChildren.length > 0) {
|
||||
acc.push({
|
||||
...item,
|
||||
children: filteredChildren.length > 0 ? filteredChildren : item.children,
|
||||
})
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
result = filterTree(result)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalAgents = computed(() => {
|
||||
const countNodes = (items: Agent[]): number => {
|
||||
return items.reduce((acc, item) => {
|
||||
return acc + 1 + (item.children ? countNodes(item.children) : 0)
|
||||
}, 0)
|
||||
}
|
||||
return countNodes(agents.value)
|
||||
})
|
||||
|
||||
const activeCount = computed(() => {
|
||||
const countByStatus = (items: Agent[], status: string): number => {
|
||||
return items.reduce((acc, item) => {
|
||||
const self = item.status === status ? 1 : 0
|
||||
const children = item.children ? countByStatus(item.children, status) : 0
|
||||
return acc + self + children
|
||||
}, 0)
|
||||
}
|
||||
return countByStatus(agents.value, 'active')
|
||||
})
|
||||
|
||||
const inactiveCount = computed(() => {
|
||||
const countByStatus = (items: Agent[], status: string): number => {
|
||||
return items.reduce((acc, item) => {
|
||||
const self = item.status === status ? 1 : 0
|
||||
const children = item.children ? countByStatus(item.children, status) : 0
|
||||
return acc + self + children
|
||||
}, 0)
|
||||
}
|
||||
return countByStatus(agents.value, 'inactive')
|
||||
})
|
||||
|
||||
const bannedCount = computed(() => {
|
||||
const countByStatus = (items: Agent[], status: string): number => {
|
||||
return items.reduce((acc, item) => {
|
||||
const self = item.status === status ? 1 : 0
|
||||
const children = item.children ? countByStatus(item.children, status) : 0
|
||||
return acc + self + children
|
||||
}, 0)
|
||||
}
|
||||
return countByStatus(agents.value, 'banned')
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.agents.status.active'), value: 'active' },
|
||||
{ label: t('developer.agents.status.inactive'), value: 'inactive' },
|
||||
{ label: t('developer.agents.status.banned'), value: 'banned' },
|
||||
])
|
||||
|
||||
async function fetchAgents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const endpoint = viewMode.value === 'tree' ? '/dev/agents/tree' : '/dev/agents'
|
||||
const data = await api.get<{ tree?: Agent[], agents?: Agent[], total: number }>(endpoint)
|
||||
if (viewMode.value === 'tree') {
|
||||
agents.value = data?.tree || []
|
||||
} else {
|
||||
agents.value = data?.agents || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
agents.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/developer/agents/create')
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
|
||||
fetchAgents()
|
||||
}
|
||||
|
||||
function handleEdit(agent: Agent) {
|
||||
router.push(`/developer/agents/${agent.id}/edit`)
|
||||
}
|
||||
|
||||
async function handleToggleStatus(agent: Agent) {
|
||||
try {
|
||||
const newStatus = agent.status === 'banned' ? 'active' : 'banned'
|
||||
await api.put(`/dev/agents/${agent.id}/status`, { status: newStatus })
|
||||
toast.success(t('developer.agents.statusUpdateSuccess'))
|
||||
fetchAgents()
|
||||
} catch (error: any) {
|
||||
console.error('切换代理状态失败:', error)
|
||||
toast.error(error.message || t('developer.agents.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(agent: Agent) {
|
||||
deleteTarget.value = agent
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/agents/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.agents.deleteSuccess'))
|
||||
fetchAgents()
|
||||
} catch (error: any) {
|
||||
console.error('删除代理失败:', error)
|
||||
toast.error(error.message || t('developer.agents.deleteFailed'))
|
||||
} finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.agents.title')"
|
||||
:description="t('developer.agents.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.agents.addAgent') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agents.totalAgents') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalAgents }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agents.status.active') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agents.status.inactive') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:clock" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.agents.status.banned') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:data="filteredAgents"
|
||||
:search-filter="searchFilter"
|
||||
:view-mode="viewMode"
|
||||
:on-toggle-status="handleToggleStatus"
|
||||
:on-edit="handleEdit"
|
||||
:on-delete="confirmDelete"
|
||||
@refresh="fetchAgents"
|
||||
@toggle-view="toggleViewMode"
|
||||
@update:search-filter="searchFilter = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.agents.columns.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('common.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.agents.deleteAgent') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.agents.deleteAgentConfirm', { username: deleteTarget?.username }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Announcement {
|
||||
id: number
|
||||
application_id: number
|
||||
application_name: string
|
||||
title: string
|
||||
content: string
|
||||
type: 'info' | 'warning' | 'error' | 'success'
|
||||
status: 'active' | 'inactive'
|
||||
is_top: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const announcement = ref<Announcement | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info' as 'info' | 'warning' | 'error' | 'success',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
is_status_active: true,
|
||||
is_top: false,
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'info', label: '信息', color: 'bg-blue-500', desc: '普通通知信息' },
|
||||
{ value: 'warning', label: '警告', color: 'bg-yellow-500', desc: '需要注意的内容' },
|
||||
{ value: 'error', label: '错误', color: 'bg-red-500', desc: '错误或故障通知' },
|
||||
{ value: 'success', label: '成功', color: 'bg-green-500', desc: '成功状态通知' },
|
||||
]
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
return applications.value.find(app => app.id === announcement.value?.application_id)
|
||||
})
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAnnouncement() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<Announcement>(`/dev/announcements/${route.params.id}`)
|
||||
announcement.value = data
|
||||
if (data) {
|
||||
formData.value.title = data.title
|
||||
formData.value.content = data.content
|
||||
formData.value.type = data.type
|
||||
formData.value.status = data.status
|
||||
formData.value.is_status_active = data.status === 'active'
|
||||
formData.value.is_top = data.is_top || false
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取公告详情失败:', error)
|
||||
toast.error('公告不存在或已被删除')
|
||||
router.push('/developer/announcements')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.title) {
|
||||
toast.error('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!formData.value.content) {
|
||||
toast.error('请输入公告内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/applications/${announcement.value?.application_id}/announcements/${route.params.id}`, {
|
||||
title: formData.value.title,
|
||||
content: formData.value.content,
|
||||
type: formData.value.type,
|
||||
status: formData.value.is_status_active ? 'active' : 'inactive',
|
||||
is_top: formData.value.is_top,
|
||||
})
|
||||
toast.success('公告更新成功')
|
||||
router.push('/developer/announcements')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新公告失败:', error)
|
||||
toast.error(error.message || '更新失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchAnnouncement()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="编辑公告"
|
||||
description="编辑现有公告内容"
|
||||
:breadcrumbs="[
|
||||
{ title: '公告管理', href: '/developer/announcements' },
|
||||
{ title: '编辑公告' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:megaphone" class="size-5" />
|
||||
编辑公告
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>修改公告的基本信息和内容</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>所属应用</UiLabel>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
<span>{{ announcement?.application_name || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="title">
|
||||
公告标题 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
placeholder="输入公告标题"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="content">
|
||||
公告内容 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<textarea
|
||||
id="content"
|
||||
v-model="formData.content"
|
||||
class="flex min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入公告详细内容..."
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>公告类型</UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in typeOptions" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="type.color" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
发布状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后公告将对用户可见
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
v-model="formData.is_status_active"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
置顶公告
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
置顶的公告将在应用内优先显示
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
v-model="formData.is_top"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">所属应用</span>
|
||||
<span>{{ selectedApplication?.name || announcement?.application_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">公告标题</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.title || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">公告类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedType?.color" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">发布状态</span>
|
||||
<span>{{ formData.is_status_active ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">是否置顶</span>
|
||||
<span>{{ formData.is_top ? '是' : '否' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
内容预览
|
||||
</p>
|
||||
<div class="rounded-lg bg-muted/50 p-3 min-h-[80px]">
|
||||
<p class="text-sm whitespace-pre-wrap">
|
||||
{{ formData.content || '暂无内容' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.title || !formData.content"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
保存更改
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { Announcement } from '@/pages/admin/announcements/data/schema'
|
||||
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
interface ColumnOptions {
|
||||
onToggleTop: (row: Announcement) => void
|
||||
onEdit: (row: Announcement) => void
|
||||
onDelete: (row: Announcement) => void
|
||||
}
|
||||
|
||||
export function getColumns(options: ColumnOptions, t: (key: string) => string): ColumnDef<Announcement>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => t('developer.announcements.columns.title'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'font-medium' }, row.original.title),
|
||||
h('div', { class: 'text-xs text-muted-foreground truncate max-w-xs' }, row.original.content),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.announcements.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.original.application_name
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => t('developer.announcements.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const typeLabels: Record<string, string> = {
|
||||
info: t('developer.announcements.types.info'),
|
||||
warning: t('developer.announcements.types.warning'),
|
||||
error: t('developer.announcements.types.error'),
|
||||
success: t('developer.announcements.types.success'),
|
||||
}
|
||||
const typeClasses: Record<string, string> = {
|
||||
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
}
|
||||
return h(Badge, { class: typeClasses[row.original.type] }, () => typeLabels[row.original.type])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.announcements.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: t('developer.announcements.statuses.active'),
|
||||
inactive: t('developer.announcements.statuses.inactive'),
|
||||
}
|
||||
const statusClasses: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
}
|
||||
return h(Badge, { class: statusClasses[row.original.status] }, () => statusLabels[row.original.status])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_top',
|
||||
header: () => t('developer.announcements.columns.isTop'),
|
||||
cell: ({ row }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onToggleTop(row.original),
|
||||
}, () => [
|
||||
h(Icon, {
|
||||
icon: row.original.is_top ? 'lucide:pin' : 'lucide:pin-off',
|
||||
class: `h-4 w-4 ${row.original.is_top ? 'text-primary' : 'text-muted-foreground'}`,
|
||||
}),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.announcements.columns.createdAt'),
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, new Date(row.original.created_at).toLocaleString('zh-CN'))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('developer.announcements.columns.actions'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex items-center justify-end gap-1' }, [
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onEdit(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:edit', class: 'h-4 w-4' }),
|
||||
]),
|
||||
h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => options.onDelete(row.original),
|
||||
}, () => [
|
||||
h(Icon, { icon: 'lucide:trash-2', class: 'h-4 w-4 text-destructive' }),
|
||||
]),
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Announcement } from '@/pages/admin/announcements/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<Announcement>
|
||||
searchFilter?: string
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => !!props.searchFilter)
|
||||
|
||||
function resetFilters() {
|
||||
emit('update:searchFilter', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.announcements.searchAll')"
|
||||
:model-value="searchFilter"
|
||||
class="h-8 w-[200px] lg:w-[300px]"
|
||||
@update:model-value="emit('update:searchFilter', $event as string)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('developer.announcements.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { Announcement } from '@/pages/admin/announcements/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/announcements/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/announcements/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Announcement>, 'columns'> & {
|
||||
onToggleTop: (row: Announcement) => void
|
||||
onEdit: (row: Announcement) => void
|
||||
onDelete: (row: Announcement) => void
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'batchDelete': []
|
||||
'batchExport': []
|
||||
'export': []
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed<ColumnDef<Announcement>[]>(() => [
|
||||
SelectColumn as ColumnDef<Announcement>,
|
||||
...getColumns({
|
||||
onToggleTop: props.onToggleTop,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Announcement>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.announcements.select',
|
||||
title: 'developer.announcements.columns.title',
|
||||
application_name: 'developer.announcements.columns.application',
|
||||
type: 'developer.announcements.columns.type',
|
||||
status: 'developer.announcements.columns.status',
|
||||
is_top: 'developer.announcements.columns.isTop',
|
||||
created_at: 'developer.announcements.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="announcements">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchExport')">
|
||||
{{ t('developer.announcements.batchExportBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.announcements.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar
|
||||
:table
|
||||
:search-filter="searchFilter"
|
||||
@update:search-filter="emit('update:searchFilter', $event)"
|
||||
/>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="emit('export')">
|
||||
{{ t('developer.announcements.exportBtn') }}
|
||||
</UiButton>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const formData = ref({
|
||||
application_id: '',
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info' as 'info' | 'warning' | 'error' | 'success',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
is_top: false,
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'info', label: '信息', color: 'bg-blue-500', desc: '普通通知信息' },
|
||||
{ value: 'warning', label: '警告', color: 'bg-yellow-500', desc: '需要注意的内容' },
|
||||
{ value: 'error', label: '错误', color: 'bg-red-500', desc: '错误或故障通知' },
|
||||
{ value: 'success', label: '成功', color: 'bg-green-500', desc: '成功状态通知' },
|
||||
]
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
return applications.value.find(app => String(app.id) === formData.value.application_id)
|
||||
})
|
||||
|
||||
const selectedType = computed(() => {
|
||||
return typeOptions.find(t => t.value === formData.value.type)
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
const appParam = route.query.app as string
|
||||
if (appParam) {
|
||||
formData.value.application_id = appParam
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.application_id) {
|
||||
toast.error('请选择应用')
|
||||
return
|
||||
}
|
||||
if (!formData.value.title) {
|
||||
toast.error('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!formData.value.content) {
|
||||
toast.error('请输入公告内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post(`/dev/applications/${formData.value.application_id}/announcements`, {
|
||||
title: formData.value.title,
|
||||
content: formData.value.content,
|
||||
type: formData.value.type,
|
||||
status: formData.value.status,
|
||||
is_top: formData.value.is_top,
|
||||
})
|
||||
toast.success('公告创建成功')
|
||||
router.push('/developer/announcements')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建公告失败:', error)
|
||||
toast.error(error.message || '创建失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="创建公告"
|
||||
description="创建新的系统公告"
|
||||
:breadcrumbs="[
|
||||
{ title: '公告管理', href: '/developer/announcements' },
|
||||
{ title: '创建公告' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:megaphone" class="size-5" />
|
||||
公告配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>填写公告的基本信息和内容</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择应用 <span class="text-destructive">*</span></UiLabel>
|
||||
<UiSelect v-model="formData.application_id" :disabled="saving || loading">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择应用" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="app in applications"
|
||||
:key="app.id"
|
||||
:value="String(app.id)"
|
||||
>
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="title">
|
||||
公告标题 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="title"
|
||||
v-model="formData.title"
|
||||
placeholder="输入公告标题"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="content">
|
||||
公告内容 <span class="text-destructive">*</span>
|
||||
</UiLabel>
|
||||
<textarea
|
||||
id="content"
|
||||
v-model="formData.content"
|
||||
class="flex min-h-[180px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入公告详细内容..."
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>公告类型</UiLabel>
|
||||
<UiSelect v-model="formData.type" :disabled="saving">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择类型" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in typeOptions" :key="type.value" :value="type.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="type.color" />
|
||||
{{ type.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
发布状态
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后公告将对用户可见
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:checked="formData.status === 'active'"
|
||||
:disabled="saving"
|
||||
@update:checked="formData.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel class="text-base">
|
||||
置顶公告
|
||||
</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
置顶的公告将在应用内优先显示
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="formData.is_top" :disabled="saving" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
预览
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">所属应用</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">公告标题</span>
|
||||
<span class="truncate max-w-[120px]">{{ formData.title || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">公告类型</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedType?.color" />
|
||||
<span>{{ selectedType?.label || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">发布状态</span>
|
||||
<span>{{ formData.status === 'active' ? '启用' : '禁用' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">是否置顶</span>
|
||||
<span>{{ formData.is_top ? '是' : '否' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
内容预览
|
||||
</p>
|
||||
<div class="rounded-lg bg-muted/50 p-3 min-h-[80px]">
|
||||
<p class="text-sm whitespace-pre-wrap">
|
||||
{{ formData.content || '暂无内容' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !formData.application_id || !formData.title || !formData.content"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:send" class="mr-2 h-4 w-4" />
|
||||
发布公告
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const announcementStatusSchema = z.enum(['active', 'inactive'])
|
||||
export const announcementTypeSchema = z.enum(['info', 'warning', 'error', 'success'])
|
||||
|
||||
export const announcementSchema = z.object({
|
||||
id: z.number(),
|
||||
application_id: z.number(),
|
||||
application_name: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
type: announcementTypeSchema,
|
||||
status: announcementStatusSchema,
|
||||
is_top: z.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type Announcement = z.infer<typeof announcementSchema>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Announcement } from '@/pages/admin/announcements/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/announcements/components/data-table.vue'
|
||||
import api, { BASE_URL } from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const typeFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const searchFilter = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Announcement | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<number[]>([])
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: t('developer.announcements.types.info'), value: 'info' },
|
||||
{ label: t('developer.announcements.types.warning'), value: 'warning' },
|
||||
{ label: t('developer.announcements.types.error'), value: 'error' },
|
||||
{ label: t('developer.announcements.types.success'), value: 'success' },
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.announcements.statuses.active'), value: 'active' },
|
||||
{ label: t('developer.announcements.statuses.inactive'), value: 'inactive' },
|
||||
])
|
||||
|
||||
const filteredAnnouncements = computed(() => {
|
||||
let result = announcements.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(a => String(a.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (typeFilter.value) {
|
||||
result = result.filter(a => a.type === typeFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(a => a.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
const search = searchFilter.value.toLowerCase()
|
||||
result = result.filter(a =>
|
||||
a.title?.toLowerCase().includes(search)
|
||||
|| a.content?.toLowerCase().includes(search)
|
||||
|| a.application_name?.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const activeCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'active').length)
|
||||
const inactiveCount = computed(() => filteredAnnouncements.value.filter(a => a.status === 'inactive').length)
|
||||
const topCount = computed(() => filteredAnnouncements.value.filter(a => a.is_top).length)
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAnnouncements() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ announcements: Announcement[], total: number }>('/dev/announcements')
|
||||
announcements.value = data?.announcements || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载公告失败:', error)
|
||||
announcements.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/developer/announcements/create')
|
||||
}
|
||||
|
||||
function goToEdit(announcement: Announcement) {
|
||||
router.push(`/developer/announcements/${announcement.id}`)
|
||||
}
|
||||
|
||||
async function toggleTop(announcement: Announcement) {
|
||||
try {
|
||||
await api.put(`/dev/applications/${announcement.application_id}/announcements/${announcement.id}/top`)
|
||||
toast.success(announcement.is_top ? t('developer.announcements.unpinned') : t('developer.announcements.pinned'))
|
||||
fetchAnnouncements()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换置顶失败:', error)
|
||||
toast.error(error.message || t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(announcement: Announcement) {
|
||||
deleteTarget.value = announcement
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/applications/${deleteTarget.value.application_id}/announcements/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.announcements.deleteSuccess'))
|
||||
fetchAnnouncements()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除公告失败:', error)
|
||||
toast.error(error.message || t('common.error'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
if (appFilter.value)
|
||||
params.append('application_id', appFilter.value)
|
||||
if (typeFilter.value)
|
||||
params.append('type', typeFilter.value)
|
||||
if (statusFilter.value)
|
||||
params.append('status', statusFilter.value)
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `${BASE_URL}/dev/announcements/export?${queryString}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleBatchExport(ids: number[]) {
|
||||
if (!ids.length)
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
params.append('ids', ids.join(','))
|
||||
const url = `${BASE_URL}/dev/announcements/export?${params.toString()}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: number[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/announcements/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('developer.announcements.batchDeleteSuccess'))
|
||||
fetchAnnouncements()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('common.error'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchAnnouncements()
|
||||
const appParam = route.query.app as string
|
||||
if (appParam) {
|
||||
appFilter.value = appParam
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.announcements.title')"
|
||||
:description="t('developer.announcements.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.announcements.create') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.announcements.totalAnnouncements') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:megaphone" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredAnnouncements.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.announcements.activeCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.announcements.inactiveCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.announcements.topCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:pin" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ topCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredAnnouncements"
|
||||
:on-toggle-top="toggleTop"
|
||||
:on-edit="goToEdit"
|
||||
:on-delete="confirmDelete"
|
||||
:search-filter="searchFilter"
|
||||
@refresh="fetchAnnouncements"
|
||||
@update:search-filter="searchFilter = $event"
|
||||
@export="handleExport"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.announcements.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="typeFilter"
|
||||
:title="t('developer.announcements.type')"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.announcements.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('common.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.announcements.deleteAnnouncement') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.announcements.deleteConfirm', { title: deleteTarget?.title }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('common.delete')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.announcements.batchDelete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.announcements.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { TSort } from '@/components/sort-select/types'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import SortSelect from '@/components/sort-select/index.vue'
|
||||
|
||||
import type { App } from './applications/data/schema'
|
||||
|
||||
import AppCard from './applications/components/app-card.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(true)
|
||||
const apps = ref<App[]>([])
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
|
||||
const sort = ref<TSort>('asc')
|
||||
|
||||
const activeAppsCount = computed(() => apps.value.filter(app => app.status === 'active').length)
|
||||
const totalUsers = computed(() => apps.value.reduce((sum, app) => sum + (app.users || 0), 0))
|
||||
const totalVerifyCount = computed(() => apps.value.reduce((sum, app) => sum + (app.verify_count || 0), 0))
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let result = [...apps.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(app =>
|
||||
app.name.toLowerCase().includes(query)
|
||||
|| app.app_key?.toLowerCase().includes(query)
|
||||
|| app.description?.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(app => app.status === statusFilter.value)
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (a.disabled_by_package !== b.disabled_by_package) {
|
||||
return a.disabled_by_package ? 1 : -1
|
||||
}
|
||||
const timeA = new Date(a.created_at || 0).getTime()
|
||||
const timeB = new Date(b.created_at || 0).getTime()
|
||||
if (sort.value === 'asc') {
|
||||
return timeA - timeB
|
||||
}
|
||||
else {
|
||||
return timeB - timeA
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
async function fetchApps() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
const applications = Array.isArray(data.data) ? data.data : data.data.applications || []
|
||||
apps.value = applications.map((app: any) => ({
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
app_key: app.app_key || '',
|
||||
billing_type: app.billing_type || 'free',
|
||||
login_policy: app.login_policy || 'loose',
|
||||
enable_trial: app.enable_trial || false,
|
||||
trial_balance: app.trial_balance || 0,
|
||||
enable_free_period: app.enable_free_period || false,
|
||||
free_period_start: app.free_period_start || '',
|
||||
free_period_end: app.free_period_end || '',
|
||||
status: app.status || 'active',
|
||||
users: app.users || 0,
|
||||
devices: app.devices || 0,
|
||||
verify_count: app.verify_count || 0,
|
||||
created_at: app.created_at || new Date(),
|
||||
icon_url: app.icon_url || '',
|
||||
encrypt_type: app.encrypt_type || 'none',
|
||||
secret_key: app.secret_key || '',
|
||||
bind_type: app.bind_type || 'device',
|
||||
max_devices: app.max_devices || 1,
|
||||
multi_open: app.multi_open || false,
|
||||
deduction_cycle: app.deduction_cycle || 'per_use',
|
||||
deduction_amount: app.deduction_amount || 1,
|
||||
heartbeat_interval: app.heartbeat_interval || 60,
|
||||
heartbeat_timeout: app.heartbeat_timeout || 300,
|
||||
max_attempts: app.max_attempts || 5,
|
||||
lock_duration: app.lock_duration || 30,
|
||||
disabled_by_package: app.disabled_by_package || false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.applications.title')"
|
||||
:description="t('developer.applications.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="router.push('/developer/applications/create')">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.applications.createApp') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.applications.totalApps') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ apps.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.applications.activeApps') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-green-500" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeAppsCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.applications.totalUsers') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-blue-500" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalUsers }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.applications.totalVerifyCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:activity" class="size-4 text-purple-500" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalVerifyCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between sm:items-center mb-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<UiInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('developer.applications.searchPlaceholder')"
|
||||
class="h-9 w-40 lg:w-[250px]"
|
||||
/>
|
||||
|
||||
<UiSelect v-model="statusFilter">
|
||||
<UiSelectTrigger class="w-32">
|
||||
<UiSelectValue :placeholder="t('developer.applications.statusFilter')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
{{ t('developer.applications.allStatus') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="active">
|
||||
{{ t('developer.applications.active') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="inactive">
|
||||
{{ t('developer.applications.inactive') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<SortSelect v-model:sort="sort" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredApps.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="size-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Icon icon="lucide:layout-grid" class="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ t('developer.applications.noApps') }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{{ searchQuery || statusFilter !== 'all' ? t('developer.applications.noMatch') : t('developer.applications.createFirst') }}
|
||||
</p>
|
||||
<UiButton v-if="!searchQuery && statusFilter === 'all'" @click="router.push('/developer/applications/create')">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.applications.createApp') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<main v-else class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<AppCard
|
||||
v-for="app in filteredApps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
@refresh="fetchApps"
|
||||
/>
|
||||
</main>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
|
||||
interface Permission {
|
||||
allow_email_verify: boolean
|
||||
allow_password_reset: boolean
|
||||
allow_custom_smtp: boolean
|
||||
allow_custom_template: boolean
|
||||
}
|
||||
|
||||
interface SMTPConfig {
|
||||
id: number
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
from_name: string
|
||||
from_email: string
|
||||
use_ssl: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
enable_email_verify: boolean
|
||||
require_email_verify: boolean
|
||||
enable_password_reset: boolean
|
||||
permission: Permission
|
||||
smtp_config: SMTPConfig | null
|
||||
}
|
||||
|
||||
interface EmailTemplate {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
content: string
|
||||
is_default: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
const config = ref<EmailConfig | null>(null)
|
||||
const templates = ref<EmailTemplate[]>([])
|
||||
|
||||
const form = ref({
|
||||
enable_email_verify: false,
|
||||
require_email_verify: false,
|
||||
enable_password_reset: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 465,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
smtp_from_name: '',
|
||||
smtp_from_email: '',
|
||||
smtp_use_ssl: true,
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
const sendCodeEmail = ref('')
|
||||
const sendCodePurpose = ref('register')
|
||||
|
||||
const canUseEmailVerify = computed(() => config.value?.permission?.allow_email_verify)
|
||||
const canUsePasswordReset = computed(() => config.value?.permission?.allow_password_reset)
|
||||
const canUseCustomSMTP = computed(() => config.value?.permission?.allow_custom_smtp)
|
||||
const canUseCustomTemplate = computed(() => config.value?.permission?.allow_custom_template)
|
||||
|
||||
async function fetchConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
config.value = data.data
|
||||
form.value.enable_email_verify = data.data.enable_email_verify || false
|
||||
form.value.require_email_verify = data.data.require_email_verify || false
|
||||
form.value.enable_password_reset = data.data.enable_password_reset || false
|
||||
|
||||
if (data.data.smtp_config) {
|
||||
form.value.smtp_host = data.data.smtp_config.host || ''
|
||||
form.value.smtp_port = data.data.smtp_config.port || 465
|
||||
form.value.smtp_user = data.data.smtp_config.user || ''
|
||||
form.value.smtp_from_name = data.data.smtp_config.from_name || ''
|
||||
form.value.smtp_from_email = data.data.smtp_config.from_email || ''
|
||||
form.value.smtp_use_ssl = data.data.smtp_config.use_ssl ?? true
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '获取配置失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
toast.error('获取配置失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-templates`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
templates.value = data.data || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取模板失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enable_email_verify: form.value.enable_email_verify,
|
||||
require_email_verify: form.value.require_email_verify,
|
||||
enable_password_reset: form.value.enable_password_reset,
|
||||
smtp_config: {
|
||||
host: form.value.smtp_host,
|
||||
port: form.value.smtp_port,
|
||||
user: form.value.smtp_user,
|
||||
password: form.value.smtp_password,
|
||||
from_name: form.value.smtp_from_name,
|
||||
from_email: form.value.smtp_from_email,
|
||||
use_ssl: form.value.smtp_use_ssl,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchConfig()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEmail() {
|
||||
if (!testEmail.value) {
|
||||
toast.error('请输入测试邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/email-config/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: testEmail.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('测试邮件已发送,请检查收件箱')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendVerifyCode() {
|
||||
if (!sendCodeEmail.value) {
|
||||
toast.error('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}/send-verify-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: sendCodeEmail.value,
|
||||
purpose: sendCodePurpose.value,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '发送失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
toast.error('发送失败')
|
||||
}
|
||||
finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="邮箱设置"
|
||||
description="配置应用的邮箱验证相关设置"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '邮箱设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>邮箱验证设置</UiCardTitle>
|
||||
<UiCardDescription>配置应用的邮箱验证功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用邮箱验证</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许用户使用邮箱注册和验证
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.enable_email_verify" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>强制邮箱验证</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
注册时必须验证邮箱才能完成注册
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.require_email_verify" :disabled="!form.enable_email_verify" />
|
||||
</div>
|
||||
|
||||
<div v-if="canUsePasswordReset" class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>启用密码重置</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许用户通过邮箱验证码重置密码
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.enable_password_reset" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>SMTP 配置</UiCardTitle>
|
||||
<UiCardDescription>配置邮件发送服务器</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_host">
|
||||
SMTP 服务器
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_host" v-model="form.smtp_host" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_port">
|
||||
端口
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_port" v-model.number="form.smtp_port" type="number" placeholder="465" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_user">
|
||||
用户名
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_user" v-model="form.smtp_user" placeholder="your@email.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_password">
|
||||
密码/授权码
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_password" v-model="form.smtp_password" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_name">
|
||||
发件人名称
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_name" v-model="form.smtp_from_name" placeholder="应用名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="smtp_from_email">
|
||||
发件人邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="smtp_from_email" v-model="form.smtp_from_email" placeholder="noreply@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<UiLabel>使用 SSL</UiLabel>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
推荐开启,端口 465 通常需要 SSL
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model:checked="form.smtp_use_ssl" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4 pt-4 border-t">
|
||||
<div class="flex-1 space-y-2">
|
||||
<UiLabel for="test_email">
|
||||
测试邮箱
|
||||
</UiLabel>
|
||||
<UiInput id="test_email" v-model="testEmail" type="email" placeholder="test@example.com" />
|
||||
</div>
|
||||
<UiButton :disabled="testing" @click="handleTestEmail">
|
||||
<Icon v-if="testing" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送测试邮件
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>发送验证码测试</UiCardTitle>
|
||||
<UiCardDescription>测试验证码发送功能</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_email">
|
||||
邮箱地址
|
||||
</UiLabel>
|
||||
<UiInput id="send_code_email" v-model="sendCodeEmail" type="email" placeholder="user@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="send_code_purpose">
|
||||
用途
|
||||
</UiLabel>
|
||||
<UiSelect v-model="sendCodePurpose">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="选择用途" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="register">
|
||||
注册验证
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="reset_password">
|
||||
重置密码
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="change_email">
|
||||
更换邮箱
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<UiButton :disabled="sendingCode" @click="handleSendVerifyCode">
|
||||
<Icon v-if="sendingCode" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
发送验证码
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="canUseCustomTemplate">
|
||||
<UiCardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<UiCardTitle>邮件模板</UiCardTitle>
|
||||
<UiCardDescription>自定义邮件模板</UiCardDescription>
|
||||
</div>
|
||||
<UiButton size="sm">
|
||||
<Icon icon="lucide:plus" class="mr-2 size-4" />
|
||||
新建模板
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="templates.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:mail" class="size-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暂无自定义模板,将使用系统默认模板</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="flex items-center justify-between p-4 rounded-lg border"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ template.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiBadge v-if="template.is_default" variant="secondary">
|
||||
默认
|
||||
</UiBadge>
|
||||
<UiButton variant="ghost" size="icon">
|
||||
<Icon icon="lucide:pencil" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 size-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,557 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
interface AppData {
|
||||
id: number
|
||||
encrypt_type: string
|
||||
secret_key: string
|
||||
bind_type: string
|
||||
max_devices: number
|
||||
change_limit: number
|
||||
change_interval: number
|
||||
change_exceed_action: string
|
||||
change_deduct_amount: number
|
||||
multi_open_mode: string
|
||||
max_instances: number
|
||||
heartbeat_interval: number
|
||||
heartbeat_timeout: number
|
||||
max_attempts: number
|
||||
lock_duration: number
|
||||
}
|
||||
|
||||
const app = ref<AppData | null>(null)
|
||||
|
||||
const form = ref({
|
||||
encrypt_type: 'none',
|
||||
secret_key: '',
|
||||
bind_type: 'none',
|
||||
max_devices: 1,
|
||||
change_limit: 3,
|
||||
change_interval: 7,
|
||||
change_exceed_action: 'deny',
|
||||
change_deduct_amount: 1,
|
||||
multi_open_mode: 'forbidden',
|
||||
max_instances: 1,
|
||||
heartbeat_interval: 60,
|
||||
heartbeat_timeout: 300,
|
||||
max_attempts: 5,
|
||||
lock_duration: 30,
|
||||
})
|
||||
|
||||
async function fetchApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const appId = route.params.id
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
const appData = data.data.application || data.data
|
||||
app.value = appData
|
||||
form.value = {
|
||||
encrypt_type: appData.encrypt_type || 'none',
|
||||
secret_key: appData.secret_key || '',
|
||||
bind_type: appData.bind_type || 'none',
|
||||
max_devices: appData.max_devices || 1,
|
||||
change_limit: appData.change_limit || 3,
|
||||
change_interval: appData.change_interval || 7,
|
||||
change_exceed_action: appData.change_exceed_action || 'deny',
|
||||
change_deduct_amount: appData.change_deduct_amount || 1,
|
||||
multi_open_mode: appData.multi_open_mode || 'forbidden',
|
||||
max_instances: appData.max_instances || 1,
|
||||
heartbeat_interval: appData.heartbeat_interval || 60,
|
||||
heartbeat_timeout: appData.heartbeat_timeout || 300,
|
||||
max_attempts: appData.max_attempts || 5,
|
||||
lock_duration: appData.lock_duration || 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用详情失败:', error)
|
||||
toast.error('获取应用详情失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
form.value.secret_key = result
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchApp()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApp()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="安全设置"
|
||||
description="配置应用的安全相关设置,包括加密方式和登录策略"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '安全设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>加密设置</UiCardTitle>
|
||||
<UiCardDescription>配置数据传输加密方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>加密方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.encrypt_type" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'none'"
|
||||
>
|
||||
<UiRadioGroupItem value="none" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
不加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
数据明文传输
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'aes' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'aes'"
|
||||
>
|
||||
<UiRadioGroupItem value="aes" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
AES 加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
对称加密,速度快
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.encrypt_type === 'rsa' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.encrypt_type = 'rsa'"
|
||||
>
|
||||
<UiRadioGroupItem value="rsa" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
RSA 加密
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
非对称加密,更安全
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.encrypt_type !== 'none'" class="space-y-2 pt-4 border-t">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel for="secret_key">
|
||||
加密密钥
|
||||
</UiLabel>
|
||||
<UiButton variant="outline" size="sm" @click="generateSecretKey">
|
||||
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
|
||||
自动生成
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiInput id="secret_key" v-model="form.secret_key" placeholder="请输入加密密钥" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
请妥善保管密钥,丢失后无法恢复
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>绑定策略</UiCardTitle>
|
||||
<UiCardDescription>配置应用的绑定策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>绑定模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.bind_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'none' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'none'"
|
||||
>
|
||||
<UiRadioGroupItem value="none" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
不绑定
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
不限制设备或IP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'device' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'device'"
|
||||
>
|
||||
<UiRadioGroupItem value="device" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
绑定设备
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
绑定用户设备硬件信息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'ip' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'ip'"
|
||||
>
|
||||
<UiRadioGroupItem value="ip" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
绑定IP
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
绑定用户IP地址
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.bind_type === 'mixed' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.bind_type = 'mixed'"
|
||||
>
|
||||
<UiRadioGroupItem value="mixed" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
混合绑定
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
同时绑定设备和IP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.bind_type !== 'none'" class="space-y-4 pt-4 border-t">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="max_devices">
|
||||
最大设备数
|
||||
</UiLabel>
|
||||
<UiInput id="max_devices" v-model.number="form.max_devices" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户最多可绑定的设备数量
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="change_limit">
|
||||
更换次数限制
|
||||
</UiLabel>
|
||||
<UiInput id="change_limit" v-model.number="form.change_limit" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
允许更换绑定的次数
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="change_interval">
|
||||
更换间隔(天)
|
||||
</UiLabel>
|
||||
<UiInput id="change_interval" v-model.number="form.change_interval" type="number" min="0" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
两次更换之间的最小间隔
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 pt-4 border-t">
|
||||
<UiLabel>超出更换次数处理</UiLabel>
|
||||
<UiRadioGroup v-model="form.change_exceed_action" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deny' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deny'"
|
||||
>
|
||||
<UiRadioGroupItem value="deny" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
拒绝更换
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后禁止更换绑定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deduct_time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deduct_time'"
|
||||
>
|
||||
<UiRadioGroupItem value="deduct_time" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
扣除时长
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后扣除使用时长
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.change_exceed_action === 'deduct_points' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.change_exceed_action = 'deduct_points'"
|
||||
>
|
||||
<UiRadioGroupItem value="deduct_points" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
扣除点数
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
超出次数后扣除点数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="form.change_exceed_action !== 'deny'" class="space-y-2 pt-2">
|
||||
<UiLabel for="change_deduct_amount">
|
||||
{{ form.change_exceed_action === 'deduct_time' ? '扣除时长(天)' : '扣除点数' }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="change_deduct_amount"
|
||||
v-model.number="form.change_deduct_amount"
|
||||
type="number"
|
||||
min="1"
|
||||
class="max-w-xs"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ form.change_exceed_action === 'deduct_time' ? '每次超出更换将扣除的时长' : '每次超出更换将扣除的点数' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>多开控制</UiCardTitle>
|
||||
<UiCardDescription>配置应用的多开限制</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>多开模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.multi_open_mode" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'forbidden' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'forbidden'"
|
||||
>
|
||||
<UiRadioGroupItem value="forbidden" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
禁止多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
只允许运行一个实例
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'limited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'limited'"
|
||||
>
|
||||
<UiRadioGroupItem value="limited" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
有限多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
限制同时运行的实例数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.multi_open_mode === 'unlimited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.multi_open_mode = 'unlimited'"
|
||||
>
|
||||
<UiRadioGroupItem value="unlimited" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
无限多开
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
不限制实例数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'limited'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel for="max_instances">
|
||||
最大实例数
|
||||
</UiLabel>
|
||||
<UiInput id="max_instances" v-model.number="form.max_instances" type="number" min="1" class="max-w-xs" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
同一账号最多可同时运行的实例数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'unlimited'" class="p-4 rounded-lg bg-muted mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许多开模式将不限制应用实例数量,请谨慎使用此选项。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.multi_open_mode === 'forbidden'" class="p-4 rounded-lg bg-muted mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
禁止多开模式将只允许应用运行一个实例,尝试启动第二个实例将被阻止。
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>心跳设置</UiCardTitle>
|
||||
<UiCardDescription>配置客户端心跳检测参数</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="heartbeat_interval">
|
||||
心跳间隔(秒)
|
||||
</UiLabel>
|
||||
<UiInput id="heartbeat_interval" v-model.number="form.heartbeat_interval" type="number" min="10" max="300" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
客户端发送心跳的间隔时间
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="heartbeat_timeout">
|
||||
心跳超时(秒)
|
||||
</UiLabel>
|
||||
<UiInput id="heartbeat_timeout" v-model.number="form.heartbeat_timeout" type="number" min="30" max="600" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
超过此时间未收到心跳则判定离线
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>安全防护</UiCardTitle>
|
||||
<UiCardDescription>配置登录安全策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="max_attempts">
|
||||
最大尝试次数
|
||||
</UiLabel>
|
||||
<UiInput id="max_attempts" v-model.number="form.max_attempts" type="number" min="1" max="10" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
连续登录失败超过此次数将锁定
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="lock_duration">
|
||||
锁定时长(分钟)
|
||||
</UiLabel>
|
||||
<UiInput id="lock_duration" v-model.number="form.lock_duration" type="number" min="1" max="1440" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
账号被锁定后的解锁时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,881 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
interface AppData {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon_url: string
|
||||
billing_type: string
|
||||
login_policy: string
|
||||
allow_register: boolean
|
||||
register_methods: string
|
||||
enable_trial: boolean
|
||||
trial_balance: number
|
||||
enable_free_period: boolean
|
||||
free_period_type: string
|
||||
free_period_start: string
|
||||
free_period_end: string
|
||||
free_period_weekdays: string
|
||||
free_period_start_time: string
|
||||
free_period_end_time: string
|
||||
status: string
|
||||
deduction_cycle: string
|
||||
deduction_amount: number
|
||||
}
|
||||
|
||||
const app = ref<AppData | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
icon_url: '',
|
||||
iconFile: null as File | null,
|
||||
iconPreview: '',
|
||||
billing_type: 'free',
|
||||
login_policy: 'loose',
|
||||
allow_register: true,
|
||||
register_methods: [] as string[],
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
trial_days: 0,
|
||||
enable_free_period: false,
|
||||
free_period_type: 'range',
|
||||
free_period_start: '',
|
||||
free_period_end: '',
|
||||
free_period_weekdays: [] as number[],
|
||||
free_period_start_time: '',
|
||||
free_period_end_time: '',
|
||||
status: 'active',
|
||||
deduction_mode: 'auto',
|
||||
deduction_type: 'login',
|
||||
deduction_interval: 1,
|
||||
deduction_unit: 'minute',
|
||||
deduction_amount: 1,
|
||||
})
|
||||
|
||||
function parseWeekdays(str: string): number[] {
|
||||
if (!str)
|
||||
return []
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function weekdaysToString(arr: number[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function parseRegisterMethods(str: string): string[] {
|
||||
if (!str)
|
||||
return ['username']
|
||||
try {
|
||||
const parsed = JSON.parse(str)
|
||||
return Array.isArray(parsed) ? parsed : ['username']
|
||||
}
|
||||
catch {
|
||||
return ['username']
|
||||
}
|
||||
}
|
||||
|
||||
function registerMethodsToString(arr: string[]): string {
|
||||
return JSON.stringify(arr)
|
||||
}
|
||||
|
||||
function getIconUrl(url: string): string {
|
||||
if (!url)
|
||||
return ''
|
||||
if (url.startsWith('http'))
|
||||
return url
|
||||
return `${API_BASE.replace('/api/v1', '')}${url}`
|
||||
}
|
||||
|
||||
function handleIconUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
form.value.iconFile = file
|
||||
form.value.iconPreview = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
form.value.iconFile = null
|
||||
form.value.iconPreview = ''
|
||||
form.value.icon_url = ''
|
||||
}
|
||||
|
||||
async function fetchApp() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const appId = route.params.id
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${appId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
const appData = data.data.application || data.data
|
||||
app.value = appData
|
||||
form.value = {
|
||||
name: appData.name || '',
|
||||
description: appData.description || '',
|
||||
icon_url: appData.icon_url || '',
|
||||
iconFile: null,
|
||||
iconPreview: getIconUrl(appData.icon_url),
|
||||
billing_type: appData.billing_type || 'free',
|
||||
login_policy: appData.login_policy || 'loose',
|
||||
allow_register: appData.allow_register ?? true,
|
||||
register_methods: parseRegisterMethods(appData.register_methods),
|
||||
enable_trial: appData.enable_trial || false,
|
||||
trial_balance: appData.trial_balance || 0,
|
||||
trial_days: appData.trial_days || 0,
|
||||
enable_free_period: appData.enable_free_period || false,
|
||||
free_period_type: appData.free_period_type || 'range',
|
||||
free_period_start: appData.free_period_start || '',
|
||||
free_period_end: appData.free_period_end || '',
|
||||
free_period_weekdays: parseWeekdays(appData.free_period_weekdays),
|
||||
free_period_start_time: appData.free_period_start_time || '',
|
||||
free_period_end_time: appData.free_period_end_time || '',
|
||||
status: appData.status || 'active',
|
||||
deduction_mode: appData.deduction_mode || 'auto',
|
||||
deduction_type: appData.deduction_type || 'login',
|
||||
deduction_interval: appData.deduction_interval || 1,
|
||||
deduction_unit: appData.deduction_unit || 'minute',
|
||||
deduction_amount: appData.deduction_amount || 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用详情失败:', error)
|
||||
toast.error('获取应用详情失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const formDataToSend = new FormData()
|
||||
formDataToSend.append('name', form.value.name)
|
||||
formDataToSend.append('description', form.value.description)
|
||||
formDataToSend.append('billing_type', form.value.billing_type)
|
||||
formDataToSend.append('login_policy', form.value.login_policy)
|
||||
formDataToSend.append('allow_register', form.value.allow_register.toString())
|
||||
formDataToSend.append('register_methods', registerMethodsToString(form.value.register_methods))
|
||||
formDataToSend.append('enable_trial', form.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', form.value.trial_balance.toString())
|
||||
formDataToSend.append('trial_days', form.value.trial_days.toString())
|
||||
formDataToSend.append('enable_free_period', form.value.enable_free_period.toString())
|
||||
formDataToSend.append('free_period_type', form.value.free_period_type)
|
||||
formDataToSend.append('free_period_start', form.value.free_period_start)
|
||||
formDataToSend.append('free_period_end', form.value.free_period_end)
|
||||
formDataToSend.append('free_period_weekdays', weekdaysToString(form.value.free_period_weekdays))
|
||||
formDataToSend.append('free_period_start_time', form.value.free_period_start_time)
|
||||
formDataToSend.append('free_period_end_time', form.value.free_period_end_time)
|
||||
formDataToSend.append('status', form.value.status)
|
||||
formDataToSend.append('deduction_mode', form.value.deduction_mode)
|
||||
formDataToSend.append('deduction_type', form.value.deduction_type)
|
||||
formDataToSend.append('deduction_interval', form.value.deduction_interval.toString())
|
||||
formDataToSend.append('deduction_unit', form.value.deduction_unit)
|
||||
formDataToSend.append('deduction_amount', form.value.deduction_amount.toString())
|
||||
|
||||
if (form.value.iconFile) {
|
||||
formDataToSend.append('icon', form.value.iconFile)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/dev/applications/${route.params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formDataToSend,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success('保存成功')
|
||||
fetchApp()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApp()
|
||||
})
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
const idx = form.value.free_period_weekdays.indexOf(index)
|
||||
if (idx === -1) {
|
||||
form.value.free_period_weekdays.push(index)
|
||||
form.value.free_period_weekdays.sort()
|
||||
}
|
||||
else {
|
||||
form.value.free_period_weekdays.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="基本设置"
|
||||
description="修改应用的基本信息,包括名称、描述和图标等"
|
||||
:breadcrumbs="[
|
||||
{ title: '应用管理', href: '/developer/applications' },
|
||||
{ title: '基本设置' },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>基本信息</UiCardTitle>
|
||||
<UiCardDescription>修改应用的基本信息</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
应用名称
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="form.name" placeholder="请输入应用名称" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>运营状态</UiLabel>
|
||||
<UiRadioGroup v-model="form.status" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'active' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'active'"
|
||||
>
|
||||
<UiRadioGroupItem value="active" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
正常
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用正常运行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'maintenance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'maintenance'"
|
||||
>
|
||||
<UiRadioGroupItem value="maintenance" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
维护
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用维护中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.status === 'stopped' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.status = 'stopped'"
|
||||
>
|
||||
<UiRadioGroupItem value="stopped" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
停止
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
应用已停止
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>应用图标</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<div v-if="form.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="form.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-16 h-16 rounded-lg object-cover border"
|
||||
>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-16 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">{{ form.iconPreview ? '更换图标' : '上传图标' }}</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
支持 JPG、PNG、GIF 格式,建议尺寸 512x512
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
应用描述
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="form.description" placeholder="请输入应用描述" rows="3" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>运营模式</UiCardTitle>
|
||||
<UiCardDescription>选择应用的计费方式</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'free'"
|
||||
>
|
||||
<UiRadioGroupItem value="free" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
免费模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户无需付费即可使用所有功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
订阅模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户按周期付费订阅使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.billing_type === 'balance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.billing_type = 'balance'"
|
||||
>
|
||||
<UiRadioGroupItem value="balance" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
余额模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户按使用量计费,从余额扣除
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="form.billing_type === 'balance'" class="space-y-4 pt-4 border-t mt-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>扣费方式</UiLabel>
|
||||
<UiRadioGroup v-model="form.deduction_mode" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_mode === 'auto' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_mode = 'auto'"
|
||||
>
|
||||
<UiRadioGroupItem value="auto" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
⚡ 自动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
系统自动触发扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_mode === 'manual' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_mode = 'manual'"
|
||||
>
|
||||
<UiRadioGroupItem value="manual" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
🔧 手动扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
通过 API 自行控制扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.deduction_mode === 'auto'" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>自动扣费类型</UiLabel>
|
||||
<UiRadioGroup v-model="form.deduction_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_type === 'login' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_type = 'login'"
|
||||
>
|
||||
<UiRadioGroupItem value="login" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
登录扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
每次登录验证时扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.deduction_type === 'timer' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.deduction_type = 'timer'"
|
||||
>
|
||||
<UiRadioGroupItem value="timer" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
计时扣费
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
按设定的时间间隔自动扣费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.deduction_type === 'timer'" class="space-y-2">
|
||||
<UiLabel>扣费间隔</UiLabel>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground">每</span>
|
||||
<UiNumberField
|
||||
v-model="form.deduction_interval"
|
||||
:min="1"
|
||||
:max="9999"
|
||||
:step="1"
|
||||
class="w-24"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<UiSelect v-model="form.deduction_unit">
|
||||
<UiSelectTrigger class="w-24">
|
||||
<UiSelectValue placeholder="单位" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="minute">
|
||||
分钟
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="hour">
|
||||
小时
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="day">
|
||||
天
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<span class="text-sm text-muted-foreground">扣费一次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>每次扣费金额</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.deduction_amount"
|
||||
:min="0.01"
|
||||
:max="10000"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每次扣费时扣除的余额数量
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4 rounded-lg bg-muted/50 border">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon="lucide:info" class="size-5 text-blue-500 mt-0.5" />
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium text-sm">
|
||||
手动扣费说明
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择手动扣费后,系统不会自动扣除用户余额。您需要通过 API 接口自行控制扣费时机和金额。
|
||||
</p>
|
||||
<div class="mt-2 p-2 rounded bg-background border text-xs font-mono">
|
||||
POST /api/v1/dev/applications/:id/deduct
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="form.billing_type === 'subscription'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>登录策略</UiCardTitle>
|
||||
<UiCardDescription>设置用户登录验证策略</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.login_policy" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.login_policy = 'loose'"
|
||||
>
|
||||
<UiRadioGroupItem value="loose" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
宽松模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户到期后仍可登录,由客户端控制功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.login_policy = 'strict'"
|
||||
>
|
||||
<UiRadioGroupItem value="strict" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
严格模式
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
用户到期后无法登录,必须续费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>注册设置</UiCardTitle>
|
||||
<UiCardDescription>配置用户注册方式和权限</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
允许注册
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
是否允许新用户注册账号
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.allow_register" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.allow_register" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>注册方式</UiLabel>
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
选择允许用户使用的注册方式
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-username"
|
||||
:checked="form.register_methods.includes('username')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('username')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'username')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-username" class="font-normal cursor-pointer">
|
||||
用户名注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-email"
|
||||
:checked="form.register_methods.includes('email')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('email')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'email')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-email" class="font-normal cursor-pointer">
|
||||
邮箱注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UiCheckbox
|
||||
id="register-phone"
|
||||
:checked="form.register_methods.includes('phone')"
|
||||
@update:checked="(checked: boolean) => {
|
||||
if (checked) {
|
||||
form.register_methods.push('phone')
|
||||
}
|
||||
else {
|
||||
form.register_methods = form.register_methods.filter(m => m !== 'phone')
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<UiLabel for="register-phone" class="font-normal cursor-pointer">
|
||||
手机号注册
|
||||
</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
至少选择一种注册方式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="form.billing_type !== 'free'">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>试用功能</UiCardTitle>
|
||||
<UiCardDescription>为新用户提供试用体验</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
启用试用
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
新用户注册后自动获得试用资格
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_trial" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_trial" class="space-y-2 pt-4 border-t">
|
||||
<template v-if="form.billing_type === 'balance'">
|
||||
<UiLabel>试用余额</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.trial_balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后自动获得的余额数量
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.billing_type === 'subscription'">
|
||||
<UiLabel>试用天数</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="form.trial_days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
:step="1"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后自动获得的试用天数
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>免费时段</UiCardTitle>
|
||||
<UiCardDescription>在指定时间段内所有用户免费使用</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
启用免费时段
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
在设定的时间段内,所有用户可免费使用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enable_free_period" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.enable_free_period" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>免费时段类型</UiLabel>
|
||||
<UiRadioGroup v-model="form.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.free_period_type = 'range'"
|
||||
>
|
||||
<UiRadioGroupItem value="range" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
日期范围
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
指定开始和结束日期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.free_period_type = 'weekly'"
|
||||
>
|
||||
<UiRadioGroupItem value="weekly" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
每周重复
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每周指定日期和时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>开始时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_start" type="datetime-local" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>结束时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_end" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.free_period_type === 'weekly'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>选择日期</UiLabel>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UiButton
|
||||
v-for="(day, index) in ['日', '一', '二', '三', '四', '五', '六']"
|
||||
:key="index"
|
||||
:variant="form.free_period_weekdays.includes(index) ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="toggleWeekday(index)"
|
||||
>
|
||||
周{{ day }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>开始时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_start_time" type="time" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>结束时间</UiLabel>
|
||||
<UiInput v-model="form.free_period_end_time" type="time" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiButton :disabled="saving" @click="handleSave">
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
保存设置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { App } from '../data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function getColumns(actions: {
|
||||
onGoToSettings: (app: App) => void
|
||||
onGoToSecurity: (app: App) => void
|
||||
onGoToEmail: (app: App) => void
|
||||
onToggleStatus: (app: App) => void
|
||||
onDelete: (app: App) => void
|
||||
}, t: Composer['t']): ColumnDef<App>[] {
|
||||
const billingTypeMap: Record<string, { label: string, class: string }> = {
|
||||
free: { label: '免费', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
subscription: { label: '订阅', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
time: { label: '计时', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||
count: { label: '计次', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
|
||||
}
|
||||
|
||||
const loginPolicyMap: Record<string, { label: string, class: string }> = {
|
||||
loose: { label: '宽松', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
strict: { label: '严格', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
hybrid: { label: '混合', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { label: string, class: string }> = {
|
||||
active: { label: '启用', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
inactive: { label: '禁用', class: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => '应用名称',
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center space-x-3' }, [
|
||||
h('div', { class: 'h-8 w-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0 overflow-hidden' }, [
|
||||
row.original.icon_url
|
||||
? h('img', {
|
||||
src: `http://localhost:8080${row.original.icon_url}`,
|
||||
alt: row.getValue('name') as string,
|
||||
class: 'w-full h-full object-cover',
|
||||
})
|
||||
: h('span', { class: 'text-primary font-bold text-sm' }, (row.getValue('name') as string).charAt(0).toUpperCase()),
|
||||
]),
|
||||
h('div', { class: 'flex flex-col' }, [
|
||||
h('p', { class: 'font-medium' }, row.getValue('name') as string),
|
||||
h('p', { class: 'text-xs text-muted-foreground truncate max-w-[200px]' }, row.original.description),
|
||||
]),
|
||||
]),
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'app_key',
|
||||
header: () => 'App Key',
|
||||
cell: ({ row }) => {
|
||||
const appKey = row.getValue('app_key') as string
|
||||
return h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, appKey || '-'),
|
||||
appKey && h(Copy, { class: 'h-4 w-4', size: 'sm', content: appKey }),
|
||||
])
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'billing_type',
|
||||
header: () => '运营模式',
|
||||
cell: ({ row }) => {
|
||||
const billingType = row.getValue('billing_type') as string
|
||||
const info = billingTypeMap[billingType]
|
||||
if (!info)
|
||||
return h('span', {}, billingType)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'login_policy',
|
||||
header: () => '登录策略',
|
||||
cell: ({ row }) => {
|
||||
const loginPolicy = row.getValue('login_policy') as string
|
||||
const info = loginPolicyMap[loginPolicy]
|
||||
if (!info)
|
||||
return h('span', {}, loginPolicy)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'enable_trial',
|
||||
header: () => '试用',
|
||||
cell: ({ row }) => {
|
||||
const enableTrial = row.getValue('enable_trial') as boolean
|
||||
return h('span', {
|
||||
class: `px-2 py-1 rounded text-xs ${enableTrial ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}`,
|
||||
}, enableTrial ? `${row.original.trial_balance}余额` : '无')
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => '状态',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const info = statusMap[status]
|
||||
if (!info)
|
||||
return h('span', {}, status)
|
||||
return h('span', { class: `px-2 py-1 rounded text-xs ${info.class}` }, info.label)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
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.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => h('span', { class: 'sr-only' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const app = row.original
|
||||
const isActive = app.status === 'active'
|
||||
|
||||
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.onGoToSettings(app) }, () => [
|
||||
h(Settings, { class: 'mr-2 h-4 w-4' }),
|
||||
'基本设置',
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToSecurity(app) }, () => [
|
||||
h(Shield, { class: 'mr-2 h-4 w-4' }),
|
||||
'安全设置',
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onGoToEmail(app) }, () => [
|
||||
h(Mail, { class: 'mr-2 h-4 w-4' }),
|
||||
'邮箱设置',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(app) }, () => [
|
||||
isActive ? h(Ban, { class: 'mr-2 h-4 w-4' }) : h(CheckCircle, { class: 'mr-2 h-4 w-4' }),
|
||||
isActive ? '禁用' : '启用',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(app) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.applications.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { App } from '@/pages/admin/applications/data/schema'
|
||||
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/applications/components/columns'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<App>, 'columns'> & {
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'goToSettings': [app: App]
|
||||
'goToSecurity': [app: App]
|
||||
'goToEmail': [app: App]
|
||||
'toggleStatus': [app: App]
|
||||
'delete': [app: App]
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<App>,
|
||||
...getColumns({
|
||||
onGoToSettings: (app: App) => emit('goToSettings', app),
|
||||
onGoToSecurity: (app: App) => emit('goToSecurity', app),
|
||||
onGoToEmail: (app: App) => emit('goToEmail', app),
|
||||
onToggleStatus: (app: App) => emit('toggleStatus', app),
|
||||
onDelete: (app: App) => emit('delete', app),
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<App>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.applications.select',
|
||||
name: 'developer.applications.columns.name',
|
||||
app_key: 'developer.applications.columns.appKey',
|
||||
billing_type: 'developer.applications.columns.billingType',
|
||||
login_policy: 'developer.applications.columns.loginPolicy',
|
||||
enable_trial: 'developer.applications.columns.trial',
|
||||
status: 'developer.applications.columns.status',
|
||||
created_at: 'developer.applications.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UiInput
|
||||
:model-value="searchFilter"
|
||||
:placeholder="t('developer.applications.searchPlaceholder')"
|
||||
class="h-9 w-40 lg:w-[250px]"
|
||||
@update:model-value="emit('update:searchFilter', String($event))"
|
||||
/>
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
billing_type: 'free' as 'free' | 'subscription' | 'time' | 'count',
|
||||
login_policy: 'loose' as 'loose' | 'strict' | 'hybrid',
|
||||
deduction_cycle: 'per_use' as 'per_use' | 'per_minute' | 'per_hour' | 'per_day',
|
||||
deduction_amount: 1,
|
||||
iconFile: null as File | null,
|
||||
iconPreview: '',
|
||||
encrypt_type: 'none' as 'none' | 'aes' | 'rsa' | 'rc4',
|
||||
secret_key: '',
|
||||
bind_type: 'device' as 'device' | 'account' | 'none',
|
||||
max_devices: 1,
|
||||
multi_open: false,
|
||||
enable_trial: false,
|
||||
trial_balance: 0,
|
||||
enable_free_period: false,
|
||||
free_period_type: 'range' as 'range' | 'weekly',
|
||||
free_period_start: '',
|
||||
free_period_end: '',
|
||||
free_period_weekdays: [] as number[],
|
||||
free_period_start_time: '',
|
||||
free_period_end_time: '',
|
||||
})
|
||||
|
||||
function handleIconUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
formData.value.iconFile = file
|
||||
formData.value.iconPreview = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
formData.value.iconFile = null
|
||||
formData.value.iconPreview = ''
|
||||
}
|
||||
|
||||
function generateSecretKey() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
formData.value.secret_key = result
|
||||
}
|
||||
|
||||
function toggleWeekday(index: number) {
|
||||
const idx = formData.value.free_period_weekdays.indexOf(index)
|
||||
if (idx === -1) {
|
||||
formData.value.free_period_weekdays.push(index)
|
||||
formData.value.free_period_weekdays.sort()
|
||||
}
|
||||
else {
|
||||
formData.value.free_period_weekdays.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.error(t('developer.applications.appNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const formDataToSend = new FormData()
|
||||
formDataToSend.append('name', formData.value.name)
|
||||
formDataToSend.append('description', formData.value.description)
|
||||
formDataToSend.append('billing_type', formData.value.billing_type)
|
||||
formDataToSend.append('login_policy', formData.value.login_policy)
|
||||
formDataToSend.append('encrypt_type', formData.value.encrypt_type)
|
||||
formDataToSend.append('secret_key', formData.value.secret_key)
|
||||
formDataToSend.append('bind_type', formData.value.bind_type)
|
||||
formDataToSend.append('max_devices', formData.value.max_devices.toString())
|
||||
formDataToSend.append('multi_open', formData.value.multi_open.toString())
|
||||
formDataToSend.append('enable_trial', formData.value.enable_trial.toString())
|
||||
formDataToSend.append('trial_balance', formData.value.trial_balance.toString())
|
||||
formDataToSend.append('enable_free_period', formData.value.enable_free_period.toString())
|
||||
formDataToSend.append('free_period_type', formData.value.free_period_type)
|
||||
formDataToSend.append('free_period_start', formData.value.free_period_start)
|
||||
formDataToSend.append('free_period_end', formData.value.free_period_end)
|
||||
formDataToSend.append('free_period_weekdays', JSON.stringify(formData.value.free_period_weekdays))
|
||||
formDataToSend.append('free_period_start_time', formData.value.free_period_start_time)
|
||||
formDataToSend.append('free_period_end_time', formData.value.free_period_end_time)
|
||||
|
||||
if (formData.value.billing_type !== 'free') {
|
||||
formDataToSend.append('deduction_cycle', formData.value.deduction_cycle)
|
||||
formDataToSend.append('deduction_amount', formData.value.deduction_amount.toString())
|
||||
}
|
||||
|
||||
if (formData.value.iconFile) {
|
||||
formDataToSend.append('icon', formData.value.iconFile)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formDataToSend,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('developer.applications.createSuccess'))
|
||||
router.push('/developer/applications')
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('developer.applications.createFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('创建应用失败:', error)
|
||||
toast.error(t('developer.applications.createFailed'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.applications.createApp')"
|
||||
:description="t('developer.applications.createAppDesc')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.applications.title'), href: '/developer/applications' },
|
||||
{ title: t('developer.applications.createApp') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.basicInfo') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.applications.appName') }} *
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="formData.name" :placeholder="t('developer.applications.appNamePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.appIcon') }}</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<div v-if="formData.iconPreview" class="relative group">
|
||||
<img
|
||||
:src="formData.iconPreview"
|
||||
alt="应用图标"
|
||||
class="w-12 h-12 rounded-lg object-cover border"
|
||||
>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="clearIcon"
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-3 w-3" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<label
|
||||
for="appIcon"
|
||||
class="flex items-center justify-center h-12 px-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">{{ formData.iconPreview ? t('developer.applications.change') : t('developer.applications.upload') }}</span>
|
||||
<input
|
||||
id="appIcon"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleIconUpload"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.applications.appDescription') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="formData.description" :placeholder="t('developer.applications.appDescriptionPlaceholder')" rows="3" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.operationMode') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.operationModeDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.billing_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'free' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'free'"
|
||||
>
|
||||
<UiRadioGroupItem value="free" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.freeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.freeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.subscriptionMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.subscriptionModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'time' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'time'"
|
||||
>
|
||||
<UiRadioGroupItem value="time" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.timeMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.timeModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.billing_type === 'count' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.billing_type = 'count'"
|
||||
>
|
||||
<UiRadioGroupItem value="count" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.countMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.countModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
|
||||
<div v-if="formData.billing_type !== 'free'" class="mt-4 pt-4 border-t space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.deductionCycle') }}</UiLabel>
|
||||
<UiSelect v-model="formData.deduction_cycle">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.applications.selectDeductionCycle')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="per_use">
|
||||
{{ t('developer.applications.perUse') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_minute">
|
||||
{{ t('developer.applications.perMinute') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_hour">
|
||||
{{ t('developer.applications.perHour') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="per_day">
|
||||
{{ t('developer.applications.perDay') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.deductionAmount') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.deduction_amount" :min="0" :step="0.01">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.loginPolicy') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.loginPolicyDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="formData.login_policy" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'loose' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'loose'"
|
||||
>
|
||||
<UiRadioGroupItem value="loose" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.looseMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.looseModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'strict' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'strict'"
|
||||
>
|
||||
<UiRadioGroupItem value="strict" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.strictMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.strictModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.login_policy === 'hybrid' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.login_policy = 'hybrid'"
|
||||
>
|
||||
<UiRadioGroupItem value="hybrid" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.hybridMode') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('developer.applications.hybridModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.trialSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.trialSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.enableTrial') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
新用户注册后自动获得试用权益
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_trial" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_trial" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.trialBalance') }}</UiLabel>
|
||||
<UiNumberField
|
||||
v-model="formData.trial_balance"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
class="max-w-xs"
|
||||
>
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
新用户注册后获得的余额
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.freePeriodSettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.freePeriodSettings') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.enableFreePeriod') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
在设定的时间段内,所有用户可免费使用
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.enable_free_period" />
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enable_free_period" class="space-y-4 pt-4 border-t">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.periodType') }}</UiLabel>
|
||||
<UiRadioGroup v-model="formData.free_period_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'range' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'range'"
|
||||
>
|
||||
<UiRadioGroupItem value="range" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.dateRange') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
指定开始和结束日期时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="formData.free_period_type === 'weekly' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="formData.free_period_type = 'weekly'"
|
||||
>
|
||||
<UiRadioGroupItem value="weekly" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.weekly') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
每周固定日期和时间段
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'range'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_start" :placeholder="t('developer.applications.startTime')" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerDateTimePicker v-model="formData.free_period_end" :placeholder="t('developer.applications.endTime')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.free_period_type === 'weekly'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.weekdays') }}</UiLabel>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="(day, index) in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="index">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="formData.free_period_weekdays.includes(index) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||
@click="toggleWeekday(index)"
|
||||
>
|
||||
{{ day }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
选择每周哪些日期免费
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.startTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_start_time" :placeholder="t('developer.applications.startTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费开始时间
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.endTime') }}</UiLabel>
|
||||
<UiDatePickerTimePicker v-model="formData.free_period_end_time" :placeholder="t('developer.applications.endTime')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每天免费结束时间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.securitySettings') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.applications.securitySettingsDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.encryptionType') }}</UiLabel>
|
||||
<UiSelect v-model="formData.encrypt_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.applications.encryptionType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="none">
|
||||
{{ t('developer.applications.noEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="aes">
|
||||
{{ t('developer.applications.aesEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rsa">
|
||||
{{ t('developer.applications.rsaEncryption') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="rc4">
|
||||
{{ t('developer.applications.rc4Encryption') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="formData.encrypt_type === 'none'">数据明文传输,仅用于测试环境</span>
|
||||
<span v-else-if="formData.encrypt_type === 'aes'">使用AES-GCM算法加密,安全性高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rsa'">非对称加密,安全性最高</span>
|
||||
<span v-else-if="formData.encrypt_type === 'rc4'">使用RC4流加密,性能较好</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.applications.secretKey') }}</UiLabel>
|
||||
<div class="relative">
|
||||
<UiInput
|
||||
v-model="formData.secret_key"
|
||||
type="password"
|
||||
placeholder="留空则自动生成"
|
||||
class="pr-10"
|
||||
/>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
@click="generateSecretKey"
|
||||
>
|
||||
<Icon icon="lucide:refresh-cw" class="h-4 w-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用于加密通信数据的密钥,留空则自动生成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.applications.deviceBind') }}</UiCardTitle>
|
||||
<UiCardDescription>控制用户登录时是否需要绑定设备</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.deviceBind') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
启用后,用户登录时需要绑定设备,可限制同时登录的设备数量
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch
|
||||
:model-value="formData.bind_type === 'device'"
|
||||
@update:model-value="(val: boolean) => formData.bind_type = val ? 'device' : 'none'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.bind_type === 'device'" class="space-y-2 pt-4 border-t">
|
||||
<UiLabel>{{ t('developer.applications.maxDevices') }}</UiLabel>
|
||||
<UiNumberField v-model="formData.max_devices" :min="1" :max="10">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
每个账号最多绑定的设备数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.applications.allowMultiOpen') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
允许同一账号在同一设备上同时运行多个实例
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="formData.multi_open" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<UiButton variant="outline" @click="router.push('/developer/applications')">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="loading" @click="handleCreate">
|
||||
<Icon v-if="loading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t('developer.applications.createApp') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const appStatusSchema = z.enum(['active', 'inactive'])
|
||||
export type AppStatus = z.infer<typeof appStatusSchema>
|
||||
|
||||
export const billingMethodSchema = z.enum(['subscription', 'time', 'count'])
|
||||
export type BillingMethod = z.infer<typeof billingMethodSchema>
|
||||
|
||||
export const operationModeSchema = z.enum(['free', 'paid'])
|
||||
export type OperationMode = z.infer<typeof operationModeSchema>
|
||||
|
||||
export const deductionCycleSchema = z.enum(['per_use', 'per_minute', 'per_hour', 'per_day'])
|
||||
export type DeductionCycle = z.infer<typeof deductionCycleSchema>
|
||||
|
||||
export const loginPolicySchema = z.enum(['loose', 'strict', 'hybrid'])
|
||||
export type LoginPolicy = z.infer<typeof loginPolicySchema>
|
||||
|
||||
export const appSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
app_key: z.string(),
|
||||
billing_type: z.enum(['free', 'subscription', 'time', 'count']),
|
||||
login_policy: loginPolicySchema.default('loose'),
|
||||
encrypt_type: z.enum(['none', 'aes', 'rsa', 'rc4']),
|
||||
secret_key: z.string(),
|
||||
bind_type: z.enum(['device', 'account', 'none']),
|
||||
max_devices: z.number().default(1),
|
||||
change_limit: z.number().optional(),
|
||||
change_interval: z.number().optional(),
|
||||
multi_open: z.boolean().default(false),
|
||||
max_instances: z.number().optional(),
|
||||
enable_trial: z.boolean().default(false),
|
||||
trial_balance: z.number().default(0),
|
||||
enable_free_period: z.boolean().default(false),
|
||||
free_period_start: z.string().optional(),
|
||||
free_period_end: z.string().optional(),
|
||||
deduction_cycle: deductionCycleSchema.optional(),
|
||||
deduction_amount: z.number().optional(),
|
||||
heartbeat_interval: z.number().default(60),
|
||||
heartbeat_timeout: z.number().default(300),
|
||||
max_attempts: z.number().default(5),
|
||||
lock_duration: z.number().default(30),
|
||||
status: appStatusSchema,
|
||||
disabled_by_package: z.boolean().default(false),
|
||||
icon_url: z.string().optional(),
|
||||
users: z.number().optional(),
|
||||
verify_count: z.number().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
})
|
||||
export type App = z.infer<typeof appSchema>
|
||||
|
||||
export const appListSchema = z.array(appSchema)
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { CardType } from '@/pages/developer/card-types/data/schema'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const cardType = ref<CardType | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
application_id: '',
|
||||
price: 0,
|
||||
value: 0,
|
||||
validity_type: 'limited' as 'permanent' | 'limited',
|
||||
})
|
||||
|
||||
async function fetchCardType() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ card_type: CardType }>(`/dev/card-types/${route.params.id}`)
|
||||
cardType.value = data?.card_type || data
|
||||
if (cardType.value) {
|
||||
form.value = {
|
||||
name: cardType.value.name,
|
||||
description: cardType.value.description,
|
||||
application_id: cardType.value.application_id ? String(cardType.value.application_id) : '',
|
||||
price: cardType.value.price,
|
||||
value: cardType.value.value === -1 ? 0 : cardType.value.value,
|
||||
validity_type: cardType.value.value === -1 ? 'permanent' : 'limited',
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('获取卡类信息失败:', error)
|
||||
toast.error(error.message || '获取卡类信息失败')
|
||||
router.push('/developer/card-types')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (form.value.validity_type === 'permanent') {
|
||||
return t('developer.cardTypes.units.permanent')
|
||||
}
|
||||
return form.value.value.toString()
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name) {
|
||||
toast.error(t('developer.cardTypes.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.cardTypes.create.applicationRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.validity_type === 'limited' && form.value.value <= 0) {
|
||||
toast.error(t('developer.cardTypes.create.valueRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const value = form.value.validity_type === 'permanent' ? -1 : form.value.value
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/card-types/${route.params.id}`, {
|
||||
application_id: Number(form.value.application_id),
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
value,
|
||||
price: form.value.price,
|
||||
})
|
||||
toast.success(t('developer.cardTypes.create.saveSuccess'))
|
||||
router.push('/developer/card-types')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('保存卡密类型失败:', error)
|
||||
toast.error(error.message || t('developer.cardTypes.create.saveFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCardType()
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cardTypes.create.editTitle')"
|
||||
:description="t('developer.cardTypes.create.editDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cardTypes.title'), href: '/developer/card-types' },
|
||||
{ title: t('developer.cardTypes.create.editTitle') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:info" class="size-5" />
|
||||
{{ t('developer.cardTypes.create.basicInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cardTypes.create.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.cardTypes.create.name') }} *
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="form.name" :placeholder="t('developer.cardTypes.create.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cardTypes.create.description') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="form.description" :placeholder="t('developer.cardTypes.create.descriptionPlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cardTypes.create.application') }} *
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cardTypes.create.selectApplication')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cardTypes.create.billingConfig') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cardTypes.create.billingConfigDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="price">
|
||||
{{ t('developer.cardTypes.create.price') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiNumberField v-model="form.price" :min="0" :step="0.01" class="max-w-[200px]">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">{{ t('developer.cardTypes.create.priceUnit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.cardTypes.create.validityType') }}</UiLabel>
|
||||
<UiRadioGroup v-model="form.validity_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.validity_type === 'limited' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.validity_type = 'limited'"
|
||||
>
|
||||
<UiRadioGroupItem value="limited" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cardTypes.create.limited') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
充值指定金额余额
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.validity_type === 'permanent' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.validity_type = 'permanent'"
|
||||
>
|
||||
<UiRadioGroupItem value="permanent" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cardTypes.create.permanent') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
永久会员,余额无限
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.validity_type === 'limited'" class="space-y-2">
|
||||
<UiLabel for="value">
|
||||
充值金额
|
||||
</UiLabel>
|
||||
<UiNumberField v-model="form.value" :min="0.01" :step="0.01" class="max-w-[200px]">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cardTypes.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewName') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewApplication') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewPrice') }}</span>
|
||||
<span>¥{{ form.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewValue') }}</span>
|
||||
<span>{{ displayValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:save" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cardTypes.create.save') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cardTypes.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { CardType } from '@/pages/developer/card-types/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 getColumns(actions: {
|
||||
onEdit: (row: CardType) => void
|
||||
onToggleStatus: (row: CardType) => void
|
||||
onDelete: (row: CardType) => void
|
||||
}, t: Composer['t']): ColumnDef<CardType>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => t('developer.cardTypes.columns.name'),
|
||||
cell: ({ row }) => {
|
||||
return row.getValue('name') as string
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: () => t('developer.cardTypes.columns.description'),
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
return description || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application.name',
|
||||
header: () => t('developer.cardTypes.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.original.application?.name
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'recharge_type',
|
||||
header: () => '充值类型',
|
||||
cell: ({ row }) => {
|
||||
const rechargeType = row.original.recharge_type
|
||||
const isBalance = rechargeType === 'balance'
|
||||
return h(Badge, { variant: isBalance ? 'outline' : 'default' }, () => isBalance ? '余额充值' : '订阅充值')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: () => t('developer.cardTypes.columns.price'),
|
||||
cell: ({ row }) => {
|
||||
const price = row.getValue('price') as number
|
||||
return `¥${price}`
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: () => t('developer.cardTypes.columns.value'),
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue('value') as number
|
||||
const rechargeType = row.original.recharge_type
|
||||
const valueUnit = row.original.value_unit
|
||||
|
||||
if (value === -1) {
|
||||
return h(Badge, { variant: 'default' }, () => t('developer.cardTypes.units.permanent'))
|
||||
}
|
||||
|
||||
if (rechargeType === 'subscription' && valueUnit) {
|
||||
const unitMap: Record<string, string> = {
|
||||
minute: '分钟',
|
||||
hour: '小时',
|
||||
day: '天',
|
||||
month: '月',
|
||||
year: '年',
|
||||
}
|
||||
return `${value} ${unitMap[valueUnit] || valueUnit}`
|
||||
}
|
||||
|
||||
return `${value}`
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'generatedCount',
|
||||
header: () => t('developer.cardTypes.columns.generatedCount'),
|
||||
cell: ({ row }) => {
|
||||
const count = row.original.generatedCount ?? 0
|
||||
return `${count} ${t('developer.cardTypes.units.cards')}`
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.cardTypes.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const isActive = status === 'active'
|
||||
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => t(`developer.cardTypes.statusOptions.${status}`))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.cardTypes.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const cardType = row.original
|
||||
const isActive = cardType.status === 'active'
|
||||
|
||||
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(cardType) }, () => [
|
||||
h(Pencil, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cardTypes.edit'),
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(cardType) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isActive ? t('developer.cardTypes.disable') : t('developer.cardTypes.enable'),
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(cardType) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cardTypes.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CardType } from '@/pages/developer/card-types/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<CardType>
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.cardTypes.searchName')"
|
||||
:model-value="(table.getColumn('name')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('name')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('developer.cardTypes.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { CardType } from '@/pages/admin/card-types/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/card-types/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/card-types/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<CardType>, 'columns'> & {
|
||||
onEdit: (row: CardType) => void
|
||||
onToggleStatus: (row: CardType) => void
|
||||
onDelete: (row: CardType) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
batchEnable: []
|
||||
batchDisable: []
|
||||
batchDelete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<CardType>,
|
||||
...getColumns({
|
||||
onEdit: props.onEdit,
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<CardType>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.cardTypes.select',
|
||||
name: 'developer.cardTypes.columns.name',
|
||||
description: 'developer.cardTypes.columns.description',
|
||||
application_name: 'developer.cardTypes.columns.application',
|
||||
billing_type: 'developer.cardTypes.columns.billingType',
|
||||
price: 'developer.cardTypes.columns.price',
|
||||
value: 'developer.cardTypes.columns.value',
|
||||
generatedCount: 'developer.cardTypes.columns.generatedCount',
|
||||
status: 'developer.cardTypes.columns.status',
|
||||
created_at: 'developer.cardTypes.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<BulkActions :table="table" entity-name="cardTypes">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
{{ t('developer.cardTypes.batchEnableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
{{ t('developer.cardTypes.batchDisableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.cardTypes.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
billing_type: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
application_id: '',
|
||||
price: 0,
|
||||
recharge_type: 'balance' as 'balance' | 'subscription',
|
||||
value: 1,
|
||||
value_unit: 'day' as 'day' | 'month' | 'year',
|
||||
is_permanent: false,
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch(selectedApplication, (app) => {
|
||||
if (app) {
|
||||
if (app.billing_type === 'subscription') {
|
||||
form.value.recharge_type = 'subscription'
|
||||
}
|
||||
else if (app.billing_type === 'time' || app.billing_type === 'count') {
|
||||
form.value.recharge_type = 'balance'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (form.value.is_permanent) {
|
||||
return t('developer.cardTypes.units.permanent')
|
||||
}
|
||||
if (form.value.recharge_type === 'subscription') {
|
||||
const unitMap: Record<string, string> = {
|
||||
minute: t('developer.cardTypes.units.minute'),
|
||||
hour: t('developer.cardTypes.units.hour'),
|
||||
day: t('developer.cardTypes.units.day'),
|
||||
month: t('developer.cardTypes.units.month'),
|
||||
year: t('developer.cardTypes.units.year'),
|
||||
}
|
||||
return `${form.value.value} ${unitMap[form.value.value_unit]}`
|
||||
}
|
||||
return `${form.value.value}`
|
||||
})
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.name) {
|
||||
toast.error(t('developer.cardTypes.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.cardTypes.create.applicationRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.is_permanent && form.value.value <= 0) {
|
||||
toast.error(t('developer.cardTypes.create.valueRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/card-types', {
|
||||
application_id: Number(form.value.application_id),
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
recharge_type: form.value.recharge_type,
|
||||
value: form.value.is_permanent ? -1 : form.value.value,
|
||||
value_unit: form.value.recharge_type === 'subscription' ? form.value.value_unit : '',
|
||||
price: form.value.price,
|
||||
})
|
||||
toast.success(t('developer.cardTypes.create.createSuccess'))
|
||||
router.push('/developer/card-types')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建卡密类型失败:', error)
|
||||
toast.error(error.message || t('developer.cardTypes.create.createFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cardTypes.create.title')"
|
||||
:description="t('developer.cardTypes.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cardTypes.title'), href: '/developer/card-types' },
|
||||
{ title: t('developer.cardTypes.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:info" class="size-5" />
|
||||
{{ t('developer.cardTypes.create.basicInfo') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cardTypes.create.basicInfoDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.cardTypes.create.name') }} *
|
||||
</UiLabel>
|
||||
<UiInput id="name" v-model="form.name" :placeholder="t('developer.cardTypes.create.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cardTypes.create.description') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="description" v-model="form.description" :placeholder="t('developer.cardTypes.create.descriptionPlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cardTypes.create.application') }} *
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cardTypes.create.selectApplication')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:credit-card" class="size-5" />
|
||||
充值配置
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>设置卡密的充值类型和金额</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>充值类型</UiLabel>
|
||||
<UiRadioGroup v-model="form.recharge_type" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.recharge_type === 'balance' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.recharge_type = 'balance'"
|
||||
>
|
||||
<UiRadioGroupItem value="balance" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
余额充值
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
充值账户余额,适用于计时/计次模式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.recharge_type === 'subscription' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.recharge_type = 'subscription'"
|
||||
>
|
||||
<UiRadioGroupItem value="subscription" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
订阅充值
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
充值会员时长,适用于订阅模式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="price">
|
||||
{{ t('developer.cardTypes.create.price') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiNumberField v-model="form.price" :min="0" :step="0.01" class="max-w-[200px]">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<span class="text-sm text-muted-foreground">{{ t('developer.cardTypes.create.priceUnit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
永久会员
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
开启后,使用此卡密的用户将成为永久会员
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.is_permanent" />
|
||||
</div>
|
||||
|
||||
<div v-if="!form.is_permanent" class="space-y-4 pt-4 border-t">
|
||||
<div v-if="form.recharge_type === 'balance'" class="space-y-2">
|
||||
<UiLabel>充值金额</UiLabel>
|
||||
<UiNumberField v-model="form.value" :min="0.01" :step="0.01" class="max-w-[200px]">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
用户充值后获得的余额数量
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.recharge_type === 'subscription'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>充值时长</UiLabel>
|
||||
<UiNumberField v-model="form.value" :min="1" :step="1">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>时长单位</UiLabel>
|
||||
<UiSelect v-model="form.value_unit">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="minute">
|
||||
分钟
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="hour">
|
||||
小时
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="day">
|
||||
天
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="month">
|
||||
月
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="year">
|
||||
年
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cardTypes.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewName') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewApplication') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">充值类型</span>
|
||||
<span>{{ form.recharge_type === 'balance' ? '余额充值' : '订阅充值' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewPrice') }}</span>
|
||||
<span>¥{{ form.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cardTypes.create.previewValue') }}</span>
|
||||
<span>{{ displayValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cardTypes.create.createNow') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cardTypes.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import type { CardTypeStatus } from './schema'
|
||||
|
||||
export function getCardTypeStatuses(t: Composer['t']): { label: string, value: CardTypeStatus }[] {
|
||||
return [
|
||||
{ label: t('developer.cardTypes.statusOptions.active'), value: 'active' },
|
||||
{ label: t('developer.cardTypes.statusOptions.inactive'), value: 'inactive' },
|
||||
]
|
||||
}
|
||||
|
||||
export function getValueLabel(): string {
|
||||
return '充值金额'
|
||||
}
|
||||
|
||||
export function getValueUnit(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
if (value === -1) {
|
||||
return '永久'
|
||||
}
|
||||
return `${value}`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const cardTypeStatusSchema = z.enum(['active', 'inactive'])
|
||||
|
||||
export const rechargeTypeSchema = z.enum(['balance', 'subscription'])
|
||||
|
||||
export const cardTypeSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
user_id: z.union([z.string(), z.number()]),
|
||||
application_id: z.union([z.string(), z.number()]).nullable(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
recharge_type: rechargeTypeSchema.default('balance'),
|
||||
value: z.number(),
|
||||
value_unit: z.string().optional(),
|
||||
price: z.number(),
|
||||
generatedCount: z.number().default(0),
|
||||
status: cardTypeStatusSchema,
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
application: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
}).optional().nullable(),
|
||||
})
|
||||
|
||||
export type CardType = z.infer<typeof cardTypeSchema>
|
||||
export type CardTypeStatus = z.infer<typeof cardTypeStatusSchema>
|
||||
export type RechargeType = z.infer<typeof rechargeTypeSchema>
|
||||
@@ -0,0 +1,409 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { CardType } from '@/pages/developer/card-types/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/developer/card-types/components/data-table.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
const tableRef = ref()
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<CardType | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.cardTypes.statusOptions.active'), value: 'active' },
|
||||
{ label: t('developer.cardTypes.statusOptions.inactive'), value: 'inactive' },
|
||||
])
|
||||
|
||||
const filteredCardTypes = computed(() => {
|
||||
let result = cardTypes.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(type => String(type.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(type => type.status === statusFilter.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const activeTypesCount = computed(() => filteredCardTypes.value.filter(type => type.status === 'active').length)
|
||||
const totalGeneratedCount = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.generatedCount || 0), 0))
|
||||
const totalValue = computed(() => filteredCardTypes.value.reduce((sum, type) => sum + (type.price * (type.generatedCount || 0)), 0))
|
||||
|
||||
async function fetchCardTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/card-types`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
cardTypes.value = Array.isArray(data.data?.card_types) ? data.data.card_types : []
|
||||
}
|
||||
else {
|
||||
cardTypes.value = []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密类型失败:', error)
|
||||
cardTypes.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
applications.value = Array.isArray(data.data?.applications) ? data.data.applications : []
|
||||
}
|
||||
else {
|
||||
applications.value = []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
applications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/developer/card-types/create')
|
||||
}
|
||||
|
||||
function goToEdit(type: CardType) {
|
||||
router.push(`/developer/card-types/${type.id}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(type: CardType) {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const newStatus = type.status === 'active' ? 'inactive' : 'active'
|
||||
const response = await fetch(`${API_BASE}/dev/card-types/${type.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('developer.cardTypes.statusUpdateSuccess'))
|
||||
fetchCardTypes()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('developer.cardTypes.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('切换卡密类型状态失败:', error)
|
||||
toast.error(t('developer.cardTypes.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteType(type: CardType) {
|
||||
deleteTarget.value = type
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteType() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/card-types/${deleteTarget.value.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
toast.success(t('developer.cardTypes.deleteSuccess'))
|
||||
fetchCardTypes()
|
||||
}
|
||||
else {
|
||||
toast.error(data.message || t('developer.cardTypes.deleteFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('删除卡密类型失败:', error)
|
||||
toast.error(t('developer.cardTypes.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: (string | number)[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const id of batchDeleteIds.value) {
|
||||
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
successCount++
|
||||
}
|
||||
else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(t('developer.cardTypes.deleteSuccess'))
|
||||
fetchCardTypes()
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(t('developer.cardTypes.deleteFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(t('developer.cardTypes.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const id of ids) {
|
||||
const response = await fetch(`${API_BASE}/dev/card-types/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
successCount++
|
||||
}
|
||||
else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(t('developer.cardTypes.batchUpdateSuccess'))
|
||||
fetchCardTypes()
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(t('developer.cardTypes.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(t('developer.cardTypes.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCardTypes()
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cardTypes.title')"
|
||||
:description="t('developer.cardTypes.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cardTypes.createCardType') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cardTypes.totalTypes') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredCardTypes.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cardTypes.activeTypes') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeTypesCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cardTypes.generatedCards') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:hash" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalGeneratedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cardTypes.totalValue') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ totalValue }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredCardTypes"
|
||||
:on-edit="goToEdit"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-delete="confirmDeleteType"
|
||||
@refresh="fetchCardTypes"
|
||||
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.cardTypes.columns.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.cardTypes.columns.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cardTypes.delete')"
|
||||
:cancel-button-text="t('developer.cards.create.cancelBtn')"
|
||||
@confirm="handleDeleteType"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cardTypes.delete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cardTypes.deleteConfirm', { name: deleteTarget?.name }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cardTypes.delete')"
|
||||
:cancel-button-text="t('developer.cards.create.cancelBtn')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cardTypes.batchDelete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cardTypes.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, MoreHorizontal, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { Card } from '@/pages/developer/cards/data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
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 getColumns(actions: {
|
||||
onToggleStatus: (row: Card) => void
|
||||
onDelete: (row: Card) => void
|
||||
}, t: Composer['t']): ColumnDef<Card>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'card_key',
|
||||
header: () => t('developer.cards.columns.cardKey'),
|
||||
cell: ({ row }) => {
|
||||
const cardKey = row.getValue('card_key') as string
|
||||
if (!cardKey)
|
||||
return '-'
|
||||
return h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, cardKey),
|
||||
h(Copy, { class: 'h-4 w-4', size: 'sm', content: cardKey }),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application.name',
|
||||
header: () => t('developer.cards.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.original.application?.name
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'card_type.name',
|
||||
header: () => t('developer.cards.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const typeName = row.original.card_type?.name
|
||||
return typeName ? h(Badge, { variant: 'outline' }, () => typeName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'card_type.price',
|
||||
header: () => t('developer.cards.columns.faceValue'),
|
||||
cell: ({ row }) => {
|
||||
const price = row.original.card_type?.price
|
||||
return price != null ? `¥${price}` : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'card_type.value',
|
||||
header: () => t('developer.cards.columns.duration'),
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.card_type?.value
|
||||
const billingType = row.original.card_type?.billing_type
|
||||
if (value == null)
|
||||
return '-'
|
||||
if (value === -1)
|
||||
return t('developer.cards.columns.permanent')
|
||||
if (billingType === 'subscription')
|
||||
return `${value}${t('developer.cards.columns.hours')}`
|
||||
if (billingType === 'timer')
|
||||
return `${value}${t('developer.cards.columns.minutes')}`
|
||||
if (billingType === 'points')
|
||||
return `${value}${t('developer.cards.columns.points')}`
|
||||
return String(value)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.cards.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
if (!status || status === '') {
|
||||
return h(Badge, { variant: 'default' }, () => t('developer.cards.unused'))
|
||||
}
|
||||
const statusMap: Record<string, { text: string, variant: any }> = {
|
||||
unused: { text: t('developer.cards.unused'), variant: 'default' },
|
||||
used: { text: t('developer.cards.used'), variant: 'secondary' },
|
||||
banned: { text: t('developer.cards.banned'), variant: 'destructive' },
|
||||
}
|
||||
const { text, variant } = statusMap[status] || { text: status, variant: 'default' }
|
||||
return h(Badge, { variant }, () => text)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'app_user.username',
|
||||
header: () => t('developer.cards.columns.usedUser'),
|
||||
cell: ({ row }) => {
|
||||
const username = row.original.app_user?.username
|
||||
return username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'used_at',
|
||||
header: () => t('developer.cards.columns.usedTime'),
|
||||
cell: ({ row }) => {
|
||||
const usedAt = row.getValue('used_at')
|
||||
if (!usedAt)
|
||||
return '-'
|
||||
try {
|
||||
const date = new Date(usedAt 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 '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'creator.username',
|
||||
header: () => t('developer.cards.columns.creator'),
|
||||
cell: ({ row }) => {
|
||||
const username = row.original.creator?.username
|
||||
return username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.cards.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const card = row.original
|
||||
const isBanned = card.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.onToggleStatus(card) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isBanned ? t('developer.cards.enable') : t('developer.cards.disable'),
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(card) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cards.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Card } from '@/pages/developer/cards/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<Card>
|
||||
searchFilter?: string
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => !!props.searchFilter)
|
||||
|
||||
function resetFilters() {
|
||||
emit('update:searchFilter', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.cards.searchAll')"
|
||||
:model-value="searchFilter"
|
||||
class="h-8 w-[200px] lg:w-[300px]"
|
||||
@update:model-value="emit('update:searchFilter', $event as string)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('developer.cards.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { Card } from '@/pages/admin/cards/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/cards/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/cards/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Card>, 'columns'> & {
|
||||
onToggleStatus: (row: Card) => void
|
||||
onDelete: (row: Card) => void
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'batchDisable': []
|
||||
'batchEnable': []
|
||||
'batchDelete': []
|
||||
'batchExport': []
|
||||
'export': []
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Card>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Card>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.cards.select',
|
||||
card_key: 'developer.cards.columns.cardKey',
|
||||
application_name: 'developer.cards.columns.application',
|
||||
card_type_name: 'developer.cards.columns.type',
|
||||
card_type_price: 'developer.cards.columns.faceValue',
|
||||
card_type_value: 'developer.cards.columns.duration',
|
||||
status: 'developer.cards.columns.status',
|
||||
app_user_username: 'developer.cards.columns.usedUser',
|
||||
used_at: 'developer.cards.columns.usedTime',
|
||||
creator_username: 'developer.cards.columns.creator',
|
||||
created_at: 'developer.cards.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<BulkActions :table="table" entity-name="cards">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
{{ t('developer.cards.batchEnableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
{{ t('developer.cards.batchDisableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchExport')">
|
||||
{{ t('developer.cards.batchExportBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.cards.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar
|
||||
:table
|
||||
:search-filter="searchFilter"
|
||||
@update:search-filter="emit('update:searchFilter', $event)"
|
||||
/>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="emit('export')">
|
||||
{{ t('developer.cards.exportBtn') }}
|
||||
</UiButton>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CardType {
|
||||
id: string
|
||||
name: string
|
||||
billing_type: string
|
||||
price: number
|
||||
value: number
|
||||
application_id: string
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
|
||||
const form = ref({
|
||||
application_id: '',
|
||||
card_type_id: '',
|
||||
count: 1,
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCardTypes() {
|
||||
try {
|
||||
const data = await api.get<{ card_types: CardType[] }>('/dev/card-types')
|
||||
cardTypes.value = Array.isArray(data?.card_types) ? data.card_types : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡类列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCardTypes = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return cardTypes.value.filter(
|
||||
ct => String(ct.application_id) === form.value.application_id,
|
||||
)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const selectedCardType = computed(() => {
|
||||
if (form.value.card_type_id) {
|
||||
return cardTypes.value.find(ct => String(ct.id) === form.value.card_type_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const totalCost = computed(() => {
|
||||
if (selectedCardType.value) {
|
||||
return selectedCardType.value.price * form.value.count
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
function onApplicationChange() {
|
||||
form.value.card_type_id = ''
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.cards.create.selectAppFirst'))
|
||||
return
|
||||
}
|
||||
if (!form.value.card_type_id) {
|
||||
toast.error(t('developer.cards.create.selectCardTypeFirst'))
|
||||
return
|
||||
}
|
||||
if (form.value.count < 1 || form.value.count > 1000) {
|
||||
toast.error(t('developer.cards.create.countRange'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/cards/batch', {
|
||||
application_id: Number(form.value.application_id),
|
||||
card_type_id: Number(form.value.card_type_id),
|
||||
count: form.value.count,
|
||||
})
|
||||
toast.success(t('developer.cards.create.generateSuccess', { count: form.value.count }))
|
||||
router.push('/developer/cards')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('生成卡密失败:', error)
|
||||
toast.error(error.message || t('developer.cards.create.generateFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchCardTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cards.create.title')"
|
||||
:description="t('developer.cards.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cards.title'), href: '/developer/cards' },
|
||||
{ title: t('developer.cards.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cards.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cards.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cards.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id" @update:model-value="onApplicationChange">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cards.create.selectApplication')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="cardType">
|
||||
{{ t('developer.cards.create.cardType') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.card_type_id" :disabled="!form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cards.create.selectCardType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="type in filteredCardTypes" :key="type.id" :value="String(type.id)">
|
||||
{{ type.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<p v-if="form.application_id && filteredCardTypes.length === 0" class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cards.create.noCardType') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="count">
|
||||
{{ t('developer.cards.create.count') }}
|
||||
</UiLabel>
|
||||
<div class="flex items-center gap-4">
|
||||
<UiNumberField v-model="form.count" :min="1" :max="1000" class="flex-1 max-w-[200px]">
|
||||
<UiNumberFieldContent>
|
||||
<UiNumberFieldDecrement />
|
||||
<UiNumberFieldInput />
|
||||
<UiNumberFieldIncrement />
|
||||
</UiNumberFieldContent>
|
||||
</UiNumberField>
|
||||
<div class="flex gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="form.count = 10">
|
||||
10
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="form.count = 50">
|
||||
50
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="form.count = 100">
|
||||
100
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="form.count = 500">
|
||||
500
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cards.create.maxCount') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:receipt" class="size-5" />
|
||||
{{ t('developer.cards.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cards.create.app') }}</span>
|
||||
<span>{{ applications.find(a => String(a.id) === form.application_id)?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cards.create.type') }}</span>
|
||||
<span>{{ selectedCardType?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cards.create.unitPrice') }}</span>
|
||||
<span>¥{{ selectedCardType?.price?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cards.create.quantity') }}</span>
|
||||
<span>{{ form.count }} {{ t('developer.cards.create.units') }}</span>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cards.create.totalAmount') }}</span>
|
||||
<span class="text-xl font-bold text-primary">¥{{ totalCost.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.application_id || !form.card_type_id"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:sparkles" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cards.create.generateNow') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cards.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, CheckCircle2, Users } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { FacetedFilterOption } from '@/components/data-table/types'
|
||||
|
||||
export function getCardStatuses(t: Composer['t']): (FacetedFilterOption & { style: string, variant?: string })[] {
|
||||
return [
|
||||
{
|
||||
label: t('developer.cards.unused'),
|
||||
value: 'unused',
|
||||
icon: h(CheckCircle2),
|
||||
style: 'bg-green-500/10 text-green-500',
|
||||
variant: 'default',
|
||||
},
|
||||
{
|
||||
label: t('developer.cards.used'),
|
||||
value: 'used',
|
||||
icon: h(Users),
|
||||
style: 'bg-blue-500/10 text-blue-500',
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: t('developer.cards.banned'),
|
||||
value: 'banned',
|
||||
icon: h(Ban),
|
||||
style: 'bg-gray-500/10 text-gray-500',
|
||||
variant: 'destructive',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const cardStatusSchema = z.enum(['unused', 'used', 'expired', 'banned'])
|
||||
|
||||
export const cardSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
card_key: z.string(),
|
||||
application_id: z.union([z.string(), z.number()]),
|
||||
card_type_id: z.union([z.string(), z.number()]),
|
||||
status: cardStatusSchema,
|
||||
used_at: z.string().optional().nullable(),
|
||||
expire_at: z.string().optional().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
application: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
}).optional(),
|
||||
card_type: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
price: z.number(),
|
||||
value: z.number(),
|
||||
billing_type: z.string(),
|
||||
}).optional(),
|
||||
creator: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional(),
|
||||
app_user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
})
|
||||
|
||||
export type Card = z.infer<typeof cardSchema>
|
||||
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Card } from '@/pages/developer/cards/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/developer/cards/components/data-table.vue'
|
||||
import api, { BASE_URL } from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface CardType {
|
||||
id: number
|
||||
name: string
|
||||
application_id: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const cards = ref<Card[]>([])
|
||||
const tableRef = ref()
|
||||
const applications = ref<Application[]>([])
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const cardTypeFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
const searchFilter = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Card | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
|
||||
const filteredCardTypes = computed(() => {
|
||||
if (appFilter.value) {
|
||||
return cardTypes.value.filter(
|
||||
ct => String(ct.application_id) === appFilter.value,
|
||||
)
|
||||
}
|
||||
return cardTypes.value
|
||||
})
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
let result = cards.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(card => String(card.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (cardTypeFilter.value) {
|
||||
result = result.filter(card => String(card.card_type_id) === cardTypeFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(card => card.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(card => card.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(card => card.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
const search = searchFilter.value.toLowerCase()
|
||||
result = result.filter(card =>
|
||||
card.card_key?.toLowerCase().includes(search)
|
||||
|| card.application?.name?.toLowerCase().includes(search)
|
||||
|| card.card_type?.name?.toLowerCase().includes(search)
|
||||
|| card.creator?.username?.toLowerCase().includes(search)
|
||||
|| card.app_user?.username?.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const unusedCount = computed(() => filteredCards.value.filter(card => card.status === 'unused').length)
|
||||
const usedCount = computed(() => filteredCards.value.filter(card => card.status === 'used').length)
|
||||
const bannedCount = computed(() => filteredCards.value.filter(card => card.status === 'banned').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const cardTypeOptions = computed(() => {
|
||||
if (appFilter.value) {
|
||||
return filteredCardTypes.value.map(ct => ({
|
||||
label: ct.name,
|
||||
value: String(ct.id),
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.cards.unused'), value: 'unused' },
|
||||
{ label: t('developer.cards.used'), value: 'used' },
|
||||
{ label: t('developer.cards.banned'), value: 'banned' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCardTypes() {
|
||||
try {
|
||||
const data = await api.get<{ card_types: CardType[] }>('/dev/card-types')
|
||||
cardTypes.value = data?.card_types || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡类列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCards() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<Card[]>('/dev/cards')
|
||||
cards.value = Array.isArray(data) ? data : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取卡密列表失败:', error)
|
||||
cards.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/developer/cards/create')
|
||||
}
|
||||
|
||||
async function toggleStatus(card: Card) {
|
||||
try {
|
||||
const newStatus = card.status === 'banned' ? 'unused' : 'banned'
|
||||
await api.put(`/dev/cards/${card.id}/status`, { status: newStatus })
|
||||
toast.success(t('developer.cards.statusUpdateSuccess'))
|
||||
fetchCards()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换卡密状态失败:', error)
|
||||
toast.error(error.message || t('developer.cards.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteCard(card: Card) {
|
||||
deleteTarget.value = card
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteCard() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/cards/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.cards.deleteSuccess'))
|
||||
fetchCards()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除卡密失败:', error)
|
||||
toast.error(error.message || t('developer.cards.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: (string | number)[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/cards/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('developer.cards.deleteSuccess'))
|
||||
fetchCards()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('developer.cards.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
await api.put('/dev/cards/batch/status', { ids, status })
|
||||
toast.success(t('developer.cards.batchUpdateSuccess'))
|
||||
fetchCards()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || t('developer.cards.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
if (appFilter.value)
|
||||
params.append('application_id', appFilter.value)
|
||||
if (cardTypeFilter.value)
|
||||
params.append('card_type_id', cardTypeFilter.value)
|
||||
if (startDate.value) {
|
||||
const startDateOnly = startDate.value.includes('T') ? startDate.value.split('T')[0] : startDate.value
|
||||
params.append('start_date', startDateOnly)
|
||||
}
|
||||
if (endDate.value) {
|
||||
const endDateOnly = endDate.value.includes('T') ? endDate.value.split('T')[0] : endDate.value
|
||||
params.append('end_date', endDateOnly)
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const url = `${BASE_URL}/dev/cards/export?${queryString}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleBatchExport(ids: (string | number)[]) {
|
||||
if (!ids.length)
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const params = new URLSearchParams()
|
||||
params.append('ids', ids.join(','))
|
||||
const url = `${BASE_URL}/dev/cards/export?${params.toString()}&token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
watch(() => appFilter.value, () => {
|
||||
cardTypeFilter.value = ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchCardTypes()
|
||||
fetchCards()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cards.title')"
|
||||
:description="t('developer.cards.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cards.generateCard') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cards.totalCards') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredCards.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cards.unused') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ unusedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cards.used') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ usedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cards.banned') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredCards"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-delete="confirmDeleteCard"
|
||||
:search-filter="searchFilter"
|
||||
@refresh="fetchCards"
|
||||
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'unused').map((r: any) => r.original.id) || [], 'banned')"
|
||||
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'unused')"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@batch-export="handleBatchExport(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@export="handleExport"
|
||||
@update:search-filter="searchFilter = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.cards.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="cardTypeFilter"
|
||||
:title="t('developer.cards.cardType')"
|
||||
:options="cardTypeOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.cards.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cards.delete')"
|
||||
:cancel-button-text="t('developer.cards.create.cancelBtn')"
|
||||
@confirm="handleDeleteCard"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cards.deleteCard') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cards.deleteCardConfirm', { cardKey: deleteTarget?.card_key }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cards.delete')"
|
||||
:cancel-button-text="t('developer.cards.create.cancelBtn')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cards.batchDeleteCards') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cards.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
id: '',
|
||||
app_id: '',
|
||||
key: '',
|
||||
value: '',
|
||||
var_type: 'string',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConstant() {
|
||||
loading.value = true
|
||||
try {
|
||||
const constId = route.params.id
|
||||
const data = await api.get<{ id: number, app_id: number, key: string, value: string, var_type: string, description: string, status: string }>(`/dev/cloud-constants/${constId}`)
|
||||
const constant = data
|
||||
if (constant) {
|
||||
form.value = {
|
||||
id: String(constant.id),
|
||||
app_id: String(constant.app_id || ''),
|
||||
key: constant.key,
|
||||
value: constant.value,
|
||||
var_type: constant.var_type || 'string',
|
||||
description: constant.description || '',
|
||||
status: constant.status || 'active',
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(t('developer.cloudConstants.create.failed'))
|
||||
router.push('/developer/cloud-constants')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端常量失败:', error)
|
||||
toast.error(t('developer.cloudConstants.create.failed'))
|
||||
router.push('/developer/cloud-constants')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.key) {
|
||||
toast.error(t('developer.cloudConstants.create.keyRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.value) {
|
||||
toast.error(t('developer.cloudConstants.create.valueRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/cloud-constants/${form.value.id}`, {
|
||||
key: form.value.key,
|
||||
value: form.value.value,
|
||||
var_type: form.value.var_type,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
})
|
||||
toast.success(t('developer.cloudConstants.create.success'))
|
||||
router.push('/developer/cloud-constants')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新云端常量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudConstants.create.failed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchConstant()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudConstants.edit')"
|
||||
:description="t('developer.cloudConstants.editDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudConstants.title'), href: '/developer/cloud-constants' },
|
||||
{ title: t('developer.cloudConstants.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudConstants.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudConstants.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.cloudConstants.create.application') }}</UiLabel>
|
||||
<div class="flex items-center gap-2 p-2.5 rounded-md border bg-muted/50">
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="varType">
|
||||
{{ t('developer.cloudConstants.create.type') }}
|
||||
</UiLabel>
|
||||
<UiSelect id="varType" v-model="form.var_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudConstants.create.selectType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="integer">
|
||||
{{ t('developer.cloudConstants.types.integer') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="decimal">
|
||||
{{ t('developer.cloudConstants.types.decimal') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="string">
|
||||
{{ t('developer.cloudConstants.types.string') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="key">
|
||||
{{ t('developer.cloudConstants.create.key') }}
|
||||
</UiLabel>
|
||||
<UiInput id="key" v-model="form.key" :placeholder="t('developer.cloudConstants.create.keyPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudConstants.create.keyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="value">
|
||||
{{ t('developer.cloudConstants.create.value') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="value" v-model="form.value" :placeholder="t('developer.cloudConstants.create.valuePlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudConstants.create.description') }}
|
||||
</UiLabel>
|
||||
<UiInput id="description" v-model="form.description" :placeholder="t('developer.cloudConstants.create.descriptionPlaceholder')" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudConstants.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.type') }}</span>
|
||||
<span>{{ t(`developer.cloudConstants.types.${form.var_type}`) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.status') }}</span>
|
||||
<UiSwitch
|
||||
:checked="form.status === 'active'"
|
||||
@update:checked="form.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudConstants.create.value') }}
|
||||
</div>
|
||||
<div class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
|
||||
{{ form.value || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.key || !form.value"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudConstants.create.submit') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cloudConstants.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Download, File, MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { CloudConstant } from '@/pages/developer/cloud-constants/data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function getColumns(actions: {
|
||||
onEdit: (row: CloudConstant) => void
|
||||
onDelete: (row: CloudConstant) => void
|
||||
onToggleStatus: (row: CloudConstant) => void
|
||||
onDownload: (row: CloudConstant) => void
|
||||
}, t: Composer['t']): ColumnDef<CloudConstant>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'key',
|
||||
header: () => t('developer.cloudConstants.columns.key'),
|
||||
cell: ({ row }) => {
|
||||
const key = row.getValue('key') as string
|
||||
if (!key)
|
||||
return '-'
|
||||
return h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, key),
|
||||
h(Copy, { class: 'h-4 w-4', size: 'sm', content: key }),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: () => t('developer.cloudConstants.columns.value'),
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue('value') as string
|
||||
const varType = row.original.var_type
|
||||
if (!value)
|
||||
return '-'
|
||||
if (varType === 'binary') {
|
||||
const originalName = row.original.original_name || value
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(File, { class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'max-w-xs truncate', title: originalName }, originalName),
|
||||
])
|
||||
}
|
||||
return h('div', { class: 'max-w-xs truncate', title: value }, value)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'var_type',
|
||||
header: () => t('developer.cloudConstants.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('var_type') as string
|
||||
const typeMap: Record<string, string> = {
|
||||
integer: t('developer.cloudConstants.types.integer'),
|
||||
decimal: t('developer.cloudConstants.types.decimal'),
|
||||
string: t('developer.cloudConstants.types.string'),
|
||||
binary: t('developer.cloudConstants.types.binary'),
|
||||
}
|
||||
return h(Badge, { variant: 'secondary' }, () => typeMap[type || 'string'] || type || 'string')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: () => t('developer.cloudConstants.columns.description'),
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
if (!description)
|
||||
return '-'
|
||||
return h('div', { class: 'max-w-xs truncate', title: description }, description)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.cloudConstants.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.getValue('application_name') as string
|
||||
const appId = row.original.app_id
|
||||
if (!appId)
|
||||
return h(Badge, { variant: 'outline' }, () => t('developer.cloudConstants.notLinked'))
|
||||
if (!appName)
|
||||
return '-'
|
||||
return h(Badge, { variant: 'outline' }, () => appName)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.cloudConstants.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const isActive = status === 'active'
|
||||
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => isActive ? t('developer.cloudConstants.statusActive') : t('developer.cloudConstants.statusInactive'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.cloudConstants.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const constant = row.original
|
||||
|
||||
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' },
|
||||
() => {
|
||||
const items = [
|
||||
h(DropdownMenuItem, { onClick: () => actions.onEdit(constant) }, () => [
|
||||
h(Pencil, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudConstants.edit'),
|
||||
]),
|
||||
]
|
||||
|
||||
if (constant.var_type === 'binary') {
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onDownload(constant) }, () => [
|
||||
h(Download, { class: 'mr-2 h-4 w-4' }),
|
||||
'下载文件',
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(constant) }, () => [
|
||||
constant.status === 'active' ? h(PowerOff, { class: 'mr-2 h-4 w-4' }) : h(Power, { class: 'mr-2 h-4 w-4' }),
|
||||
constant.status === 'active' ? t('developer.cloudConstants.disable') : t('developer.cloudConstants.enable'),
|
||||
]),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(constant) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudConstants.delete'),
|
||||
]),
|
||||
)
|
||||
|
||||
return items
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { Row } from '@tanstack/vue-table'
|
||||
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
import type { CloudConstant } from '../data/schema'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<CloudConstant>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableRowActionsProps>()
|
||||
const emit = defineEmits<{
|
||||
edit: [constant: CloudConstant]
|
||||
delete: [constant: CloudConstant]
|
||||
}>()
|
||||
|
||||
const constant = props.row.original
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">打开菜单</span>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end" class="w-[160px]">
|
||||
<UiDropdownMenuItem @click="emit('edit', constant)">
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem class="text-destructive" @click="emit('delete', constant)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CloudConstant } from '@/pages/developer/cloud-constants/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<CloudConstant>
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.cloudConstants.searchPlaceholder')"
|
||||
:model-value="(table.getColumn('key')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('key')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { CloudConstant } from '@/pages/admin/cloud-constants/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/cloud-constants/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/cloud-constants/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<CloudConstant>, 'columns'> & {
|
||||
onEdit: (row: CloudConstant) => void
|
||||
onDelete: (row: CloudConstant) => void
|
||||
onToggleStatus: (row: CloudConstant) => void
|
||||
onDownload: (row: CloudConstant) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
batchAction: [action: 'enable' | 'disable' | 'delete', rows: CloudConstant[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<CloudConstant>,
|
||||
...getColumns({
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDownload: props.onDownload,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<CloudConstant>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.cloudConstants.select',
|
||||
key: 'developer.cloudConstants.columns.key',
|
||||
value: 'developer.cloudConstants.columns.value',
|
||||
var_type: 'developer.cloudConstants.columns.type',
|
||||
description: 'developer.cloudConstants.columns.description',
|
||||
application_name: 'developer.cloudConstants.columns.application',
|
||||
status: 'developer.cloudConstants.columns.status',
|
||||
created_at: 'developer.cloudConstants.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
|
||||
function handleBatchAction(action: 'enable' | 'disable' | 'delete') {
|
||||
const selectedRows = table.getSelectedRowModel().rows.map(row => row.original)
|
||||
emit('batchAction', action, selectedRows)
|
||||
table.resetRowSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<BulkActions :table="table" entity-name="constants">
|
||||
<UiButton variant="outline" size="sm" @click="handleBatchAction('enable')">
|
||||
{{ t('developer.cloudConstants.batchEnableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="handleBatchAction('disable')">
|
||||
{{ t('developer.cloudConstants.batchDisableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="handleBatchAction('delete')">
|
||||
{{ t('developer.cloudConstants.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
app_id: '',
|
||||
key: '',
|
||||
value: '',
|
||||
var_type: 'string',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
selectedFile.value = target.files[0]
|
||||
if (!form.value.key) {
|
||||
form.value.key = selectedFile.value.name.replace(/\.[^/.]+$/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.key) {
|
||||
toast.error(t('developer.cloudConstants.create.keyRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.app_id) {
|
||||
toast.error(t('developer.cloudConstants.create.appRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.var_type === 'binary') {
|
||||
if (!selectedFile.value) {
|
||||
toast.error('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
await uploadFile()
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.value) {
|
||||
toast.error(t('developer.cloudConstants.create.valueRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/cloud-constants', {
|
||||
app_id: Number(form.value.app_id),
|
||||
key: form.value.key,
|
||||
value: form.value.value,
|
||||
var_type: form.value.var_type,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
})
|
||||
toast.success(t('developer.cloudConstants.create.success'))
|
||||
router.push('/developer/cloud-constants')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建云端常量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudConstants.create.failed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value || !form.value.app_id || !form.value.key)
|
||||
return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('app_id', form.value.app_id)
|
||||
formData.append('key', form.value.key)
|
||||
formData.append('description', form.value.description)
|
||||
formData.append('status', form.value.status)
|
||||
|
||||
await api.postFormData('/dev/cloud-constants/upload', formData)
|
||||
toast.success(t('developer.cloudConstants.create.success'))
|
||||
router.push('/developer/cloud-constants')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('上传文件失败:', error)
|
||||
toast.error(error.message || '上传文件失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudConstants.addConstant')"
|
||||
:description="t('developer.cloudConstants.createDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudConstants.title'), href: '/developer/cloud-constants' },
|
||||
{ title: t('developer.cloudConstants.addConstant') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudConstants.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudConstants.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cloudConstants.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.app_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudConstants.create.selectApp')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="varType">
|
||||
{{ t('developer.cloudConstants.create.type') }}
|
||||
</UiLabel>
|
||||
<UiSelect id="varType" v-model="form.var_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudConstants.create.selectType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="integer">
|
||||
{{ t('developer.cloudConstants.types.integer') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="decimal">
|
||||
{{ t('developer.cloudConstants.types.decimal') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="string">
|
||||
{{ t('developer.cloudConstants.types.string') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="binary">
|
||||
二进制
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="key">
|
||||
{{ t('developer.cloudConstants.create.key') }}
|
||||
</UiLabel>
|
||||
<UiInput id="key" v-model="form.key" :placeholder="t('developer.cloudConstants.create.keyPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudConstants.create.keyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.var_type === 'binary'" class="space-y-2">
|
||||
<UiLabel>选择文件</UiLabel>
|
||||
<div v-if="!selectedFile" class="border-2 border-dashed rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
拖拽文件到此处或点击选择
|
||||
</p>
|
||||
<UiButton variant="outline" @click="fileInput?.click()">
|
||||
选择文件
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-else class="border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="lucide:file" class="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton variant="ghost" size="icon" @click="clearFile">
|
||||
<Icon icon="lucide:x" class="h-4 w-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<UiLabel for="value">
|
||||
{{ t('developer.cloudConstants.create.value') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="value" v-model="form.value" :placeholder="t('developer.cloudConstants.create.valuePlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudConstants.create.description') }}
|
||||
</UiLabel>
|
||||
<UiInput id="description" v-model="form.description" :placeholder="t('developer.cloudConstants.create.descriptionPlaceholder')" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudConstants.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.type') }}</span>
|
||||
<span>{{ t(`developer.cloudConstants.types.${form.var_type}`) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudConstants.create.status') }}</span>
|
||||
<UiSwitch
|
||||
:checked="form.status === 'active'"
|
||||
@update:checked="form.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudConstants.create.value') }}
|
||||
</div>
|
||||
<div v-if="form.var_type === 'binary'" class="bg-muted p-2 rounded text-xs">
|
||||
{{ selectedFile ? `${selectedFile.name} (${formatFileSize(selectedFile.size)})` : '未选择文件' }}
|
||||
</div>
|
||||
<div v-else class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
|
||||
{{ form.value || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="(saving || uploading) || !form.app_id || !form.key || (form.var_type !== 'binary' && !form.value) || (form.var_type === 'binary' && !selectedFile)"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving || uploading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ uploading ? '上传中...' : t('developer.cloudConstants.create.submit') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cloudConstants.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CloudConstant } from './schema'
|
||||
|
||||
export const constantAppOptions: { label: string, value: string }[] = []
|
||||
|
||||
export function filterConstantsByApp(constants: CloudConstant[], appId: string): CloudConstant[] {
|
||||
if (!appId || appId === 'all')
|
||||
return constants
|
||||
return constants.filter(c => String(c.app_id) === appId)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const cloudConstantSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
user_id: z.union([z.string(), z.number()]),
|
||||
app_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
application_name: z.string().optional().nullable(),
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
var_type: z.string().optional().nullable(),
|
||||
file_path: z.string().optional().nullable(),
|
||||
file_size: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
mime_type: z.string().optional().nullable(),
|
||||
original_name: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
status: z.string().optional().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export type CloudConstant = z.infer<typeof cloudConstantSchema>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
import type { CloudConstant } from './data/schema'
|
||||
|
||||
import DataTable from './components/data-table.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
app_key: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const constants = ref<CloudConstant[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<CloudConstant | null>(null)
|
||||
const batchDialogOpen = ref(false)
|
||||
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
|
||||
const selectedRows = ref<CloudConstant[]>([])
|
||||
|
||||
const filteredConstants = computed(() => {
|
||||
let result = [...constants.value]
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(c => String(c.app_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(c => c.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(c => c.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const linkedCount = computed(() => constants.value.filter(c => c.app_id).length)
|
||||
const unlinkedCount = computed(() => constants.value.filter(c => !c.app_id).length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConstants() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ constants: CloudConstant[] }>('/dev/cloud-constants')
|
||||
const consts = data?.constants || []
|
||||
constants.value = consts.map((c: any) => ({
|
||||
...c,
|
||||
application_name: applications.value.find(app => app.id === c.app_id)?.name || '',
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端常量失败:', error)
|
||||
constants.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreatePage() {
|
||||
router.push('/developer/cloud-constants/create')
|
||||
}
|
||||
|
||||
function openEditPage(constant: CloudConstant) {
|
||||
router.push(`/developer/cloud-constants/${constant.id}`)
|
||||
}
|
||||
|
||||
function confirmDelete(constant: CloudConstant) {
|
||||
deleteTarget.value = constant
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/cloud-constants/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.cloudConstants.deleteSuccess'))
|
||||
fetchConstants()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端常量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudConstants.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(constant: CloudConstant) {
|
||||
const newStatus = constant.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/cloud-constants/${constant.id}`, {
|
||||
key: constant.key,
|
||||
value: constant.value,
|
||||
var_type: constant.var_type,
|
||||
description: constant.description,
|
||||
status: newStatus,
|
||||
})
|
||||
toast.success(newStatus === 'active' ? t('developer.cloudConstants.enableSuccess') : t('developer.cloudConstants.disableSuccess'))
|
||||
fetchConstants()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || t('developer.cloudConstants.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(constant: CloudConstant) {
|
||||
if (constant.var_type !== 'binary')
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/v1/dev/cloud-constants/${constant.id}/download?token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudConstant[]) {
|
||||
batchAction.value = action
|
||||
selectedRows.value = rows
|
||||
batchDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchAction() {
|
||||
if (!batchAction.value || selectedRows.value.length === 0)
|
||||
return
|
||||
|
||||
const ids = selectedRows.value.map(c => c.id)
|
||||
try {
|
||||
if (batchAction.value === 'delete') {
|
||||
await Promise.all(ids.map(id => api.delete(`/dev/cloud-constants/${id}`)))
|
||||
toast.success(t('developer.cloudConstants.batchDeleteSuccess'))
|
||||
}
|
||||
else {
|
||||
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
|
||||
await Promise.all(selectedRows.value.map(c => api.put(`/dev/cloud-constants/${c.id}`, {
|
||||
key: c.key,
|
||||
value: c.value,
|
||||
var_type: c.var_type,
|
||||
description: c.description,
|
||||
status,
|
||||
})))
|
||||
toast.success(batchAction.value === 'enable' ? t('developer.cloudConstants.batchEnableSuccess') : t('developer.cloudConstants.batchDisableSuccess'))
|
||||
}
|
||||
fetchConstants()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量操作失败:', error)
|
||||
toast.error(error.message || t('developer.cloudConstants.batchFailed'))
|
||||
}
|
||||
finally {
|
||||
batchAction.value = null
|
||||
selectedRows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchConstants()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudConstants.title')"
|
||||
:description="t('developer.cloudConstants.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="openCreatePage">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudConstants.addConstant') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudConstants.totalConstants') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:database" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredConstants.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudConstants.linkedApps') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ linkedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudConstants.unlinked') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:unlink" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ unlinkedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudConstants.appCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ applications.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
:loading
|
||||
:data="filteredConstants"
|
||||
:on-edit="openEditPage"
|
||||
:on-delete="confirmDelete"
|
||||
:on-toggle-status="handleToggleStatus"
|
||||
:on-download="handleDownload"
|
||||
@refresh="fetchConstants"
|
||||
@batch-action="openBatchDialog"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.cloudConstants.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cloudConstants.delete')"
|
||||
:cancel-button-text="t('common.reset')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cloudConstants.deleteConstant') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cloudConstants.deleteConfirm', { key: deleteTarget?.key }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDialogOpen"
|
||||
:destructive="batchAction === 'delete'"
|
||||
:confirm-button-text="batchAction === 'delete' ? t('developer.cloudConstants.delete') : t('common.confirm')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchAction"
|
||||
>
|
||||
<template #title>
|
||||
{{ batchAction === 'delete' ? t('developer.cloudConstants.batchDelete') : batchAction === 'enable' ? t('developer.cloudConstants.batchEnable') : t('developer.cloudConstants.batchDisable') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ batchAction === 'delete' ? t('developer.cloudConstants.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('developer.cloudConstants.batchEnableConfirm', { count: selectedRows.length }) : t('developer.cloudConstants.batchDisableConfirm', { count: selectedRows.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
id: '',
|
||||
app_id: '',
|
||||
key: '',
|
||||
default_value: '',
|
||||
var_type: 'string',
|
||||
data_type: 'single',
|
||||
max_records: 0,
|
||||
scope: 'app',
|
||||
write_permission: 'developer',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVariable() {
|
||||
loading.value = true
|
||||
try {
|
||||
const varId = route.params.id
|
||||
const data = await api.get<{ id: number, app_id: number, key: string, default_value: string, var_type: string, data_type: string, max_records: number, scope: string, write_permission: string, description: string, status: string }>(`/dev/cloud-variables/${varId}`)
|
||||
const variable = data
|
||||
if (variable) {
|
||||
form.value = {
|
||||
id: String(variable.id),
|
||||
app_id: String(variable.app_id || ''),
|
||||
key: variable.key,
|
||||
default_value: variable.default_value || '',
|
||||
var_type: variable.var_type || 'string',
|
||||
data_type: variable.data_type || 'single',
|
||||
max_records: variable.max_records || 0,
|
||||
scope: variable.scope || 'app',
|
||||
write_permission: variable.write_permission || 'developer',
|
||||
description: variable.description || '',
|
||||
status: variable.status || 'active',
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(t('developer.cloudVariables.create.failed'))
|
||||
router.push('/developer/cloud-variables')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端变量失败:', error)
|
||||
toast.error(t('developer.cloudVariables.create.failed'))
|
||||
router.push('/developer/cloud-variables')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.key) {
|
||||
toast.error(t('developer.cloudVariables.create.keyRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/cloud-variables/${form.value.id}`, {
|
||||
key: form.value.key,
|
||||
default_value: form.value.default_value,
|
||||
var_type: form.value.var_type,
|
||||
data_type: form.value.data_type,
|
||||
max_records: form.value.max_records,
|
||||
write_permission: form.value.write_permission,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
})
|
||||
toast.success(t('developer.cloudVariables.create.success'))
|
||||
router.push('/developer/cloud-variables')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新云端变量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudVariables.create.failed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchVariable()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudVariables.edit')"
|
||||
:description="t('developer.cloudVariables.editDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudVariables.title'), href: '/developer/cloud-variables' },
|
||||
{ title: t('developer.cloudVariables.edit') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudVariables.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.cloudVariables.create.application') }}</UiLabel>
|
||||
<div class="flex items-center gap-2 p-2.5 rounded-md border bg-muted/50">
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="varType">
|
||||
{{ t('developer.cloudVariables.create.type') }}
|
||||
</UiLabel>
|
||||
<UiSelect id="varType" v-model="form.var_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudVariables.create.selectType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="integer">
|
||||
{{ t('developer.cloudVariables.types.integer') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="decimal">
|
||||
{{ t('developer.cloudVariables.types.decimal') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="string">
|
||||
{{ t('developer.cloudVariables.types.string') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>数据模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.data_type" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.data_type === 'single' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.data_type = 'single'"
|
||||
>
|
||||
<UiRadioGroupItem value="single" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
单值模式
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
存储单个值,适合配置项
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.data_type === 'stream' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.data_type = 'stream'"
|
||||
>
|
||||
<UiRadioGroupItem value="stream" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
流水模式
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
存储记录列表,适合订单、日志等
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.data_type === 'stream'" class="space-y-2">
|
||||
<UiLabel for="maxRecords">
|
||||
最大记录数
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="maxRecords"
|
||||
v-model.number="form.max_records"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 = 不限制"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
超过限制时自动删除最旧的记录,0表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="key">
|
||||
{{ t('developer.cloudVariables.create.key') }}
|
||||
</UiLabel>
|
||||
<UiInput id="key" v-model="form.key" :placeholder="t('developer.cloudVariables.create.keyPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudVariables.create.keyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="defaultValue">
|
||||
{{ t('developer.cloudVariables.create.defaultValue') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="defaultValue" v-model="form.default_value" :placeholder="t('developer.cloudVariables.create.defaultValuePlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudVariables.create.description') }}
|
||||
</UiLabel>
|
||||
<UiInput id="description" v-model="form.description" :placeholder="t('developer.cloudVariables.create.descriptionPlaceholder')" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.cloudVariables.create.scopeTitle') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.scopeDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border"
|
||||
:class="form.scope === 'app' ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30' : 'border-purple-500 bg-purple-50 dark:bg-purple-950/30'"
|
||||
>
|
||||
<Icon
|
||||
:icon="form.scope === 'app' ? 'lucide:globe' : 'lucide:user'"
|
||||
class="size-5 mt-0.5"
|
||||
:class="form.scope === 'app' ? 'text-blue-500' : 'text-purple-500'"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.appScope') : t('developer.cloudVariables.create.userScope') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.appScopeDesc') : t('developer.cloudVariables.create.userScopeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
{{ t('developer.cloudVariables.create.scopeImmutable') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.cloudVariables.create.permissionTitle') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.permissionDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.write_permission" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'developer' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'developer'"
|
||||
>
|
||||
<UiRadioGroupItem value="developer" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:lock" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.developerOnly') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.developerOnlyAppDesc') : t('developer.cloudVariables.create.developerOnlyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'user'"
|
||||
>
|
||||
<UiRadioGroupItem value="user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:unlock" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.userWritable') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.userWritableAppDesc') : t('developer.cloudVariables.create.userWritableDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.data_type === 'stream'"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'app_user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'app_user'"
|
||||
>
|
||||
<UiRadioGroupItem value="app_user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:users" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
应用用户可写
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
允许应用端用户通过API写入记录,适合订单、日志等场景
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudVariables.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.type') }}</span>
|
||||
<span>{{ t(`developer.cloudVariables.types.${form.var_type}`) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">数据模式</span>
|
||||
<UiBadge variant="outline">
|
||||
{{ form.data_type === 'stream' ? '流水' : '单值' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div v-if="form.data_type === 'stream'" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最大记录数</span>
|
||||
<span>{{ form.max_records || '不限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.scope') }}</span>
|
||||
<UiBadge variant="outline">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.appScope') : t('developer.cloudVariables.userScope') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.writePermission') }}</span>
|
||||
<UiBadge variant="secondary">
|
||||
{{ form.write_permission === 'developer' ? t('developer.cloudVariables.create.developerOnly') : form.write_permission === 'app_user' ? '应用用户可写' : t('developer.cloudVariables.create.userWritable') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.status') }}</span>
|
||||
<UiSwitch
|
||||
:checked="form.status === 'active'"
|
||||
@update:checked="form.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudVariables.create.defaultValue') }}
|
||||
</div>
|
||||
<div class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
|
||||
{{ form.default_value || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.key"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudVariables.create.submit') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cloudVariables.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Variable {
|
||||
id: number
|
||||
key: string
|
||||
data_type: string
|
||||
scope: string
|
||||
max_records: number
|
||||
}
|
||||
|
||||
interface Record {
|
||||
id: number
|
||||
data: Record<string, any>
|
||||
user_id?: number
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const variable = ref<Variable | null>(null)
|
||||
const records = ref<Record[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
async function fetchVariable() {
|
||||
try {
|
||||
const data = await api.get<{ variables: Variable[] }>(`/dev/cloud-variables?app_id=${route.query.app_id}`)
|
||||
const vars = data?.variables || []
|
||||
variable.value = vars.find((v: Variable) => String(v.id) === route.params.id) || null
|
||||
if (variable.value && variable.value.data_type !== 'stream') {
|
||||
toast.error('该变量不是流水类型')
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取变量信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecords() {
|
||||
if (!variable.value)
|
||||
return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', String(page.value))
|
||||
params.append('page_size', String(pageSize.value))
|
||||
if (startDate.value)
|
||||
params.append('start_date', startDate.value)
|
||||
if (endDate.value)
|
||||
params.append('end_date', endDate.value)
|
||||
|
||||
const data = await api.get(`/dev/cloud-variables/${variable.value.id}/records?${params}`)
|
||||
records.value = data?.records || []
|
||||
total.value = data?.total || 0
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取记录失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
toast.error('请选择要删除的记录')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/cloud-variables/${variable.value?.id}/records`, {
|
||||
ids: selectedIds.value,
|
||||
})
|
||||
toast.success('删除成功')
|
||||
selectedIds.value = []
|
||||
fetchRecords()
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectedIds.value.length === records.value.length) {
|
||||
selectedIds.value = []
|
||||
}
|
||||
else {
|
||||
selectedIds.value = records.value.map(r => r.id)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) {
|
||||
selectedIds.value.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
selectedIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatData(data: Record<string, any>) {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
catch {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchVariable()
|
||||
if (variable.value) {
|
||||
fetchRecords()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="流水记录"
|
||||
description="查看云端变量的流水记录数据"
|
||||
:breadcrumbs="[
|
||||
{ title: '云端变量', href: '/developer/cloud-variables' },
|
||||
{ title: '流水记录' },
|
||||
]"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<UiLabel class="text-sm">开始日期</UiLabel>
|
||||
<UiInput
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiLabel class="text-sm">结束日期</UiLabel>
|
||||
<UiInput
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<UiButton @click="fetchRecords">
|
||||
<Icon icon="lucide:search" class="h-4 w-4 mr-2" />
|
||||
查询
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
v-if="selectedIds.length > 0"
|
||||
variant="destructive"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="h-4 w-4 mr-2" />
|
||||
删除选中 ({{ selectedIds.length }})
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="w-10 p-3 text-left">
|
||||
<UiCheckbox
|
||||
:checked="selectedIds.length === records.length && records.length > 0"
|
||||
@update:checked="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="p-3 text-left font-medium">
|
||||
ID
|
||||
</th>
|
||||
<th v-if="variable?.scope === 'user'" class="p-3 text-left font-medium">
|
||||
用户
|
||||
</th>
|
||||
<th class="p-3 text-left font-medium">
|
||||
数据
|
||||
</th>
|
||||
<th class="p-3 text-left font-medium">
|
||||
创建时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="border-t hover:bg-muted/30"
|
||||
>
|
||||
<td class="p-3">
|
||||
<UiCheckbox
|
||||
:checked="selectedIds.includes(record.id)"
|
||||
@update:checked="toggleSelect(record.id)"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-3 text-muted-foreground">
|
||||
{{ record.id }}
|
||||
</td>
|
||||
<td v-if="variable?.scope === 'user'" class="p-3">
|
||||
<span v-if="record.user">{{ record.user.username }}</span>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<pre class="text-xs bg-muted p-2 rounded max-w-md overflow-auto max-h-32">{{ formatData(record.data) }}</pre>
|
||||
</td>
|
||||
<td class="p-3 text-muted-foreground">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="records.length === 0">
|
||||
<td :colspan="variable?.scope === 'user' ? 5 : 4" class="p-8 text-center text-muted-foreground">
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
共 {{ total }} 条记录
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="page <= 1"
|
||||
@click="page--; fetchRecords()"
|
||||
>
|
||||
上一页
|
||||
</UiButton>
|
||||
<span class="text-sm">第 {{ page }} 页</span>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="page * pageSize >= total"
|
||||
@click="page++; fetchRecords()"
|
||||
>
|
||||
下一页
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Download, File, MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { CloudVariable } from '@/pages/developer/cloud-variables/data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function getColumns(actions: {
|
||||
onEdit: (row: CloudVariable) => void
|
||||
onDelete: (row: CloudVariable) => void
|
||||
onToggleStatus: (row: CloudVariable) => void
|
||||
onDownload: (row: CloudVariable) => void
|
||||
}, t: Composer['t']): ColumnDef<CloudVariable>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'key',
|
||||
header: () => t('developer.cloudVariables.columns.key'),
|
||||
cell: ({ row }) => {
|
||||
const key = row.getValue('key') as string
|
||||
if (!key)
|
||||
return '-'
|
||||
return h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, key),
|
||||
h(Copy, { class: 'h-4 w-4', size: 'sm', content: key }),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'default_value',
|
||||
header: () => t('developer.cloudVariables.columns.defaultValue'),
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue('default_value') as string
|
||||
const varType = row.original.var_type
|
||||
if (!value)
|
||||
return '-'
|
||||
if (varType === 'binary') {
|
||||
const originalName = row.original.original_name || value
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(File, { class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'max-w-xs truncate', title: originalName }, originalName),
|
||||
])
|
||||
}
|
||||
return h('div', { class: 'max-w-xs truncate', title: value }, value)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'scope',
|
||||
header: () => t('developer.cloudVariables.columns.scope'),
|
||||
cell: ({ row }) => {
|
||||
const scope = row.getValue('scope') as string
|
||||
if (scope === 'app') {
|
||||
return h(Badge, { variant: 'default' }, () => t('developer.cloudVariables.appScope'))
|
||||
}
|
||||
return h(Badge, { variant: 'secondary' }, () => t('developer.cloudVariables.userScope'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'write_permission',
|
||||
header: () => t('developer.cloudVariables.create.writePermission'),
|
||||
cell: ({ row }) => {
|
||||
const permission = row.getValue('write_permission') as string
|
||||
if (permission === 'user') {
|
||||
return h(Badge, { variant: 'outline', class: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' }, () => t('developer.cloudVariables.create.userWritable'))
|
||||
}
|
||||
return h(Badge, { variant: 'outline' }, () => t('developer.cloudVariables.create.developerOnly'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'var_type',
|
||||
header: () => t('developer.cloudVariables.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('var_type') as string
|
||||
const typeMap: Record<string, string> = {
|
||||
integer: t('developer.cloudVariables.types.integer'),
|
||||
decimal: t('developer.cloudVariables.types.decimal'),
|
||||
string: t('developer.cloudVariables.types.string'),
|
||||
binary: t('developer.cloudVariables.types.binary'),
|
||||
}
|
||||
return h(Badge, { variant: 'outline' }, () => typeMap[type || 'string'] || type || 'string')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: () => t('developer.cloudVariables.columns.description'),
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
if (!description)
|
||||
return '-'
|
||||
return h('div', { class: 'max-w-xs truncate', title: description }, description)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.cloudVariables.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.getValue('application_name') as string
|
||||
if (!appName) {
|
||||
const appId = row.original.app_id
|
||||
if (!appId)
|
||||
return h(Badge, { variant: 'outline' }, () => t('developer.cloudVariables.notLinked'))
|
||||
return '-'
|
||||
}
|
||||
return h(Badge, { variant: 'outline' }, () => appName)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.cloudVariables.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const isActive = status === 'active'
|
||||
return h(Badge, { variant: isActive ? 'default' : 'destructive' }, () => isActive ? t('developer.cloudVariables.statusActive') : t('developer.cloudVariables.statusInactive'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.cloudVariables.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const variable = row.original
|
||||
|
||||
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' },
|
||||
() => {
|
||||
const items = [
|
||||
h(DropdownMenuItem, { onClick: () => actions.onEdit(variable) }, () => [
|
||||
h(Pencil, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudVariables.edit'),
|
||||
]),
|
||||
]
|
||||
|
||||
if (variable.var_type === 'binary') {
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onDownload(variable) }, () => [
|
||||
h(Download, { class: 'mr-2 h-4 w-4' }),
|
||||
'下载文件',
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
items.push(
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(variable) }, () => [
|
||||
variable.status === 'active' ? h(PowerOff, { class: 'mr-2 h-4 w-4' }) : h(Power, { class: 'mr-2 h-4 w-4' }),
|
||||
variable.status === 'active' ? t('developer.cloudVariables.disable') : t('developer.cloudVariables.enable'),
|
||||
]),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(variable) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudVariables.delete'),
|
||||
]),
|
||||
)
|
||||
|
||||
return items
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { Row } from '@tanstack/vue-table'
|
||||
|
||||
import { Ellipsis, Pencil, Trash2, List } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { CloudVariable } from '../data/schema'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<CloudVariable>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableRowActionsProps>()
|
||||
const emit = defineEmits<{
|
||||
edit: [variable: CloudVariable]
|
||||
delete: [variable: CloudVariable]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const variable = props.row.original
|
||||
|
||||
function viewRecords() {
|
||||
router.push(`/developer/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">打开菜单</span>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end" class="w-[160px]">
|
||||
<UiDropdownMenuItem v-if="variable.data_type === 'stream'" @click="viewRecords">
|
||||
<List class="mr-2 h-4 w-4" />
|
||||
查看记录
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem @click="emit('edit', variable)">
|
||||
<Pencil class="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem class="text-destructive" @click="emit('delete', variable)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CloudVariable } from '@/pages/admin/cloud-variables/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<CloudVariable>
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.cloudVariables.searchPlaceholder')"
|
||||
:model-value="(table.getColumn('key')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('key')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { CloudVariable } from '@/pages/admin/cloud-variables/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/cloud-variables/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/cloud-variables/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<CloudVariable>, 'columns'> & {
|
||||
onEdit: (row: CloudVariable) => void
|
||||
onDelete: (row: CloudVariable) => void
|
||||
onToggleStatus: (row: CloudVariable) => void
|
||||
onDownload: (row: CloudVariable) => void
|
||||
onViewRecords: (row: CloudVariable) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
batchAction: [action: 'enable' | 'disable' | 'delete', rows: CloudVariable[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<CloudVariable>,
|
||||
...getColumns({
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDownload: props.onDownload,
|
||||
onViewRecords: props.onViewRecords,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<CloudVariable>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.cloudVariables.select',
|
||||
key: 'developer.cloudVariables.columns.key',
|
||||
default_value: 'developer.cloudVariables.columns.defaultValue',
|
||||
scope: 'developer.cloudVariables.columns.scope',
|
||||
write_permission: 'developer.cloudVariables.create.writePermission',
|
||||
var_type: 'developer.cloudVariables.columns.type',
|
||||
description: 'developer.cloudVariables.columns.description',
|
||||
application_name: 'developer.cloudVariables.columns.application',
|
||||
status: 'developer.cloudVariables.columns.status',
|
||||
created_at: 'developer.cloudVariables.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
|
||||
function handleBatchAction(action: 'enable' | 'disable' | 'delete') {
|
||||
const selectedRows = table.getSelectedRowModel().rows.map(row => row.original)
|
||||
emit('batchAction', action, selectedRows)
|
||||
table.resetRowSelection()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<BulkActions :table="table" entity-name="variables">
|
||||
<UiButton variant="outline" size="sm" @click="handleBatchAction('enable')">
|
||||
{{ t('developer.cloudVariables.batchEnableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="handleBatchAction('disable')">
|
||||
{{ t('developer.cloudVariables.batchDisableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="handleBatchAction('delete')">
|
||||
{{ t('developer.cloudVariables.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,546 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
app_id: '',
|
||||
key: '',
|
||||
default_value: '',
|
||||
var_type: 'string',
|
||||
data_type: 'single',
|
||||
max_records: 0,
|
||||
scope: 'app',
|
||||
write_permission: 'developer',
|
||||
description: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.app_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.app_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
selectedFile.value = target.files[0]
|
||||
if (!form.value.key) {
|
||||
form.value.key = selectedFile.value.name.replace(/\.[^/.]+$/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.key) {
|
||||
toast.error(t('developer.cloudVariables.create.keyRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.app_id) {
|
||||
toast.error(t('developer.cloudVariables.create.appRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.var_type === 'binary') {
|
||||
if (!selectedFile.value) {
|
||||
toast.error('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
await uploadFile()
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/cloud-variables', {
|
||||
app_id: Number(form.value.app_id),
|
||||
key: form.value.key,
|
||||
default_value: form.value.default_value,
|
||||
var_type: form.value.var_type,
|
||||
data_type: form.value.data_type,
|
||||
max_records: form.value.max_records,
|
||||
scope: form.value.scope,
|
||||
write_permission: form.value.write_permission,
|
||||
description: form.value.description,
|
||||
status: form.value.status,
|
||||
})
|
||||
toast.success(t('developer.cloudVariables.create.success'))
|
||||
router.push('/developer/cloud-variables')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建云端变量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudVariables.create.failed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value || !form.value.app_id || !form.value.key)
|
||||
return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('app_id', form.value.app_id)
|
||||
formData.append('key', form.value.key)
|
||||
formData.append('description', form.value.description)
|
||||
formData.append('status', form.value.status)
|
||||
formData.append('scope', form.value.scope)
|
||||
formData.append('write_permission', form.value.write_permission)
|
||||
|
||||
await api.postFormData('/dev/cloud-variables/upload', formData)
|
||||
toast.success(t('developer.cloudVariables.create.success'))
|
||||
router.push('/developer/cloud-variables')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('上传文件失败:', error)
|
||||
toast.error(error.message || '上传文件失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudVariables.addVariable')"
|
||||
:description="t('developer.cloudVariables.createDescription')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudVariables.title'), href: '/developer/cloud-variables' },
|
||||
{ title: t('developer.cloudVariables.addVariable') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudVariables.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cloudVariables.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.app_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudVariables.create.selectApp')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="varType">
|
||||
{{ t('developer.cloudVariables.create.type') }}
|
||||
</UiLabel>
|
||||
<UiSelect id="varType" v-model="form.var_type">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudVariables.create.selectType')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="integer">
|
||||
{{ t('developer.cloudVariables.types.integer') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="decimal">
|
||||
{{ t('developer.cloudVariables.types.decimal') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="string">
|
||||
{{ t('developer.cloudVariables.types.string') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="binary">
|
||||
二进制
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>数据模式</UiLabel>
|
||||
<UiRadioGroup v-model="form.data_type" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.data_type === 'single' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.data_type = 'single'"
|
||||
>
|
||||
<UiRadioGroupItem value="single" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
单值模式
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
存储单个值,适合配置项
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.data_type === 'stream' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.data_type = 'stream'"
|
||||
>
|
||||
<UiRadioGroupItem value="stream" class="mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
流水模式
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
存储记录列表,适合订单、日志等
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="form.data_type === 'stream'" class="space-y-2">
|
||||
<UiLabel for="maxRecords">
|
||||
最大记录数
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="maxRecords"
|
||||
v-model.number="form.max_records"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0 = 不限制"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
超过限制时自动删除最旧的记录,0表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="key">
|
||||
{{ t('developer.cloudVariables.create.key') }}
|
||||
</UiLabel>
|
||||
<UiInput id="key" v-model="form.key" :placeholder="t('developer.cloudVariables.create.keyPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudVariables.create.keyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.var_type === 'binary'" class="space-y-2">
|
||||
<UiLabel>选择文件</UiLabel>
|
||||
<div v-if="!selectedFile" class="border-2 border-dashed rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<Icon icon="lucide:upload" class="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
拖拽文件到此处或点击选择
|
||||
</p>
|
||||
<UiButton variant="outline" @click="fileInput?.click()">
|
||||
选择文件
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-else class="border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="lucide:file" class="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatFileSize(selectedFile.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton variant="ghost" size="icon" @click="clearFile">
|
||||
<Icon icon="lucide:x" class="h-4 w-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<UiLabel for="defaultValue">
|
||||
{{ t('developer.cloudVariables.create.defaultValue') }}
|
||||
</UiLabel>
|
||||
<UiTextarea id="defaultValue" v-model="form.default_value" :placeholder="t('developer.cloudVariables.create.defaultValuePlaceholder')" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudVariables.create.description') }}
|
||||
</UiLabel>
|
||||
<UiInput id="description" v-model="form.description" :placeholder="t('developer.cloudVariables.create.descriptionPlaceholder')" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.cloudVariables.create.scopeTitle') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.scopeDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.scope" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.scope === 'app' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.scope = 'app'"
|
||||
>
|
||||
<UiRadioGroupItem value="app" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:globe" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.appScope') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ t('developer.cloudVariables.create.appScopeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.scope === 'user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.scope = 'user'"
|
||||
>
|
||||
<UiRadioGroupItem value="user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:user" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.userScope') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ t('developer.cloudVariables.create.userScopeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.cloudVariables.create.permissionTitle') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudVariables.create.permissionDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<UiRadioGroup v-model="form.write_permission" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'developer' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'developer'"
|
||||
>
|
||||
<UiRadioGroupItem value="developer" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:lock" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.developerOnly') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.developerOnlyAppDesc') : t('developer.cloudVariables.create.developerOnlyDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'user'"
|
||||
>
|
||||
<UiRadioGroupItem value="user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:unlock" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudVariables.create.userWritable') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.create.userWritableAppDesc') : t('developer.cloudVariables.create.userWritableDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.data_type === 'stream'"
|
||||
class="flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="form.write_permission === 'app_user' ? 'border-primary bg-primary/5' : 'hover:bg-accent'"
|
||||
@click="form.write_permission = 'app_user'"
|
||||
>
|
||||
<UiRadioGroupItem value="app_user" class="mt-0.5" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:users" class="size-4 text-primary" />
|
||||
<p class="font-medium">
|
||||
应用用户可写
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
允许应用端用户通过API写入记录,适合订单、日志等场景
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiRadioGroup>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudVariables.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.type') }}</span>
|
||||
<span>{{ t(`developer.cloudVariables.types.${form.var_type}`) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">数据模式</span>
|
||||
<UiBadge variant="outline">
|
||||
{{ form.data_type === 'stream' ? '流水' : '单值' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div v-if="form.data_type === 'stream'" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">最大记录数</span>
|
||||
<span>{{ form.max_records || '不限制' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.key') }}</span>
|
||||
<span>{{ form.key || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.scope') }}</span>
|
||||
<UiBadge variant="outline">
|
||||
{{ form.scope === 'app' ? t('developer.cloudVariables.appScope') : t('developer.cloudVariables.userScope') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.writePermission') }}</span>
|
||||
<UiBadge variant="secondary">
|
||||
{{ form.write_permission === 'developer' ? t('developer.cloudVariables.create.developerOnly') : form.write_permission === 'app_user' ? '应用用户可写' : t('developer.cloudVariables.create.userWritable') }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudVariables.create.status') }}</span>
|
||||
<UiSwitch
|
||||
:checked="form.status === 'active'"
|
||||
@update:checked="form.status = $event ? 'active' : 'inactive'"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudVariables.create.defaultValue') }}
|
||||
</div>
|
||||
<div class="bg-muted p-2 rounded max-h-[100px] overflow-y-auto text-xs">
|
||||
{{ form.default_value || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="(saving || uploading) || !form.app_id || !form.key || (form.var_type === 'binary' && !selectedFile)"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving || uploading" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ uploading ? '上传中...' : t('developer.cloudVariables.create.submit') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.cloudVariables.create.cancel') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CloudVariable } from './schema'
|
||||
|
||||
export const variableAppOptions: { label: string, value: string }[] = []
|
||||
|
||||
export function filterVariablesByApp(variables: CloudVariable[], appId: string): CloudVariable[] {
|
||||
if (!appId || appId === 'all')
|
||||
return variables
|
||||
return variables.filter(v => String(v.app_id) === appId)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const cloudVariableSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
user_id: z.union([z.string(), z.number()]),
|
||||
app_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
application_name: z.string().optional().nullable(),
|
||||
key: z.string(),
|
||||
default_value: z.string().optional().nullable(),
|
||||
var_type: z.string().optional().nullable(),
|
||||
data_type: z.string().optional().nullable(),
|
||||
max_records: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
file_path: z.string().optional().nullable(),
|
||||
file_size: z.union([z.string(), z.number()]).optional().nullable(),
|
||||
mime_type: z.string().optional().nullable(),
|
||||
original_name: z.string().optional().nullable(),
|
||||
scope: z.string().optional().nullable(),
|
||||
write_permission: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
status: z.string().optional().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export type CloudVariable = z.infer<typeof cloudVariableSchema>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
import type { CloudVariable } from './data/schema'
|
||||
|
||||
import DataTable from './components/data-table.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
app_key: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const variables = ref<CloudVariable[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const scopeFilter = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<CloudVariable | null>(null)
|
||||
const batchDialogOpen = ref(false)
|
||||
const batchAction = ref<'enable' | 'disable' | 'delete' | null>(null)
|
||||
const selectedRows = ref<CloudVariable[]>([])
|
||||
|
||||
const filteredVariables = computed(() => {
|
||||
let result = [...variables.value]
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(v => String(v.app_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (scopeFilter.value) {
|
||||
result = result.filter(v => v.scope === scopeFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(v => v.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(v => v.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const appScopeCount = computed(() => variables.value.filter(v => v.scope === 'app').length)
|
||||
const userScopeCount = computed(() => variables.value.filter(v => v.scope === 'user').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const scopeOptions = computed(() => [
|
||||
{ label: t('developer.cloudVariables.appScope'), value: 'app' },
|
||||
{ label: t('developer.cloudVariables.userScope'), value: 'user' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVariables() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ variables: CloudVariable[] }>('/dev/cloud-variables')
|
||||
const vars = data?.variables || []
|
||||
variables.value = vars.map((v: any) => ({
|
||||
...v,
|
||||
application_name: applications.value.find(app => app.id === v.app_id)?.name || '',
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端变量失败:', error)
|
||||
variables.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreatePage() {
|
||||
router.push('/developer/cloud-variables/create')
|
||||
}
|
||||
|
||||
function openEditPage(variable: CloudVariable) {
|
||||
router.push(`/developer/cloud-variables/${variable.id}`)
|
||||
}
|
||||
|
||||
function confirmDelete(variable: CloudVariable) {
|
||||
deleteTarget.value = variable
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/cloud-variables/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.cloudVariables.deleteSuccess'))
|
||||
fetchVariables()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端变量失败:', error)
|
||||
toast.error(error.message || t('developer.cloudVariables.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStatus(variable: CloudVariable) {
|
||||
const newStatus = variable.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await api.put(`/dev/cloud-variables/${variable.id}`, {
|
||||
key: variable.key,
|
||||
default_value: variable.default_value,
|
||||
var_type: variable.var_type,
|
||||
scope: variable.scope,
|
||||
write_permission: variable.write_permission,
|
||||
description: variable.description,
|
||||
status: newStatus,
|
||||
})
|
||||
toast.success(newStatus === 'active' ? t('developer.cloudVariables.enableSuccess') : t('developer.cloudVariables.disableSuccess'))
|
||||
fetchVariables()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新状态失败:', error)
|
||||
toast.error(error.message || t('developer.cloudVariables.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(variable: CloudVariable) {
|
||||
if (variable.var_type !== 'binary')
|
||||
return
|
||||
const token = localStorage.getItem('token')
|
||||
const url = `/api/v1/dev/cloud-variables/${variable.id}/download?token=${token}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleViewRecords(variable: CloudVariable) {
|
||||
router.push(`/developer/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
|
||||
}
|
||||
|
||||
function openBatchDialog(action: 'enable' | 'disable' | 'delete', rows: CloudVariable[]) {
|
||||
batchAction.value = action
|
||||
selectedRows.value = rows
|
||||
batchDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchAction() {
|
||||
if (!batchAction.value || selectedRows.value.length === 0)
|
||||
return
|
||||
|
||||
const ids = selectedRows.value.map(v => v.id)
|
||||
try {
|
||||
if (batchAction.value === 'delete') {
|
||||
await Promise.all(ids.map(id => api.delete(`/dev/cloud-variables/${id}`)))
|
||||
toast.success(t('developer.cloudVariables.batchDeleteSuccess'))
|
||||
}
|
||||
else {
|
||||
const status = batchAction.value === 'enable' ? 'active' : 'inactive'
|
||||
await Promise.all(selectedRows.value.map(v => api.put(`/dev/cloud-variables/${v.id}`, {
|
||||
key: v.key,
|
||||
default_value: v.default_value,
|
||||
var_type: v.var_type,
|
||||
scope: v.scope,
|
||||
write_permission: v.write_permission,
|
||||
description: v.description,
|
||||
status,
|
||||
})))
|
||||
toast.success(batchAction.value === 'enable' ? t('developer.cloudVariables.batchEnableSuccess') : t('developer.cloudVariables.batchDisableSuccess'))
|
||||
}
|
||||
fetchVariables()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量操作失败:', error)
|
||||
toast.error(error.message || t('developer.cloudVariables.batchFailed'))
|
||||
}
|
||||
finally {
|
||||
batchAction.value = null
|
||||
selectedRows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchApplications()
|
||||
fetchVariables()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudVariables.title')"
|
||||
:description="t('developer.cloudVariables.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="openCreatePage">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudVariables.addVariable') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudVariables.totalVariables') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:variable" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredVariables.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudVariables.appScopeVariables') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:globe" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ appScopeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudVariables.userScopeVariables') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:user" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ userScopeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudVariables.appCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ applications.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
:loading
|
||||
:data="filteredVariables"
|
||||
:on-edit="openEditPage"
|
||||
:on-delete="confirmDelete"
|
||||
:on-toggle-status="handleToggleStatus"
|
||||
:on-download="handleDownload"
|
||||
:on-view-records="handleViewRecords"
|
||||
@refresh="fetchVariables"
|
||||
@batch-action="openBatchDialog"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.cloudVariables.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="scopeFilter"
|
||||
:title="t('developer.cloudVariables.scope')"
|
||||
:options="scopeOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cloudVariables.delete')"
|
||||
:cancel-button-text="t('common.reset')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cloudVariables.deleteVariable') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cloudVariables.deleteConfirm', { key: deleteTarget?.key }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDialogOpen"
|
||||
:destructive="batchAction === 'delete'"
|
||||
:confirm-button-text="batchAction === 'delete' ? t('developer.cloudVariables.delete') : t('common.confirm')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchAction"
|
||||
>
|
||||
<template #title>
|
||||
{{ batchAction === 'delete' ? t('developer.cloudVariables.batchDelete') : batchAction === 'enable' ? t('developer.cloudVariables.batchEnable') : t('developer.cloudVariables.batchDisable') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ batchAction === 'delete' ? t('developer.cloudVariables.batchDeleteConfirm', { count: selectedRows.length }) : batchAction === 'enable' ? t('developer.cloudVariables.batchEnableConfirm', { count: selectedRows.length }) : t('developer.cloudVariables.batchDisableConfirm', { count: selectedRows.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { Ban, Eye, LogOut, MoreHorizontal, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { Device } from '@/pages/developer/devices/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 getColumns(actions: {
|
||||
onToggleStatus: (row: Device) => void
|
||||
onDelete: (row: Device) => void
|
||||
onForceOffline: (row: Device) => void
|
||||
}): ColumnDef<Device>[] {
|
||||
const router = useRouter()
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'device_id',
|
||||
header: '设备指纹',
|
||||
cell: ({ row }) => {
|
||||
const deviceId = row.getValue('device_id') as string
|
||||
if (!deviceId)
|
||||
return '-'
|
||||
const displayText = deviceId.length > 16
|
||||
? `${deviceId.substring(0, 8)}...${deviceId.substring(deviceId.length - 4)}`
|
||||
: deviceId
|
||||
return h('span', { class: 'font-mono text-xs' }, displayText)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_name',
|
||||
header: '设备名称',
|
||||
cell: ({ row }) => {
|
||||
const deviceName = row.getValue('device_name') as string
|
||||
return deviceName || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_type',
|
||||
header: '设备类型',
|
||||
cell: ({ row }) => {
|
||||
const deviceType = row.getValue('device_type') as string
|
||||
return deviceType || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user.username',
|
||||
header: '所属用户',
|
||||
cell: ({ row }) => {
|
||||
const username = row.original.user?.username
|
||||
return username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application.name',
|
||||
header: '所属应用',
|
||||
cell: ({ row }) => {
|
||||
const appName = row.original.application?.name
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'online_status',
|
||||
header: '在线状态',
|
||||
cell: ({ row }) => {
|
||||
const onlineSessions = row.original.online_sessions || 0
|
||||
const isOnline = onlineSessions > 0
|
||||
return h(Badge, { variant: isOnline ? 'default' : 'secondary' }, () => isOnline ? '在线' : '离线')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'online_sessions',
|
||||
header: '在线实例',
|
||||
cell: ({ row }) => {
|
||||
const onlineSessions = row.getValue('online_sessions') as number
|
||||
const count = onlineSessions || 0
|
||||
if (count === 0) {
|
||||
return h('span', { class: 'text-muted-foreground' }, '0')
|
||||
}
|
||||
return h(Badge, { variant: 'default', class: 'cursor-pointer' }, () => `${count} 个`)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '状态',
|
||||
cell: ({ row }) => {
|
||||
const status = (row.getValue('status') as string) || 'active'
|
||||
const isBanned = status === 'banned'
|
||||
return h(Badge, { variant: isBanned ? 'destructive' : 'default' }, () => isBanned ? '已禁用' : '正常')
|
||||
},
|
||||
},
|
||||
{
|
||||
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 device = row.original
|
||||
const isBanned = device.status === 'banned'
|
||||
const hasOnlineSessions = (device.online_sessions || 0) > 0
|
||||
|
||||
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' }, '打开菜单'),
|
||||
])),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{ align: 'end' },
|
||||
() => [
|
||||
hasOnlineSessions
|
||||
? h(DropdownMenuItem, { onClick: () => router.push(`/developer/sessions?device_fingerprint=${device.device_id}`) }, () => [
|
||||
h(Eye, { class: 'mr-2 h-4 w-4' }),
|
||||
'查看实例',
|
||||
])
|
||||
: null,
|
||||
hasOnlineSessions
|
||||
? h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onForceOffline(device) }, () => [
|
||||
h(LogOut, { class: 'mr-2 h-4 w-4' }),
|
||||
'强制离线',
|
||||
])
|
||||
: null,
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(device) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isBanned ? '启用' : '禁用',
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(device) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
'解绑',
|
||||
]),
|
||||
].filter(Boolean),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Device } from '@/pages/admin/devices/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<{
|
||||
table: Table<Device>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
batchUnbind: []
|
||||
batchBan: []
|
||||
batchUnban: []
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.devices.searchPlaceholder')"
|
||||
:model-value="(table.getColumn('device_id')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@update:model-value="table.getColumn('device_id')?.setFilterValue($event)"
|
||||
/>
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('developer.devices.reset') }}
|
||||
<Icon icon="lucide:x" class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<Icon icon="lucide:refresh-cw" class="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
<template v-if="selectedCount > 0">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('developer.devices.selected', { count: selectedCount }) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="emit('batchUnban')"
|
||||
>
|
||||
<Icon icon="lucide:check-circle" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.devices.batchUnban') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="emit('batchBan')"
|
||||
>
|
||||
<Icon icon="lucide:ban" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.devices.batchBan') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
@click="emit('batchUnbind')"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.devices.batchUnbind') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { Device } from '@/pages/admin/devices/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/devices/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/devices/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Device>, 'columns'> & {
|
||||
onToggleStatus: (row: Device) => void
|
||||
onDelete: (row: Device) => void
|
||||
onForceOffline: (row: Device) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
batchUnbind: []
|
||||
batchBan: []
|
||||
batchUnban: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Device>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onDelete: props.onDelete,
|
||||
onForceOffline: props.onForceOffline,
|
||||
}),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Device>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
'select': 'developer.devices.select',
|
||||
'device_id': 'developer.devices.columns.deviceId',
|
||||
'device_name': 'developer.devices.columns.deviceName',
|
||||
'device_type': 'developer.devices.columns.deviceType',
|
||||
'user.username': 'developer.devices.columns.user',
|
||||
'application.name': 'developer.devices.columns.application',
|
||||
'online_status': 'developer.devices.columns.status',
|
||||
'online_sessions': 'developer.devices.columns.onlineSessions',
|
||||
'status': 'developer.devices.columns.deviceStatus',
|
||||
'created_at': 'developer.devices.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="devices">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchUnban')">
|
||||
{{ t('developer.devices.batchUnban') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchBan')">
|
||||
{{ t('developer.devices.batchBan') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchUnbind')">
|
||||
{{ t('developer.devices.batchUnbind') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar
|
||||
:table
|
||||
@batch-unbind="emit('batchUnbind')"
|
||||
@batch-ban="emit('batchBan')"
|
||||
@batch-unban="emit('batchUnban')"
|
||||
@refresh="emit('refresh')"
|
||||
/>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DeviceStatus } from './schema'
|
||||
|
||||
export function getDeviceStatusLabel(status: DeviceStatus): string {
|
||||
const labels: Record<DeviceStatus, string> = {
|
||||
active: '在线',
|
||||
inactive: '离线',
|
||||
banned: '已禁用',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
export function getDeviceStatusVariant(status: DeviceStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
const variants: Record<DeviceStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
inactive: 'secondary',
|
||||
banned: 'destructive',
|
||||
}
|
||||
return variants[status] || 'secondary'
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const deviceStatusSchema = z.enum(['active', 'inactive', 'banned'])
|
||||
|
||||
export const deviceSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
user_id: z.union([z.string(), z.number()]),
|
||||
application_id: z.union([z.string(), z.number()]),
|
||||
device_id: z.string(),
|
||||
device_name: z.string().optional().nullable(),
|
||||
device_type: z.string().optional().nullable(),
|
||||
status: deviceStatusSchema,
|
||||
online_sessions: z.number().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
user: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
username: z.string(),
|
||||
}).optional().nullable(),
|
||||
application: z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
}).optional().nullable(),
|
||||
})
|
||||
|
||||
export type Device = z.infer<typeof deviceSchema>
|
||||
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Device } from '@/pages/developer/devices/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/developer/devices/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const devices = ref<Device[]>([])
|
||||
const tableRef = ref()
|
||||
const applications = ref<Application[]>([])
|
||||
const users = ref<User[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const userIdFilter = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Device | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
const forceOfflineDialogOpen = ref(false)
|
||||
const forceOfflineTarget = ref<Device | null>(null)
|
||||
|
||||
const filteredDevices = computed(() => {
|
||||
let result = devices.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(device => String(device.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(device => device.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (userIdFilter.value) {
|
||||
result = result.filter(device => String(device.user_id) === userIdFilter.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalDevices = computed(() => filteredDevices.value.length)
|
||||
const onlineSessionCount = computed(() => filteredDevices.value.reduce((sum, d) => sum + (d.online_sessions || 0), 0))
|
||||
const bannedCount = computed(() => filteredDevices.value.filter(device => device.status === 'banned').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const userOptions = computed(() => {
|
||||
return users.value.map(user => ({
|
||||
label: user.username,
|
||||
value: String(user.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.devices.normal'), value: 'active' },
|
||||
{ label: t('developer.devices.banned'), value: 'banned' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const data = await api.get<{ users: User[] }>('/dev/app-users')
|
||||
users.value = data?.users || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const userId = route.query.user_id as string
|
||||
const deviceId = route.query.device_id as string
|
||||
|
||||
let url = '/dev/devices'
|
||||
const params: string[] = []
|
||||
if (userId) {
|
||||
params.push(`user_id=${userId}`)
|
||||
}
|
||||
if (deviceId) {
|
||||
params.push(`device_id=${deviceId}`)
|
||||
}
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join('&')}`
|
||||
}
|
||||
|
||||
const data = await api.get<{ devices: Device[] }>(url)
|
||||
devices.value = Array.isArray(data?.devices) ? data.devices : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
devices.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDeviceStatus(device: Device) {
|
||||
const newStatus = device.status === 'banned' ? 'active' : 'banned'
|
||||
try {
|
||||
await api.put(`/dev/devices/${device.id}/status`, { status: newStatus })
|
||||
toast.success(t('developer.devices.statusUpdateSuccess'))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换设备状态失败:', error)
|
||||
toast.error(error.message || t('developer.devices.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteDevice(device: Device) {
|
||||
deleteTarget.value = device
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteDevice() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/devices/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.devices.unbindSuccess'))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('解绑设备失败:', error)
|
||||
toast.error(error.message || t('developer.devices.unbindFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: (string | number)[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/devices/batch', { device_ids: batchDeleteIds.value.map(id => Number(id)) } as any)
|
||||
toast.success(t('developer.devices.batchUnbindSuccess'))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量解绑失败:', error)
|
||||
toast.error(error.message || t('developer.devices.batchUnbindFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
await api.post('/dev/devices/batch/status', { device_ids: ids.map(id => Number(id)), status })
|
||||
toast.success(t('developer.devices.batchUpdateSuccess'))
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || t('developer.devices.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmForceOffline(device: Device) {
|
||||
forceOfflineTarget.value = device
|
||||
forceOfflineDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleForceOffline() {
|
||||
if (!forceOfflineTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const data = await api.post<{ count: number }>(`/dev/devices/${forceOfflineTarget.value.id}/force-offline`)
|
||||
toast.success(`已强制离线 ${data?.count || 0} 个实例`)
|
||||
fetchDevices()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('强制离线失败:', error)
|
||||
toast.error(error.message || '强制离线失败')
|
||||
}
|
||||
finally {
|
||||
forceOfflineTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const userId = route.query.user_id as string
|
||||
if (userId) {
|
||||
userIdFilter.value = userId
|
||||
}
|
||||
|
||||
fetchApplications()
|
||||
fetchUsers()
|
||||
fetchDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.devices.title')"
|
||||
:description="t('developer.devices.description')"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.devices.totalDevices') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:monitor" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalDevices }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.devices.onlineSessions') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layers" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ onlineSessionCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.devices.normalDevices') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ totalDevices - bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.devices.banned') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ bannedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredDevices"
|
||||
:on-toggle-status="toggleDeviceStatus"
|
||||
:on-delete="confirmDeleteDevice"
|
||||
:on-force-offline="confirmForceOffline"
|
||||
@refresh="fetchDevices"
|
||||
@batch-unbind="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@batch-ban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status !== 'banned').map((r: any) => r.original.id) || [], 'banned')"
|
||||
@batch-unban="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'banned').map((r: any) => r.original.id) || [], 'active')"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.devices.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="userIdFilter"
|
||||
:title="t('developer.devices.user')"
|
||||
:options="userOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.devices.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.devices.unbind')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleDeleteDevice"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.devices.unbindDevice') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.devices.unbindConfirm', { deviceId: deleteTarget?.device_id }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.devices.unbind')"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.devices.batchUnbind') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.devices.batchUnbindConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="forceOfflineDialogOpen"
|
||||
destructive
|
||||
confirm-button-text="强制离线"
|
||||
:cancel-button-text="t('common.cancel')"
|
||||
@confirm="handleForceOffline"
|
||||
>
|
||||
<template #title>
|
||||
强制离线
|
||||
</template>
|
||||
<template #description>
|
||||
确定要将设备 "{{ forceOfflineTarget?.device_id }}" 的所有在线实例强制离线吗?该设备将需要重新登录。
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
interface DynamicCode {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
applicationId: string
|
||||
appName: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const codes = ref<DynamicCode[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
const selectedCode = ref<DynamicCode | null>(null)
|
||||
const showCodeDialog = ref(false)
|
||||
|
||||
async function fetchCodes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/dynamic-codes`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
codes.value = data.data || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端函数失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/applications`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
applications.value = data.data || []
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function openCodeDialog(code: DynamicCode) {
|
||||
selectedCode.value = code
|
||||
showCodeDialog.value = true
|
||||
}
|
||||
|
||||
async function toggleCodeStatus(code: DynamicCode) {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/dev/dynamic-codes/${code.id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled: !code.enabled }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code === 200) {
|
||||
fetchCodes()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('切换云端函数状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCodes()
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="云端函数"
|
||||
description="管理应用的云端函数配置"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardContent class="p-4 sm:p-6">
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
加载中...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="codes.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:code" class="mx-auto h-12 w-12 text-muted-foreground mb-2" />
|
||||
<p>暂无云端函数</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="code in codes"
|
||||
:key="code.id"
|
||||
class="border rounded-lg p-4 space-y-3"
|
||||
:class="code.enabled ? 'bg-card' : 'bg-muted/30'"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Icon icon="lucide:code" class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ code.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ code.appName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiBadge :variant="code.enabled ? 'default' : 'secondary'">
|
||||
{{ code.enabled ? '已启用' : '已禁用' }}
|
||||
</UiBadge>
|
||||
<UiButton variant="outline" size="sm" @click="toggleCodeStatus(code)">
|
||||
{{ code.enabled ? '禁用' : '启用' }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="openCodeDialog(code)">
|
||||
<Icon icon="lucide:eye" class="h-4 w-4 mr-1" />
|
||||
查看代码
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="code.description" class="text-sm text-muted-foreground">
|
||||
{{ code.description }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>创建时间: {{ formatDate(code.createdAt) }}</span>
|
||||
<span>更新时间: {{ formatDate(code.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiDialog v-model:open="showCodeDialog">
|
||||
<UiDialogContent class="w-[95vw] sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>{{ selectedCode?.name }}</UiDialogTitle>
|
||||
<UiDialogDescription>云端函数详情</UiDialogDescription>
|
||||
</UiDialogHeader>
|
||||
<div class="py-4">
|
||||
<div class="bg-muted rounded-lg p-4">
|
||||
<pre class="text-sm font-mono whitespace-pre-wrap break-all">{{ selectedCode?.code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton @click="showCodeDialog = false">
|
||||
关闭
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { DynamicCode } from '@/pages/developer/dynamic-code/data/schema'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const saving = ref(false)
|
||||
const loading = ref(true)
|
||||
const applications = ref<Application[]>([])
|
||||
const codeId = ref<number>(0)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
application_id: '',
|
||||
description: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const filteredApplications = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCode() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<DynamicCode>(`/dev/dynamic-codes/${codeId.value}`)
|
||||
if (data) {
|
||||
form.value.name = data.name
|
||||
form.value.application_id = String(data.application_id)
|
||||
form.value.description = data.description || ''
|
||||
form.value.code = data.code
|
||||
form.value.enabled = data.enabled
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端函数失败:', error)
|
||||
toast.error(t('developer.cloudFunction.fetchFailed'))
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name.trim()) {
|
||||
toast.error(t('developer.cloudFunction.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.cloudFunction.create.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.code.trim()) {
|
||||
toast.error(t('developer.cloudFunction.create.codeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/dev/dynamic-codes/${codeId.value}`, {
|
||||
name: form.value.name,
|
||||
application_id: Number.parseInt(form.value.application_id),
|
||||
description: form.value.description,
|
||||
code: form.value.code,
|
||||
enabled: form.value.enabled,
|
||||
})
|
||||
toast.success(t('developer.cloudFunction.updateSuccess'))
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新云端函数失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.updateFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const id = route.params.id
|
||||
if (id && typeof id === 'string') {
|
||||
codeId.value = Number.parseInt(id)
|
||||
fetchApplications()
|
||||
fetchCode()
|
||||
}
|
||||
else {
|
||||
toast.error('无效的云端函数ID')
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudFunction.edit.title')"
|
||||
:description="t('developer.cloudFunction.edit.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudFunction.title'), href: '/developer/cloud-function' },
|
||||
{ title: t('developer.cloudFunction.edit.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudFunction.edit.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudFunction.edit.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cloudFunction.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudFunction.create.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="app in filteredApplications"
|
||||
:key="app.value"
|
||||
:value="app.value"
|
||||
>
|
||||
{{ app.label }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.cloudFunction.create.name') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:placeholder="t('developer.cloudFunction.create.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudFunction.create.description') }}
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
:placeholder="t('developer.cloudFunction.create.descriptionPlaceholder')"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="code">
|
||||
{{ t('developer.cloudFunction.create.code') }}
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
:placeholder="t('developer.cloudFunction.create.codePlaceholder')"
|
||||
rows="12"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudFunction.create.codeHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.cloudFunction.create.status') }}</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudFunction.create.enabled') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enabled" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudFunction.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.name') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.description') }}</span>
|
||||
<span class="text-right max-w-[150px] truncate">{{ form.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.status') }}</span>
|
||||
<UiSwitch
|
||||
v-model="form.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudFunction.create.code') }}
|
||||
</div>
|
||||
<div class="font-mono text-xs bg-muted p-2 rounded max-h-[150px] overflow-y-auto">
|
||||
{{ form.code || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id || !form.code"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudFunction.edit.submitBtn') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('developer.cloudFunction.edit.cancelBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, MoreHorizontal, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { DynamicCode } from '@/pages/developer/dynamic-code/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 getColumns(actions: {
|
||||
onToggleStatus: (row: DynamicCode) => void
|
||||
onEdit: (row: DynamicCode) => void
|
||||
onDelete: (row: DynamicCode) => void
|
||||
}, t: Composer['t']): ColumnDef<DynamicCode>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => t('developer.cloudFunction.columns.name'),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('name') as string
|
||||
return name || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.cloudFunction.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.getValue('application_name') as string
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: () => t('developer.cloudFunction.columns.description'),
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
return description || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'enabled',
|
||||
header: () => t('developer.cloudFunction.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const enabled = row.getValue('enabled') as boolean
|
||||
return h(Badge, { variant: enabled ? 'default' : 'destructive' }, () => enabled ? t('developer.cloudFunction.enabled') : t('developer.cloudFunction.disabled'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'creator.username',
|
||||
header: () => t('developer.cloudFunction.columns.creator'),
|
||||
cell: ({ row }) => {
|
||||
const username = row.original.creator?.username
|
||||
return username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.cloudFunction.columns.createdAt'),
|
||||
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 '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: () => t('developer.cloudFunction.columns.updatedAt'),
|
||||
cell: ({ row }) => {
|
||||
const updatedAt = row.getValue('updated_at')
|
||||
if (!updatedAt)
|
||||
return '-'
|
||||
try {
|
||||
const date = new Date(updatedAt 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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const code = row.original
|
||||
const isDisabled = !code.enabled
|
||||
|
||||
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(code) }, () => [
|
||||
h(Pencil, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudFunction.edit.title'),
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(code) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isDisabled ? t('developer.cloudFunction.enable') : t('developer.cloudFunction.disable'),
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(code) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.cloudFunction.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DynamicCode } from '@/pages/developer/dynamic-code/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<DynamicCode>
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.cloudFunction.searchCode')"
|
||||
:model-value="(table.getColumn('name')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('name')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
{{ t('developer.cloudFunction.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { DynamicCode } from '@/pages/admin/dynamic-code/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getColumns } from '@/pages/admin/dynamic-code/components/columns'
|
||||
import DataTableToolbar from '@/pages/admin/dynamic-code/components/data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<DynamicCode>, 'columns'> & {
|
||||
onToggleStatus: (row: DynamicCode) => void
|
||||
onEdit: (row: DynamicCode) => void
|
||||
onDelete: (row: DynamicCode) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
batchEnable: []
|
||||
batchDisable: []
|
||||
batchDelete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<DynamicCode>,
|
||||
...getColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onEdit: props.onEdit,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<DynamicCode>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.cloudFunction.select',
|
||||
name: 'developer.cloudFunction.columns.name',
|
||||
application_name: 'developer.cloudFunction.columns.application',
|
||||
description: 'developer.cloudFunction.columns.description',
|
||||
enabled: 'developer.cloudFunction.columns.status',
|
||||
creator_username: 'developer.cloudFunction.columns.creator',
|
||||
created_at: 'developer.cloudFunction.columns.createdAt',
|
||||
updated_at: 'developer.cloudFunction.columns.updatedAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<BulkActions :table="table" entity-name="functions">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
{{ t('developer.cloudFunction.batchEnableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
{{ t('developer.cloudFunction.batchDisableBtn') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.cloudFunction.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DataTableToolbar :table />
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
application_id: '',
|
||||
description: '',
|
||||
code: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const filteredApplications = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name.trim()) {
|
||||
toast.error(t('developer.cloudFunction.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.cloudFunction.create.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.code.trim()) {
|
||||
toast.error(t('developer.cloudFunction.create.codeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/dynamic-codes', {
|
||||
name: form.value.name,
|
||||
application_id: Number.parseInt(form.value.application_id),
|
||||
description: form.value.description,
|
||||
code: form.value.code,
|
||||
enabled: form.value.enabled,
|
||||
})
|
||||
toast.success(t('developer.cloudFunction.create.submitSuccess'))
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建云端函数失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.create.submitFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.push('/developer/cloud-function')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudFunction.create.title')"
|
||||
:description="t('developer.cloudFunction.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.cloudFunction.title'), href: '/developer/cloud-function' },
|
||||
{ title: t('developer.cloudFunction.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.cloudFunction.create.config') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.cloudFunction.create.configDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.cloudFunction.create.application') }}
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.cloudFunction.create.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem
|
||||
v-for="app in filteredApplications"
|
||||
:key="app.value"
|
||||
:value="app.value"
|
||||
>
|
||||
{{ app.label }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.cloudFunction.create.name') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:placeholder="t('developer.cloudFunction.create.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="description">
|
||||
{{ t('developer.cloudFunction.create.description') }}
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
:placeholder="t('developer.cloudFunction.create.descriptionPlaceholder')"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="code">
|
||||
{{ t('developer.cloudFunction.create.code') }}
|
||||
</UiLabel>
|
||||
<UiTextarea
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
:placeholder="t('developer.cloudFunction.create.codePlaceholder')"
|
||||
rows="12"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('developer.cloudFunction.create.codeHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('developer.cloudFunction.create.enabled') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiSwitch v-model="form.enabled" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:eye" class="size-5" />
|
||||
{{ t('developer.cloudFunction.create.preview') }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.name') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.description') }}</span>
|
||||
<span class="text-right max-w-[150px] truncate">{{ form.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm items-center">
|
||||
<span class="text-muted-foreground">{{ t('developer.cloudFunction.create.status') }}</span>
|
||||
<UiSwitch
|
||||
v-model="form.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="text-sm">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{{ t('developer.cloudFunction.create.code') }}
|
||||
</div>
|
||||
<div class="font-mono text-xs bg-muted p-2 rounded max-h-[150px] overflow-y-auto">
|
||||
{{ form.code || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id || !form.code"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudFunction.create.submitBtn') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('developer.cloudFunction.create.cancelBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface DynamicCode {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
application_id: number
|
||||
application_name: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
creator?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { DynamicCode } from '@/pages/developer/dynamic-code/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/developer/dynamic-code/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const codes = ref<DynamicCode[]>([])
|
||||
const tableRef = ref()
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const createdStartDate = ref<string>('')
|
||||
const createdEndDate = ref<string>('')
|
||||
const updatedStartDate = ref<string>('')
|
||||
const updatedEndDate = ref<string>('')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<DynamicCode | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
|
||||
const filteredCodes = computed(() => {
|
||||
let result = codes.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(code => String(code.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
if (statusFilter.value === 'enabled') {
|
||||
result = result.filter(code => code.enabled === true)
|
||||
}
|
||||
else if (statusFilter.value === 'disabled') {
|
||||
result = result.filter(code => code.enabled === false)
|
||||
}
|
||||
}
|
||||
|
||||
if (createdStartDate.value) {
|
||||
const fromDateTime = createdStartDate.value.includes('T')
|
||||
? createdStartDate.value.replace('T', ' ')
|
||||
: `${createdStartDate.value} 00:00`
|
||||
result = result.filter(code => code.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (createdEndDate.value) {
|
||||
const toDateTime = createdEndDate.value.includes('T')
|
||||
? createdEndDate.value.replace('T', ' ')
|
||||
: `${createdEndDate.value} 23:59`
|
||||
result = result.filter(code => code.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
if (updatedStartDate.value) {
|
||||
const fromDateTime = updatedStartDate.value.includes('T')
|
||||
? updatedStartDate.value.replace('T', ' ')
|
||||
: `${updatedStartDate.value} 00:00`
|
||||
result = result.filter(code => code.updated_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (updatedEndDate.value) {
|
||||
const toDateTime = updatedEndDate.value.includes('T')
|
||||
? updatedEndDate.value.replace('T', ' ')
|
||||
: `${updatedEndDate.value} 23:59`
|
||||
result = result.filter(code => code.updated_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const enabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === true).length)
|
||||
const disabledCount = computed(() => filteredCodes.value.filter(code => code.enabled === false).length)
|
||||
const linkedAppsCount = computed(() => new Set(filteredCodes.value.map(code => code.application_id)).size)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.cloudFunction.enabled'), value: 'enabled' },
|
||||
{ label: t('developer.cloudFunction.disabled'), value: 'disabled' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCodes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<DynamicCode[]>('/dev/dynamic-codes')
|
||||
codes.value = Array.isArray(data) ? data : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取云端函数列表失败:', error)
|
||||
codes.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreate() {
|
||||
router.push('/developer/cloud-function/create')
|
||||
}
|
||||
|
||||
async function toggleStatus(code: DynamicCode) {
|
||||
try {
|
||||
const newStatus = !code.enabled
|
||||
await api.put(`/dev/dynamic-codes/${code.id}/status`, { enabled: newStatus })
|
||||
toast.success(t('developer.cloudFunction.statusUpdateSuccess'))
|
||||
fetchCodes()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换云端函数状态失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function editCode(code: DynamicCode) {
|
||||
router.push(`/developer/cloud-function/${code.id}`)
|
||||
}
|
||||
|
||||
function confirmDeleteCode(code: DynamicCode) {
|
||||
deleteTarget.value = code
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteCode() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/dynamic-codes/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.cloudFunction.deleteSuccess'))
|
||||
fetchCodes()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除云端函数失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(ids: (string | number)[]) {
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await api.delete('/dev/dynamic-codes/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('developer.cloudFunction.deleteSuccess'))
|
||||
fetchCodes()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleStatus(ids: (string | number)[], enabled: boolean) {
|
||||
try {
|
||||
await api.put('/dev/dynamic-codes/batch/status', { ids, enabled })
|
||||
toast.success(t('developer.cloudFunction.batchUpdateSuccess'))
|
||||
fetchCodes()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新状态失败:', error)
|
||||
toast.error(error.message || t('developer.cloudFunction.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchCodes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.cloudFunction.title')"
|
||||
:description="t('developer.cloudFunction.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton size="sm" @click="goToCreate">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.cloudFunction.createBtn') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudFunction.totalCodes') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:code" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredCodes.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudFunction.enabled') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ enabledCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudFunction.disabled') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ disabledCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.cloudFunction.linkedApps') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:link" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ linkedAppsCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
ref="tableRef"
|
||||
:loading
|
||||
:data="filteredCodes"
|
||||
:on-toggle-status="toggleStatus"
|
||||
:on-edit="editCode"
|
||||
:on-delete="confirmDeleteCode"
|
||||
@refresh="fetchCodes"
|
||||
@batch-enable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], true)"
|
||||
@batch-disable="batchToggleStatus(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [], false)"
|
||||
@batch-delete="confirmBatchDelete(tableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.cloudFunction.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.cloudFunction.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="createdStartDate"
|
||||
v-model:end-model-value="createdEndDate"
|
||||
:title="t('developer.cloudFunction.columns.createdAt')"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="updatedStartDate"
|
||||
v-model:end-model-value="updatedEndDate"
|
||||
:title="t('developer.cloudFunction.columns.updatedAt')"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cloudFunction.delete')"
|
||||
:cancel-button-text="t('developer.cloudFunction.create.cancelBtn')"
|
||||
@confirm="handleDeleteCode"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cloudFunction.deleteCode') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cloudFunction.deleteCodeConfirm', { codeName: deleteTarget?.name }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.cloudFunction.delete')"
|
||||
:cancel-button-text="t('developer.cloudFunction.create.cancelBtn')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.cloudFunction.batchDelete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.cloudFunction.batchDeleteConfirm', { count: batchDeleteIds.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
application_id: '',
|
||||
permissions: [] as string[],
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const permissionGroups = computed(() => [
|
||||
{
|
||||
name: t('developer.extension.apiKeys.permissions.groups.user'),
|
||||
permissions: [
|
||||
{ value: 'user.read', label: t('developer.extension.apiKeys.permissions.userRead'), description: t('developer.extension.apiKeys.permissions.userReadDesc') },
|
||||
{ value: 'user.list', label: t('developer.extension.apiKeys.permissions.userList'), description: t('developer.extension.apiKeys.permissions.userListDesc') },
|
||||
{ value: 'user.recharge', label: t('developer.extension.apiKeys.permissions.userRecharge'), description: t('developer.extension.apiKeys.permissions.userRechargeDesc') },
|
||||
{ value: 'user.deduct', label: t('developer.extension.apiKeys.permissions.userDeduct'), description: t('developer.extension.apiKeys.permissions.userDeductDesc') },
|
||||
{ value: 'user.variables', label: t('developer.extension.apiKeys.permissions.userVariables'), description: t('developer.extension.apiKeys.permissions.userVariablesDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('developer.extension.apiKeys.permissions.groups.card'),
|
||||
permissions: [
|
||||
{ value: 'card.read', label: t('developer.extension.apiKeys.permissions.cardRead'), description: t('developer.extension.apiKeys.permissions.cardReadDesc') },
|
||||
{ value: 'card.generate', label: t('developer.extension.apiKeys.permissions.cardGenerate'), description: t('developer.extension.apiKeys.permissions.cardGenerateDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('developer.extension.apiKeys.permissions.groups.notification'),
|
||||
permissions: [
|
||||
{ value: 'notification.send', label: t('developer.extension.apiKeys.permissions.notificationSend'), description: t('developer.extension.apiKeys.permissions.notificationSendDesc') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('developer.extension.apiKeys.permissions.groups.app'),
|
||||
permissions: [
|
||||
{ value: 'app.info', label: t('developer.extension.apiKeys.permissions.appInfo'), description: t('developer.extension.apiKeys.permissions.appInfoDesc') },
|
||||
{ value: 'app.stats', label: t('developer.extension.apiKeys.permissions.appStats'), description: t('developer.extension.apiKeys.permissions.appStatsDesc') },
|
||||
{ value: 'app.variables', label: t('developer.extension.apiKeys.permissions.appVariables'), description: t('developer.extension.apiKeys.permissions.appVariablesDesc') },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const filteredApplications = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permission: string) {
|
||||
const index = form.value.permissions.indexOf(permission)
|
||||
if (index > -1) {
|
||||
form.value.permissions.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
form.value.permissions.push(permission)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllInGroup(permissions: { value: string }[]) {
|
||||
for (const perm of permissions) {
|
||||
if (!form.value.permissions.includes(perm.value)) {
|
||||
form.value.permissions.push(perm.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deselectAllInGroup(permissions: { value: string }[]) {
|
||||
for (const perm of permissions) {
|
||||
const index = form.value.permissions.indexOf(perm.value)
|
||||
if (index > -1) {
|
||||
form.value.permissions.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
toast.error(t('developer.extension.apiKeys.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.extension.apiKeys.create.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (form.value.permissions.length === 0) {
|
||||
toast.error(t('developer.extension.apiKeys.create.permissionsRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/extension/api-keys', {
|
||||
name: form.value.name,
|
||||
application_id: Number.parseInt(form.value.application_id),
|
||||
permissions: form.value.permissions,
|
||||
enabled: form.value.enabled,
|
||||
})
|
||||
toast.success(t('developer.extension.apiKeys.create.submitSuccess'))
|
||||
router.push('/developer/extension')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建API密钥失败:', error)
|
||||
toast.error(error.message || t('developer.extension.apiKeys.create.submitFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.extension.apiKeys.create.title')"
|
||||
:description="t('developer.extension.apiKeys.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.extension.title'), href: '/developer/extension' },
|
||||
{ title: t('developer.extension.apiKeys.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.extension.apiKeys.create.title') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.extension.apiKeys.create.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.extension.apiKeys.create.name') }} *
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:placeholder="t('developer.extension.apiKeys.create.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.extension.apiKeys.create.application') }} *
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.extension.apiKeys.create.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in filteredApplications" :key="app.value" :value="app.value">
|
||||
{{ app.label }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<UiLabel>{{ t('developer.extension.apiKeys.create.permissions') }} *</UiLabel>
|
||||
<div class="flex gap-2">
|
||||
<UiButton variant="outline" size="sm" @click="form.permissions = permissionGroups.flatMap(g => g.permissions.map(p => p.value))">
|
||||
{{ t('developer.extension.apiKeys.create.selectAll') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="form.permissions = []">
|
||||
{{ t('developer.extension.apiKeys.create.deselectAll') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="group in permissionGroups" :key="group.name" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">
|
||||
{{ group.name }}
|
||||
</h4>
|
||||
<div class="flex gap-1">
|
||||
<UiButton variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="selectAllInGroup(group.permissions)">
|
||||
{{ t('developer.extension.apiKeys.create.selectAll') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="deselectAllInGroup(group.permissions)">
|
||||
{{ t('developer.extension.apiKeys.create.deselectAll') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.value"
|
||||
class="flex items-start space-x-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
:class="{ 'bg-primary/10 border-primary': form.permissions.includes(perm.value) }"
|
||||
@click="togglePermission(perm.value)"
|
||||
>
|
||||
<UiCheckbox
|
||||
:checked="form.permissions.includes(perm.value)"
|
||||
class="mt-0.5"
|
||||
@update:checked="togglePermission(perm.value)"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
{{ perm.label }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ perm.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<UiSwitch id="enabled" v-model:checked="form.enabled" />
|
||||
<UiLabel for="enabled">
|
||||
{{ t('developer.extension.apiKeys.create.enabled') }}
|
||||
</UiLabel>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:info" class="size-5" />
|
||||
{{ t('developer.extension.apiKeys.create.preview') || '预览' }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.apiKeys.create.name') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.apiKeys.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.apiKeys.create.permissions') }}</span>
|
||||
<span>{{ form.permissions.length }} {{ t('developer.extension.apiKeys.create.selected') || '已选择' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id || form.permissions.length === 0"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.extension.apiKeys.create.submitBtn') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.extension.apiKeys.create.cancelBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { APIKey } from '@/pages/developer/extension/data/schema'
|
||||
|
||||
import { Copy } from '@/components/sva-ui/copy'
|
||||
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 getAPIKeyColumns(actions: {
|
||||
onToggleStatus: (row: APIKey) => void
|
||||
onRegenerate: (row: APIKey) => void
|
||||
onDelete: (row: APIKey) => void
|
||||
}, t: Composer['t']): ColumnDef<APIKey>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => t('developer.extension.apiKeys.columns.name'),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('name') as string
|
||||
return name || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.extension.apiKeys.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.getValue('application_name') as string
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'access_key',
|
||||
header: () => t('developer.extension.apiKeys.columns.accessKey'),
|
||||
cell: ({ row }) => {
|
||||
const accessKey = row.getValue('access_key') as string
|
||||
return accessKey
|
||||
? h('div', { class: 'flex items-center space-x-2' }, [
|
||||
h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, accessKey),
|
||||
h(Copy, { class: 'h-4 w-4', size: 'sm', content: accessKey }),
|
||||
])
|
||||
: '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'secret_key',
|
||||
header: () => t('developer.extension.apiKeys.columns.secretKey'),
|
||||
cell: ({ row }) => {
|
||||
const secretKey = row.getValue('secret_key') as string
|
||||
return secretKey ? h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono' }, '••••••••••••••••') : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'permissions',
|
||||
header: () => t('developer.extension.apiKeys.columns.permissions'),
|
||||
cell: ({ row }) => {
|
||||
const permissions = row.getValue('permissions') as string
|
||||
try {
|
||||
const permissionList = JSON.parse(permissions || '[]')
|
||||
return h('div', { class: 'flex flex-wrap gap-1' }, permissionList.slice(0, 3).map((permission: string) =>
|
||||
h(Badge, { variant: 'outline', class: 'text-xs' }, () => permission),
|
||||
))
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.extension.apiKeys.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
return h(Badge, { variant: status === 'active' ? 'default' : 'secondary' }, () =>
|
||||
status === 'active' ? t('developer.extension.enabled') : t('developer.extension.disabled'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.extension.apiKeys.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
const isDisabled = apiKey.status === 'inactive'
|
||||
|
||||
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.onRegenerate(apiKey) }, () => [
|
||||
h(RefreshCw, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.extension.apiKeys.regenerate'),
|
||||
]),
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(apiKey) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isDisabled ? t('developer.extension.apiKeys.enable') : t('developer.extension.apiKeys.disable'),
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(apiKey) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.extension.apiKeys.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { APIKey } from '@/pages/admin/extension/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<APIKey>
|
||||
searchFilter?: string
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => !!props.searchFilter)
|
||||
|
||||
function resetFilters() {
|
||||
emit('update:searchFilter', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.extension.apiKeys.searchAPIKey')"
|
||||
:model-value="searchFilter"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@update:model-value="emit('update:searchFilter', $event as string)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('developer.extension.apiKeys.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { APIKey } from '@/pages/admin/extension/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getAPIKeyColumns } from '@/pages/admin/extension/components/apikey-columns'
|
||||
import APIKeyDataTableToolbar from '@/pages/admin/extension/components/apikey-data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<APIKey>, 'columns'> & {
|
||||
onToggleStatus: (row: APIKey) => void
|
||||
onRegenerate: (row: APIKey) => void
|
||||
onDelete: (row: APIKey) => void
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'batchEnable': []
|
||||
'batchDisable': []
|
||||
'batchDelete': []
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<APIKey>,
|
||||
...getAPIKeyColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onRegenerate: props.onRegenerate,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<APIKey>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.extension.apiKeys.select',
|
||||
name: 'developer.extension.apiKeys.columns.name',
|
||||
application_name: 'developer.extension.apiKeys.columns.application',
|
||||
access_key: 'developer.extension.apiKeys.columns.accessKey',
|
||||
secret_key: 'developer.extension.apiKeys.columns.secretKey',
|
||||
permissions: 'developer.extension.apiKeys.columns.permissions',
|
||||
status: 'developer.extension.apiKeys.columns.status',
|
||||
created_at: 'developer.extension.apiKeys.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="apiKeys">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
{{ t('developer.extension.apiKeys.enable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
{{ t('developer.extension.apiKeys.disable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.extension.apiKeys.batchDelete') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<APIKeyDataTableToolbar
|
||||
:table
|
||||
:search-filter="searchFilter"
|
||||
@update:search-filter="emit('update:searchFilter', $event)"
|
||||
/>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
import { Ban, Eye, MoreHorizontal, Send, Trash2 } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { Webhook } from '@/pages/admin/extension/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 getWebhookColumns(actions: {
|
||||
onToggleStatus: (row: Webhook) => void
|
||||
onTest: (row: Webhook) => void
|
||||
onViewLogs?: (row: Webhook) => void
|
||||
onDelete: (row: Webhook) => void
|
||||
}, t: Composer['t']): ColumnDef<Webhook>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => t('developer.extension.webhooks.columns.name'),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('name') as string
|
||||
return name || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: () => t('developer.extension.webhooks.columns.url'),
|
||||
cell: ({ row }) => {
|
||||
const url = row.getValue('url') as string
|
||||
return url ? h('code', { class: 'text-xs bg-muted px-2 py-1 rounded font-mono max-w-[200px] truncate inline-block' }, url) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'application_name',
|
||||
header: () => t('developer.extension.webhooks.columns.application'),
|
||||
cell: ({ row }) => {
|
||||
const appName = row.getValue('application_name') as string
|
||||
return appName ? h(Badge, { variant: 'secondary' }, () => appName) : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'events',
|
||||
header: () => t('developer.extension.webhooks.columns.events'),
|
||||
cell: ({ row }) => {
|
||||
const events = row.getValue('events') as string
|
||||
try {
|
||||
const eventList = JSON.parse(events || '[]')
|
||||
return h('div', { class: 'flex flex-wrap gap-1' }, eventList.slice(0, 3).map((event: string) =>
|
||||
h(Badge, { variant: 'outline', class: 'text-xs' }, () => event),
|
||||
))
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.extension.webhooks.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
return h(Badge, { variant: status === 'active' ? 'default' : 'secondary' }, () =>
|
||||
status === 'active' ? t('developer.extension.enabled') : t('developer.extension.disabled'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'retry_count',
|
||||
header: () => t('developer.extension.webhooks.columns.retryCount'),
|
||||
cell: ({ row }) => {
|
||||
const retryCount = row.getValue('retry_count') as number
|
||||
return retryCount ?? '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeout',
|
||||
header: () => t('developer.extension.webhooks.columns.timeout'),
|
||||
cell: ({ row }) => {
|
||||
const timeout = row.getValue('timeout') as number
|
||||
return timeout ? `${timeout}s` : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.extension.webhooks.columns.createdAt'),
|
||||
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' }, t('common.actions')),
|
||||
cell: ({ row }) => {
|
||||
const webhook = row.original
|
||||
const isDisabled = webhook.status === 'inactive'
|
||||
|
||||
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.onTest(webhook) }, () => [
|
||||
h(Send, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.extension.webhooks.test'),
|
||||
]),
|
||||
actions.onViewLogs
|
||||
? h(DropdownMenuItem, { onClick: () => actions.onViewLogs?.(webhook) }, () => [
|
||||
h(Eye, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.extension.webhooks.viewLogs'),
|
||||
])
|
||||
: null,
|
||||
h(DropdownMenuItem, { onClick: () => actions.onToggleStatus(webhook) }, () => [
|
||||
h(Ban, { class: 'mr-2 h-4 w-4' }),
|
||||
isDisabled ? t('developer.extension.webhooks.enable') : t('developer.extension.webhooks.disable'),
|
||||
]),
|
||||
h(DropdownMenuSeparator),
|
||||
h(DropdownMenuItem, { class: 'text-destructive', onClick: () => actions.onDelete(webhook) }, () => [
|
||||
h(Trash2, { class: 'mr-2 h-4 w-4' }),
|
||||
t('developer.extension.webhooks.delete'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Webhook } from '@/pages/admin/extension/data/schema'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<Webhook>
|
||||
searchFilter?: string
|
||||
}
|
||||
|
||||
const isFiltered = computed(() => !!props.searchFilter)
|
||||
|
||||
function resetFilters() {
|
||||
emit('update:searchFilter', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
:placeholder="t('developer.extension.webhooks.searchWebhook')"
|
||||
:model-value="searchFilter"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@update:model-value="emit('update:searchFilter', $event as string)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('developer.extension.webhooks.reset') }}
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table/types'
|
||||
import type { Webhook } from '@/pages/admin/extension/data/schema'
|
||||
|
||||
import BulkActions from '@/components/data-table/bulk-actions.vue'
|
||||
import DataTable from '@/components/data-table/data-table.vue'
|
||||
import { SelectColumn } from '@/components/data-table/table-columns'
|
||||
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
|
||||
import DataTableViewOptions from '@/components/data-table/view-options.vue'
|
||||
import { getWebhookColumns } from '@/pages/admin/extension/components/webhook-columns'
|
||||
import WebhookDataTableToolbar from '@/pages/admin/extension/components/webhook-data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<Omit<DataTableProps<Webhook>, 'columns'> & {
|
||||
onToggleStatus: (row: Webhook) => void
|
||||
onTest: (row: Webhook) => void
|
||||
onDelete: (row: Webhook) => void
|
||||
searchFilter?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'batchEnable': []
|
||||
'batchDisable': []
|
||||
'batchDelete': []
|
||||
'update:searchFilter': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed(() => [
|
||||
SelectColumn as ColumnDef<Webhook>,
|
||||
...getWebhookColumns({
|
||||
onToggleStatus: props.onToggleStatus,
|
||||
onTest: props.onTest,
|
||||
onDelete: props.onDelete,
|
||||
}, t),
|
||||
])
|
||||
|
||||
const table = generateVueTable<Webhook>({
|
||||
get data() { return props.data },
|
||||
get loading() { return props.loading },
|
||||
columns: columns.value,
|
||||
}, columns.value)
|
||||
|
||||
const columnLabels: Record<string, string> = {
|
||||
select: 'developer.extension.webhooks.select',
|
||||
name: 'developer.extension.webhooks.columns.name',
|
||||
url: 'developer.extension.webhooks.columns.url',
|
||||
application_name: 'developer.extension.webhooks.columns.application',
|
||||
events: 'developer.extension.webhooks.columns.events',
|
||||
status: 'developer.extension.webhooks.columns.status',
|
||||
retry_count: 'developer.extension.webhooks.columns.retryCount',
|
||||
timeout: 'developer.extension.webhooks.columns.timeout',
|
||||
created_at: 'developer.extension.webhooks.columns.createdAt',
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
table,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns="columns" :data :loading :table @refresh="emit('refresh')">
|
||||
<template #toolbar>
|
||||
<div class="space-y-3">
|
||||
<BulkActions :table="table" entity-name="webhooks">
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchEnable')">
|
||||
{{ t('developer.extension.webhooks.enable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="sm" @click="emit('batchDisable')">
|
||||
{{ t('developer.extension.webhooks.disable') }}
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" size="sm" @click="emit('batchDelete')">
|
||||
{{ t('developer.extension.webhooks.batchDelete') }}
|
||||
</UiButton>
|
||||
</BulkActions>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<WebhookDataTableToolbar
|
||||
:table
|
||||
:search-filter="searchFilter"
|
||||
@update:search-filter="emit('update:searchFilter', $event)"
|
||||
/>
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface Webhook {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
secret_key: string
|
||||
events: string
|
||||
retry_count: number
|
||||
timeout: number
|
||||
status: 'active' | 'inactive'
|
||||
application_id: number
|
||||
application_name: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
creator?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: number
|
||||
name: string
|
||||
access_key: string
|
||||
secret_key: string
|
||||
permissions: string
|
||||
status: 'active' | 'inactive'
|
||||
application_id: number
|
||||
application_name: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
creator?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { APIKey, Webhook } from '@/pages/developer/extension/data/schema'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import APIKeyDataTable from '@/pages/developer/extension/components/apikey-data-table.vue'
|
||||
import WebhookDataTable from '@/pages/developer/extension/components/webhook-data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const webhooks = ref<Webhook[]>([])
|
||||
const apiKeys = ref<APIKey[]>([])
|
||||
const webhookTableRef = ref()
|
||||
const apiKeyTableRef = ref()
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
const webhookSearchFilter = ref<string>('')
|
||||
const apiKeySearchFilter = ref<string>('')
|
||||
const currentTab = ref<string>('webhooks')
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<Webhook | APIKey | null>(null)
|
||||
const deleteType = ref<'webhook' | 'apikey'>('webhook')
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
const batchDeleteIds = ref<(string | number)[]>([])
|
||||
const regenerateDialogOpen = ref(false)
|
||||
const regenerateTarget = ref<APIKey | null>(null)
|
||||
|
||||
const filteredWebhooks = computed(() => {
|
||||
let result = webhooks.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(webhook => String(webhook.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(webhook => webhook.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(webhook => webhook.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(webhook => webhook.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
if (webhookSearchFilter.value) {
|
||||
const search = webhookSearchFilter.value.toLowerCase()
|
||||
result = result.filter(webhook =>
|
||||
webhook.name?.toLowerCase().includes(search)
|
||||
|| webhook.url?.toLowerCase().includes(search)
|
||||
|| webhook.application_name?.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredAPIKeys = computed(() => {
|
||||
let result = apiKeys.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(apiKey => String(apiKey.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(apiKey => apiKey.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(apiKey => apiKey.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(apiKey => apiKey.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
if (apiKeySearchFilter.value) {
|
||||
const search = apiKeySearchFilter.value.toLowerCase()
|
||||
result = result.filter(apiKey =>
|
||||
apiKey.name?.toLowerCase().includes(search)
|
||||
|| apiKey.access_key?.toLowerCase().includes(search)
|
||||
|| apiKey.application_name?.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const activeWebhookCount = computed(() => filteredWebhooks.value.filter(webhook => webhook.status === 'active').length)
|
||||
const inactiveWebhookCount = computed(() => filteredWebhooks.value.filter(webhook => webhook.status === 'inactive').length)
|
||||
const activeAPIKeyCount = computed(() => filteredAPIKeys.value.filter(apiKey => apiKey.status === 'active').length)
|
||||
const inactiveAPIKeyCount = computed(() => filteredAPIKeys.value.filter(apiKey => apiKey.status === 'inactive').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.extension.enabled'), value: 'active' },
|
||||
{ label: t('developer.extension.disabled'), value: 'inactive' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWebhooks() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<Webhook[]>('/dev/extension/webhooks')
|
||||
webhooks.value = Array.isArray(data) ? data : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取Webhook列表失败:', error)
|
||||
webhooks.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAPIKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<APIKey[]>('/dev/extension/api-keys')
|
||||
apiKeys.value = Array.isArray(data) ? data : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取API密钥列表失败:', error)
|
||||
apiKeys.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToCreateWebhook() {
|
||||
router.push('/developer/extension/webhooks/create')
|
||||
}
|
||||
|
||||
function goToCreateAPIKey() {
|
||||
router.push('/developer/extension/api-keys/create')
|
||||
}
|
||||
|
||||
async function toggleWebhookStatus(webhook: Webhook) {
|
||||
try {
|
||||
const newStatus = webhook.status === 'active' ? 'inactive' : 'active'
|
||||
await api.put(`/dev/extension/webhooks/${webhook.id}/status`, { status: newStatus })
|
||||
toast.success(t('developer.extension.webhooks.statusUpdateSuccess'))
|
||||
fetchWebhooks()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换Webhook状态失败:', error)
|
||||
toast.error(error.message || t('developer.extension.webhooks.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAPIKeyStatus(apiKey: APIKey) {
|
||||
try {
|
||||
const newStatus = apiKey.status === 'active' ? 'inactive' : 'active'
|
||||
await api.put(`/dev/extension/api-keys/${apiKey.id}/status`, { status: newStatus })
|
||||
toast.success(t('developer.extension.apiKeys.statusUpdateSuccess'))
|
||||
fetchAPIKeys()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('切换API密钥状态失败:', error)
|
||||
toast.error(error.message || t('developer.extension.apiKeys.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook(webhook: Webhook) {
|
||||
try {
|
||||
await api.post(`/dev/extension/webhooks/${webhook.id}/test`, {})
|
||||
toast.success(t('developer.extension.webhooks.testSuccess'))
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('测试Webhook失败:', error)
|
||||
toast.error(error.message || t('developer.extension.webhooks.testFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRegenerateAPIKey(apiKey: APIKey) {
|
||||
regenerateTarget.value = apiKey
|
||||
regenerateDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleRegenerateAPIKey() {
|
||||
if (!regenerateTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.post(`/dev/extension/api-keys/${regenerateTarget.value.id}/regenerate`, {})
|
||||
toast.success(t('developer.extension.apiKeys.regenerateSuccess'))
|
||||
fetchAPIKeys()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('重新生成API密钥失败:', error)
|
||||
toast.error(error.message || t('developer.extension.apiKeys.regenerateFailed'))
|
||||
}
|
||||
finally {
|
||||
regenerateTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteWebhook(webhook: Webhook) {
|
||||
deleteTarget.value = webhook
|
||||
deleteType.value = 'webhook'
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function confirmDeleteAPIKey(apiKey: APIKey) {
|
||||
deleteTarget.value = apiKey
|
||||
deleteType.value = 'apikey'
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
if (deleteType.value === 'webhook') {
|
||||
await api.delete(`/dev/extension/webhooks/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.extension.webhooks.deleteSuccess'))
|
||||
fetchWebhooks()
|
||||
}
|
||||
else {
|
||||
await api.delete(`/dev/extension/api-keys/${deleteTarget.value.id}`)
|
||||
toast.success(t('developer.extension.apiKeys.deleteSuccess'))
|
||||
fetchAPIKeys()
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除失败:', error)
|
||||
toast.error(error.message || t('developer.extension.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete(type: 'webhook' | 'apikey', ids: (string | number)[]) {
|
||||
deleteType.value = type
|
||||
batchDeleteIds.value = ids
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
if (deleteType.value === 'webhook') {
|
||||
await api.delete('/dev/extension/webhooks/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('developer.extension.webhooks.deleteSuccess'))
|
||||
fetchWebhooks()
|
||||
}
|
||||
else {
|
||||
await api.delete('/dev/extension/api-keys/batch', { ids: batchDeleteIds.value } as any)
|
||||
toast.success(t('developer.extension.apiKeys.deleteSuccess'))
|
||||
fetchAPIKeys()
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量删除失败:', error)
|
||||
toast.error(error.message || t('developer.extension.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
batchDeleteIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleWebhookStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
await api.put('/dev/extension/webhooks/batch/status', { ids, status })
|
||||
toast.success(t('developer.extension.webhooks.batchUpdateSuccess'))
|
||||
fetchWebhooks()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新Webhook状态失败:', error)
|
||||
toast.error(error.message || t('developer.extension.webhooks.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function batchToggleAPIKeyStatus(ids: (string | number)[], status: string) {
|
||||
try {
|
||||
await api.put('/dev/extension/api-keys/batch/status', { ids, status })
|
||||
toast.success(t('developer.extension.apiKeys.batchUpdateSuccess'))
|
||||
fetchAPIKeys()
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('批量更新API密钥状态失败:', error)
|
||||
toast.error(error.message || t('developer.extension.apiKeys.batchUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchWebhooks()
|
||||
fetchAPIKeys()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.extension.title')"
|
||||
:description="t('developer.extension.description')"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton v-if="currentTab === 'webhooks'" @click="goToCreateWebhook">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.extension.webhooks.createBtn') }}
|
||||
</UiButton>
|
||||
<UiButton v-else @click="goToCreateAPIKey">
|
||||
<Icon icon="lucide:plus" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.extension.apiKeys.createBtn') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.extension.totalWebhooks') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:webhook" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredWebhooks.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.extension.totalAPIKeys') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredAPIKeys.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.extension.enabled') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ activeWebhookCount + activeAPIKeyCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.extension.disabled') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:ban" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ inactiveWebhookCount + inactiveAPIKeyCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiTabs v-model="currentTab" default-value="webhooks" class="w-full">
|
||||
<UiTabsList class="grid w-full grid-cols-2">
|
||||
<UiTabsTrigger value="webhooks">
|
||||
<Icon icon="lucide:webhook" class="h-4 w-4 mr-2" />
|
||||
{{ t('developer.extension.webhooks.title') }}
|
||||
</UiTabsTrigger>
|
||||
<UiTabsTrigger value="apikeys">
|
||||
<Icon icon="lucide:key" class="h-4 w-4 mr-2" />
|
||||
{{ t('developer.extension.apiKeys.title') }}
|
||||
</UiTabsTrigger>
|
||||
</UiTabsList>
|
||||
|
||||
<UiTabsContent value="webhooks" class="space-y-4">
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<WebhookDataTable
|
||||
ref="webhookTableRef"
|
||||
:loading
|
||||
:data="filteredWebhooks"
|
||||
:on-toggle-status="toggleWebhookStatus"
|
||||
:on-test="testWebhook"
|
||||
:on-delete="confirmDeleteWebhook"
|
||||
:search-filter="webhookSearchFilter"
|
||||
@refresh="fetchWebhooks"
|
||||
@batch-enable="batchToggleWebhookStatus(webhookTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-disable="batchToggleWebhookStatus(webhookTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
|
||||
@batch-delete="confirmBatchDelete('webhook', webhookTableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@update:search-filter="webhookSearchFilter = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.extension.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.extension.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</WebhookDataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</UiTabsContent>
|
||||
|
||||
<UiTabsContent value="apikeys" class="space-y-4">
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<APIKeyDataTable
|
||||
ref="apiKeyTableRef"
|
||||
:loading
|
||||
:data="filteredAPIKeys"
|
||||
:on-toggle-status="toggleAPIKeyStatus"
|
||||
:on-regenerate="confirmRegenerateAPIKey"
|
||||
:on-delete="confirmDeleteAPIKey"
|
||||
:search-filter="apiKeySearchFilter"
|
||||
@refresh="fetchAPIKeys"
|
||||
@batch-enable="batchToggleAPIKeyStatus(apiKeyTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'inactive').map((r: any) => r.original.id) || [], 'active')"
|
||||
@batch-disable="batchToggleAPIKeyStatus(apiKeyTableRef?.table?.getSelectedRowModel().rows.filter((r: any) => r.original.status === 'active').map((r: any) => r.original.id) || [], 'inactive')"
|
||||
@batch-delete="confirmBatchDelete('apikey', apiKeyTableRef?.table?.getSelectedRowModel().rows.map((r: any) => r.original.id) || [])"
|
||||
@update:search-filter="apiKeySearchFilter = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.extension.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.extension.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</APIKeyDataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</UiTabsContent>
|
||||
</UiTabs>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.extension.delete')"
|
||||
:cancel-button-text="t('developer.extension.cancel')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ deleteType === 'webhook' ? t('developer.extension.webhooks.deleteWebhook') : t('developer.extension.apiKeys.deleteAPIKey') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ deleteType === 'webhook'
|
||||
? t('developer.extension.webhooks.deleteConfirm', { webhookName: (deleteTarget as Webhook)?.name })
|
||||
: t('developer.extension.apiKeys.deleteConfirm', { apiKeyName: (deleteTarget as APIKey)?.name })
|
||||
}}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.extension.delete')"
|
||||
:cancel-button-text="t('developer.extension.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ deleteType === 'webhook' ? t('developer.extension.webhooks.batchDelete') : t('developer.extension.apiKeys.batchDelete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ deleteType === 'webhook'
|
||||
? t('developer.extension.webhooks.batchDeleteConfirm', { count: batchDeleteIds.length })
|
||||
: t('developer.extension.apiKeys.batchDeleteConfirm', { count: batchDeleteIds.length })
|
||||
}}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
v-model:open="regenerateDialogOpen"
|
||||
:confirm-button-text="t('developer.extension.apiKeys.regenerate')"
|
||||
:cancel-button-text="t('developer.extension.cancel')"
|
||||
@confirm="handleRegenerateAPIKey"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.extension.apiKeys.regenerate') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.extension.apiKeys.regenerateConfirm') }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const saving = ref(false)
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
application_id: '',
|
||||
url: '',
|
||||
secret_key: '',
|
||||
events: [] as string[],
|
||||
retry_count: 3,
|
||||
timeout: 10,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const availableEvents = computed(() => [
|
||||
{ value: 'user.registered', label: t('developer.extension.webhooks.events.userRegistered') },
|
||||
{ value: 'user.login', label: t('developer.extension.webhooks.events.userLogin') },
|
||||
{ value: 'user.recharged', label: t('developer.extension.webhooks.events.userRecharged') },
|
||||
{ value: 'user.expired', label: t('developer.extension.webhooks.events.userExpired') },
|
||||
{ value: 'card.used', label: t('developer.extension.webhooks.events.cardUsed') },
|
||||
{ value: 'card.expired', label: t('developer.extension.webhooks.events.cardExpired') },
|
||||
{ value: 'abnormal.detected', label: t('developer.extension.webhooks.events.abnormalDetected') },
|
||||
])
|
||||
|
||||
const filteredApplications = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedApplication = computed(() => {
|
||||
if (form.value.application_id) {
|
||||
return applications.value.find(app => String(app.id) === form.value.application_id)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = Array.isArray(data?.applications) ? data.applications : []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEvent(event: string) {
|
||||
const index = form.value.events.indexOf(event)
|
||||
if (index > -1) {
|
||||
form.value.events.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
form.value.events.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
toast.error(t('developer.extension.webhooks.create.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.application_id) {
|
||||
toast.error(t('developer.extension.webhooks.create.selectAppRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.value.url) {
|
||||
toast.error(t('developer.extension.webhooks.create.urlRequired'))
|
||||
return
|
||||
}
|
||||
if (form.value.events.length === 0) {
|
||||
toast.error(t('developer.extension.webhooks.create.eventsRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/dev/extension/webhooks', {
|
||||
name: form.value.name,
|
||||
application_id: Number.parseInt(form.value.application_id),
|
||||
url: form.value.url,
|
||||
secret_key: form.value.secret_key,
|
||||
events: form.value.events,
|
||||
retry_count: form.value.retry_count,
|
||||
timeout: form.value.timeout,
|
||||
enabled: form.value.enabled,
|
||||
})
|
||||
toast.success(t('developer.extension.webhooks.create.submitSuccess'))
|
||||
router.push('/developer/extension')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建Webhook失败:', error)
|
||||
toast.error(error.message || t('developer.extension.webhooks.create.submitFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.extension.webhooks.create.title')"
|
||||
:description="t('developer.extension.webhooks.create.description')"
|
||||
:breadcrumbs="[
|
||||
{ title: t('developer.extension.title'), href: '/developer/extension' },
|
||||
{ title: t('developer.extension.webhooks.create.title') },
|
||||
]"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:settings-2" class="size-5" />
|
||||
{{ t('developer.extension.webhooks.create.title') }}
|
||||
</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.extension.webhooks.create.description') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="name">
|
||||
{{ t('developer.extension.webhooks.create.name') }} *
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:placeholder="t('developer.extension.webhooks.create.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="application">
|
||||
{{ t('developer.extension.webhooks.create.application') }} *
|
||||
</UiLabel>
|
||||
<UiSelect v-model="form.application_id">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.extension.webhooks.create.selectAppPlaceholder')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem v-for="app in filteredApplications" :key="app.value" :value="app.value">
|
||||
{{ app.label }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="url">
|
||||
{{ t('developer.extension.webhooks.create.url') }} *
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="url"
|
||||
v-model="form.url"
|
||||
:placeholder="t('developer.extension.webhooks.create.urlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="secret_key">
|
||||
{{ t('developer.extension.webhooks.create.secretKey') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="secret_key"
|
||||
v-model="form.secret_key"
|
||||
:placeholder="t('developer.extension.webhooks.create.secretKeyPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel>{{ t('developer.extension.webhooks.create.events') }} *</UiLabel>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="event in availableEvents"
|
||||
:key="event.value"
|
||||
class="flex items-center space-x-2 p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
:class="{ 'bg-primary/10 border-primary': form.events.includes(event.value) }"
|
||||
@click="toggleEvent(event.value)"
|
||||
>
|
||||
<UiCheckbox
|
||||
:checked="form.events.includes(event.value)"
|
||||
@update:checked="toggleEvent(event.value)"
|
||||
/>
|
||||
<span class="text-sm">{{ event.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="retry_count">
|
||||
{{ t('developer.extension.webhooks.create.retryCount') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="retry_count"
|
||||
v-model.number="form.retry_count"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
:placeholder="t('developer.extension.webhooks.create.retryCountPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<UiLabel for="timeout">
|
||||
{{ t('developer.extension.webhooks.create.timeout') }}
|
||||
</UiLabel>
|
||||
<UiInput
|
||||
id="timeout"
|
||||
v-model.number="form.timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
:placeholder="t('developer.extension.webhooks.create.timeoutPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<UiSwitch id="enabled" v-model:checked="form.enabled" />
|
||||
<UiLabel for="enabled">
|
||||
{{ t('developer.extension.webhooks.create.enabled') }}
|
||||
</UiLabel>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle class="flex items-center gap-2">
|
||||
<Icon icon="lucide:info" class="size-5" />
|
||||
{{ t('developer.extension.webhooks.create.preview') || '预览' }}
|
||||
</UiCardTitle>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.webhooks.create.name') }}</span>
|
||||
<span>{{ form.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.webhooks.create.application') }}</span>
|
||||
<span>{{ selectedApplication?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.webhooks.create.events') }}</span>
|
||||
<span>{{ form.events.length }} {{ t('developer.extension.webhooks.create.selected') || '已选择' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.webhooks.create.retryCount') }}</span>
|
||||
<span>{{ form.retry_count }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('developer.extension.webhooks.create.timeout') }}</span>
|
||||
<span>{{ form.timeout }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<UiButton
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="saving || !form.name || !form.application_id || !form.url || form.events.length === 0"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Icon v-if="saving" icon="lucide:loader-2" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Icon v-else icon="lucide:check" class="mr-2 h-4 w-4" />
|
||||
{{ t('developer.extension.webhooks.create.submitBtn') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ t('developer.extension.webhooks.create.cancelBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,580 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
|
||||
import { Icon } from '@iconify/vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
import DataTablePagination from '@/components/data-table/table-pagination.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { valueUpdater } from '@/lib/utils'
|
||||
import api from '@/services/api'
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FinanceRecord {
|
||||
id: number
|
||||
order_no: string
|
||||
type: 'recharge' | 'consumption'
|
||||
user_id: number
|
||||
app_id?: number
|
||||
amount: number
|
||||
detail: string
|
||||
status: string
|
||||
payment_type: string
|
||||
remark: string
|
||||
created_at: string
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(true)
|
||||
const records = ref<FinanceRecord[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('all')
|
||||
const statusFilter = ref<string>('all')
|
||||
const dateRange = ref<{ from: string, to: string }>({ from: '', to: '' })
|
||||
|
||||
function setQuickRange(range: 'today' | 'week' | 'month') {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
dateRange.value = { from: `${today}T00:00`, to: `${today}T23:59` }
|
||||
break
|
||||
case 'week': {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
dateRange.value = {
|
||||
from: `${weekAgo.toISOString().split('T')[0]}T00:00`,
|
||||
to: `${today}T23:59`,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'month':
|
||||
dateRange.value = { from: `${firstDayOfMonth}T00:00`, to: `${today}T23:59` }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let result = records.value
|
||||
|
||||
if (appFilter.value !== 'all') {
|
||||
result = result.filter(r => r.app_id === Number(appFilter.value))
|
||||
}
|
||||
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(r => r.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (dateRange.value.from) {
|
||||
const fromDateTime = dateRange.value.from.includes('T')
|
||||
? dateRange.value.from.replace('T', ' ')
|
||||
: `${dateRange.value.from} 00:00`
|
||||
result = result.filter(r => r.created_at >= fromDateTime)
|
||||
}
|
||||
if (dateRange.value.to) {
|
||||
const toDateTime = dateRange.value.to.includes('T')
|
||||
? dateRange.value.to.replace('T', ' ')
|
||||
: `${dateRange.value.to} 23:59`
|
||||
result = result.filter(r => r.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const rechargeRecords = filteredRecords.value.filter(r => r.type === 'recharge' && r.status === 'success')
|
||||
const consumptionRecords = filteredRecords.value.filter(r => r.type === 'consumption' && r.status === 'success')
|
||||
|
||||
const totalRecharge = rechargeRecords.reduce((sum, r) => sum + r.amount, 0)
|
||||
const totalConsumption = consumptionRecords.reduce((sum, r) => sum + r.amount, 0)
|
||||
|
||||
return {
|
||||
rechargeCount: rechargeRecords.length,
|
||||
rechargeAmount: totalRecharge,
|
||||
consumptionCount: consumptionRecords.length,
|
||||
consumptionAmount: totalConsumption,
|
||||
profit: totalRecharge - totalConsumption,
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(time: string) {
|
||||
if (!time)
|
||||
return '-'
|
||||
try {
|
||||
const date = new Date(time)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnFilters = ref<ColumnFiltersState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref({})
|
||||
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTarget = ref<FinanceRecord | null>(null)
|
||||
const batchDeleteDialogOpen = ref(false)
|
||||
|
||||
async function confirmDelete(record: FinanceRecord) {
|
||||
deleteTarget.value = record
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await api.delete(`/dev/finance/records/${deleteTarget.value.id}?type=${deleteTarget.value.type}`)
|
||||
toast.success(t('developer.finance.deleteSuccess'))
|
||||
fetchRecords()
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || t('developer.finance.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<FinanceRecord>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => h(Checkbox, {
|
||||
'checked': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
'ariaLabel': 'Select all',
|
||||
}),
|
||||
cell: ({ row }) => h(Checkbox, {
|
||||
'checked': row.getIsSelected(),
|
||||
'onUpdate:checked': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
'ariaLabel': 'Select row',
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: () => t('developer.finance.columns.type'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as string
|
||||
return h(Badge, { variant: type === 'recharge' ? 'default' : 'secondary' }, () => type === 'recharge' ? t('developer.finance.types.recharge') : t('developer.finance.types.consumption'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: () => t('developer.finance.columns.amount'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as string
|
||||
const amount = row.getValue('amount') as number
|
||||
const prefix = type === 'recharge' ? '+' : '-'
|
||||
return h('span', { class: 'font-medium' }, `${prefix}¥${amount.toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: () => t('developer.finance.columns.user'),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.user
|
||||
return user?.username || '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'detail',
|
||||
header: () => t('developer.finance.columns.detail'),
|
||||
cell: ({ row }) => {
|
||||
const detail = row.getValue('detail') as string
|
||||
return h('span', { class: 'max-w-[200px] truncate text-muted-foreground block' }, detail || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => t('developer.finance.columns.status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
const map: Record<string, { label: string, variant: 'default' | 'secondary' | 'destructive' }> = {
|
||||
success: { label: t('developer.finance.success'), variant: 'default' },
|
||||
pending: { label: t('developer.finance.pending'), variant: 'secondary' },
|
||||
failed: { label: t('developer.finance.failed'), variant: 'destructive' },
|
||||
}
|
||||
const { label, variant } = map[status] || { label: status, variant: 'secondary' }
|
||||
return h(Badge, { variant }, () => label)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => t('developer.finance.columns.time'),
|
||||
cell: ({ row }) => {
|
||||
return h('span', { class: 'text-muted-foreground whitespace-nowrap' }, formatTime(row.getValue('created_at')))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('developer.finance.columns.actions'),
|
||||
cell: ({ row }) => {
|
||||
return h(Button, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onClick: () => confirmDelete(row.original),
|
||||
}, () => h(Icon, { icon: 'lucide:trash-2', class: 'size-4 text-muted-foreground hover:text-destructive' }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return filteredRecords.value },
|
||||
get columns() { return columns },
|
||||
state: {
|
||||
get sorting() { return sorting.value },
|
||||
get columnFilters() { return columnFilters.value },
|
||||
get columnVisibility() { return columnVisibility.value },
|
||||
get rowSelection() { return rowSelection.value },
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
if (selectedRows.length === 0) {
|
||||
toast.error(t('developer.finance.delete'))
|
||||
return
|
||||
}
|
||||
batchDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
const ids = selectedRows.map(row => row.original.id)
|
||||
try {
|
||||
await api.post('/dev/finance/records/batch-delete', { ids })
|
||||
toast.success(t('developer.finance.deleteSuccess'))
|
||||
fetchRecords()
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.error(error.message || t('developer.finance.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ records: FinanceRecord[] }>('/dev/finance/records')
|
||||
records.value = data?.records || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取财务记录失败:', error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.finance.title')"
|
||||
:description="t('developer.finance.description')"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.rechargeCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:arrow-down-left" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ stats.rechargeCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.rechargeAmount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.rechargeAmount.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.consumptionCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:arrow-up-right" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ stats.consumptionCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.consumptionAmount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:credit-card" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.consumptionAmount.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.finance.profit') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:trending-up" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ stats.profit.toFixed(2) }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.finance.transactionRecords') }}</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
{{ t('developer.finance.transactionRecordsDesc') }}
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<UiButton
|
||||
v-if="table.getSelectedRowModel().rows.length > 0"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="mr-2 size-4" />
|
||||
{{ t('developer.finance.batchDeleteBtn') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UiSelect v-model="appFilter" class="w-[140px]">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.finance.allApplications')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
{{ t('developer.finance.allApplications') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
|
||||
{{ app.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
|
||||
<UiSelect v-model="statusFilter" class="w-[120px]">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="t('developer.finance.allStatus')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
{{ t('developer.finance.allStatus') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="success">
|
||||
{{ t('developer.finance.success') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="pending">
|
||||
{{ t('developer.finance.pending') }}
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="failed">
|
||||
{{ t('developer.finance.failed') }}
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
|
||||
<DateTimePicker
|
||||
v-model="dateRange.from"
|
||||
:placeholder="t('developer.finance.startTime')"
|
||||
class="w-[180px]"
|
||||
/>
|
||||
<span class="text-muted-foreground">{{ t('developer.finance.to') }}</span>
|
||||
<DateTimePicker
|
||||
v-model="dateRange.to"
|
||||
:placeholder="t('developer.finance.endTime')"
|
||||
class="w-[180px]"
|
||||
/>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('today')">
|
||||
{{ t('developer.finance.today') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('week')">
|
||||
{{ t('developer.finance.last7Days') }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="setQuickRange('month')">
|
||||
{{ t('developer.finance.thisMonth') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else class="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() && 'selected'"
|
||||
>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<TableRow v-else>
|
||||
<TableCell
|
||||
:colspan="columns.length"
|
||||
class="h-24 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Icon icon="lucide:inbox" class="size-12 mb-2 opacity-50" />
|
||||
<span>{{ t('developer.finance.noRecords') }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination v-if="!loading && table.getRowModel().rows?.length" :table="table" class="mt-4" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="deleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.finance.delete')"
|
||||
:cancel-button-text="t('developer.finance.cancel')"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.finance.delete') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.finance.deleteConfirm') }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:open="batchDeleteDialogOpen"
|
||||
destructive
|
||||
:confirm-button-text="t('developer.finance.delete')"
|
||||
:cancel-button-text="t('developer.finance.cancel')"
|
||||
@confirm="handleBatchDelete"
|
||||
>
|
||||
<template #title>
|
||||
{{ t('developer.finance.batchDeleteBtn') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ t('developer.finance.batchDeleteConfirm', { count: table.getSelectedRowModel().rows.length }) }}
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,695 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { BASE_URL } from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
interface DashboardStats {
|
||||
totalApplications: number
|
||||
totalCards: number
|
||||
totalUsers: number
|
||||
monthlyRevenue: number
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface Province {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const currentUser = ref<any>(null)
|
||||
const stats = ref<DashboardStats>({
|
||||
totalApplications: 0,
|
||||
totalCards: 0,
|
||||
totalUsers: 0,
|
||||
monthlyRevenue: 0,
|
||||
})
|
||||
|
||||
const provinces = ref<Province[]>([])
|
||||
const chinaProvinces = ref<Province[]>([])
|
||||
const onlineTrendData = ref<number[]>([])
|
||||
const recentActivities = ref<Activity[]>([])
|
||||
const recentTickets = ref<Ticket[]>([])
|
||||
|
||||
const mapChart = ref<HTMLElement | null>(null)
|
||||
const activityChart = ref<HTMLCanvasElement | null>(null)
|
||||
let chartInstance: any = null
|
||||
let activityChartInstance: any = null
|
||||
const currentMapType = ref<'china'>('china')
|
||||
|
||||
const domesticUsers = computed(() => {
|
||||
if (!chinaProvinces.value || chinaProvinces.value.length === 0)
|
||||
return 0
|
||||
return chinaProvinces.value.reduce((sum, province) => sum + (province.count || 0), 0)
|
||||
})
|
||||
|
||||
const overseasUsers = computed(() => {
|
||||
if (!provinces.value || provinces.value.length === 0)
|
||||
return 0
|
||||
return provinces.value.reduce((sum, country) => {
|
||||
if (country.name !== 'China')
|
||||
return sum + (country.count || 0)
|
||||
return sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalUsers = computed(() => {
|
||||
if (stats.value?.totalUsers && stats.value.totalUsers > 0) {
|
||||
return stats.value.totalUsers
|
||||
}
|
||||
return (domesticUsers.value || 0) + (overseasUsers.value || 0)
|
||||
})
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatNumber(num: number) {
|
||||
return (num || 0).toLocaleString()
|
||||
}
|
||||
|
||||
function getTicketStatusColor(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
open: 'bg-yellow-500/10 text-yellow-500',
|
||||
processing: 'bg-blue-500/10 text-blue-500',
|
||||
resolved: 'bg-green-500/10 text-green-500',
|
||||
closed: 'bg-gray-500/10 text-gray-500',
|
||||
}
|
||||
return colors[status] || 'bg-gray-500/10 text-gray-500'
|
||||
}
|
||||
|
||||
function getTicketStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
open: t('developer.ticketStatusOpen'),
|
||||
processing: t('developer.ticketStatusProcessing'),
|
||||
resolved: t('developer.ticketStatusResolved'),
|
||||
closed: t('developer.ticketStatusClosed'),
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
function getTicketPriorityColor(priority: string) {
|
||||
const colors: Record<string, string> = {
|
||||
low: 'text-gray-500',
|
||||
normal: 'text-blue-500',
|
||||
high: 'text-orange-500',
|
||||
urgent: 'text-red-500',
|
||||
}
|
||||
return colors[priority] || 'text-gray-500'
|
||||
}
|
||||
|
||||
function getTicketPriorityText(priority: string) {
|
||||
const texts: Record<string, string> = {
|
||||
low: t('developer.priorityLow'),
|
||||
normal: t('developer.priorityNormal'),
|
||||
high: t('developer.priorityHigh'),
|
||||
urgent: t('developer.priorityUrgent'),
|
||||
}
|
||||
return texts[priority] || priority
|
||||
}
|
||||
|
||||
function goToTicket(ticketId: number) {
|
||||
router.push(`/developer/tickets/${ticketId}`)
|
||||
}
|
||||
|
||||
function goToTickets() {
|
||||
router.push('/developer/tickets')
|
||||
}
|
||||
|
||||
async function fetchDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
console.error('No token found')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/dev/dashboard`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
if (data.data.stats) {
|
||||
stats.value = {
|
||||
totalApplications: data.data.stats.totalApplications || 0,
|
||||
totalCards: data.data.stats.totalCards || 0,
|
||||
totalUsers: data.data.stats.totalUsers || 0,
|
||||
monthlyRevenue: data.data.stats.monthlyRevenue || 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.data.userDistribution) {
|
||||
if (data.data.userDistribution.provinces && Array.isArray(data.data.userDistribution.provinces)) {
|
||||
chinaProvinces.value = data.data.userDistribution.provinces.map((p: any) => ({
|
||||
name: p.name,
|
||||
count: p.count || 0,
|
||||
}))
|
||||
}
|
||||
if (data.data.userDistribution.overseas && Array.isArray(data.data.userDistribution.overseas)) {
|
||||
provinces.value = data.data.userDistribution.overseas.map((p: any) => ({
|
||||
name: p.name,
|
||||
count: p.count || 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (data.data.onlineTrend && Array.isArray(data.data.onlineTrend)) {
|
||||
onlineTrendData.value = data.data.onlineTrend.map((t: any) => t.value || t.count || 0)
|
||||
}
|
||||
|
||||
if (data.data.recentActivities && Array.isArray(data.data.recentActivities)) {
|
||||
recentActivities.value = data.data.recentActivities.map((log: any) => {
|
||||
let icon = 'lucide:activity'
|
||||
let title = log.action || t('developer.operation')
|
||||
let description = log.details || log.resource || t('developer.noDescription')
|
||||
|
||||
if (log.log_type === 'operation') {
|
||||
icon = 'lucide:plus-circle'
|
||||
title = t('developer.operationRecord')
|
||||
}
|
||||
else if (log.log_type === 'verification') {
|
||||
icon = 'lucide:check-circle'
|
||||
title = t('developer.cardVerification')
|
||||
description = `${t('developer.verification')}${log.status === 'success' ? t('developer.success') : t('developer.failed')}`
|
||||
}
|
||||
else if (log.log_type === 'exception') {
|
||||
icon = 'lucide:alert-circle'
|
||||
title = t('developer.exceptionRecord')
|
||||
}
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
createdAt: new Date(log.created_at),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (data.data.recentTickets && Array.isArray(data.data.recentTickets)) {
|
||||
recentTickets.value = data.data.recentTickets.map((t: any) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
createdAt: new Date(t.created_at),
|
||||
}))
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error('API returned error:', data)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('developer.fetchDashboardError'), error)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCurrentUser() {
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
currentUser.value = JSON.parse(userStr)
|
||||
}
|
||||
}
|
||||
|
||||
async function initMapChart() {
|
||||
if (!mapChart.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const chinaResponse = await fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json')
|
||||
|
||||
if (!chinaResponse.ok) {
|
||||
throw new Error('Failed to load China map data')
|
||||
}
|
||||
|
||||
const chinaData = await chinaResponse.json()
|
||||
|
||||
echarts.registerMap('china', chinaData)
|
||||
|
||||
chartInstance = echarts.init(mapChart.value)
|
||||
|
||||
updateMapOption()
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
chartInstance?.resize()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('developer.initMapError'), error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateMapOption() {
|
||||
if (!chartInstance)
|
||||
return
|
||||
|
||||
const isChinaMap = currentMapType.value === 'china'
|
||||
const data = isChinaMap ? chinaProvinces.value : provinces.value
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
formatter: (params: any) => {
|
||||
if (params.data && params.data.count > 0) {
|
||||
return `<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
|
||||
<div style="color: #cbd5e1;">${t('developer.userCount')}: ${params.data.count}</div>
|
||||
</div>`
|
||||
}
|
||||
return `<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px; color: #f8fafc;">${params.name}</div>
|
||||
<div style="color: #cbd5e1;">${t('developer.noUserData')}</div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: Math.max(...data.map((d: any) => d.count || 0), 1),
|
||||
inRange: {
|
||||
color: ['#e0f2fe', '#3b82f6'],
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
map: isChinaMap ? 'china' : 'world',
|
||||
roam: false,
|
||||
zoom: isChinaMap ? 1.2 : 1.1,
|
||||
center: isChinaMap ? [104.195, 35.861] : undefined,
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#3b82f6',
|
||||
borderColor: '#1d4ed8',
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(226, 232, 240, 0.3)',
|
||||
borderColor: '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('developer.userDistribution'),
|
||||
type: 'map',
|
||||
geoIndex: 0,
|
||||
data,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
function initActivityChart() {
|
||||
if (!activityChart.value)
|
||||
return
|
||||
|
||||
try {
|
||||
import('chart.js/auto').then(({ default: Chart }) => {
|
||||
const ctx = activityChart.value?.getContext('2d')
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
const labels = Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - (29 - i))
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
})
|
||||
|
||||
const data = onlineTrendData.value.length > 0
|
||||
? onlineTrendData.value
|
||||
: Array.from({ length: 30 }).fill(0)
|
||||
|
||||
if (activityChartInstance) {
|
||||
activityChartInstance.destroy()
|
||||
}
|
||||
|
||||
activityChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('developer.verificationCount'),
|
||||
data,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 6,
|
||||
pointHoverBackgroundColor: '#3b82f6',
|
||||
pointHoverBorderColor: '#fff',
|
||||
pointHoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||
titleColor: '#f8fafc',
|
||||
bodyColor: '#cbd5e1',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (context: any) => `${context.parsed.y} ${t('developer.online')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#334155',
|
||||
},
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error(t('developer.initActivityChartError'), error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchCurrentUser()
|
||||
await fetchDashboard()
|
||||
await nextTick()
|
||||
|
||||
initMapChart()
|
||||
initActivityChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
if (activityChartInstance) {
|
||||
activityChartInstance.destroy()
|
||||
activityChartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.dashboard')"
|
||||
:description="`${t('developer.welcomeBack')}, ${currentUser?.username || t('developer.developer')}`"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<Icon icon="lucide:loader-2" class="size-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
{{ t('developer.loading') }}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.totalApplications') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:layout-grid" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ formatNumber(stats.totalApplications) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+12.5% {{ t('developer.lastMonth') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.totalCards') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:key" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ formatNumber(stats.totalCards) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+8.2% {{ t('developer.lastMonth') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.totalUsers') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:users" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ formatNumber(totalUsers) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+15.3% {{ t('developer.lastMonth') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.monthlyRevenue') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:dollar-sign" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
¥{{ formatNumber(stats.monthlyRevenue) }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+23.1% {{ t('developer.lastMonth') }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<UiCardTitle>{{ t('developer.userDistribution') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.userDistributionDesc') }}</UiCardDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="size-3 rounded-full bg-blue-500" />
|
||||
<span class="text-muted-foreground">{{ t('developer.domesticUsers') }}: {{ formatNumber(domesticUsers) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="size-3 rounded-full bg-green-500" />
|
||||
<span class="text-muted-foreground">{{ t('developer.overseasUsers') }}: {{ formatNumber(overseasUsers) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="relative h-[350px] rounded-lg overflow-hidden bg-card">
|
||||
<div ref="mapChart" class="w-full h-full" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.onlineTrend') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.onlineTrendDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent class="pl-2">
|
||||
<div class="relative h-[350px]">
|
||||
<canvas ref="activityChart" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<UiCardTitle>{{ t('developer.recentTickets') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.recentTicketsDesc') }}</UiCardDescription>
|
||||
</div>
|
||||
<UiButton variant="ghost" size="sm" @click="goToTickets">
|
||||
{{ t('developer.viewAll') }}
|
||||
<Icon icon="lucide:arrow-right" class="ml-1 size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="recentTickets.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="ticket in recentTickets.slice(0, 5)"
|
||||
:key="ticket.id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
@click="goToTicket(ticket.id)"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="size-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="lucide:ticket" class="size-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ ticket.title }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UiBadge :class="getTicketStatusColor(ticket.status)" class="text-[10px] flex-shrink-0">
|
||||
{{ getTicketStatusText(ticket.status) }}
|
||||
</UiBadge>
|
||||
<span :class="`text-xs ${getTicketPriorityColor(ticket.priority)}`">
|
||||
{{ getTicketPriorityText(ticket.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{{ formatDate(ticket.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:ticket" class="mx-auto size-10 text-muted-foreground mb-2" />
|
||||
<p class="text-sm">
|
||||
{{ t('developer.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>{{ t('developer.recentActivities') }}</UiCardTitle>
|
||||
<UiCardDescription>{{ t('developer.recentActivitiesDesc') }}</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div v-if="recentActivities.length > 0" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="activity in recentActivities.slice(0, 8)"
|
||||
:key="activity.id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="size-7 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon :icon="activity.icon" class="size-3.5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">
|
||||
{{ activity.title }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ activity.description }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ formatDate(activity.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:activity" class="mx-auto size-10 text-muted-foreground mb-2" />
|
||||
<p class="text-sm">
|
||||
{{ t('developer.noActivities') }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Log } from '@/pages/admin/logs/data/schema'
|
||||
|
||||
import DateRangeFilter from '@/components/data-table/date-range-filter.vue'
|
||||
import SingleFilter from '@/components/data-table/single-filter.vue'
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import DataTable from '@/pages/admin/logs/components/data-table.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Application {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const logs = ref<Log[]>([])
|
||||
const applications = ref<Application[]>([])
|
||||
|
||||
const appFilter = ref<string>('')
|
||||
const typeFilter = ref<string>('')
|
||||
const statusFilter = ref<string>('')
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
let result = logs.value
|
||||
|
||||
if (appFilter.value) {
|
||||
result = result.filter(log => String(log.application_id) === appFilter.value)
|
||||
}
|
||||
|
||||
if (typeFilter.value) {
|
||||
result = result.filter(log => log.log_type === typeFilter.value)
|
||||
}
|
||||
|
||||
if (statusFilter.value) {
|
||||
result = result.filter(log => log.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (startDate.value) {
|
||||
const fromDateTime = startDate.value.includes('T')
|
||||
? startDate.value.replace('T', ' ')
|
||||
: `${startDate.value} 00:00`
|
||||
result = result.filter(log => log.created_at >= fromDateTime)
|
||||
}
|
||||
|
||||
if (endDate.value) {
|
||||
const toDateTime = endDate.value.includes('T')
|
||||
? endDate.value.replace('T', ' ')
|
||||
: `${endDate.value} 23:59`
|
||||
result = result.filter(log => log.created_at <= toDateTime)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const successCount = computed(() => filteredLogs.value.filter(log => log.status === 'success').length)
|
||||
const failedCount = computed(() => filteredLogs.value.filter(log => log.status === 'failed').length)
|
||||
const operationCount = computed(() => filteredLogs.value.filter(log => log.log_type === 'operation').length)
|
||||
|
||||
const applicationOptions = computed(() => {
|
||||
return applications.value.map(app => ({
|
||||
label: app.name,
|
||||
value: String(app.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: t('developer.logs.types.operation'), value: 'operation' },
|
||||
{ label: t('developer.logs.types.verification'), value: 'verification' },
|
||||
{ label: t('developer.logs.types.exception'), value: 'exception' },
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('developer.success'), value: 'success' },
|
||||
{ label: t('developer.failed'), value: 'failed' },
|
||||
])
|
||||
|
||||
async function fetchApplications() {
|
||||
try {
|
||||
const data = await api.get<{ applications: Application[] }>('/dev/applications')
|
||||
applications.value = data?.applications || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取应用列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ logs: Log[] }>('/dev/logs')
|
||||
logs.value = data?.logs || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取日志记录失败:', error)
|
||||
logs.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplications()
|
||||
fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
:title="t('developer.logs.title')"
|
||||
:description="t('developer.logs.description')"
|
||||
sticky
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.totalLogs') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:file-text" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ filteredLogs.length }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.successCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:check-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ successCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.failedCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:x-circle" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ failedCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
{{ t('developer.logs.operationCount') }}
|
||||
</UiCardTitle>
|
||||
<Icon icon="lucide:activity" class="size-4 text-muted-foreground" />
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ operationCount }}
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard>
|
||||
<UiCardContent class="p-6">
|
||||
<DataTable
|
||||
:loading
|
||||
:data="filteredLogs"
|
||||
@refresh="fetchLogs"
|
||||
>
|
||||
<template #filters>
|
||||
<SingleFilter
|
||||
v-model="appFilter"
|
||||
:title="t('developer.logs.columns.application')"
|
||||
:options="applicationOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="typeFilter"
|
||||
:title="t('developer.logs.columns.type')"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
<SingleFilter
|
||||
v-model="statusFilter"
|
||||
:title="t('developer.logs.columns.status')"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<DateRangeFilter
|
||||
v-model:start-model-value="startDate"
|
||||
v-model:end-model-value="endDate"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user