Compare commits
11 Commits
7ea0406336
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 611b488af3 | |||
| d11d64469c | |||
| d4e7a163f7 | |||
| a8688c0f4a | |||
| 16b7e2fb61 | |||
| f2d6b029f1 | |||
| ffab0ec25c | |||
| 2ae30ac692 | |||
| 6084cd5a5b | |||
| 60836da3c2 | |||
| 2fe14aa2b4 |
@@ -33,7 +33,29 @@
|
|||||||
"usedAt": 1775921148885,
|
"usedAt": 1775921148885,
|
||||||
"industryId": "all"
|
"industryId": "all"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"784e1c35ace143c4bd1aad6e187435b1": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1775972794982,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"e1bb762122264b5d9d218f575fecf04b": [
|
||||||
|
{
|
||||||
|
"expertId": "SeniorDeveloper",
|
||||||
|
"name": "Will",
|
||||||
|
"profession": "高级开发工程师",
|
||||||
|
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
|
||||||
|
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
|
||||||
|
"usedAt": 1776019266960,
|
||||||
|
"industryId": "all"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lastUpdated": 1775921440281
|
"lastUpdated": 1776023218479
|
||||||
}
|
}
|
||||||
@@ -42,3 +42,212 @@
|
|||||||
|
|
||||||
### 文件修改
|
### 文件修改
|
||||||
- `src/passport/invite/index.tsx` - 完整重构邀请流程
|
- `src/passport/invite/index.tsx` - 完整重构邀请流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:重写 passport/login 页面
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
`passport/login.tsx` 页面只有 UI 框架,没有实现登录逻辑:
|
||||||
|
- 输入框没有绑定状态
|
||||||
|
- 登录按钮没有点击事件
|
||||||
|
- 不支持微信手机号登录
|
||||||
|
- 无法处理邀请流程的重定向
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
基于 `phone-auth/index.tsx` 的实现,重写 `login.tsx`:
|
||||||
|
|
||||||
|
#### 1. 功能实现
|
||||||
|
- 微信手机号一键登录(使用 `open-type="getPhoneNumber"`)
|
||||||
|
- 支持 `redirect` 参数,登录后返回原页面
|
||||||
|
- 未注册用户自动注册
|
||||||
|
- 支持邀请关系绑定
|
||||||
|
- 服务协议和隐私政策弹窗
|
||||||
|
|
||||||
|
#### 2. 跳转逻辑
|
||||||
|
```
|
||||||
|
有 redirect 参数:
|
||||||
|
- TabBar 页面 → switchTab
|
||||||
|
- 普通页面 → navigateBack 或 redirectTo
|
||||||
|
|
||||||
|
无 redirect 参数:
|
||||||
|
- 跳转到首页 /pages/index/index
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 统一登录入口
|
||||||
|
- 将 `invite/index.tsx` 中的登录跳转统一改为 `/passport/login`
|
||||||
|
- 保持与现有代码引用兼容(5处引用无需修改)
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/login.tsx` - 完全重写为微信手机号登录页面
|
||||||
|
- `src/passport/invite/index.tsx` - 更新登录跳转链接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:修复登录后加入应用流程
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
用户从邀请页面跳转到登录页面完成注册后,返回邀请页面提示"您尚未注册",无法成功加入应用。
|
||||||
|
|
||||||
|
### 问题分析
|
||||||
|
1. 用户在邀请页面点击"微信手机号快速加入"时保存了微信授权码
|
||||||
|
2. 跳转到登录页面后,用户**重新**进行了微信授权,获得了新的授权码
|
||||||
|
3. 登录成功后返回邀请页面,但邀请页面使用的是旧的已失效的授权码
|
||||||
|
4. 导致加入应用失败
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
在登录页面登录成功后,直接使用**当前获取的微信授权码**完成加入应用操作:
|
||||||
|
|
||||||
|
#### 1. 登录页面修改 (`login.tsx`)
|
||||||
|
- 添加 `handleJoinAppAfterLogin` 方法
|
||||||
|
- 登录成功后检测 `pending_invite_token`
|
||||||
|
- 如果存在,使用当前授权码调用 `/api/_app/developer/invite/accept` 接口
|
||||||
|
- 加入成功后清理 pending 数据并跳转到首页
|
||||||
|
- 加入失败则继续正常登录流程
|
||||||
|
|
||||||
|
#### 2. 邀请页面修改 (`invite/index.tsx`)
|
||||||
|
- 优化从登录页返回的处理逻辑
|
||||||
|
- 检测 `pending_invite_phone_code` 是否存在
|
||||||
|
- 延迟检查登录页面是否已处理加入操作
|
||||||
|
- 如果登录页面已处理(token 被清除),则显示成功提示并跳转
|
||||||
|
- 如果登录页面未处理,则自动执行加入操作
|
||||||
|
|
||||||
|
### 新的完整流程
|
||||||
|
```
|
||||||
|
新用户扫码加入流程:
|
||||||
|
1. 扫码进入邀请页面
|
||||||
|
2. 点击"微信手机号快速加入"
|
||||||
|
3. 检测到未登录,保存 pending 数据,跳转到登录页面
|
||||||
|
4. 在登录页面勾选协议,点击"微信手机号一键登录"
|
||||||
|
5. 获取新的微信授权码,完成登录/注册
|
||||||
|
6. 登录成功后检测到 pending_invite_token,自动执行加入应用
|
||||||
|
7. 加入成功,清理数据,跳转到首页
|
||||||
|
|
||||||
|
已登录用户流程:
|
||||||
|
1. 扫码进入邀请页面
|
||||||
|
2. 点击"微信手机号快速加入"
|
||||||
|
3. 直接执行加入操作
|
||||||
|
4. 加入成功,跳转到首页
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/login.tsx` - 添加登录后自动加入应用逻辑
|
||||||
|
- `src/passport/invite/index.tsx` - 优化从登录页返回的处理逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:修复"用户创建失败"问题
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
后端返回 `"用户创建失败"`,原因是微信授权码失效。
|
||||||
|
|
||||||
|
### 问题分析
|
||||||
|
1. 用户在邀请页面获取了微信授权码
|
||||||
|
2. 跳转到登录页面后,用户**重新**点击授权按钮获取了新的授权码
|
||||||
|
3. 但代码逻辑可能混淆了新旧授权码
|
||||||
|
4. 或者授权码已过期(5分钟有效期)
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
1. **明确使用当前获取的授权码**:在登录页面登录成功后,使用**当前**获取的微信授权码执行加入应用操作
|
||||||
|
2. **不使用旧的授权码**:从邀请页面带过来的授权码仅作为标记用途,实际使用登录时获取的新授权码
|
||||||
|
3. **添加错误处理**:加入失败时显示错误信息并延迟跳转
|
||||||
|
|
||||||
|
### 关键修改
|
||||||
|
- `login.tsx`:优化 `handleLogin` 中的邀请流程处理逻辑
|
||||||
|
- 明确使用当前 `phoneCode` 执行加入操作
|
||||||
|
- 添加错误处理和用户提示
|
||||||
|
- 加入失败后延迟跳转,让用户看到错误信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:修复已登录用户加入应用失败问题
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
已登录用户点击"微信手机号快速加入"时,后端返回 `"用户创建失败"`。
|
||||||
|
|
||||||
|
### 问题分析
|
||||||
|
1. 用户已经是登录状态(有 `access_token`)
|
||||||
|
2. 但后端接口 `/api/_app/developer/invite/accept` 仍然尝试"创建用户"
|
||||||
|
3. 原因是后端无法识别当前已登录的用户身份
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
为已登录用户在请求头中添加 `Authorization: Bearer {access_token}`,让后端能正确识别用户身份:
|
||||||
|
|
||||||
|
#### 1. 邀请页面修改 (`invite/index.tsx`)
|
||||||
|
- 在 `handleJoinApp` 方法中获取 `access_token`
|
||||||
|
- 构建请求头时,如果用户已登录,添加 `Authorization` 头
|
||||||
|
- 添加日志记录是否使用了认证头
|
||||||
|
|
||||||
|
#### 2. 登录页面修改 (`login.tsx`)
|
||||||
|
- 在 `handleJoinAppAfterLogin` 方法中同样添加 `Authorization` 头
|
||||||
|
- 确保登录成功后调用加入接口时携带认证信息
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 添加 Authorization 请求头支持
|
||||||
|
- `src/passport/login.tsx` - 添加 Authorization 请求头支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:修复 401 认证失败问题
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
后端返回 `401` 和 `"请退出重新登录"`,`error: "Username not found"`。
|
||||||
|
|
||||||
|
### 问题分析
|
||||||
|
1. 请求带了 `Authorization: Bearer {token}` 头
|
||||||
|
2. 但后端验证 token 时发现用户不存在
|
||||||
|
3. 原因可能是:
|
||||||
|
- token 已过期
|
||||||
|
- token 对应的用户已被删除
|
||||||
|
- `/api/_app` 前缀的接口设计为"免登录",使用 `Authorization` 头反而导致认证失败
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
`/api/_app` 前缀的接口是小程序专用免登录接口,应该优先使用微信授权码方式:
|
||||||
|
|
||||||
|
#### 策略调整
|
||||||
|
- **有微信授权码**:使用授权码方式,不传 `Authorization` 头
|
||||||
|
- **无授权码但已登录**:使用 `Authorization` 头
|
||||||
|
|
||||||
|
#### 修改内容
|
||||||
|
- `invite/index.tsx`:调整 `handleJoinApp` 方法中的请求头逻辑
|
||||||
|
- 优先使用微信授权码,避免使用可能过期的 token
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 调整认证策略,优先使用微信授权码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:重写邀请加入应用流程
|
||||||
|
|
||||||
|
### 新流程设计
|
||||||
|
1. 扫码进入邀请页面
|
||||||
|
2. 调用 `wx.login()` 获取 code
|
||||||
|
3. 调用 `loginByOpenId` 判断用户是否已注册
|
||||||
|
4. 已注册:显示邀请页面,用户点击加入
|
||||||
|
5. 未注册:跳转到 `passport/login` 页面完成登录/注册
|
||||||
|
6. 登录成功后自动执行加入应用操作
|
||||||
|
|
||||||
|
### 实现细节
|
||||||
|
|
||||||
|
#### 1. 邀请页面 (`invite/index.tsx`)
|
||||||
|
- 页面状态管理:`loading` → `checking` → `invite`/`login`/`error`
|
||||||
|
- `initPage()`:解析 token,保存到 storage
|
||||||
|
- `checkLoginStatus()`:调用 `loginByOpenId` 检查登录状态
|
||||||
|
- `navigateToLogin()`:未登录用户跳转到登录页
|
||||||
|
- `fetchInviteInfo()`:获取邀请信息
|
||||||
|
- `handleGetPhoneNumber()`:处理微信授权
|
||||||
|
- `handleJoinApp()`:执行加入应用操作
|
||||||
|
|
||||||
|
#### 2. 登录页面 (`login.tsx`)
|
||||||
|
- 登录成功后检查 `invite_token`
|
||||||
|
- 如果存在,使用当前授权码自动执行加入应用
|
||||||
|
- 加入成功后跳转到首页
|
||||||
|
|
||||||
|
### 简化点
|
||||||
|
- 不再使用复杂的 `pending_invite_*` 系列 storage key
|
||||||
|
- 只使用 `invite_token` 保存邀请标识
|
||||||
|
- 登录页面直接使用当前获取的微信授权码
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 完全重写
|
||||||
|
- `src/passport/login.tsx` - 更新为使用 `invite_token`
|
||||||
|
|||||||
615
.workbuddy/memory/2026-04-12.md
Normal file
615
.workbuddy/memory/2026-04-12.md
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
# 2026-04-12 工作日志
|
||||||
|
|
||||||
|
## 项目:小程序改造规划
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
用户需要为 websopy-mp 小程序增加:
|
||||||
|
- 🛠️ **开发者中心** - 面向开发者,围绕项目展开
|
||||||
|
- 🏢 **企业控制台** - 面向企业客户
|
||||||
|
|
||||||
|
### 关键决策
|
||||||
|
1. **用户角色分两类**:
|
||||||
|
- 开发者 (Developer):独立于企业存在,申请审核制
|
||||||
|
- 企业客户 (Enterprise):属于企业组织,购买产品开通
|
||||||
|
|
||||||
|
2. **开发模式**:独立开发,不复用现有模块(商城/分销/用户订单)
|
||||||
|
|
||||||
|
3. **核心围绕"项目"展开**:
|
||||||
|
- 项目类型:基础/专业/企业
|
||||||
|
- 项目成员、API Key、应用关联
|
||||||
|
|
||||||
|
### Phase 1 开发完成 ✅
|
||||||
|
已完成以下功能开发:
|
||||||
|
|
||||||
|
#### 新增目录结构
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── developer/ # 🛠️ 开发者模块
|
||||||
|
│ ├── index.tsx # 开发者工作台
|
||||||
|
│ ├── project/ # 项目管理
|
||||||
|
│ │ ├── index.tsx # 项目列表
|
||||||
|
│ │ ├── create.tsx # 创建项目
|
||||||
|
│ │ └── [id]/ # 项目详情
|
||||||
|
│ ├── app/ # 应用管理
|
||||||
|
│ │ ├── index.tsx # 应用列表
|
||||||
|
│ │ ├── create.tsx # 创建应用
|
||||||
|
│ │ ├── [id]/ # 应用详情
|
||||||
|
│ │ └── api-keys/ # API Key 管理
|
||||||
|
│ └── docs/ # 开发者文档
|
||||||
|
│
|
||||||
|
├── enterprise/ # 🏢 企业模块
|
||||||
|
│ ├── index.tsx # 企业工作台
|
||||||
|
│ ├── apps/ # 企业应用
|
||||||
|
│ │ ├── index.tsx # 应用列表
|
||||||
|
│ │ ├── [id]/ # 应用详情
|
||||||
|
│ │ └── purchase/ # 购买应用
|
||||||
|
│ ├── members/ # 成员管理
|
||||||
|
│ │ ├── index.tsx # 成员列表
|
||||||
|
│ │ └── invite/ # 邀请成员
|
||||||
|
│ ├── orders/ # 订单账单
|
||||||
|
│ │ ├── index.tsx # 订单列表
|
||||||
|
│ │ └── detail/ # 订单详情
|
||||||
|
│ ├── billing/ # 费用中心
|
||||||
|
│ └── settings/ # 企业设置
|
||||||
|
│
|
||||||
|
├── api/developer/ # API 模块
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── developer.ts # 开发者 API
|
||||||
|
│ └── enterprise.ts # 企业 API
|
||||||
|
│
|
||||||
|
└── types/developer.ts # 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增页面
|
||||||
|
- 首页角色入口卡片(开发者中心/企业控制台)
|
||||||
|
- 开发者工作台 + 统计卡片 + 快捷操作
|
||||||
|
- 项目列表/创建/详情
|
||||||
|
- 应用列表/创建/详情
|
||||||
|
- 企业工作台 + 统计卡片 + 快捷操作
|
||||||
|
- 企业应用列表/购买/详情
|
||||||
|
- 成员列表/邀请
|
||||||
|
- 订单列表/详情(占位)
|
||||||
|
- 费用中心/企业设置(占位)
|
||||||
|
|
||||||
|
#### 新增 API
|
||||||
|
- 项目管理 API
|
||||||
|
- 应用管理 API
|
||||||
|
- API Key 管理 API
|
||||||
|
- 企业成员 API
|
||||||
|
- 订单账单 API
|
||||||
|
|
||||||
|
### 待确认
|
||||||
|
- 后端 API 接口地址是否正确
|
||||||
|
- 是否需要权限验证
|
||||||
|
- 微信小程序码支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 开发完成 ✅ (2026-04-12 19:49)
|
||||||
|
|
||||||
|
### 新增功能模块
|
||||||
|
|
||||||
|
#### 1. API Key 完整管理
|
||||||
|
- 列表展示(名称/类型/状态/创建时间)
|
||||||
|
- 创建 Key(名称/类型/备注)
|
||||||
|
- 复制 AppId/AppSecret
|
||||||
|
- 删除 Key
|
||||||
|
- 状态筛选
|
||||||
|
|
||||||
|
#### 2. 项目成员管理
|
||||||
|
- 成员列表展示(头像/名称/角色)
|
||||||
|
- 角色统计(所有者/管理员/开发者/查看者)
|
||||||
|
- 邀请成员(用户名/角色选择)
|
||||||
|
- 修改成员角色
|
||||||
|
- 移除成员
|
||||||
|
|
||||||
|
#### 3. 应用版本发布
|
||||||
|
- 版本列表(版本号/状态/环境)
|
||||||
|
- 环境 Tab 筛选(全部/开发/预发布/生产)
|
||||||
|
- 发布新版本(名称/版本号/环境/更新日志)
|
||||||
|
- 状态标签(构建中/已发布/已回滚/构建失败)
|
||||||
|
|
||||||
|
#### 4. 消息通知中心
|
||||||
|
- 通知列表(类型/标题/内容/时间)
|
||||||
|
- 类型筛选(全部/系统/应用/成员/订单)
|
||||||
|
- 未读数量徽标
|
||||||
|
- 标记已读/全部已读
|
||||||
|
- 删除通知
|
||||||
|
- 点击跳转详情
|
||||||
|
|
||||||
|
#### 5. 开发者申请
|
||||||
|
- 申请表单(姓名/手机/邮箱/公司/职位/经验)
|
||||||
|
- 申请记录列表
|
||||||
|
- 状态筛选(待审核/已通过/已拒绝)
|
||||||
|
- 审核备注展示
|
||||||
|
|
||||||
|
#### 6. 权限审批
|
||||||
|
- 申请列表(申请人/类型/目标/状态)
|
||||||
|
- Tab 筛选(待审核/已通过/已拒绝/全部)
|
||||||
|
- 待审核数量徽标
|
||||||
|
- 通过/拒绝操作
|
||||||
|
- 审核备注展示
|
||||||
|
|
||||||
|
### 新增/扩展的页面
|
||||||
|
```
|
||||||
|
src/developer/
|
||||||
|
├── project/[id]/
|
||||||
|
│ ├── members.tsx # 项目成员管理 ✅
|
||||||
|
│ ├── members.scss
|
||||||
|
│ ├── api-keys.tsx # 项目级 API Key
|
||||||
|
│ ├── api-keys.scss
|
||||||
|
│ ├── api-keys.config.ts
|
||||||
|
│ ├── settings.tsx # 项目设置
|
||||||
|
│ └── settings.scss
|
||||||
|
├── app/[id]/
|
||||||
|
│ ├── version.tsx # 版本管理 ✅
|
||||||
|
│ ├── version.scss
|
||||||
|
│ ├── config.tsx # 应用配置
|
||||||
|
│ ├── publish.tsx # 发布管理
|
||||||
|
│ └── *.config.ts
|
||||||
|
├── notification/ # 消息通知 ✅
|
||||||
|
│ ├── index.tsx
|
||||||
|
│ └── index.scss
|
||||||
|
├── developer/
|
||||||
|
│ ├── apply.tsx # 开发者申请 ✅
|
||||||
|
│ ├── apply.scss
|
||||||
|
│ └── profile.tsx # 开发者资料
|
||||||
|
├── docs/
|
||||||
|
│ ├── quickstart.tsx # 快速开始
|
||||||
|
│ └── api-docs.tsx # API 文档
|
||||||
|
└── market/ # 开发者市场
|
||||||
|
└── index.tsx
|
||||||
|
|
||||||
|
src/enterprise/
|
||||||
|
├── apps/[id]/
|
||||||
|
│ ├── monitor.tsx # 运营监控
|
||||||
|
│ ├── analytics.tsx # 数据分析
|
||||||
|
│ └── settings.tsx # 应用设置
|
||||||
|
├── members/
|
||||||
|
│ ├── roles.tsx # 角色权限
|
||||||
|
│ └── audit.tsx # 权限审批 ✅
|
||||||
|
├── orders/
|
||||||
|
│ ├── invoice.tsx # 发票管理
|
||||||
|
│ └── bills.tsx # 账单明细
|
||||||
|
├── billing/
|
||||||
|
│ ├── consumption.tsx # 消费明细
|
||||||
|
│ ├── recharge.tsx # 充值中心
|
||||||
|
│ └── coupons.tsx # 优惠券
|
||||||
|
├── settings/
|
||||||
|
│ ├── info.tsx # 企业信息
|
||||||
|
│ ├── domain.tsx # 域名配置
|
||||||
|
│ └── security.tsx # 安全设置
|
||||||
|
└── developer/
|
||||||
|
├── index.tsx # 开发者入口
|
||||||
|
└── apply.tsx # 申请开发者
|
||||||
|
```
|
||||||
|
|
||||||
|
### 扩展的类型定义
|
||||||
|
```typescript
|
||||||
|
// 消息通知
|
||||||
|
interface Notification {
|
||||||
|
id, type, title, content, data, isRead, readTime, createTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限申请
|
||||||
|
interface Apply {
|
||||||
|
id, type, applicantId, applicantName, applicantAvatar,
|
||||||
|
targetId, targetName, reason, status, reviewerId,
|
||||||
|
reviewerName, reviewTime, reviewRemark, createTime
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 扩展的 API
|
||||||
|
```typescript
|
||||||
|
// 通知相关
|
||||||
|
pageNotification, getUnreadCount, markAsRead, markAllAsRead, deleteNotification
|
||||||
|
|
||||||
|
// 申请相关
|
||||||
|
pageApply, pageMyApply, createApply, reviewApply
|
||||||
|
```
|
||||||
|
|
||||||
|
### 路由配置更新
|
||||||
|
新增 20+ 个页面路由
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 开发完成 ✅ (2026-04-12 20:15)
|
||||||
|
|
||||||
|
### 新增功能模块
|
||||||
|
|
||||||
|
#### 1. CI/CD 流水线
|
||||||
|
- **构建任务列表**:状态筛选/触发构建/取消构建/查看详情
|
||||||
|
- **构建详情**:构建信息/Git 信息/构建日志/构建产物
|
||||||
|
- **部署列表**:环境筛选/部署统计/触发部署/回滚操作
|
||||||
|
- **部署详情**:部署进度/日志/回滚功能
|
||||||
|
- **流水线配置**:启用/禁用、自动部署、分支规则、环境变量、Webhook
|
||||||
|
|
||||||
|
#### 2. 运营监控/数据分析
|
||||||
|
- **实时数据看板**:UV/PV/API 调用/错误数/Live 指示器
|
||||||
|
- **今日概览**:UV/PV/API 调用统计卡片
|
||||||
|
- **数据趋势**:图表占位(可集成 ECharts)
|
||||||
|
- **性能指标**:核心指标展示/趋势标签/错误统计
|
||||||
|
- **用户分析**:活跃用户/新增用户/留存率/地域分布/流量来源
|
||||||
|
- **页面排行**:PV/UV 排行
|
||||||
|
|
||||||
|
#### 3. 发票管理
|
||||||
|
- **发票列表**:状态筛选/发票详情/取消申请
|
||||||
|
- **可开票金额**:实时金额展示
|
||||||
|
- **申请发票**:抬头选择/金额输入/提交申请
|
||||||
|
- **发票抬头管理**:增删改查/默认设置
|
||||||
|
- **发票统计**:申请总数/已开票数量
|
||||||
|
|
||||||
|
#### 4. SSO 单点登录
|
||||||
|
- **SSO 配置**:OIDC/SAML/CAS/LDAP/OAuth2 提供商支持
|
||||||
|
- **连接配置**:Issuer/Client ID/Client Secret/授权 URL 等
|
||||||
|
- **用户同步**:同步开关/自动创建/默认角色
|
||||||
|
- **服务提供商信息**:Entity ID/ACS URL(可复制)
|
||||||
|
- **操作日志**:登录/登出/错误记录
|
||||||
|
|
||||||
|
### 新增类型定义
|
||||||
|
```
|
||||||
|
src/types/
|
||||||
|
├── index.ts # 统一导出
|
||||||
|
├── cicd.ts # CI/CD 流水线类型
|
||||||
|
├── analytics.ts # 数据分析类型
|
||||||
|
├── invoice.ts # 发票管理类型
|
||||||
|
└── sso.ts # SSO 单点登录类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增 API
|
||||||
|
```
|
||||||
|
src/api/
|
||||||
|
├── cicd.ts # CI/CD 流水线 API
|
||||||
|
├── analytics.ts # 数据分析 API
|
||||||
|
├── invoice.ts # 发票管理 API
|
||||||
|
└── sso.ts # SSO 单点登录 API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增页面
|
||||||
|
```
|
||||||
|
src/developer/app/[id]/
|
||||||
|
├── builds.tsx # 构建列表
|
||||||
|
├── builds.scss
|
||||||
|
├── build/[id].tsx # 构建详情
|
||||||
|
├── build/[id].scss
|
||||||
|
├── deploys.tsx # 部署列表
|
||||||
|
├── deploys.scss
|
||||||
|
├── deploy/[id].tsx # 部署详情
|
||||||
|
├── deploy/[id].scss
|
||||||
|
├── pipeline.tsx # 流水线配置
|
||||||
|
├── pipeline.scss
|
||||||
|
├── analytics.tsx # 运营监控
|
||||||
|
└── analytics.scss
|
||||||
|
|
||||||
|
src/enterprise/
|
||||||
|
├── orders/
|
||||||
|
│ ├── invoice.tsx # 发票管理
|
||||||
|
│ └── invoice.scss
|
||||||
|
└── settings/
|
||||||
|
├── sso.tsx # SSO 配置
|
||||||
|
└── sso.scss
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3 完成的功能
|
||||||
|
| 功能 | 页面 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **CI/CD 构建** | 构建列表/详情 | ✅ 完整 |
|
||||||
|
| **CI/CD 部署** | 部署列表/详情/回滚 | ✅ 完整 |
|
||||||
|
| **流水线配置** | 流水线设置 | ✅ 完整 |
|
||||||
|
| **运营监控** | 数据看板/趋势 | ✅ 完整 |
|
||||||
|
| **发票管理** | 发票列表/申请/抬头 | ✅ 完整 |
|
||||||
|
| **SSO 单点登录** | SSO 配置/日志 | ✅ 完整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 待开发 (Phase 4)
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| SDK 下载 | 各语言 SDK 下载页面 |
|
||||||
|
| 工单系统 | 客服工单/技术支持 |
|
||||||
|
| 账单导出 | 账单 Excel 导出 |
|
||||||
|
| 数据导入 | 企业数据批量导入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 开发完成 ✅ (2026-04-12 20:25)
|
||||||
|
|
||||||
|
### 新增功能模块
|
||||||
|
|
||||||
|
#### 1. SDK 下载中心
|
||||||
|
- **SDK 分类浏览**:Web/移动/服务端/小程序
|
||||||
|
- **SDK 列表展示**:图标/版本/下载量/Star
|
||||||
|
- **搜索功能**:按名称/描述搜索
|
||||||
|
- **SDK 详情弹窗**:版本/依赖/更新日志
|
||||||
|
- **一键安装**:NPM 命令复制/直接下载
|
||||||
|
|
||||||
|
#### 2. 工单系统
|
||||||
|
- **工单列表**:状态/类型/优先级筛选
|
||||||
|
- **工单统计**:总数/待处理/处理中/已解决
|
||||||
|
- **创建工单**:类型/优先级/标题/内容
|
||||||
|
- **工单详情**:沟通记录/解决方案/回复
|
||||||
|
- **工单评价**:满意度评分/反馈
|
||||||
|
|
||||||
|
#### 3. FAQ 常见问题
|
||||||
|
- **分类浏览**:API/账单/账户/SDK 等分类
|
||||||
|
- **搜索功能**:关键词搜索
|
||||||
|
- **展开查看**:问题详情/回答
|
||||||
|
- **有帮助/没帮助**:用户反馈
|
||||||
|
|
||||||
|
#### 4. 数据导入
|
||||||
|
- **导入记录列表**:状态/进度/错误统计
|
||||||
|
- **模板下载**:各类数据导入模板
|
||||||
|
- **字段说明**:必填/类型/示例
|
||||||
|
- **错误详情**:行号/字段/错误原因/建议
|
||||||
|
|
||||||
|
#### 5. 账单导出
|
||||||
|
- **导出记录**:任务列表/状态/文件信息
|
||||||
|
- **新建导出**:类型/格式/时间范围
|
||||||
|
- **下载管理**:文件下载/过期提醒
|
||||||
|
- **格式支持**:Excel/CSV/PDF
|
||||||
|
|
||||||
|
### 新增类型定义
|
||||||
|
```
|
||||||
|
src/types/
|
||||||
|
├── sdk.ts # SDK 下载类型
|
||||||
|
├── ticket.ts # 工单系统类型
|
||||||
|
└── import.ts # 导入导出类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增 API
|
||||||
|
```
|
||||||
|
src/api/
|
||||||
|
├── sdk.ts # SDK 下载 API
|
||||||
|
├── ticket.ts # 工单系统 API
|
||||||
|
└── import.ts # 导入导出 API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增页面
|
||||||
|
```
|
||||||
|
src/developer/
|
||||||
|
├── sdk/
|
||||||
|
│ └── index.tsx # SDK 下载中心
|
||||||
|
├── ticket/
|
||||||
|
│ ├── index.tsx # 工单中心
|
||||||
|
│ ├── detail.tsx # 工单详情
|
||||||
|
│ └── faq.tsx # FAQ 常见问题
|
||||||
|
|
||||||
|
src/enterprise/
|
||||||
|
├── settings/
|
||||||
|
│ └── import.tsx # 数据导入
|
||||||
|
└── orders/
|
||||||
|
└── bills.tsx # 账单导出
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4 完成的功能
|
||||||
|
| 功能 | 页面 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **SDK 下载中心** | 列表/详情/下载 | ✅ 完整 |
|
||||||
|
| **工单系统** | 列表/创建/详情/评价 | ✅ 完整 |
|
||||||
|
| **FAQ 常见问题** | 分类/搜索/反馈 | ✅ 完整 |
|
||||||
|
| **数据导入** | 导入记录/模板下载 | ✅ 完整 |
|
||||||
|
| **账单导出** | 导出记录/新建导出 | ✅ 完整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 四个 Phase 累计完成
|
||||||
|
|
||||||
|
| Phase | 功能数 | 页面数 | API 数 |
|
||||||
|
|-------|--------|--------|--------|
|
||||||
|
| Phase 1 | 基础框架 | 15+ | 10+ |
|
||||||
|
| Phase 2 | API Key/成员/通知等 | 20+ | 15+ |
|
||||||
|
| Phase 3 | CI-CD/监控/发票/SSO | 10+ | 20+ |
|
||||||
|
| Phase 4 | SDK/工单/导入/导出 | 10+ | 15+ |
|
||||||
|
| **合计** | - | **55+** | **60+** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复 (2026-04-12 21:00)
|
||||||
|
|
||||||
|
### 已修复的问题
|
||||||
|
|
||||||
|
#### 1. JSX 语法错误
|
||||||
|
- 修复 8 个被截断的 `.tsx` 文件(appCredential、appEvent、appUser、appVersion)
|
||||||
|
- 修复 `user/apps/index.tsx` 中的 `>` token 问题
|
||||||
|
|
||||||
|
#### 2. API 导入问题
|
||||||
|
- 修复 `analytics.ts`、`cicd.ts`、`invoice.ts`、`sso.ts` 的 request 导入
|
||||||
|
- 添加缺失的 `ImportType` 导入
|
||||||
|
- 修复 `sdk.ts` 未使用的导入
|
||||||
|
|
||||||
|
#### 3. Config 文件错误
|
||||||
|
- 修复所有 `defineComponentConfig` → `definePageConfig`
|
||||||
|
- 涉及 12 个 config 文件
|
||||||
|
|
||||||
|
#### 4. 组件命名错误
|
||||||
|
- 修复 `PullRefresh` → `PullToRefresh`(nutui 组件)
|
||||||
|
- 涉及 4 个文件
|
||||||
|
|
||||||
|
#### 5. taro-ui 依赖问题
|
||||||
|
- 将 `taro-ui` 替换为 `nutui-react-taro`
|
||||||
|
- 重写 `invoice.tsx`、`sso.tsx` 页面
|
||||||
|
|
||||||
|
#### 6. SCSS 导入问题
|
||||||
|
- 修复 `notification/index.tsx` 的 scss 导入路径
|
||||||
|
|
||||||
|
#### 7. 类型定义
|
||||||
|
- 创建 `src/types/developer.ts` 缺失类型文件
|
||||||
|
- 修复 `RequestConfig` 缺少 `params` 属性
|
||||||
|
|
||||||
|
### 项目状态
|
||||||
|
- ✅ 编译成功
|
||||||
|
- ✅ 开发服务器运行中 (端口: dist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:改造 invite/index.tsx 登录流程
|
||||||
|
|
||||||
|
### 需求
|
||||||
|
- `loginByOpenId` 已注册 → 显示**「确认加入」**按钮
|
||||||
|
- `loginByOpenId` 未注册 → 显示**「微信手机号快速加入」**按钮(走授权流程)
|
||||||
|
|
||||||
|
### 关键逻辑
|
||||||
|
1. `checkLoginStatus`:调用 `loginByOpenId` 检查用户是否已注册
|
||||||
|
2. 已注册:`isLoggedIn = true`,显示「确认加入」按钮
|
||||||
|
3. 未注册:`isLoggedIn = false`,显示「微信手机号授权」按钮
|
||||||
|
4. 授权成功 → 调用 `loginByMpWxPhone` 完成注册/登录 → 自动执行加入应用
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 完整重写,区分已登录/未注册两种按钮状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:未注册用户在邀请页内完成授权注册,不跳登录页
|
||||||
|
|
||||||
|
### 需求
|
||||||
|
- loginByOpenId 未注册 → 在页面内显示「微信手机号授权」按钮
|
||||||
|
- 授权成功 → 调用 `loginByMpWxPhone` 注册/登录 → 自动执行加入应用
|
||||||
|
- 不再跳转 passport/login 页面
|
||||||
|
|
||||||
|
### 关键逻辑
|
||||||
|
1. `checkLoginStatus`:已注册 isLoggedIn=true,未注册 isLoggedIn=false,**两种情况都显示邀请页**
|
||||||
|
2. 未注册按钮:`open-type="getPhoneNumber"` → `handleGetPhoneNumber`
|
||||||
|
- 授权码调 `SERVER_API_URL/wx-login/loginByMpWxPhone` 完成注册登录
|
||||||
|
- 保存 token → isLoggedIn=true → 立即调 `doJoinApp`
|
||||||
|
3. 已注册按钮:普通 `onClick` → `handleConfirmJoin` → `doJoinApp(access_token)`
|
||||||
|
4. `doJoinApp`:统一加入接口,请求头带 `Authorization: Bearer {access_token}`
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 完整重写(彻底移除跳登录页逻辑)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复:「授权码不能为空」报错
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
后端 `/api/_app/developer/invite/accept` 接口强制要求传 `code`(微信授权码),不传就报「授权码不能为空」。
|
||||||
|
|
||||||
|
### 解决
|
||||||
|
统一用一个 `getPhoneNumber` 按钮处理两种场景:
|
||||||
|
- **已注册**:文字「确认加入」→ 触发 getPhoneNumber → `doJoinApp(code, accessToken)`
|
||||||
|
- **未注册**:文字「微信手机号快速加入」→ 触发 getPhoneNumber → 先 `loginByMpWxPhone` 注册 → 再 `wx.login()` 获 code → `doJoinApp(newCode, access_token)`
|
||||||
|
|
||||||
|
### doJoinApp 参数
|
||||||
|
```ts
|
||||||
|
doJoinApp(wxCode: string, accessToken: string)
|
||||||
|
// 请求体带 code,请求头带 Authorization: Bearer xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优化:已登录用户不弹手机号授权
|
||||||
|
|
||||||
|
### 改动
|
||||||
|
- 已登录按钮:普通 `onClick`,文字「确认加入」
|
||||||
|
- 未注册按钮:`getPhoneNumber` 授权,文字「微信手机号快速加入」
|
||||||
|
|
||||||
|
### 逻辑差异
|
||||||
|
| 用户状态 | 按钮类型 | 获取 code 方式 |
|
||||||
|
|------|------|------|
|
||||||
|
| 已登录 | 普通 onClick | `wx.login()` |
|
||||||
|
| 未注册 | getPhoneNumber | 授权回调的 `code` |
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 按钮区分两种类型,已登录用普通 onClick
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优化:已登录用户不强制勾选协议
|
||||||
|
|
||||||
|
### 改动
|
||||||
|
- 已登录用户点击「确认加入」时,不再检查 `agreementChecked`
|
||||||
|
- 未注册用户仍需勾选协议后才能授权手机号
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - `handleConfirmJoin` 移除协议检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复:后端需要手机号授权码
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
后端 `invite/accept` 接口只接受 `getPhoneNumber` 获取的手机号授权码,用 `wx.login()` 的 code 会报「获取手机号失败」。
|
||||||
|
|
||||||
|
### 解决
|
||||||
|
两种用户状态都走 `getPhoneNumber` 按钮:
|
||||||
|
- 已登录:文字「确认加入」,回调 `handleConfirmJoinGetPhoneNumber`
|
||||||
|
- 未注册:文字「微信手机号快速加入」,回调 `handleGetPhoneNumber`
|
||||||
|
|
||||||
|
### 差异
|
||||||
|
| 用户状态 | 回调 | 行为 |
|
||||||
|
|------|------|------|
|
||||||
|
| 已登录 | `handleConfirmJoinGetPhoneNumber` | 直接用 `code + access_token` 调加入接口 |
|
||||||
|
| 未注册 | `handleGetPhoneNumber` | 先 `loginByMpWxPhone` 注册登录,再调加入接口 |
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/passport/invite/index.tsx` - 两种状态都用 getPhoneNumber 按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务:后端改造支持已登录用户直接加入
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
后端 `/api/_app/developer/invite/accept` 接口强制要求传 `code`(手机号授权码),导致已登录用户也需要弹手机号授权。
|
||||||
|
|
||||||
|
### 后端改造方案
|
||||||
|
修改 `AppMpInviteController.acceptInvite` 方法:
|
||||||
|
|
||||||
|
#### 1. 参数校验调整
|
||||||
|
- `code` 改为可选参数
|
||||||
|
- 不传 `code` 时,从 `Authorization` 头获取当前登录用户
|
||||||
|
|
||||||
|
#### 2. 双模式支持
|
||||||
|
```java
|
||||||
|
if (StrUtil.isBlank(code)) {
|
||||||
|
// 模式一:已登录用户(通过 Authorization 头识别)
|
||||||
|
userId = getCurrentUserId();
|
||||||
|
} else {
|
||||||
|
// 模式二:未注册用户(通过手机号授权码获取手机号,创建用户)
|
||||||
|
String phone = getPhoneByCode(code);
|
||||||
|
userId = getOrCreateUserByPhone(phone);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. getCurrentUserId 方法
|
||||||
|
- 尝试从 Spring Security Context 获取
|
||||||
|
- 如果获取不到(免登录接口),手动解析 `Authorization` 头的 JWT Token
|
||||||
|
|
||||||
|
### 前端配合改造
|
||||||
|
- 已登录用户:普通 `onClick` 按钮 → `handleConfirmJoin` → `doJoinAppForLoggedInUser`(不传 `code`)
|
||||||
|
- 未注册用户:`getPhoneNumber` 按钮 → `handleGetPhoneNumber` → `doJoinAppForNewUser`(传 `code`)
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
**后端:**
|
||||||
|
- `/Users/gxwebsoft/JAVA/websopy-java/src/main/java/com/gxwebsoft/app/controller/AppMpInviteController.java`
|
||||||
|
- `acceptInvite` 方法支持 `code` 可选
|
||||||
|
- 使用 `BaseController.getLoginUserId()` 获取当前登录用户(无需额外方法)
|
||||||
|
|
||||||
|
**前端:**
|
||||||
|
- `/Users/gxwebsoft/VUE/websopy-mp/src/passport/invite/index.tsx`
|
||||||
|
- 已登录按钮改为普通 `onClick`
|
||||||
|
- 新增 `handleConfirmJoin` 方法
|
||||||
|
- 拆分 `doJoinApp` 为 `doJoinAppForLoggedInUser` 和 `doJoinAppForNewUser`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复:开发者中心加载不到应用
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
用户通过邀请加入应用后,开发者中心显示「加载中...」,无法显示应用列表。
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
- 前端 `developer/index.tsx` 只调用了 `pageMyApp` 接口(查询用户**创建**的应用)
|
||||||
|
- 用户通过邀请加入的应用属于**参与**的应用,不是创建的应用
|
||||||
|
- 后端 `loginByOpenId` 返回了应用列表,但前端没有使用这个数据
|
||||||
|
|
||||||
|
### 解决
|
||||||
|
1. **前端改造**:`loadData` 同时调用两个接口:
|
||||||
|
- `pageMyApp` - 查询创建的应用
|
||||||
|
- `pageJoinedApp` - 查询参与的应用(新增 API)
|
||||||
|
- 合并两个列表,根据 `productId` 去重
|
||||||
|
|
||||||
|
2. **新增 API**:`src/api/developer/developer.ts` 添加 `pageJoinedApp` 方法
|
||||||
|
|
||||||
|
### 文件修改
|
||||||
|
- `src/developer/index.tsx` - `loadData` 同时查询创建和参与的应用
|
||||||
|
- `src/api/developer/developer.ts` - 新增 `pageJoinedApp` 方法
|
||||||
77
.workbuddy/memory/2026-04-13.md
Normal file
77
.workbuddy/memory/2026-04-13.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 2026-04-13 工作日志
|
||||||
|
|
||||||
|
## 完成的工作
|
||||||
|
|
||||||
|
### 1. 阿里云实人认证接入
|
||||||
|
**目的**:为 user/userVerify 页面添加阿里云身份证二要素核验
|
||||||
|
|
||||||
|
**后端改动**(JAVA/websopy-java):
|
||||||
|
- pom.xml:添加 `com.aliyun:cloudauth20190307:2.2.4` 依赖
|
||||||
|
- application.yml:添加 `cloudauth` 配置项(accessKeyId、accessKeySecret、endpoint、regionId)
|
||||||
|
- 新增 `CloudAuthProperties.java`:配置属性类
|
||||||
|
- 新增 `IdVerificationService.java`:实人认证服务
|
||||||
|
- 新增 `IdVerificationController.java`:实人认证 API 控制器
|
||||||
|
|
||||||
|
**前端改动**(VUE/websopy-mp):
|
||||||
|
- api/system/userVerify/index.ts:添加 `verifyIdCard()` API 调用
|
||||||
|
- user/userVerify/index.tsx:
|
||||||
|
- 导入 verifyIdCard
|
||||||
|
- 修改 submitSucceed 函数:个人认证时先调用实名校验,核验通过后再提交
|
||||||
|
|
||||||
|
### 2. 调研结论
|
||||||
|
- **城市服务实名校验**:已于2021年11月停止开放,不可用
|
||||||
|
- **阿里云实人认证**:推荐方案,0.2元/次,有100次免费试用额度
|
||||||
|
- **接入方式**:身份证二要素核验(姓名+身份证号)最简单
|
||||||
|
|
||||||
|
## 待办事项
|
||||||
|
- [x] 配置阿里云 AccessKey(在 application-prod.yml 中设置 cloudauth.accessKeyId 和 cloudauth.accessKeySecret)
|
||||||
|
- [ ] 在阿里云实人认证控制台开通服务并充值
|
||||||
|
- [ ] 测试验证接口是否正常工作
|
||||||
|
|
||||||
|
## 阿里云 AccessKey 配置
|
||||||
|
- 项目:websopy-java
|
||||||
|
- 文件:application-prod.yml
|
||||||
|
- accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP
|
||||||
|
- 备注:与 OSS 使用同一个 AccessKey
|
||||||
|
|
||||||
|
## 3. 前端接口地址修正
|
||||||
|
**目的**:修正小程序端开发者相关 API 接口地址,与后端 Controller 路径保持一致
|
||||||
|
|
||||||
|
**问题发现**:`BaseUrl` 配置已包含 `/api` 后缀 (`https://websopy-api.websoft.top/api`),前端代码中不应再重复添加 `/api` 前缀,否则会导致 `/api/api/` 路径错误。
|
||||||
|
|
||||||
|
**修正文件**:
|
||||||
|
- `src/api/developer/enterprise.ts`:
|
||||||
|
- 企业信息:`/system/tenant/info`, `/system/tenant`
|
||||||
|
- 企业成员:`/system/user/page`, `/system/user`
|
||||||
|
- 订单:`/system/order/page`, `/system/order`
|
||||||
|
- 账单:`/sys/recharge-order/page`
|
||||||
|
- 企业应用:`/app/product/page`
|
||||||
|
- 邀请:`/app/developer/invite`
|
||||||
|
|
||||||
|
- `src/api/developer/developer.ts`:
|
||||||
|
- 项目/应用:`/app/product/*` (create, update, delete, detail, page, my/page, joined/page)
|
||||||
|
- API Key:`/app/app-credential/*`
|
||||||
|
- 版本发布:`/app/app-version/*`
|
||||||
|
- 开发者信息:`/app/developer/git-account`, `/app/developer/gitea-info`
|
||||||
|
- 通知:`/app/notification/*`
|
||||||
|
- 权限申请:`/app/developer/permission-requests/*`
|
||||||
|
|
||||||
|
- `src/api/invite/index.ts`:
|
||||||
|
- 所有接口移除 `/api` 前缀
|
||||||
|
|
||||||
|
**后端 Controller 对应关系**:
|
||||||
|
| 前端 API | 后端 Controller | 路径前缀 |
|
||||||
|
|---------|----------------|---------|
|
||||||
|
| enterprise.ts | TenantController | /api/system/tenant |
|
||||||
|
| enterprise.ts | UserController | /api/system/user |
|
||||||
|
| enterprise.ts | OrderController | /api/system/order |
|
||||||
|
| enterprise.ts | RechargeOrderController | /api/sys/recharge-order |
|
||||||
|
| developer.ts | AppProductController | /api/app/product |
|
||||||
|
| developer.ts | AppCredentialController | /api/app/app-credential |
|
||||||
|
| developer.ts | AppVersionController | /api/app/app-version |
|
||||||
|
| developer.ts | GitAccountController | /api/app/developer |
|
||||||
|
| developer.ts | AppNotificationController | /api/app/notification |
|
||||||
|
| developer.ts | AppPermissionRequestController | /api/app/developer/permission-requests |
|
||||||
|
| invite/index.ts | - | /api/invite/* |
|
||||||
|
|
||||||
|
**重要配置**:`config/env.ts` 中 `API_BASE_URL` 已包含 `/api` 后缀,前端代码路径不应再以 `/api` 开头。
|
||||||
445
docs/mp-upgrade-plan.md
Normal file
445
docs/mp-upgrade-plan.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# 小程序改造方案 - 开发者中心 & 企业控制台
|
||||||
|
|
||||||
|
> 制定时间:2026-04-12
|
||||||
|
> 项目中心:以「项目」为核心构建所有功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、整体架构设计
|
||||||
|
|
||||||
|
### 1.1 用户角色划分
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 小程序端 │
|
||||||
|
├─────────────────────────┬───────────────────────────────────┤
|
||||||
|
│ 🛠️ 开发者角色 │ 🏢 企业客户角色 │
|
||||||
|
│ (Developer) │ (Enterprise) │
|
||||||
|
├─────────────────────────┼───────────────────────────────────┤
|
||||||
|
│ • 拥有开发者权限的用户 │ • 拥有企业权限的用户 │
|
||||||
|
│ • 可开发/发布应用 │ • 可管理企业应用、成员、账单 │
|
||||||
|
│ • 独立于企业存在 │ • 属于某个企业组织 │
|
||||||
|
│ │ • 可申请成为开发者 │
|
||||||
|
└─────────────────────────┴───────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 角色获取方式
|
||||||
|
|
||||||
|
| 角色 | 获取方式 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 开发者 | 申请审核制 | 用户主动申请 → 资料审核 → 获得开发者权限 |
|
||||||
|
| 企业客户 | 购买产品 | 下单支付 → 自动开通企业账号 → 成为企业主 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、页面结构设计
|
||||||
|
|
||||||
|
### 2.1 入口设计
|
||||||
|
|
||||||
|
```
|
||||||
|
首页 (pages/index/index)
|
||||||
|
├── 产品展示区
|
||||||
|
├── 快捷入口区
|
||||||
|
│ ├── 🛠️ 开发者中心 (开发者角色入口)
|
||||||
|
│ └── 🏢 企业控制台 (企业客户入口)
|
||||||
|
└── 底部 TabBar (商城/购物车/我的)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:两个入口对普通用户可见,但需要对应角色权限才能进入。
|
||||||
|
|
||||||
|
### 2.2 开发者中心 (developer/)
|
||||||
|
|
||||||
|
```
|
||||||
|
developer/
|
||||||
|
├── index.tsx # 开发者工作台
|
||||||
|
│ ├── 我的项目列表
|
||||||
|
│ ├── 快捷操作 (新建项目、查看文档)
|
||||||
|
│ └── 开发者动态/通知
|
||||||
|
│
|
||||||
|
├── project/
|
||||||
|
│ ├── index.tsx # 项目列表
|
||||||
|
│ ├── create.tsx # 创建项目
|
||||||
|
│ └── [id]/
|
||||||
|
│ ├── index.tsx # 项目概览
|
||||||
|
│ ├── settings.tsx # 项目设置
|
||||||
|
│ ├── members.tsx # 项目成员
|
||||||
|
│ ├── api-keys.tsx # API Key 管理
|
||||||
|
│ ├── deployments.tsx # 部署记录
|
||||||
|
│ └── logs.tsx # 运行日志
|
||||||
|
│
|
||||||
|
├── app/ # 应用管理
|
||||||
|
│ ├── index.tsx # 应用列表
|
||||||
|
│ ├── create.tsx # 创建应用
|
||||||
|
│ └── [id]/
|
||||||
|
│ ├── index.tsx # 应用详情
|
||||||
|
│ ├── version.tsx # 版本管理
|
||||||
|
│ ├── config.tsx # 应用配置
|
||||||
|
│ └── publish.tsx # 发布管理
|
||||||
|
│
|
||||||
|
├── market/ # 开发者市场
|
||||||
|
│ ├── index.tsx # 市场首页
|
||||||
|
│ ├── templates.tsx # 模板市场
|
||||||
|
│ └── plugins.tsx # 插件市场
|
||||||
|
│
|
||||||
|
├── orders/ # 开发订单 (已有)
|
||||||
|
│ └── index.tsx
|
||||||
|
│
|
||||||
|
├── developer/ # 开发者中心
|
||||||
|
│ ├── index.tsx # 开发者设置
|
||||||
|
│ ├── profile.tsx # 个人资料
|
||||||
|
│ ├── credentials.tsx # 开发者凭证
|
||||||
|
│ └── notification.tsx # 通知设置
|
||||||
|
│
|
||||||
|
└── docs/ # 文档中心
|
||||||
|
├── index.tsx # 文档首页
|
||||||
|
├── quickstart.tsx # 快速开始
|
||||||
|
├── api-docs.tsx # API 文档
|
||||||
|
└── sdk-download.tsx # SDK 下载
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 企业控制台 (enterprise/)
|
||||||
|
|
||||||
|
```
|
||||||
|
enterprise/
|
||||||
|
├── index.tsx # 企业工作台
|
||||||
|
│ ├── 企业概览仪表盘
|
||||||
|
│ ├── 快捷入口 (应用/成员/账单)
|
||||||
|
│ └── 最新动态
|
||||||
|
│
|
||||||
|
├── apps/ # 企业应用
|
||||||
|
│ ├── index.tsx # 应用列表
|
||||||
|
│ ├── [id]/
|
||||||
|
│ │ ├── index.tsx # 应用详情
|
||||||
|
│ │ ├── monitor.tsx # 运营监控
|
||||||
|
│ │ ├── analytics.tsx # 数据分析
|
||||||
|
│ │ └── settings.tsx # 应用设置
|
||||||
|
│ └── purchase.tsx # 购买应用
|
||||||
|
│
|
||||||
|
├── members/ # 成员管理
|
||||||
|
│ ├── index.tsx # 成员列表
|
||||||
|
│ ├── invite.tsx # 邀请成员
|
||||||
|
│ ├── roles.tsx # 角色权限
|
||||||
|
│ └── audit.tsx # 权限审批
|
||||||
|
│
|
||||||
|
├── orders/ # 订单账单
|
||||||
|
│ ├── index.tsx # 订单列表
|
||||||
|
│ ├── detail.tsx # 订单详情
|
||||||
|
│ ├── invoice.tsx # 发票管理
|
||||||
|
│ └── bills.tsx # 账单明细
|
||||||
|
│
|
||||||
|
├── billing/ # 费用中心
|
||||||
|
│ ├── index.tsx # 费用概览
|
||||||
|
│ ├── consumption.tsx # 消费明细
|
||||||
|
│ ├── recharge.tsx # 充值中心
|
||||||
|
│ └── coupons.tsx # 优惠券
|
||||||
|
│
|
||||||
|
├── enterprise/ # 企业设置
|
||||||
|
│ ├── index.tsx # 企业设置
|
||||||
|
│ ├── info.tsx # 企业信息
|
||||||
|
│ ├── domain.tsx # 域名配置
|
||||||
|
│ ├── security.tsx # 安全设置
|
||||||
|
│ └── notification.tsx # 通知设置
|
||||||
|
│
|
||||||
|
└── developer/ # 开发者入口
|
||||||
|
├── index.tsx # 开发者介绍
|
||||||
|
└── apply.tsx # 申请开发者
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、核心功能设计
|
||||||
|
|
||||||
|
### 3.1 项目管理 (项目中心)
|
||||||
|
|
||||||
|
**项目类型**:
|
||||||
|
| 类型 | 说明 | 适用角色 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 基础项目 | 免费,限制功能 | 所有开发者 |
|
||||||
|
| 专业项目 | 付费,功能完整 | 开发者/企业 |
|
||||||
|
| 企业项目 | 全功能,支持团队协作 | 企业 |
|
||||||
|
|
||||||
|
**项目核心属性**:
|
||||||
|
```typescript
|
||||||
|
interface Project {
|
||||||
|
id: string
|
||||||
|
name: string // 项目名称
|
||||||
|
type: 'basic' | 'pro' | 'enterprise'
|
||||||
|
owner: string // 所有者 (开发者ID 或 企业ID)
|
||||||
|
ownerType: 'developer' | 'enterprise'
|
||||||
|
description?: string // 项目描述
|
||||||
|
icon?: string // 项目图标
|
||||||
|
status: 'active' | 'suspended' | 'archived'
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
|
||||||
|
// 关联信息
|
||||||
|
apps: string[] // 关联的应用
|
||||||
|
members: ProjectMember[] // 项目成员
|
||||||
|
apiKeys: ApiKey[] // API Key
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
appCount: number
|
||||||
|
memberCount: number
|
||||||
|
apiCallCount: number // API 调用次数
|
||||||
|
storageUsed: number // 存储使用量
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 开发者角色功能
|
||||||
|
|
||||||
|
**权限项**:
|
||||||
|
| 权限 | 说明 | 默认 |
|
||||||
|
|------|------|------|
|
||||||
|
| project.create | 创建项目 | ✅ |
|
||||||
|
| project.edit | 编辑项目 | ✅ |
|
||||||
|
| project.delete | 删除项目 | ✅ |
|
||||||
|
| app.create | 创建应用 | ✅ |
|
||||||
|
| app.publish | 发布应用 | ✅ |
|
||||||
|
| apiKey.manage | 管理 API Key | ✅ |
|
||||||
|
| market.access | 访问市场 | ✅ |
|
||||||
|
| docs.access | 访问文档 | ✅ |
|
||||||
|
|
||||||
|
**开发者特有功能**:
|
||||||
|
- ✅ 创建和管理个人项目
|
||||||
|
- ✅ 开发、测试、发布应用
|
||||||
|
- ✅ 访问模板/插件市场
|
||||||
|
- ✅ 生成和管理 API Key
|
||||||
|
- ✅ 查看开发者文档和 SDK
|
||||||
|
- ✅ 提交工单和技术支持
|
||||||
|
|
||||||
|
### 3.3 企业客户角色功能
|
||||||
|
|
||||||
|
**权限项**:
|
||||||
|
| 权限 | 说明 | 适用角色 |
|
||||||
|
|------|------|------|
|
||||||
|
| enterprise.manage | 企业管理 | 企业主 |
|
||||||
|
| app.manage | 应用管理 | 企业主/管理员 |
|
||||||
|
| member.invite | 邀请成员 | 企业主/管理员 |
|
||||||
|
| member.role | 分配角色 | 企业主/管理员 |
|
||||||
|
| order.view | 查看订单 | 所有成员 |
|
||||||
|
| billing.view | 查看账单 | 企业主/财务 |
|
||||||
|
| billing.pay | 支付充值 | 企业主/财务 |
|
||||||
|
|
||||||
|
**企业特有功能**:
|
||||||
|
- ✅ 企业仪表盘和数据概览
|
||||||
|
- ✅ 应用运营监控
|
||||||
|
- ✅ 成员邀请和权限管理
|
||||||
|
- ✅ 订单管理和发票
|
||||||
|
- ✅ 费用中心和充值
|
||||||
|
- ✅ 企业信息配置
|
||||||
|
|
||||||
|
### 3.4 角色切换机制
|
||||||
|
|
||||||
|
```
|
||||||
|
用户登录
|
||||||
|
│
|
||||||
|
├── 开发者身份
|
||||||
|
│ └── 可进入开发者中心
|
||||||
|
│
|
||||||
|
├── 企业身份
|
||||||
|
│ └── 可进入企业控制台
|
||||||
|
│
|
||||||
|
└── 两者皆有
|
||||||
|
└── 可切换身份入口
|
||||||
|
├── [开发者] 🛠️ 开发者中心
|
||||||
|
└── [企业] 🏢 企业控制台
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、开发优先级与里程碑
|
||||||
|
|
||||||
|
### Phase 1:基础框架 ⭐ 必须上线
|
||||||
|
|
||||||
|
**目标**:建立核心框架,实现最小可用产品
|
||||||
|
|
||||||
|
| 模块 | 页面 | 工作量 | 优先级 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| 入口设计 | 首页快捷入口 + 身份切换 | ⭐⭐ | P0 |
|
||||||
|
| 开发者工作台 | 开发者首页/概览 | ⭐⭐⭐ | P0 |
|
||||||
|
| 项目管理 | 项目列表 + 创建 | ⭐⭐⭐⭐ | P0 |
|
||||||
|
| 应用管理 | 应用列表 + 创建 | ⭐⭐⭐⭐ | P0 |
|
||||||
|
| 企业工作台 | 企业首页/概览 | ⭐⭐⭐ | P0 |
|
||||||
|
| 企业应用 | 应用列表 + 详情 | ⭐⭐⭐⭐ | P0 |
|
||||||
|
|
||||||
|
### Phase 2:核心功能 ⭐ 必须上线
|
||||||
|
|
||||||
|
**目标**:完成核心业务闭环
|
||||||
|
|
||||||
|
| 模块 | 页面 | 工作量 | 优先级 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| API Key | 生成/查看/禁用 | ⭐⭐⭐ | P0 |
|
||||||
|
| 项目成员 | 邀请/移除/角色 | ⭐⭐⭐ | P0 |
|
||||||
|
| 应用发布 | 版本管理/发布 | ⭐⭐⭐ | P0 |
|
||||||
|
| 企业成员 | 列表/邀请/角色 | ⭐⭐⭐⭐ | P0 |
|
||||||
|
| 订单管理 | 列表/详情 | ⭐⭐⭐ | P0 |
|
||||||
|
| 账单查看 | 消费明细 | ⭐⭐⭐ | P0 |
|
||||||
|
|
||||||
|
### Phase 3:增强功能 🔄 后续迭代
|
||||||
|
|
||||||
|
| 模块 | 页面 | 工作量 | 优先级 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| 开发者申请 | 申请流程/审核 | ⭐⭐⭐ | P1 |
|
||||||
|
| 权限审批 | 权限申请/审批 | ⭐⭐⭐ | P1 |
|
||||||
|
| 市场浏览 | 模板/插件市场 | ⭐⭐⭐⭐ | P1 |
|
||||||
|
| 运营监控 | 数据看板 | ⭐⭐⭐⭐ | P1 |
|
||||||
|
| 开发者文档 | API文档/SDK | ⭐⭐⭐⭐ | P1 |
|
||||||
|
| 消息通知 | 通知中心 | ⭐⭐⭐ | P1 |
|
||||||
|
|
||||||
|
### Phase 4:高级功能 🔄 后续迭代
|
||||||
|
|
||||||
|
| 模块 | 页面 | 工作量 | 优先级 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| CI/CD | 流水线管理 | ⭐⭐⭐⭐⭐ | P2 |
|
||||||
|
| 部署管理 | 一键部署 | ⭐⭐⭐⭐ | P2 |
|
||||||
|
| 数据分析 | 高级统计 | ⭐⭐⭐⭐ | P2 |
|
||||||
|
| 工单系统 | 技术支持 | ⭐⭐⭐ | P2 |
|
||||||
|
| 发票管理 | 电子发票 | ⭐⭐⭐ | P2 |
|
||||||
|
| SSO | 单点登录 | ⭐⭐⭐⭐⭐ | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、与现有模块的关系
|
||||||
|
|
||||||
|
### 5.1 独立开发,不依赖现有模块
|
||||||
|
|
||||||
|
| 新建模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| developer/ | 开发者中心全套功能 |
|
||||||
|
| enterprise/ | 企业控制台全套功能 |
|
||||||
|
|
||||||
|
### 5.2 共享基础设施
|
||||||
|
|
||||||
|
| 共享内容 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| 用户认证 | 复用 passport 模块的登录体系 |
|
||||||
|
| 用户状态 | 复用 globalData/storage 中的用户信息 |
|
||||||
|
| 消息通知 | 可复用 notification 组件 |
|
||||||
|
| 基础 UI | 复用组件库样式 |
|
||||||
|
|
||||||
|
### 5.3 不复用模块
|
||||||
|
|
||||||
|
- ❌ 不复用 shop/ (商城) - 独立的企业应用体系
|
||||||
|
- ❌ 不复用 dealer/ (分销) - 独立的开发者体系
|
||||||
|
- ❌ 不复用 user/order (用户订单) - 使用新的企业订单体系
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、技术实现建议
|
||||||
|
|
||||||
|
### 6.1 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── developer/ # 🛠️ 开发者模块
|
||||||
|
│ ├── index.tsx # 开发者入口
|
||||||
|
│ ├── project/ # 项目管理
|
||||||
|
│ ├── app/ # 应用管理
|
||||||
|
│ ├── market/ # 市场
|
||||||
|
│ └── docs/ # 文档
|
||||||
|
│
|
||||||
|
├── enterprise/ # 🏢 企业模块
|
||||||
|
│ ├── index.tsx # 企业入口
|
||||||
|
│ ├── apps/ # 企业应用
|
||||||
|
│ ├── members/ # 成员管理
|
||||||
|
│ ├── orders/ # 订单账单
|
||||||
|
│ ├── billing/ # 费用中心
|
||||||
|
│ └── settings/ # 企业设置
|
||||||
|
│
|
||||||
|
└── shared/ # 共享
|
||||||
|
├── components/ # 共享组件
|
||||||
|
├── types/ # 类型定义
|
||||||
|
└── api/ # API 接口
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 路由配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app.config.ts
|
||||||
|
export default {
|
||||||
|
subpackages: [
|
||||||
|
{
|
||||||
|
root: "developer",
|
||||||
|
pages: [
|
||||||
|
"index",
|
||||||
|
"project/index",
|
||||||
|
"project/create",
|
||||||
|
"project/[id]/index",
|
||||||
|
"project/[id]/settings",
|
||||||
|
"app/index",
|
||||||
|
"app/create",
|
||||||
|
"app/[id]/index",
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: "enterprise",
|
||||||
|
pages: [
|
||||||
|
"index",
|
||||||
|
"apps/index",
|
||||||
|
"apps/[id]/index",
|
||||||
|
"members/index",
|
||||||
|
"members/invite",
|
||||||
|
"orders/index",
|
||||||
|
"orders/detail",
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、API 接口设计建议
|
||||||
|
|
||||||
|
### 7.1 项目相关
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| /api/project/list | GET | 项目列表 |
|
||||||
|
| /api/project/create | POST | 创建项目 |
|
||||||
|
| /api/project/:id | GET/PUT/DELETE | 项目 CRUD |
|
||||||
|
| /api/project/:id/members | GET/POST/DELETE | 项目成员 |
|
||||||
|
| /api/project/:id/keys | GET/POST/DELETE | API Key |
|
||||||
|
|
||||||
|
### 7.2 应用相关
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| /api/app/list | GET | 应用列表 |
|
||||||
|
| /api/app/create | POST | 创建应用 |
|
||||||
|
| /api/app/:id | GET/PUT/DELETE | 应用 CRUD |
|
||||||
|
| /api/app/:id/version | POST | 发布版本 |
|
||||||
|
|
||||||
|
### 7.3 企业相关
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| /api/enterprise/info | GET | 企业信息 |
|
||||||
|
| /api/enterprise/members | GET/POST | 成员管理 |
|
||||||
|
| /api/enterprise/roles | GET/POST | 角色管理 |
|
||||||
|
| /api/enterprise/orders | GET | 订单列表 |
|
||||||
|
| /api/enterprise/bills | GET | 账单明细 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、下一步行动
|
||||||
|
|
||||||
|
### 立即开始 (Phase 1)
|
||||||
|
|
||||||
|
1. ✅ 创建 developer/ 和 enterprise/ 目录结构
|
||||||
|
2. ✅ 设计 API 接口文档 (与后端对齐)
|
||||||
|
3. ✅ 开发首页入口组件
|
||||||
|
4. ✅ 开发工作台基础页面
|
||||||
|
|
||||||
|
### 需要确认
|
||||||
|
|
||||||
|
- [ ] 后端 API 是否已准备好?
|
||||||
|
- [ ] 数据库表结构是否已设计?
|
||||||
|
- [ ] 是否需要支持微信小程序码扫码登录?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**最后更新**:2026-04-12
|
||||||
@@ -4,12 +4,26 @@
|
|||||||
"condition": {
|
"condition": {
|
||||||
"miniprogram": {
|
"miniprogram": {
|
||||||
"list": [
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "passport/webview/index",
|
||||||
|
"pathName": "passport/webview/index",
|
||||||
|
"query": "url=https%3A%2F%2Fwebsopy.websoft.top%2Fdeveloper",
|
||||||
|
"scene": null,
|
||||||
|
"launchMode": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "passport/login",
|
||||||
|
"pathName": "passport/login",
|
||||||
|
"query": "",
|
||||||
|
"launchMode": "default",
|
||||||
|
"scene": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "passport/invite/index",
|
"name": "passport/invite/index",
|
||||||
"pathName": "passport/invite/index",
|
"pathName": "passport/invite/index",
|
||||||
"query": "",
|
"query": "",
|
||||||
"scene": null,
|
"launchMode": "default",
|
||||||
"launchMode": "default"
|
"scene": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "passport/invite/index",
|
"name": "passport/invite/index",
|
||||||
|
|||||||
226
src/api/analytics.ts
Normal file
226
src/api/analytics.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 数据分析/运营监控 API
|
||||||
|
*/
|
||||||
|
import { request } from '../utils/request'
|
||||||
|
import type {
|
||||||
|
OverviewStats,
|
||||||
|
ApiCallStat,
|
||||||
|
ErrorStat,
|
||||||
|
PerformanceMetric,
|
||||||
|
RegionStat,
|
||||||
|
DeviceStat,
|
||||||
|
BrowserStat,
|
||||||
|
OsStat,
|
||||||
|
SourceStat,
|
||||||
|
PageStat,
|
||||||
|
EventTrack,
|
||||||
|
AnalyticsParam,
|
||||||
|
} from '../types/analytics'
|
||||||
|
|
||||||
|
// ==================== 概览统计 ====================
|
||||||
|
|
||||||
|
/** 获取概览统计 */
|
||||||
|
export const getOverviewStats = (websiteId: number, date?: string) => {
|
||||||
|
return request<OverviewStats>({
|
||||||
|
url: '/developer/analytics/overview',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId, date },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取趋势数据 */
|
||||||
|
export const getTrendData = (
|
||||||
|
websiteId: number,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
granularity: 'hour' | 'day' | 'week' | 'month' = 'day'
|
||||||
|
) => {
|
||||||
|
return request<{ list: ApiCallStat[] }>({
|
||||||
|
url: '/developer/analytics/trend',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId, startDate, endDate, granularity },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取今日实时数据 */
|
||||||
|
export const getRealtimeData = (websiteId: number) => {
|
||||||
|
return request<{
|
||||||
|
uv: number
|
||||||
|
pv: number
|
||||||
|
apiCalls: number
|
||||||
|
errors: number
|
||||||
|
activeUsers: number
|
||||||
|
}>({
|
||||||
|
url: '/developer/analytics/realtime',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 调用统计 ====================
|
||||||
|
|
||||||
|
/** 获取 API 调用统计 */
|
||||||
|
export const pageApiCalls = (params: AnalyticsParam) => {
|
||||||
|
return request<{ list: ApiCallStat[]; total: number }>({
|
||||||
|
url: '/developer/analytics/api-calls',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 错误统计 ====================
|
||||||
|
|
||||||
|
/** 获取错误列表 */
|
||||||
|
export const pageErrors = (params: AnalyticsParam) => {
|
||||||
|
return request<{ list: ErrorStat[]; total: number }>({
|
||||||
|
url: '/developer/analytics/errors',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取错误详情 */
|
||||||
|
export const getErrorDetail = (id: number) => {
|
||||||
|
return request<ErrorStat & { samples: any[] }>({
|
||||||
|
url: `/developer/analytics/errors/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 性能指标 ====================
|
||||||
|
|
||||||
|
/** 获取性能指标 */
|
||||||
|
export const getPerformanceMetrics = (websiteId: number) => {
|
||||||
|
return request<{ list: PerformanceMetric[] }>({
|
||||||
|
url: '/developer/analytics/performance',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取性能趋势 */
|
||||||
|
export const getPerformanceTrend = (
|
||||||
|
websiteId: number,
|
||||||
|
metric: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
) => {
|
||||||
|
return request<{ list: PerformanceMetric[] }>({
|
||||||
|
url: '/developer/analytics/performance/trend',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId, metric, startDate, endDate },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户统计 ====================
|
||||||
|
|
||||||
|
/** 获取用户统计 */
|
||||||
|
export const getUserStats = (websiteId: number) => {
|
||||||
|
return request<{
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
new: number
|
||||||
|
retention: number
|
||||||
|
}>({
|
||||||
|
url: '/developer/analytics/users',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取新增用户趋势 */
|
||||||
|
export const getNewUsersTrend = (
|
||||||
|
websiteId: number,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
|
) => {
|
||||||
|
return request<{ list: { date: string; count: number }[] }>({
|
||||||
|
url: '/developer/analytics/users/trend',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId, startDate, endDate },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 区域分布 ====================
|
||||||
|
|
||||||
|
/** 获取区域分布 */
|
||||||
|
export const getRegionStats = (websiteId: number) => {
|
||||||
|
return request<{ list: RegionStat[] }>({
|
||||||
|
url: '/developer/analytics/regions',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 设备统计 ====================
|
||||||
|
|
||||||
|
/** 获取设备类型统计 */
|
||||||
|
export const getDeviceStats = (websiteId: number) => {
|
||||||
|
return request<{ list: DeviceStat[] }>({
|
||||||
|
url: '/developer/analytics/devices',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取浏览器统计 */
|
||||||
|
export const getBrowserStats = (websiteId: number) => {
|
||||||
|
return request<{ list: BrowserStat[] }>({
|
||||||
|
url: '/developer/analytics/browsers',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取操作系统统计 */
|
||||||
|
export const getOsStats = (websiteId: number) => {
|
||||||
|
return request<{ list: OsStat[] }>({
|
||||||
|
url: '/developer/analytics/os',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 来源统计 ====================
|
||||||
|
|
||||||
|
/** 获取流量来源 */
|
||||||
|
export const getSourceStats = (websiteId: number) => {
|
||||||
|
return request<{ list: SourceStat[] }>({
|
||||||
|
url: '/developer/analytics/sources',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取页面访问排行 */
|
||||||
|
export const getPageStats = (params: AnalyticsParam) => {
|
||||||
|
return request<{ list: PageStat[]; total: number }>({
|
||||||
|
url: '/developer/analytics/pages',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 事件追踪 ====================
|
||||||
|
|
||||||
|
/** 获取事件列表 */
|
||||||
|
export const pageEvents = (params: AnalyticsParam) => {
|
||||||
|
return request<{ list: EventTrack[]; total: number }>({
|
||||||
|
url: '/developer/analytics/events',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发自定义事件 */
|
||||||
|
export const trackEvent = (data: {
|
||||||
|
websiteId: number
|
||||||
|
event: string
|
||||||
|
properties?: Record<string, any>
|
||||||
|
}) => {
|
||||||
|
return request<void>({
|
||||||
|
url: '/developer/analytics/track',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
193
src/api/cicd.ts
Normal file
193
src/api/cicd.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* CI/CD 流水线 API
|
||||||
|
*/
|
||||||
|
import { request } from '../utils/request'
|
||||||
|
import type {
|
||||||
|
Build,
|
||||||
|
BuildParam,
|
||||||
|
BuildStatus,
|
||||||
|
Deploy,
|
||||||
|
DeployParam,
|
||||||
|
DeployStatus,
|
||||||
|
PipelineConfig,
|
||||||
|
RuntimeInstance,
|
||||||
|
} from '../types/cicd'
|
||||||
|
|
||||||
|
// ==================== 构建相关 ====================
|
||||||
|
|
||||||
|
/** 获取构建列表 */
|
||||||
|
export const pageBuild = (params: BuildParam) => {
|
||||||
|
return request<{ list: Build[]; total: number }>({
|
||||||
|
url: '/developer/build/page',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取构建详情 */
|
||||||
|
export const getBuild = (id: number) => {
|
||||||
|
return request<Build>({
|
||||||
|
url: `/developer/build/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发构建 */
|
||||||
|
export const triggerBuild = (data: { websiteId: number; branch?: string; commitId?: string }) => {
|
||||||
|
return request<Build>({
|
||||||
|
url: '/developer/build/trigger',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消构建 */
|
||||||
|
export const cancelBuild = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/build/${id}/cancel`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取构建日志 */
|
||||||
|
export const getBuildLogs = (id: number) => {
|
||||||
|
return request<{ logs: string }>({
|
||||||
|
url: `/developer/build/${id}/logs`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取构建产物 */
|
||||||
|
export const getBuildArtifacts = (id: number) => {
|
||||||
|
return request<{ artifacts: any[] }>({
|
||||||
|
url: `/developer/build/${id}/artifacts`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 部署相关 ====================
|
||||||
|
|
||||||
|
/** 获取部署列表 */
|
||||||
|
export const pageDeploy = (params: DeployParam) => {
|
||||||
|
return request<{ list: Deploy[]; total: number }>({
|
||||||
|
url: '/developer/deploy/page',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取部署详情 */
|
||||||
|
export const getDeploy = (id: number) => {
|
||||||
|
return request<Deploy>({
|
||||||
|
url: `/developer/deploy/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发部署 */
|
||||||
|
export const triggerDeploy = (data: {
|
||||||
|
websiteId: number
|
||||||
|
buildId: number
|
||||||
|
env: string
|
||||||
|
}) => {
|
||||||
|
return request<Deploy>({
|
||||||
|
url: '/developer/deploy/trigger',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回滚部署 */
|
||||||
|
export const rollbackDeploy = (id: number) => {
|
||||||
|
return request<Deploy>({
|
||||||
|
url: `/developer/deploy/${id}/rollback`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取部署日志 */
|
||||||
|
export const getDeployLogs = (id: number) => {
|
||||||
|
return request<{ logs: string }>({
|
||||||
|
url: `/developer/deploy/${id}/logs`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 流水线配置相关 ====================
|
||||||
|
|
||||||
|
/** 获取流水线配置 */
|
||||||
|
export const getPipelineConfig = (websiteId: number) => {
|
||||||
|
return request<PipelineConfig>({
|
||||||
|
url: `/developer/pipeline/config/${websiteId}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新流水线配置 */
|
||||||
|
export const updatePipelineConfig = (data: PipelineConfig) => {
|
||||||
|
return request<PipelineConfig>({
|
||||||
|
url: '/developer/pipeline/config',
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取分支列表 */
|
||||||
|
export const getBranches = (websiteId: number) => {
|
||||||
|
return request<{ branches: string[] }>({
|
||||||
|
url: `/developer/pipeline/branches/${websiteId}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加环境变量 */
|
||||||
|
export const addEnvVar = (websiteId: number, data: { key: string; value: string; isSecret?: boolean }) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/pipeline/env/${websiteId}`,
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除环境变量 */
|
||||||
|
export const deleteEnvVar = (websiteId: number, key: string) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/pipeline/env/${websiteId}/${key}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 运行时相关 ====================
|
||||||
|
|
||||||
|
/** 获取运行时实例列表 */
|
||||||
|
export const pageRuntime = (websiteId: number, env?: string) => {
|
||||||
|
return request<{ list: RuntimeInstance[]; total: number }>({
|
||||||
|
url: '/developer/runtime/page',
|
||||||
|
method: 'GET',
|
||||||
|
params: { websiteId, env },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动实例 */
|
||||||
|
export const startRuntime = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/runtime/${id}/start`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止实例 */
|
||||||
|
export const stopRuntime = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/runtime/${id}/stop`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重启实例 */
|
||||||
|
export const restartRuntime = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/developer/runtime/${id}/restart`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
444
src/api/developer/developer.ts
Normal file
444
src/api/developer/developer.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { ApiResult, PageResult } from '@/api'
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
AppParam,
|
||||||
|
ApiKey,
|
||||||
|
ApiKeyParam,
|
||||||
|
Developer,
|
||||||
|
DeveloperApplyParam,
|
||||||
|
Project,
|
||||||
|
ProjectParam,
|
||||||
|
ProjectMember,
|
||||||
|
Version,
|
||||||
|
VersionParam,
|
||||||
|
Notification,
|
||||||
|
NotificationParam,
|
||||||
|
Apply,
|
||||||
|
ApplyParam,
|
||||||
|
} from '@/types/developer'
|
||||||
|
|
||||||
|
// ==================== 项目/应用相关 ====================
|
||||||
|
// 注意:后端使用 AppProduct 作为项目/应用的概念,路径为 /app/product
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的项目列表(我创建的应用)
|
||||||
|
*/
|
||||||
|
export async function pageMyProject(params?: ProjectParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Project>>>('/app/product/my/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目列表(分页查询应用列表)
|
||||||
|
*/
|
||||||
|
export async function pageProject(params?: ProjectParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Project>>>('/app/product/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目详情
|
||||||
|
*/
|
||||||
|
export async function getProject(id: number) {
|
||||||
|
const res = await request.get<ApiResult<Project>>(`/app/product/detail/${id}`)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建项目
|
||||||
|
*/
|
||||||
|
export async function createProject(data: Partial<Project>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/product/create', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新项目
|
||||||
|
*/
|
||||||
|
export async function updateProject(data: Partial<Project>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/app/product/update', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除项目
|
||||||
|
*/
|
||||||
|
export async function deleteProject(id: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/app/product/delete/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目成员列表(使用应用用户服务)
|
||||||
|
*/
|
||||||
|
export async function listProjectMember(projectId: number) {
|
||||||
|
const res = await request.get<ApiResult<ProjectMember[]>>(`/app/app-user/page`, { appId: projectId })
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加项目成员
|
||||||
|
*/
|
||||||
|
export async function addProjectMember(projectId: number, data: Partial<ProjectMember>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(`/app/app-user`, { ...data, appId: projectId })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除项目成员
|
||||||
|
*/
|
||||||
|
export async function removeProjectMember(projectId: number, memberId: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/app/app-user/${memberId}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 应用相关(别名,与项目共用)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询我的应用(创建的应用)
|
||||||
|
*/
|
||||||
|
export async function pageMyApp(params?: AppParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<App>>>('/app/product/my/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询我参与的应用(通过邀请加入的应用)
|
||||||
|
*/
|
||||||
|
export async function pageJoinedApp(params?: AppParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<App>>>('/app/product/joined/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用列表
|
||||||
|
*/
|
||||||
|
export async function pageApp(params?: AppParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<App>>>('/app/product/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取应用详情
|
||||||
|
*/
|
||||||
|
export async function getApp(id: number) {
|
||||||
|
const res = await request.get<ApiResult<App>>(`/app/product/detail/${id}`)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建应用
|
||||||
|
*/
|
||||||
|
export async function createApp(data: Partial<App>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/product/create', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新应用
|
||||||
|
*/
|
||||||
|
export async function updateApp(data: Partial<App>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/app/product/update', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除应用
|
||||||
|
*/
|
||||||
|
export async function deleteApp(id: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/app/product/delete/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API Key 相关 ====================
|
||||||
|
// 对应后端 AppCredentialController,路径为 /app/app-credential
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 API Key 列表
|
||||||
|
*/
|
||||||
|
export async function pageApiKey(params?: ApiKeyParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<ApiKey>>>('/app/app-credential/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 API Key 列表(不分页)
|
||||||
|
*/
|
||||||
|
export async function listApiKey(params?: ApiKeyParam) {
|
||||||
|
const res = await request.get<ApiResult<ApiKey[]>>('/app/app-credential', params)
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 API Key
|
||||||
|
*/
|
||||||
|
export async function createApiKey(data: Partial<ApiKey>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/app-credential', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 API Key
|
||||||
|
*/
|
||||||
|
export async function updateApiKey(data: Partial<ApiKey>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/app/app-credential', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 API Key
|
||||||
|
*/
|
||||||
|
export async function deleteApiKey(id: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/app/app-credential/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置 API Key Secret
|
||||||
|
*/
|
||||||
|
export async function resetApiKeySecret(id: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(`/app/app-credential/resetSecret/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 版本发布相关 ====================
|
||||||
|
// 对应后端 AppVersionController,路径为 /app/app-version
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取版本列表
|
||||||
|
*/
|
||||||
|
export async function pageVersion(params?: VersionParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Version>>>('/app/app-version/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取版本详情
|
||||||
|
*/
|
||||||
|
export async function getVersion(id: number) {
|
||||||
|
const res = await request.get<ApiResult<Version>>(`/app/app-version/${id}`)
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建版本
|
||||||
|
*/
|
||||||
|
export async function createVersion(data: Partial<Version>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/app-version', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布版本
|
||||||
|
*/
|
||||||
|
export async function publishVersion(id: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(`/app/app-version/publish/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚版本
|
||||||
|
*/
|
||||||
|
export async function rollbackVersion(id: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(`/app/app-version/rollback/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 开发者相关 ====================
|
||||||
|
// 对应后端 GitAccountController,路径为 /app/developer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取开发者信息(Git账号绑定状态)
|
||||||
|
*/
|
||||||
|
export async function getDeveloperInfo() {
|
||||||
|
const res = await request.get<ApiResult<Developer>>('/app/developer/git-account')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请成为开发者(绑定Git账号)
|
||||||
|
*/
|
||||||
|
export async function applyDeveloper(data: DeveloperApplyParam) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/developer/git-account', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新开发者信息(更新Git账号)
|
||||||
|
*/
|
||||||
|
export async function updateDeveloperInfo(data: Partial<Developer>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/app/developer/git-account', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Gitea服务器信息
|
||||||
|
*/
|
||||||
|
export async function getGiteaInfo() {
|
||||||
|
const res = await request.get<ApiResult<{ url: string; version: string; registrationEnabled: boolean }>>('/app/developer/gitea-info')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 消息通知相关 ====================
|
||||||
|
// 对应后端 AppNotificationController,路径为 /app/notification
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取通知列表
|
||||||
|
*/
|
||||||
|
export async function pageNotification(params?: NotificationParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Notification>>>('/app/notification/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近通知(铃铛下拉)
|
||||||
|
*/
|
||||||
|
export async function getRecentNotifications(type?: string, limit?: number) {
|
||||||
|
const res = await request.get<ApiResult<Notification[]>>('/app/notification/recent', { type, limit })
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未读通知数量
|
||||||
|
*/
|
||||||
|
export async function getUnreadCount() {
|
||||||
|
const res = await request.get<ApiResult<{ count: number }>>('/app/notification/unread-count')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记通知为已读
|
||||||
|
*/
|
||||||
|
export async function markAsRead(id: number) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(`/app/notification/read/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记所有通知为已读
|
||||||
|
*/
|
||||||
|
export async function markAllAsRead(type?: string) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/app/notification/read-all', { type })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除通知
|
||||||
|
*/
|
||||||
|
export async function deleteNotification(id: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/app/notification/${id}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空已读通知
|
||||||
|
*/
|
||||||
|
export async function clearReadNotifications(type?: string) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>('/app/notification/clear-read', { type })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 权限审批相关 ====================
|
||||||
|
// 对应后端 AppPermissionRequestController,路径为 /app/developer/permission-requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取申请列表
|
||||||
|
*/
|
||||||
|
export async function pageApply(params?: ApplyParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Apply>>>('/app/developer/permission-requests/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的申请列表
|
||||||
|
*/
|
||||||
|
export async function pageMyApply(params?: ApplyParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Apply>>>('/app/developer/permission-requests', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取权限申请统计
|
||||||
|
*/
|
||||||
|
export async function getApplyStats() {
|
||||||
|
const res = await request.get<ApiResult<{ [key: string]: number }>>('/app/developer/permission-requests/stats')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可申请的仓库列表
|
||||||
|
*/
|
||||||
|
export async function getAvailableRepos() {
|
||||||
|
const res = await request.get<ApiResult<Array<{ name: string; fullName: string }>>>('/app/developer/permission-requests/available-repos')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建申请
|
||||||
|
*/
|
||||||
|
export async function createApply(data: { repo: string; reason: string; gitUsername?: string }) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/app/developer/permission-requests', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批申请-通过
|
||||||
|
*/
|
||||||
|
export async function approveApply(id: number, note?: string) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(`/app/developer/permission-requests/${id}/approve`, { note })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批申请-拒绝
|
||||||
|
*/
|
||||||
|
export async function rejectApply(id: number, reason: string) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(`/app/developer/permission-requests/${id}/reject`, { reason })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
148
src/api/developer/enterprise.ts
Normal file
148
src/api/developer/enterprise.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import type { ApiResult, PageResult } from '@/api'
|
||||||
|
import type { Enterprise, EnterpriseMember, EnterpriseMemberParam, Order, Bill, BillParam, App, AppParam } from '@/types/developer'
|
||||||
|
|
||||||
|
// ==================== 企业/租户相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业信息
|
||||||
|
*/
|
||||||
|
export async function getEnterpriseInfo() {
|
||||||
|
const res = await request.get<ApiResult<Enterprise>>('/system/tenant/info')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新企业信息
|
||||||
|
*/
|
||||||
|
export async function updateEnterpriseInfo(data: Partial<Enterprise>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>('/system/tenant', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 企业成员/用户相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业成员列表
|
||||||
|
*/
|
||||||
|
export async function pageEnterpriseMember(params?: EnterpriseMemberParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<EnterpriseMember>>>('/system/user/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业成员列表(不分页)
|
||||||
|
*/
|
||||||
|
export async function listEnterpriseMember(params?: EnterpriseMemberParam) {
|
||||||
|
const res = await request.get<ApiResult<EnterpriseMember[]>>('/system/user', params)
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请企业成员
|
||||||
|
*/
|
||||||
|
export async function inviteEnterpriseMember(enterpriseId: number, data: Partial<EnterpriseMember>) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>(`/app/developer/invite/${enterpriseId}/invite`, data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新企业成员
|
||||||
|
*/
|
||||||
|
export async function updateEnterpriseMember(data: Partial<EnterpriseMember>) {
|
||||||
|
const res = await request.put<ApiResult<unknown>>(`/system/user`, data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除企业成员
|
||||||
|
*/
|
||||||
|
export async function removeEnterpriseMember(memberId: number) {
|
||||||
|
const res = await request.del<ApiResult<unknown>>(`/system/user/${memberId}`)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 订单相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单列表
|
||||||
|
*/
|
||||||
|
export async function pageOrder(params?: { page?: number; limit?: number; status?: number }) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Order>>>('/system/order/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单详情
|
||||||
|
*/
|
||||||
|
export async function getOrder(id: number) {
|
||||||
|
const res = await request.get<ApiResult<Order>>(`/system/order/${id}`)
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建订单
|
||||||
|
*/
|
||||||
|
export async function createOrder(data: any) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/system/order', data)
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 充值/账单相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账单列表
|
||||||
|
*/
|
||||||
|
export async function pageBill(params?: BillParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<Bill>>>('/sys/recharge-order/page', params)
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账单概览
|
||||||
|
*/
|
||||||
|
export async function getBillOverview() {
|
||||||
|
const res = await request.get<ApiResult<{ balance: number; totalConsume: number; totalRecharge: number }>>('/sys/user-balance-log/overview')
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 企业应用相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业应用列表
|
||||||
|
*/
|
||||||
|
export async function pageEnterpriseApp(params?: AppParam) {
|
||||||
|
const res = await request.get<ApiResult<PageResult<App>>>('/app/product/page', { params })
|
||||||
|
if (res.code === 0) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取企业应用详情
|
||||||
|
*/
|
||||||
|
export async function getEnterpriseApp(id: number) {
|
||||||
|
const res = await request.get<ApiResult<App>>(`/app/product/${id}`)
|
||||||
|
if (res.code === 0 && res.data) return res.data
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买应用
|
||||||
|
*/
|
||||||
|
export async function purchaseApp(productId: number) {
|
||||||
|
const res = await request.post<ApiResult<unknown>>('/api/system/order/createOrder', { productId })
|
||||||
|
if (res.code === 0) return res.message
|
||||||
|
return Promise.reject(new Error(res.message))
|
||||||
|
}
|
||||||
5
src/api/developer/index.ts
Normal file
5
src/api/developer/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 开发者 API 模块
|
||||||
|
*/
|
||||||
|
export * from './developer'
|
||||||
|
export * from './enterprise'
|
||||||
331
src/api/import.ts
Normal file
331
src/api/import.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* 数据导入/导出 API
|
||||||
|
*/
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import type { ImportTask, ImportTemplate, ImportField, ImportError, ExportTask, ExportFormat, ExportType, ImportType } from '../types/import';
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockImportTasks: ImportTask[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '商品批量导入',
|
||||||
|
type: 'product',
|
||||||
|
status: 'completed',
|
||||||
|
total: 500,
|
||||||
|
success: 485,
|
||||||
|
failed: 15,
|
||||||
|
progress: 100,
|
||||||
|
fileName: 'products_20260412.xlsx',
|
||||||
|
fileSize: '1.2MB',
|
||||||
|
errorFile: '/downloads/import_error_1.xlsx',
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
startTime: '2026-04-12 10:00:00',
|
||||||
|
endTime: '2026-04-12 10:05:30',
|
||||||
|
createTime: '2026-04-12 09:58:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '用户数据迁移',
|
||||||
|
type: 'user',
|
||||||
|
status: 'importing',
|
||||||
|
total: 2000,
|
||||||
|
success: 1200,
|
||||||
|
failed: 0,
|
||||||
|
progress: 60,
|
||||||
|
fileName: 'users_migration.csv',
|
||||||
|
fileSize: '3.5MB',
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
startTime: '2026-04-12 14:00:00',
|
||||||
|
createTime: '2026-04-12 13:55:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '订单历史导入',
|
||||||
|
type: 'order',
|
||||||
|
status: 'validating',
|
||||||
|
total: 10000,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
progress: 25,
|
||||||
|
fileName: 'orders_history.xlsx',
|
||||||
|
fileSize: '8.2MB',
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
startTime: '2026-04-12 15:00:00',
|
||||||
|
createTime: '2026-04-12 14:50:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockExportTasks: ExportTask[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '月度订单导出',
|
||||||
|
type: 'order',
|
||||||
|
status: 'completed',
|
||||||
|
format: 'xlsx',
|
||||||
|
filters: { month: '2026-03' },
|
||||||
|
total: 5680,
|
||||||
|
fileName: 'orders_202603.xlsx',
|
||||||
|
fileSize: '2.8MB',
|
||||||
|
downloadUrl: '/downloads/orders_202603.xlsx',
|
||||||
|
expiresAt: '2026-04-19 15:00:00',
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
createTime: '2026-04-12 10:00:00',
|
||||||
|
completeTime: '2026-04-12 10:02:30'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '用户数据导出',
|
||||||
|
type: 'user',
|
||||||
|
status: 'processing',
|
||||||
|
format: 'csv',
|
||||||
|
filters: { registeredAfter: '2026-01-01' },
|
||||||
|
total: 0,
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
createTime: '2026-04-12 14:30:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 导入模板字段定义
|
||||||
|
const mockTemplates: ImportTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
name: '商品导入模板',
|
||||||
|
type: 'product',
|
||||||
|
description: '用于批量导入商品信息,支持商品名称、价格、库存等字段',
|
||||||
|
downloadUrl: '/templates/product_import.xlsx',
|
||||||
|
fields: [
|
||||||
|
{ key: 'name', name: '商品名称', type: 'string', required: true, maxLength: 100, example: 'iPhone 15 Pro' },
|
||||||
|
{ key: 'price', name: '价格', type: 'number', required: true, example: '8999.00' },
|
||||||
|
{ key: 'stock', name: '库存', type: 'number', required: true, example: '100' },
|
||||||
|
{ key: 'category', name: '分类', type: 'select', required: true, options: ['手机', '电脑', '配件', '其他'], example: '手机' },
|
||||||
|
{ key: 'description', name: '描述', type: 'string', required: false, maxLength: 500, example: '最新款苹果手机' },
|
||||||
|
{ key: 'status', name: '状态', type: 'select', required: true, options: ['上架', '下架'], example: '上架' },
|
||||||
|
{ key: 'images', name: '图片URL', type: 'string', required: false, example: 'https://example.com/img.jpg' }
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ field: 'name', rule: 'required', message: '商品名称不能为空' },
|
||||||
|
{ field: 'price', rule: 'range', message: '价格必须大于0', params: { min: 0.01 } },
|
||||||
|
{ field: 'stock', rule: 'required', message: '库存不能为空' }
|
||||||
|
],
|
||||||
|
exampleUrl: '/templates/product_example.xlsx',
|
||||||
|
createTime: '2026-01-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't2',
|
||||||
|
name: '用户导入模板',
|
||||||
|
type: 'user',
|
||||||
|
description: '用于批量导入用户信息,包含用户名、手机、邮箱等',
|
||||||
|
downloadUrl: '/templates/user_import.xlsx',
|
||||||
|
fields: [
|
||||||
|
{ key: 'username', name: '用户名', type: 'string', required: true, maxLength: 50, example: 'zhangsan' },
|
||||||
|
{ key: 'phone', name: '手机号', type: 'string', required: true, pattern: '^1[3-9]\\d{9}$', example: '13800138000' },
|
||||||
|
{ key: 'email', name: '邮箱', type: 'string', required: false, pattern: '^\\w+@\\w+\\.\\w+$', example: 'user@example.com' },
|
||||||
|
{ key: 'nickname', name: '昵称', type: 'string', required: false, maxLength: 30, example: '小张' },
|
||||||
|
{ key: 'role', name: '角色', type: 'select', required: true, options: ['user', 'admin'], example: 'user' }
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ field: 'username', rule: 'unique', message: '用户名已存在' },
|
||||||
|
{ field: 'phone', rule: 'pattern', message: '手机号格式不正确' }
|
||||||
|
],
|
||||||
|
createTime: '2026-01-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't3',
|
||||||
|
name: '订单导入模板',
|
||||||
|
type: 'order',
|
||||||
|
description: '用于导入历史订单数据',
|
||||||
|
downloadUrl: '/templates/order_import.xlsx',
|
||||||
|
fields: [
|
||||||
|
{ key: 'orderNo', name: '订单号', type: 'string', required: true, example: 'ORD202604120001' },
|
||||||
|
{ key: 'userId', name: '用户ID', type: 'string', required: true, example: '10001' },
|
||||||
|
{ key: 'amount', name: '订单金额', type: 'number', required: true, example: '299.00' },
|
||||||
|
{ key: 'status', name: '订单状态', type: 'select', required: true, options: ['pending', 'paid', 'shipped', 'completed', 'cancelled'], example: 'completed' },
|
||||||
|
{ key: 'createTime', name: '创建时间', type: 'date', required: true, example: '2026-04-01 10:30:00' }
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
createTime: '2026-01-01'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务列表
|
||||||
|
*/
|
||||||
|
export async function pageImportTask(params: {
|
||||||
|
type?: ImportType;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: ImportTask[]; total: number }> {
|
||||||
|
const { type, status, page = 1, pageSize = 10 } = params;
|
||||||
|
|
||||||
|
let filtered = [...mockImportTasks];
|
||||||
|
if (type) filtered = filtered.filter(t => t.type === type);
|
||||||
|
if (status) filtered = filtered.filter(t => t.status === status);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { list: filtered.slice(start, start + pageSize), total: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入任务详情
|
||||||
|
*/
|
||||||
|
export async function getImportTaskDetail(id: string): Promise<ImportTask | null> {
|
||||||
|
return mockImportTasks.find(t => t.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入错误详情
|
||||||
|
*/
|
||||||
|
export async function getImportErrors(id: string): Promise<ImportError[]> {
|
||||||
|
return [
|
||||||
|
{ row: 15, field: 'price', value: '-100', error: '价格不能为负数', suggestion: '请输入正数' },
|
||||||
|
{ row: 28, field: 'name', value: '', error: '商品名称不能为空', suggestion: '请填写商品名称' },
|
||||||
|
{ row: 56, field: 'stock', value: 'abc', error: '库存必须是数字', suggestion: '请输入整数' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入模板列表
|
||||||
|
*/
|
||||||
|
export async function listImportTemplate(): Promise<ImportTemplate[]> {
|
||||||
|
return mockTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板字段详情
|
||||||
|
*/
|
||||||
|
export async function getTemplateFields(type: ImportType): Promise<ImportField[]> {
|
||||||
|
const template = mockTemplates.find(t => t.type === type);
|
||||||
|
return template?.fields || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建导入任务
|
||||||
|
*/
|
||||||
|
export async function createImportTask(data: {
|
||||||
|
name: string;
|
||||||
|
type: ImportType;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: string;
|
||||||
|
}): Promise<ImportTask> {
|
||||||
|
const task: ImportTask = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
|
status: 'uploading',
|
||||||
|
total: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
progress: 0,
|
||||||
|
fileName: data.fileName,
|
||||||
|
fileSize: data.fileSize,
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
startTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockImportTasks.unshift(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消导入任务
|
||||||
|
*/
|
||||||
|
export async function cancelImportTask(id: string): Promise<void> {
|
||||||
|
const task = mockImportTasks.find(t => t.id === id);
|
||||||
|
if (task) task.status = 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除导入任务
|
||||||
|
*/
|
||||||
|
export async function deleteImportTask(id: string): Promise<void> {
|
||||||
|
const index = mockImportTasks.findIndex(t => t.id === id);
|
||||||
|
if (index > -1) mockImportTasks.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导出任务列表
|
||||||
|
*/
|
||||||
|
export async function pageExportTask(params: {
|
||||||
|
type?: ExportType;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: ExportTask[]; total: number }> {
|
||||||
|
const { type, status, page = 1, pageSize = 10 } = params;
|
||||||
|
|
||||||
|
let filtered = [...mockExportTasks];
|
||||||
|
if (type) filtered = filtered.filter(t => t.type === type);
|
||||||
|
if (status) filtered = filtered.filter(t => t.status === status);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { list: filtered.slice(start, start + pageSize), total: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建导出任务
|
||||||
|
*/
|
||||||
|
export async function createExportTask(data: {
|
||||||
|
name: string;
|
||||||
|
type: ExportType;
|
||||||
|
format: ExportFormat;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
}): Promise<ExportTask> {
|
||||||
|
const task: ExportTask = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
|
status: 'pending',
|
||||||
|
format: data.format,
|
||||||
|
filters: data.filters || {},
|
||||||
|
total: 0,
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExportTasks.unshift(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除导出任务
|
||||||
|
*/
|
||||||
|
export async function deleteExportTask(id: string): Promise<void> {
|
||||||
|
const index = mockExportTasks.findIndex(t => t.id === id);
|
||||||
|
if (index > -1) mockExportTasks.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载导入模板
|
||||||
|
*/
|
||||||
|
export function downloadTemplate(type: ImportType): void {
|
||||||
|
const template = mockTemplates.find(t => t.type === type);
|
||||||
|
if (template) {
|
||||||
|
Taro.showToast({ title: '开始下载模板', icon: 'success' });
|
||||||
|
console.log(`Downloading: ${template.downloadUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出账单
|
||||||
|
*/
|
||||||
|
export async function exportBill(params: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
}): Promise<ExportTask> {
|
||||||
|
return createExportTask({
|
||||||
|
name: `账单导出 ${params.startDate} ~ ${params.endDate}`,
|
||||||
|
type: 'bill',
|
||||||
|
format: params.format,
|
||||||
|
filters: { startDate: params.startDate, endDate: params.endDate }
|
||||||
|
});
|
||||||
|
}
|
||||||
109
src/api/invoice.ts
Normal file
109
src/api/invoice.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 发票管理 API
|
||||||
|
*/
|
||||||
|
import { request } from '../utils/request'
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceTitle,
|
||||||
|
InvoiceApplyParam,
|
||||||
|
InvoiceParam,
|
||||||
|
InvoiceStatus,
|
||||||
|
InvoiceType,
|
||||||
|
} from '../types/invoice'
|
||||||
|
|
||||||
|
// ==================== 发票抬头 ====================
|
||||||
|
|
||||||
|
/** 获取发票抬头列表 */
|
||||||
|
export const listInvoiceTitles = () => {
|
||||||
|
return request<{ list: InvoiceTitle[] }>({
|
||||||
|
url: '/enterprise/invoice/titles',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建发票抬头 */
|
||||||
|
export const createInvoiceTitle = (data: Omit<InvoiceTitle, 'id' | 'createTime'>) => {
|
||||||
|
return request<InvoiceTitle>({
|
||||||
|
url: '/enterprise/invoice/titles',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新发票抬头 */
|
||||||
|
export const updateInvoiceTitle = (id: number, data: Partial<InvoiceTitle>) => {
|
||||||
|
return request<InvoiceTitle>({
|
||||||
|
url: `/enterprise/invoice/titles/${id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除发票抬头 */
|
||||||
|
export const deleteInvoiceTitle = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/invoice/titles/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置默认发票抬头 */
|
||||||
|
export const setDefaultInvoiceTitle = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/invoice/titles/${id}/default`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 发票申请 ====================
|
||||||
|
|
||||||
|
/** 获取发票列表 */
|
||||||
|
export const pageInvoice = (params: InvoiceParam) => {
|
||||||
|
return request<{ list: Invoice[]; total: number }>({
|
||||||
|
url: '/enterprise/invoice/page',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取发票详情 */
|
||||||
|
export const getInvoice = (id: number) => {
|
||||||
|
return request<Invoice>({
|
||||||
|
url: `/enterprise/invoice/${id}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 申请发票 */
|
||||||
|
export const applyInvoice = (data: InvoiceApplyParam) => {
|
||||||
|
return request<Invoice>({
|
||||||
|
url: '/enterprise/invoice/apply',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消发票申请 */
|
||||||
|
export const cancelInvoice = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/invoice/${id}/cancel`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新申请发票 */
|
||||||
|
export const reapplyInvoice = (id: number, data: Partial<InvoiceApplyParam>) => {
|
||||||
|
return request<Invoice>({
|
||||||
|
url: `/enterprise/invoice/${id}/reapply`,
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取可开票金额 */
|
||||||
|
export const getInvoiceableAmount = () => {
|
||||||
|
return request<{ amount: number }>({
|
||||||
|
url: '/enterprise/invoice/amount',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
287
src/api/sdk.ts
Normal file
287
src/api/sdk.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* SDK 管理 API
|
||||||
|
*/
|
||||||
|
import type { SDK, SDKVersion, SDKDownloadRecord, SDKCategory } from '../types/sdk';
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockSDKs: SDK[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'JavaScript SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/javascript@1.0.0/javascript.png',
|
||||||
|
description: '适用于 Web 浏览器和 Node.js 的 JavaScript SDK',
|
||||||
|
version: '2.8.0',
|
||||||
|
language: 'JavaScript',
|
||||||
|
category: 'web',
|
||||||
|
downloadCount: 125680,
|
||||||
|
stars: 3420,
|
||||||
|
docsUrl: '/docs/sdk/javascript',
|
||||||
|
npmPackage: '@websopy/sdk-js',
|
||||||
|
downloadUrl: 'https://www.npmjs.com/package/@websopy/sdk-js',
|
||||||
|
changelog: '- 优化性能 20%\n- 新增 TypeScript 支持\n- 修复若干 bug',
|
||||||
|
dependencies: ['axios'],
|
||||||
|
supportedVersions: ['ES6+', 'Node.js 14+'],
|
||||||
|
lastUpdated: '2026-04-10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'TypeScript SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/typescript@3.0.3/typescript.png',
|
||||||
|
description: '完整的 TypeScript 类型支持,适用于现代 Web 开发',
|
||||||
|
version: '2.8.0',
|
||||||
|
language: 'TypeScript',
|
||||||
|
category: 'web',
|
||||||
|
downloadCount: 98650,
|
||||||
|
stars: 2890,
|
||||||
|
docsUrl: '/docs/sdk/typescript',
|
||||||
|
npmPackage: '@websopy/sdk-ts',
|
||||||
|
downloadUrl: 'https://www.npmjs.com/package/@websopy/sdk-ts',
|
||||||
|
changelog: '- 完整的类型定义\n- 更好的 IDE 支持\n- 新增装饰器支持',
|
||||||
|
dependencies: [],
|
||||||
|
supportedVersions: ['TypeScript 4.0+'],
|
||||||
|
lastUpdated: '2026-04-10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '微信小程序 SDK',
|
||||||
|
icon: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico',
|
||||||
|
description: '专为微信小程序优化的 SDK,支持插件机制',
|
||||||
|
version: '2.7.5',
|
||||||
|
language: 'JavaScript',
|
||||||
|
category: 'mini-program',
|
||||||
|
downloadCount: 78540,
|
||||||
|
stars: 1560,
|
||||||
|
docsUrl: '/docs/sdk/wx-mini-program',
|
||||||
|
npmPackage: '@websopy/sdk-wx',
|
||||||
|
downloadUrl: 'https://www.npmjs.com/package/@websopy/sdk-wx',
|
||||||
|
changelog: '- 适配最新微信版本\n- 新增分享能力\n- 优化包体积',
|
||||||
|
dependencies: ['wechat-miniprogram'],
|
||||||
|
supportedVersions: ['微信小程序 2.0+'],
|
||||||
|
lastUpdated: '2026-04-08'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Flutter SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/gh/flutter/website@main/src/assets/images/flutter-icon.svg',
|
||||||
|
description: 'Flutter 跨平台 SDK,支持 iOS 和 Android',
|
||||||
|
version: '1.5.2',
|
||||||
|
language: 'Flutter',
|
||||||
|
category: 'mobile',
|
||||||
|
downloadCount: 45230,
|
||||||
|
stars: 980,
|
||||||
|
docsUrl: '/docs/sdk/flutter',
|
||||||
|
downloadUrl: 'https://pub.dev/packages/websopy_flutter',
|
||||||
|
changelog: '- 支持 Flutter 3.0\n- 新增状态管理\n- 优化首屏加载',
|
||||||
|
dependencies: ['http', 'shared_preferences'],
|
||||||
|
supportedVersions: ['Flutter 2.0+', 'Dart 2.12+'],
|
||||||
|
lastUpdated: '2026-04-05'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'React Native SDK',
|
||||||
|
icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/512px-React-icon.svg.png',
|
||||||
|
description: 'React Native 原生模块,完美的性能体验',
|
||||||
|
version: '2.4.0',
|
||||||
|
language: 'React Native',
|
||||||
|
category: 'mobile',
|
||||||
|
downloadCount: 38560,
|
||||||
|
stars: 870,
|
||||||
|
docsUrl: '/docs/sdk/react-native',
|
||||||
|
downloadUrl: 'https://www.npmjs.com/package/@websopy/sdk-rn',
|
||||||
|
changelog: '- 新增 Hooks API\n- 支持 TypeScript\n- 修复内存泄漏',
|
||||||
|
dependencies: ['react-native'],
|
||||||
|
supportedVersions: ['React Native 0.65+', 'React 17+'],
|
||||||
|
lastUpdated: '2026-04-03'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: 'Java SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/java@1.0.0/java-original.svg',
|
||||||
|
description: '企业级 Java SDK,支持 Spring Boot 集成',
|
||||||
|
version: '3.2.0',
|
||||||
|
language: 'Java',
|
||||||
|
category: 'server',
|
||||||
|
downloadCount: 65420,
|
||||||
|
stars: 1230,
|
||||||
|
docsUrl: '/docs/sdk/java',
|
||||||
|
downloadUrl: '/downloads/sdk-java-3.2.0.jar',
|
||||||
|
changelog: '- 支持 Spring Boot 3.0\n- 新增响应式编程\n- 优化连接池',
|
||||||
|
dependencies: ['slf4j', 'jackson'],
|
||||||
|
supportedVersions: ['Java 8+', 'Spring Boot 2.0+'],
|
||||||
|
lastUpdated: '2026-04-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
name: 'Go SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/go@1.0.0/go-original.svg',
|
||||||
|
description: '高性能 Go SDK,支持 goroutine 并发',
|
||||||
|
version: '2.5.0',
|
||||||
|
language: 'Go',
|
||||||
|
category: 'server',
|
||||||
|
downloadCount: 32180,
|
||||||
|
stars: 760,
|
||||||
|
docsUrl: '/docs/sdk/go',
|
||||||
|
downloadUrl: 'go get github.com/websopy/sdk-go@latest',
|
||||||
|
changelog: '- 支持 Go 1.18 泛型\n- 新增中间件支持\n- 优化性能',
|
||||||
|
dependencies: [],
|
||||||
|
supportedVersions: ['Go 1.16+'],
|
||||||
|
lastUpdated: '2026-03-28'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
name: 'Python SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/python@3.10.0/python-original.svg',
|
||||||
|
description: 'Pythonic 的 Python SDK,支持异步编程',
|
||||||
|
version: '2.6.0',
|
||||||
|
language: 'Python',
|
||||||
|
category: 'server',
|
||||||
|
downloadCount: 52100,
|
||||||
|
stars: 980,
|
||||||
|
docsUrl: '/docs/sdk/python',
|
||||||
|
downloadUrl: 'pip install websopy-sdk',
|
||||||
|
changelog: '- 支持 asyncio\n- 新增类型提示\n- 兼容 Python 3.11',
|
||||||
|
dependencies: ['requests', 'typing-extensions'],
|
||||||
|
supportedVersions: ['Python 3.7+'],
|
||||||
|
lastUpdated: '2026-03-25'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
name: 'Swift SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/swift@1.0.0/swift-original.svg',
|
||||||
|
description: '原生 iOS/macOS Swift SDK,支持 SwiftUI',
|
||||||
|
version: '1.8.0',
|
||||||
|
language: 'Swift',
|
||||||
|
category: 'mobile',
|
||||||
|
downloadCount: 24560,
|
||||||
|
stars: 560,
|
||||||
|
docsUrl: '/docs/sdk/swift',
|
||||||
|
downloadUrl: 'https://github.com/websopy/sdk-swift/releases',
|
||||||
|
changelog: '- 支持 Swift 5.7\n- 新增 Combine 支持\n- 优化包体积',
|
||||||
|
dependencies: [],
|
||||||
|
supportedVersions: ['iOS 13+', 'macOS 11+', 'Swift 5.0+'],
|
||||||
|
lastUpdated: '2026-03-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: '.NET SDK',
|
||||||
|
icon: 'https://cdn.jsdelivr.net/npm/@programming-icons/cdn/dot-net@1.0.0/dot-net-original.svg',
|
||||||
|
description: '.NET 5/6/7 SDK,支持 Entity Framework Core',
|
||||||
|
version: '2.3.0',
|
||||||
|
language: '.NET',
|
||||||
|
category: 'server',
|
||||||
|
downloadCount: 28940,
|
||||||
|
stars: 450,
|
||||||
|
docsUrl: '/docs/sdk/dotnet',
|
||||||
|
downloadUrl: 'https://www.nuget.org/packages/WebsopySDK',
|
||||||
|
changelog: '- 支持 .NET 7\n- 新增依赖注入\n- 支持 Blazor',
|
||||||
|
dependencies: ['Microsoft.Extensions.DependencyInjection'],
|
||||||
|
supportedVersions: ['.NET 5+', '.NET Core 3.1+'],
|
||||||
|
lastUpdated: '2026-03-18'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SDK 分类列表
|
||||||
|
*/
|
||||||
|
export async function listSDKCategories(): Promise<SDKCategory[]> {
|
||||||
|
return [
|
||||||
|
{ id: 'web', name: 'Web 开发', icon: 'globe', count: 2, description: '适用于浏览器和 Node.js 环境' },
|
||||||
|
{ id: 'mobile', name: '移动开发', icon: 'mobile', count: 3, description: 'iOS、Android、Flutter、React Native' },
|
||||||
|
{ id: 'server', name: '服务端', icon: 'server', count: 4, description: 'Java、Go、Python、.NET 等后端语言' },
|
||||||
|
{ id: 'mini-program', name: '小程序', icon: 'wechat', count: 1, description: '微信小程序、支付宝小程序等' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SDK 列表
|
||||||
|
*/
|
||||||
|
export async function pageSDK(params: {
|
||||||
|
category?: string;
|
||||||
|
language?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: SDK[]; total: number }> {
|
||||||
|
const { category, language, keyword, page = 1, pageSize = 10 } = params;
|
||||||
|
|
||||||
|
let filtered = [...mockSDKs];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
filtered = filtered.filter(sdk => sdk.category === category);
|
||||||
|
}
|
||||||
|
if (language) {
|
||||||
|
filtered = filtered.filter(sdk => sdk.language === language);
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
const kw = keyword.toLowerCase();
|
||||||
|
filtered = filtered.filter(sdk =>
|
||||||
|
sdk.name.toLowerCase().includes(kw) ||
|
||||||
|
sdk.description.toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return { list, total: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SDK 详情
|
||||||
|
*/
|
||||||
|
export async function getSDKDetail(id: string): Promise<SDK | null> {
|
||||||
|
return mockSDKs.find(sdk => sdk.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SDK 版本列表
|
||||||
|
*/
|
||||||
|
export async function listSDKVersions(id: string): Promise<SDKVersion[]> {
|
||||||
|
const sdk = mockSDKs.find(s => s.id === id);
|
||||||
|
if (!sdk) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ version: sdk.version, releaseDate: sdk.lastUpdated, releaseNotes: sdk.changelog, downloadUrl: sdk.downloadUrl, size: '2.5MB', sha256: 'a1b2c3d4e5f6...' },
|
||||||
|
{ version: '2.7.0', releaseDate: '2026-03-15', releaseNotes: '- 性能优化\n- Bug 修复', downloadUrl: '', size: '2.3MB', sha256: 'b2c3d4e5f6g7...' },
|
||||||
|
{ version: '2.6.0', releaseDate: '2026-02-20', releaseNotes: '- 新增功能\n- 文档更新', downloadUrl: '', size: '2.1MB', sha256: 'c3d4e5f6g7h8...' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载记录
|
||||||
|
*/
|
||||||
|
export async function pageDownloadRecord(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: SDKDownloadRecord[]; total: number }> {
|
||||||
|
return {
|
||||||
|
list: [
|
||||||
|
{ id: '1', sdkId: '1', sdkName: 'JavaScript SDK', version: '2.8.0', language: 'JavaScript', userId: '1', userName: '张三', downloadTime: '2026-04-12 14:30:00', ipAddress: '127.0.0.1' },
|
||||||
|
{ id: '2', sdkId: '3', sdkName: '微信小程序 SDK', version: '2.7.5', language: 'JavaScript', userId: '1', userName: '张三', downloadTime: '2026-04-11 10:20:00', ipAddress: '127.0.0.1' },
|
||||||
|
{ id: '3', sdkId: '6', sdkName: 'Java SDK', version: '3.2.0', language: 'Java', userId: '1', userName: '张三', downloadTime: '2026-04-10 16:45:00', ipAddress: '127.0.0.1' }
|
||||||
|
],
|
||||||
|
total: 3
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 SDK 下载
|
||||||
|
*/
|
||||||
|
export async function recordDownload(id: string, version: string): Promise<void> {
|
||||||
|
console.log(`Download recorded: ${id} v${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SDK 统计
|
||||||
|
*/
|
||||||
|
export async function getSDKStats(): Promise<{
|
||||||
|
totalDownloads: number;
|
||||||
|
totalSDKs: number;
|
||||||
|
topSDKs: SDK[];
|
||||||
|
}> {
|
||||||
|
return {
|
||||||
|
totalDownloads: mockSDKs.reduce((sum, sdk) => sum + sdk.downloadCount, 0),
|
||||||
|
totalSDKs: mockSDKs.length,
|
||||||
|
topSDKs: [...mockSDKs].sort((a, b) => b.downloadCount - a.downloadCount).slice(0, 5)
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/api/sso.ts
Normal file
114
src/api/sso.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* SSO 单点登录 API
|
||||||
|
*/
|
||||||
|
import { request } from '../utils/request'
|
||||||
|
import type { SSOConfig, SSOSession, SSOLog, SSOProvider, SyncDirection } from '../types/sso'
|
||||||
|
|
||||||
|
// ==================== SSO 配置 ====================
|
||||||
|
|
||||||
|
/** 获取 SSO 配置 */
|
||||||
|
export const getSSOConfig = (enterpriseId: number) => {
|
||||||
|
return request<SSOConfig>({
|
||||||
|
url: `/enterprise/sso/config/${enterpriseId}`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 SSO 配置 */
|
||||||
|
export const createSSOConfig = (data: Omit<SSOConfig, 'id' | 'createTime' | 'updateTime'>) => {
|
||||||
|
return request<SSOConfig>({
|
||||||
|
url: '/enterprise/sso/config',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 SSO 配置 */
|
||||||
|
export const updateSSOConfig = (data: Partial<SSOConfig> & { id: number }) => {
|
||||||
|
return request<SSOConfig>({
|
||||||
|
url: '/enterprise/sso/config',
|
||||||
|
method: 'PUT',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 SSO 配置 */
|
||||||
|
export const deleteSSOConfig = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/sso/config/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用/禁用 SSO */
|
||||||
|
export const toggleSSO = (id: number, enabled: boolean) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/sso/config/${id}/toggle`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试 SSO 连接 */
|
||||||
|
export const testSSOConnection = (id: number) => {
|
||||||
|
return request<{ success: boolean; message: string }>({
|
||||||
|
url: `/enterprise/sso/config/${id}/test`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 SSO 登录链接 */
|
||||||
|
export const getSSOLoginUrl = (id: number, redirectUri?: string) => {
|
||||||
|
return request<{ url: string }>({
|
||||||
|
url: `/enterprise/sso/config/${id}/login-url`,
|
||||||
|
method: 'GET',
|
||||||
|
params: { redirectUri },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSO 会话 ====================
|
||||||
|
|
||||||
|
/** 获取 SSO 会话列表 */
|
||||||
|
export const pageSSOSessions = (params: { enterpriseId: number; page?: number; limit?: number }) => {
|
||||||
|
return request<{ list: SSOSession[]; total: number }>({
|
||||||
|
url: '/enterprise/sso/sessions',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制下线 SSO 会话 */
|
||||||
|
export const logoutSSOSession = (id: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/sso/sessions/${id}/logout`,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制下线所有 SSO 会话 */
|
||||||
|
export const logoutAllSSOSessions = (enterpriseId: number) => {
|
||||||
|
return request<void>({
|
||||||
|
url: `/enterprise/sso/sessions/logout-all`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { enterpriseId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSO 日志 ====================
|
||||||
|
|
||||||
|
/** 获取 SSO 日志列表 */
|
||||||
|
export const pageSSOLogs = (params: {
|
||||||
|
enterpriseId: number
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
timeStart?: string
|
||||||
|
timeEnd?: string
|
||||||
|
}) => {
|
||||||
|
return request<{ list: SSOLog[]; total: number }>({
|
||||||
|
url: '/enterprise/sso/logs',
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -128,3 +128,24 @@ export async function submit(data: UserVerify) {
|
|||||||
}
|
}
|
||||||
return Promise.reject(new Error(res.message));
|
return Promise.reject(new Error(res.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云实人认证 - 身份证二要素核验
|
||||||
|
* 验证姓名和身份证号是否一致
|
||||||
|
*/
|
||||||
|
export async function verifyIdCard(realName: string, idCard: string) {
|
||||||
|
const res = await request.post<ApiResult<{
|
||||||
|
success: boolean;
|
||||||
|
isMatch: boolean;
|
||||||
|
bizCode: string;
|
||||||
|
message: string;
|
||||||
|
}>>(
|
||||||
|
SERVER_API_URL + '/id-verification/verify-id-card',
|
||||||
|
null,
|
||||||
|
{ params: { realName, idCard } }
|
||||||
|
);
|
||||||
|
if (res.code === 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.message));
|
||||||
|
}
|
||||||
|
|||||||
296
src/api/ticket.ts
Normal file
296
src/api/ticket.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* 工单/技术支持 API
|
||||||
|
*/
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import type { Ticket, TicketReply, TicketTemplate, TicketStats, FAQ } from '../types/ticket';
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockTickets: Ticket[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
ticketNo: 'TK202604120001',
|
||||||
|
title: 'API 调用频率限制问题',
|
||||||
|
content: '我在使用批量接口时遇到了 429 错误,请问如何申请提高调用频率限制?',
|
||||||
|
type: 'technical',
|
||||||
|
priority: 'high',
|
||||||
|
status: 'processing',
|
||||||
|
category: 'api',
|
||||||
|
attachments: [],
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
creatorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
assigneeId: 's1',
|
||||||
|
assigneeName: '技术支持小王',
|
||||||
|
assigneeAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
|
||||||
|
responseCount: 2,
|
||||||
|
solution: '已为您开通企业版调用配额',
|
||||||
|
createTime: '2026-04-12 10:30:00',
|
||||||
|
updateTime: '2026-04-12 14:20:00',
|
||||||
|
resolveTime: 230
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
ticketNo: 'TK202604110002',
|
||||||
|
title: '微信支付回调异常',
|
||||||
|
content: '支付完成后回调地址没有收到通知,请问如何排查问题?',
|
||||||
|
type: 'bug',
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'resolved',
|
||||||
|
category: 'payment',
|
||||||
|
attachments: ['/uploads/error-log.png'],
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
creatorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
assigneeId: 's2',
|
||||||
|
assigneeName: '技术支持小李',
|
||||||
|
assigneeAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Bobby',
|
||||||
|
responseCount: 5,
|
||||||
|
solution: '回调地址需要配置白名单,已协助配置完成',
|
||||||
|
rating: 5,
|
||||||
|
feedback: '响应很快,问题解决了',
|
||||||
|
resolveTime: 180,
|
||||||
|
createTime: '2026-04-11 09:15:00',
|
||||||
|
updateTime: '2026-04-11 12:15:00',
|
||||||
|
resolveTime2: '2026-04-11 12:15:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
ticketNo: 'TK202604100003',
|
||||||
|
title: '功能建议:支持 Webhook 重试机制',
|
||||||
|
content: '希望 Webhook 能够支持失败重试功能,提高消息可靠性',
|
||||||
|
type: 'feature',
|
||||||
|
priority: 'medium',
|
||||||
|
status: 'pending',
|
||||||
|
category: 'api',
|
||||||
|
attachments: [],
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
creatorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
responseCount: 0,
|
||||||
|
createTime: '2026-04-10 16:45:00',
|
||||||
|
updateTime: '2026-04-10 16:45:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockReplies: TicketReply[] = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
ticketId: '1',
|
||||||
|
content: '您好,请问您的应用日调用量是多少?企业版默认配额为 10万次/天',
|
||||||
|
attachments: [],
|
||||||
|
isInternal: false,
|
||||||
|
senderId: 's1',
|
||||||
|
senderName: '技术支持小王',
|
||||||
|
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
|
||||||
|
senderRole: 'support',
|
||||||
|
createTime: '2026-04-12 11:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
ticketId: '1',
|
||||||
|
content: '我的日调用量大约在 15 万次左右',
|
||||||
|
attachments: [],
|
||||||
|
isInternal: false,
|
||||||
|
senderId: '1',
|
||||||
|
senderName: '张三',
|
||||||
|
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
senderRole: 'user',
|
||||||
|
createTime: '2026-04-12 11:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r3',
|
||||||
|
ticketId: '1',
|
||||||
|
content: '已为您升级到企业高级版,配额提升至 50万次/天',
|
||||||
|
attachments: [],
|
||||||
|
isInternal: false,
|
||||||
|
senderId: 's1',
|
||||||
|
senderName: '技术支持小王',
|
||||||
|
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
|
||||||
|
senderRole: 'support',
|
||||||
|
createTime: '2026-04-12 14:20:00'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTemplates: TicketTemplate[] = [
|
||||||
|
{ id: 't1', title: 'API 调用问题', content: '【问题描述】\n\n【复现步骤】\n1.\n2.\n3.\n\n【错误信息】\n\n【环境信息】', type: 'technical', category: 'api', priority: 'medium' },
|
||||||
|
{ id: 't2', title: '支付相关问题', content: '【问题类型】\n□ 支付失败 □ 退款 □ 发票\n\n【订单号】\n\n【问题描述】', type: 'billing', category: 'payment', priority: 'high' },
|
||||||
|
{ id: 't3', title: 'Bug 反馈', content: '【Bug 标题】\n\n【影响范围】\n\n【复现步骤】\n1.\n2.\n\n【预期行为】\n\n【实际行为】', type: 'bug', category: 'other', priority: 'high' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFAQs: FAQ[] = [
|
||||||
|
{ id: 'f1', question: '如何获取 API Key?', answer: '登录控制台 -> 开发管理 -> API Key -> 创建 Key', category: 'api', viewCount: 1256, helpful: 234, notHelpful: 12, tags: ['API', '密钥'], relatedQuestions: [], createTime: '2026-01-01', updateTime: '2026-04-01' },
|
||||||
|
{ id: 'f2', question: '调用频率限制是多少?', answer: '免费版 1000次/天,个人版 1万次/天,企业版 10万次/天', category: 'api', viewCount: 2345, helpful: 456, notHelpful: 23, tags: ['限流', '配额'], relatedQuestions: [], createTime: '2026-01-01', updateTime: '2026-04-05' },
|
||||||
|
{ id: 'f3', question: '如何申请发票?', answer: '控制台 -> 财务 -> 发票管理 -> 申请发票', category: 'billing', viewCount: 1890, helpful: 345, notHelpful: 15, tags: ['发票', '财务'], relatedQuestions: [], createTime: '2026-01-01', updateTime: '2026-03-20' },
|
||||||
|
{ id: 'f4', question: 'Webhook 回调失败怎么办?', answer: '1. 检查回调地址是否可公网访问\n2. 确保返回 200 状态码\n3. 检查签名验证', category: 'api', viewCount: 1567, helpful: 289, notHelpful: 34, tags: ['Webhook', '回调'], relatedQuestions: [], createTime: '2026-01-01', updateTime: '2026-04-10' },
|
||||||
|
{ id: 'f5', question: '如何升级账户?', answer: '控制台 -> 套餐管理 -> 选择套餐 -> 在线支付', category: 'account', viewCount: 987, helpful: 178, notHelpful: 8, tags: ['升级', '套餐'], relatedQuestions: [], createTime: '2026-01-01', updateTime: '2026-03-15' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工单统计
|
||||||
|
*/
|
||||||
|
export async function getTicketStats(): Promise<TicketStats> {
|
||||||
|
return {
|
||||||
|
total: mockTickets.length,
|
||||||
|
pending: 1,
|
||||||
|
processing: 1,
|
||||||
|
resolved: 1,
|
||||||
|
avgResponseTime: 45,
|
||||||
|
avgResolveTime: 4.2,
|
||||||
|
satisfaction: 4.8
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取工单列表
|
||||||
|
*/
|
||||||
|
export async function pageTicket(params: {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
priority?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: Ticket[]; total: number }> {
|
||||||
|
const { status, type, priority, keyword, page = 1, pageSize = 10 } = params;
|
||||||
|
|
||||||
|
let filtered = [...mockTickets];
|
||||||
|
|
||||||
|
if (status) filtered = filtered.filter(t => t.status === status);
|
||||||
|
if (type) filtered = filtered.filter(t => t.type === type);
|
||||||
|
if (priority) filtered = filtered.filter(t => t.priority === priority);
|
||||||
|
if (keyword) {
|
||||||
|
const kw = keyword.toLowerCase();
|
||||||
|
filtered = filtered.filter(t => t.title.toLowerCase().includes(kw) || t.content.toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { list: filtered.slice(start, start + pageSize), total: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工单详情
|
||||||
|
*/
|
||||||
|
export async function getTicketDetail(id: string): Promise<Ticket | null> {
|
||||||
|
return mockTickets.find(t => t.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工单回复列表
|
||||||
|
*/
|
||||||
|
export async function listTicketReply(ticketId: string): Promise<TicketReply[]> {
|
||||||
|
return mockReplies.filter(r => r.ticketId === ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工单
|
||||||
|
*/
|
||||||
|
export async function createTicket(data: Partial<Ticket>): Promise<Ticket> {
|
||||||
|
const newTicket: Ticket = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
ticketNo: `TK${new Date().toISOString().replace(/[\-:T]/g, '').slice(0, 12)}`,
|
||||||
|
title: data.title || '',
|
||||||
|
content: data.content || '',
|
||||||
|
type: data.type || 'technical',
|
||||||
|
priority: data.priority || 'medium',
|
||||||
|
status: 'pending',
|
||||||
|
category: data.category || 'api',
|
||||||
|
attachments: data.attachments || [],
|
||||||
|
creatorId: '1',
|
||||||
|
creatorName: '张三',
|
||||||
|
creatorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
responseCount: 0,
|
||||||
|
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTickets.unshift(newTicket);
|
||||||
|
return newTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回复工单
|
||||||
|
*/
|
||||||
|
export async function replyTicket(ticketId: string, content: string, attachments: string[] = []): Promise<TicketReply> {
|
||||||
|
const reply: TicketReply = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
ticketId,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
isInternal: false,
|
||||||
|
senderId: '1',
|
||||||
|
senderName: '张三',
|
||||||
|
senderAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
senderRole: 'user',
|
||||||
|
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockReplies.push(reply);
|
||||||
|
|
||||||
|
const ticket = mockTickets.find(t => t.id === ticketId);
|
||||||
|
if (ticket) {
|
||||||
|
ticket.responseCount++;
|
||||||
|
ticket.updateTime = reply.createTime;
|
||||||
|
if (ticket.status === 'pending') ticket.status = 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭工单
|
||||||
|
*/
|
||||||
|
export async function closeTicket(id: string): Promise<void> {
|
||||||
|
const ticket = mockTickets.find(t => t.id === id);
|
||||||
|
if (ticket) ticket.status = 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价工单
|
||||||
|
*/
|
||||||
|
export async function rateTicket(id: string, rating: number, feedback: string): Promise<void> {
|
||||||
|
const ticket = mockTickets.find(t => t.id === id);
|
||||||
|
if (ticket) {
|
||||||
|
ticket.rating = rating;
|
||||||
|
ticket.feedback = feedback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工单模板
|
||||||
|
*/
|
||||||
|
export async function listTicketTemplate(): Promise<TicketTemplate[]> {
|
||||||
|
return mockTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 FAQ 列表
|
||||||
|
*/
|
||||||
|
export async function pageFAQ(params: {
|
||||||
|
category?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{ list: FAQ[]; total: number }> {
|
||||||
|
const { category, keyword, page = 1, pageSize = 10 } = params;
|
||||||
|
|
||||||
|
let filtered = [...mockFAQs];
|
||||||
|
if (category) filtered = filtered.filter(f => f.category === category);
|
||||||
|
if (keyword) {
|
||||||
|
const kw = keyword.toLowerCase();
|
||||||
|
filtered = filtered.filter(f => f.question.toLowerCase().includes(kw) || f.answer.toLowerCase().includes(kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { list: filtered.slice(start, start + pageSize), total: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FAQ 反馈
|
||||||
|
*/
|
||||||
|
export async function feedbackFAQ(id: string, helpful: boolean): Promise<void> {
|
||||||
|
const faq = mockFAQs.find(f => f.id === id);
|
||||||
|
if (faq) {
|
||||||
|
if (helpful) faq.helpful++;
|
||||||
|
else faq.notHelpful++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,62 @@ export default {
|
|||||||
"index",
|
"index",
|
||||||
"article/index",
|
"article/index",
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "developer",
|
||||||
|
"pages": [
|
||||||
|
"index",
|
||||||
|
"project/index",
|
||||||
|
"project/create",
|
||||||
|
"project/[id]/index",
|
||||||
|
"project/[id]/members",
|
||||||
|
"project/[id]/api-keys",
|
||||||
|
"project/[id]/settings",
|
||||||
|
"app/index",
|
||||||
|
"app/create",
|
||||||
|
"app/[id]/index",
|
||||||
|
"app/[id]/version",
|
||||||
|
"app/[id]/config",
|
||||||
|
"app/[id]/publish",
|
||||||
|
"app/api-keys/index",
|
||||||
|
"notification/index",
|
||||||
|
"developer/apply",
|
||||||
|
"developer/profile",
|
||||||
|
"docs/index",
|
||||||
|
"docs/quickstart",
|
||||||
|
"docs/api-docs",
|
||||||
|
"market/index"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "enterprise",
|
||||||
|
"pages": [
|
||||||
|
"index",
|
||||||
|
"apps/index",
|
||||||
|
"apps/[id]/index",
|
||||||
|
"apps/[id]/monitor",
|
||||||
|
"apps/[id]/analytics",
|
||||||
|
"apps/[id]/settings",
|
||||||
|
"apps/purchase",
|
||||||
|
"members/index",
|
||||||
|
"members/invite",
|
||||||
|
"members/roles",
|
||||||
|
"members/audit",
|
||||||
|
"orders/index",
|
||||||
|
"orders/detail",
|
||||||
|
"orders/invoice",
|
||||||
|
"orders/bills",
|
||||||
|
"billing/index",
|
||||||
|
"billing/consumption",
|
||||||
|
"billing/recharge",
|
||||||
|
"billing/coupons",
|
||||||
|
"settings/index",
|
||||||
|
"settings/info",
|
||||||
|
"settings/domain",
|
||||||
|
"settings/security",
|
||||||
|
"developer/apply",
|
||||||
|
"developer/index"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import {useEffect, useState, useRef} from "react";
|
|||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppCredential} from "@/api/app/appCredential/model";
|
import {AppCredential} from "@/api/app/appCredential/model";
|
||||||
import {getAppCredential, listAppCredential, updateAppCredential, addAppCredential} from "@/api/app/appCredential";
|
import {getAppCredential, updateAppCredential, addAppCredential} from "@/api/app/appCredential";
|
||||||
|
|
||||||
const AddAppCredential = () => {
|
const AddAppCredential = () => {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
@@ -21,17 +20,14 @@ const AddAppCredential = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitSucceed = async (values: any) => {
|
const submitSucceed = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
// 编辑模式
|
|
||||||
await updateAppCredential({
|
await updateAppCredential({
|
||||||
...values,
|
...values,
|
||||||
id: Number(params.id)
|
id: Number(params.id)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 新增模式
|
|
||||||
await addAppCredential(values)
|
await addAppCredential(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,3 +92,21 @@ const AddAppCredential = () => {
|
|||||||
>
|
>
|
||||||
<CellGroup style={{padding: '4px 0'}}>
|
<CellGroup style={{padding: '4px 0'}}>
|
||||||
<Form.Item name="websiteId" label="关联应用ID" initialValue={FormData.websiteId} required>
|
<Form.Item name="websiteId" label="关联应用ID" initialValue={FormData.websiteId} required>
|
||||||
|
<Input placeholder="请输入关联应用ID" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="credentialName" label="凭证名称" initialValue={FormData.credentialName}>
|
||||||
|
<Input placeholder="请输入凭证名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="credentialValue" label="凭证值" initialValue={FormData.credentialValue}>
|
||||||
|
<Input placeholder="请输入凭证值" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述" initialValue={FormData.description}>
|
||||||
|
<TextArea placeholder="请输入描述" />
|
||||||
|
</Form.Item>
|
||||||
|
</CellGroup>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddAppCredential;
|
||||||
|
|||||||
@@ -2,17 +2,14 @@ import {useState} from "react";
|
|||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro, {useDidShow} from '@tarojs/taro'
|
||||||
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
||||||
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
|
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppCredential} from "@/api/app/appCredential/model";
|
import {AppCredential} from "@/api/app/appCredential/model";
|
||||||
import {listAppCredential, removeAppCredential, updateAppCredential} from "@/api/app/appCredential";
|
import {listAppCredential, removeAppCredential} from "@/api/app/appCredential";
|
||||||
|
|
||||||
const AppCredentialList = () => {
|
const AppCredentialList = () => {
|
||||||
const [list, setList] = useState<AppCredential[]>([])
|
const [list, setList] = useState<AppCredential[]>([])
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
listAppCredential({
|
listAppCredential({})
|
||||||
// 添加查询条件
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setList(data || [])
|
setList(data || [])
|
||||||
})
|
})
|
||||||
@@ -24,7 +21,6 @@ const AppCredentialList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onDel = async (id?: number) => {
|
const onDel = async (id?: number) => {
|
||||||
await removeAppCredential(id)
|
await removeAppCredential(id)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -59,6 +55,26 @@ const AppCredentialList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfigProvider>
|
||||||
{list.map((item, _) => (
|
<CellGroup>
|
||||||
<Cell.Group key={item.
|
{list.map((item) => (
|
||||||
|
<Cell
|
||||||
|
key={item.id}
|
||||||
|
title={item.credentialName || '未命名凭证'}
|
||||||
|
description={item.description}
|
||||||
|
align="center"
|
||||||
|
rightIcon={<ArrowRight />}
|
||||||
|
onClick={() => Taro.navigateTo({url: `/app/appCredential/add?id=${item.id}`})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CellGroup>
|
||||||
|
<Space direction="vertical" style={{padding: '16px'}}>
|
||||||
|
<Button onClick={() => Taro.navigateTo({url: '/app/appCredential/add'})}>
|
||||||
|
新增应用密钥凭证
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppCredentialList;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import {useEffect, useState, useRef} from "react";
|
import {useEffect, useState, useRef} from "react";
|
||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
import {Button, Loading, CellGroup, Input, TextArea, Form, Picker} from '@nutui/nutui-react-taro'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppEvent} from "@/api/app/appEvent/model";
|
import {AppEvent} from "@/api/app/appEvent/model";
|
||||||
import {getAppEvent, listAppEvent, updateAppEvent, addAppEvent} from "@/api/app/appEvent";
|
import {getAppEvent, updateAppEvent, addAppEvent} from "@/api/app/appEvent";
|
||||||
|
|
||||||
|
const EVENT_TYPES = [
|
||||||
|
{ value: 'user_register', label: '用户注册' },
|
||||||
|
{ value: 'user_login', label: '用户登录' },
|
||||||
|
{ value: 'order_create', label: '订单创建' },
|
||||||
|
{ value: 'order_pay', label: '订单支付' },
|
||||||
|
{ value: 'custom', label: '自定义' }
|
||||||
|
];
|
||||||
|
|
||||||
const AddAppEvent = () => {
|
const AddAppEvent = () => {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
@@ -21,17 +28,14 @@ const AddAppEvent = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitSucceed = async (values: any) => {
|
const submitSucceed = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
// 编辑模式
|
|
||||||
await updateAppEvent({
|
await updateAppEvent({
|
||||||
...values,
|
...values,
|
||||||
id: Number(params.id)
|
id: Number(params.id)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 新增模式
|
|
||||||
await addAppEvent(values)
|
await addAppEvent(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +99,21 @@ const AddAppEvent = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CellGroup style={{padding: '4px 0'}}>
|
<CellGroup style={{padding: '4px 0'}}>
|
||||||
<Form.Item name="websiteId" label="关联应用ID" initialValue={FormData.websiteId} required>
|
<Form.Item name="eventName" label="事件名称" initialValue={FormData.eventName} required>
|
||||||
|
<Input placeholder="请输入事件名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="eventType" label="事件类型" initialValue={FormData.eventType}>
|
||||||
|
<Picker options={EVENT_TYPES}>
|
||||||
|
<Input placeholder="请选择事件类型" readonly />
|
||||||
|
</Picker>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述" initialValue={FormData.description}>
|
||||||
|
<TextArea placeholder="请输入描述" />
|
||||||
|
</Form.Item>
|
||||||
|
</CellGroup>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddAppEvent;
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro, {useDidShow} from '@tarojs/taro'
|
||||||
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
||||||
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
|
import {ArrowRight} from '@nutui/icons-react-taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppEvent} from "@/api/app/appEvent/model";
|
import {AppEvent} from "@/api/app/appEvent/model";
|
||||||
import {listAppEvent, removeAppEvent, updateAppEvent} from "@/api/app/appEvent";
|
import {listAppEvent, removeAppEvent} from "@/api/app/appEvent";
|
||||||
|
|
||||||
const AppEventList = () => {
|
const AppEventList = () => {
|
||||||
const [list, setList] = useState<AppEvent[]>([])
|
const [list, setList] = useState<AppEvent[]>([])
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
listAppEvent({
|
listAppEvent({})
|
||||||
// 添加查询条件
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setList(data || [])
|
setList(data || [])
|
||||||
})
|
})
|
||||||
@@ -24,7 +21,6 @@ const AppEventList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onDel = async (id?: number) => {
|
const onDel = async (id?: number) => {
|
||||||
await removeAppEvent(id)
|
await removeAppEvent(id)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -51,7 +47,7 @@ const AppEventList = () => {
|
|||||||
description="暂无数据"
|
description="暂无数据"
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => Taro.navigateTo({url: '/app/appEvent/add'})}>新增应用操作动态</Button>
|
<Button onClick={() => Taro.navigateTo({url: '/app/appEvent/add'})}>新增应用事件</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
@@ -59,6 +55,26 @@ const AppEventList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfigProvider>
|
||||||
{list.map((item, _) => (
|
<CellGroup>
|
||||||
<Cell.Group key={item.
|
{list.map((item) => (
|
||||||
|
<Cell
|
||||||
|
key={item.id}
|
||||||
|
title={item.eventName || '未命名事件'}
|
||||||
|
description={item.eventType}
|
||||||
|
align="center"
|
||||||
|
rightIcon={<ArrowRight />}
|
||||||
|
onClick={() => Taro.navigateTo({url: `/app/appEvent/add?id=${item.id}`})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CellGroup>
|
||||||
|
<Space direction="vertical" style={{padding: '16px'}}>
|
||||||
|
<Button onClick={() => Taro.navigateTo({url: '/app/appEvent/add'})}>
|
||||||
|
新增应用事件
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppEventList;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import {useEffect, useState, useRef} from "react";
|
import {useEffect, useState, useRef} from "react";
|
||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
import {Button, Loading, CellGroup, Input, Form} from '@nutui/nutui-react-taro'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppUser} from "@/api/app/appUser/model";
|
import {AppUser} from "@/api/app/appUser/model";
|
||||||
import {getAppUser, listAppUser, updateAppUser, addAppUser} from "@/api/app/appUser";
|
import {getAppUser, updateAppUser} from "@/api/app/appUser";
|
||||||
|
|
||||||
const AddAppUser = () => {
|
const AddAppUser = () => {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
@@ -21,18 +20,13 @@ const AddAppUser = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitSucceed = async (values: any) => {
|
const submitSucceed = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
// 编辑模式
|
|
||||||
await updateAppUser({
|
await updateAppUser({
|
||||||
...values,
|
...values,
|
||||||
id: Number(params.id)
|
id: Number(params.id)
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
// 新增模式
|
|
||||||
await addAppUser(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -89,10 +83,25 @@ const AddAppUser = () => {
|
|||||||
className={'w-full'}
|
className={'w-full'}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
{params.id ? '更新' : '保存'}
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CellGroup style={{padding: '4px 0'}}>
|
<CellGroup style={{padding: '4px 0'}}>
|
||||||
<Form.Item name="websiteId" label="关联应用ID" initialValue={FormData.websiteId} required>
|
<Form.Item name="userName" label="用户名" initialValue={FormData.userName}>
|
||||||
|
<Input placeholder="请输入用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="nickName" label="昵称" initialValue={FormData.nickName}>
|
||||||
|
<Input placeholder="请输入昵称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="邮箱" initialValue={FormData.email}>
|
||||||
|
<Input placeholder="请输入邮箱" />
|
||||||
|
</Form.Item>
|
||||||
|
</CellGroup>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddAppUser;
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro, {useDidShow} from '@tarojs/taro'
|
||||||
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Avatar} from '@nutui/nutui-react-taro'
|
||||||
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
|
import {ArrowRight} from '@nutui/icons-react-taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppUser} from "@/api/app/appUser/model";
|
import {AppUser} from "@/api/app/appUser/model";
|
||||||
import {listAppUser, removeAppUser, updateAppUser} from "@/api/app/appUser";
|
import {listAppUser} from "@/api/app/appUser";
|
||||||
|
|
||||||
const AppUserList = () => {
|
const AppUserList = () => {
|
||||||
const [list, setList] = useState<AppUser[]>([])
|
const [list, setList] = useState<AppUser[]>([])
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
listAppUser({
|
listAppUser({})
|
||||||
// 添加查询条件
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setList(data || [])
|
setList(data || [])
|
||||||
})
|
})
|
||||||
@@ -24,16 +21,6 @@ const AppUserList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onDel = async (id?: number) => {
|
|
||||||
await removeAppUser(id)
|
|
||||||
Taro.showToast({
|
|
||||||
title: '删除成功',
|
|
||||||
icon: 'success'
|
|
||||||
});
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
reload()
|
reload()
|
||||||
});
|
});
|
||||||
@@ -50,15 +37,27 @@ const AppUserList = () => {
|
|||||||
}}
|
}}
|
||||||
description="暂无数据"
|
description="暂无数据"
|
||||||
/>
|
/>
|
||||||
<Space>
|
|
||||||
<Button onClick={() => Taro.navigateTo({url: '/app/appUser/add'})}>新增应用成员</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfigProvider>
|
||||||
{list.map((item, _) => (
|
<CellGroup>
|
||||||
<Cell.Group key={item.
|
{list.map((item) => (
|
||||||
|
<Cell
|
||||||
|
key={item.id}
|
||||||
|
title={item.userName || item.nickName || '未命名用户'}
|
||||||
|
description={item.email}
|
||||||
|
align="center"
|
||||||
|
rightIcon={<ArrowRight />}
|
||||||
|
onClick={() => Taro.navigateTo({url: `/app/appUser/add?id=${item.id}`})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CellGroup>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppUserList;
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import {useEffect, useState, useRef} from "react";
|
|||||||
import {useRouter} from '@tarojs/taro'
|
import {useRouter} from '@tarojs/taro'
|
||||||
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppVersion} from "@/api/app/appVersion/model";
|
import {AppVersion} from "@/api/app/appVersion/model";
|
||||||
import {getAppVersion, listAppVersion, updateAppVersion, addAppVersion} from "@/api/app/appVersion";
|
import {getAppVersion, updateAppVersion} from "@/api/app/appVersion";
|
||||||
|
|
||||||
const AddAppVersion = () => {
|
const AddAppVersion = () => {
|
||||||
const {params} = useRouter();
|
const {params} = useRouter();
|
||||||
@@ -21,18 +20,13 @@ const AddAppVersion = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const submitSucceed = async (values: any) => {
|
const submitSucceed = async (values: any) => {
|
||||||
try {
|
try {
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
// 编辑模式
|
|
||||||
await updateAppVersion({
|
await updateAppVersion({
|
||||||
...values,
|
...values,
|
||||||
id: Number(params.id)
|
id: Number(params.id)
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
// 新增模式
|
|
||||||
await addAppVersion(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -89,10 +83,25 @@ const AddAppVersion = () => {
|
|||||||
className={'w-full'}
|
className={'w-full'}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
{params.id ? '更新' : '保存'}
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CellGroup style={{padding: '4px 0'}}>
|
<CellGroup style={{padding: '4px 0'}}>
|
||||||
<Form.Item name="websiteId" label="关联应用ID" initialValue={FormData.websiteId} required>
|
<Form.Item name="versionName" label="版本名称" initialValue={FormData.versionName} required>
|
||||||
|
<Input placeholder="请输入版本名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="versionCode" label="版本号" initialValue={FormData.versionCode}>
|
||||||
|
<Input placeholder="请输入版本号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="版本说明" initialValue={FormData.description}>
|
||||||
|
<TextArea placeholder="请输入版本说明" />
|
||||||
|
</Form.Item>
|
||||||
|
</CellGroup>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddAppVersion;
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Taro, {useDidShow} from '@tarojs/taro'
|
import Taro, {useDidShow} from '@tarojs/taro'
|
||||||
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
|
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Tag} from '@nutui/nutui-react-taro'
|
||||||
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
|
import {ArrowRight} from '@nutui/icons-react-taro'
|
||||||
import {View} from '@tarojs/components'
|
|
||||||
import {AppVersion} from "@/api/app/appVersion/model";
|
import {AppVersion} from "@/api/app/appVersion/model";
|
||||||
import {listAppVersion, removeAppVersion, updateAppVersion} from "@/api/app/appVersion";
|
import {listAppVersion} from "@/api/app/appVersion";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: '待发布', color: 'warning' },
|
||||||
|
published: { label: '已发布', color: 'success' },
|
||||||
|
offline: { label: '已下架', color: 'default' }
|
||||||
|
};
|
||||||
|
|
||||||
const AppVersionList = () => {
|
const AppVersionList = () => {
|
||||||
const [list, setList] = useState<AppVersion[]>([])
|
const [list, setList] = useState<AppVersion[]>([])
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
listAppVersion({
|
listAppVersion({})
|
||||||
// 添加查询条件
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setList(data || [])
|
setList(data || [])
|
||||||
})
|
})
|
||||||
@@ -24,16 +27,6 @@ const AppVersionList = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const onDel = async (id?: number) => {
|
|
||||||
await removeAppVersion(id)
|
|
||||||
Taro.showToast({
|
|
||||||
title: '删除成功',
|
|
||||||
icon: 'success'
|
|
||||||
});
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
reload()
|
reload()
|
||||||
});
|
});
|
||||||
@@ -48,10 +41,10 @@ const AppVersionList = () => {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent'
|
backgroundColor: 'transparent'
|
||||||
}}
|
}}
|
||||||
description="暂无数据"
|
description="暂无版本"
|
||||||
/>
|
/>
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => Taro.navigateTo({url: '/app/appVersion/add'})}>新增应用版本发布记录</Button>
|
<Button onClick={() => Taro.navigateTo({url: '/app/appVersion/add'})}>新增版本</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
@@ -59,6 +52,30 @@ const AppVersionList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ConfigProvider>
|
||||||
{list.map((item, _) => (
|
<CellGroup>
|
||||||
<Cell.Group key={item.
|
{list.map((item) => (
|
||||||
|
<Cell
|
||||||
|
key={item.id}
|
||||||
|
title={`${item.versionName || '未命名版本'} (${item.versionCode || 'v0.0.0'})`}
|
||||||
|
description={item.description}
|
||||||
|
align="center"
|
||||||
|
rightIcon={<ArrowRight />}
|
||||||
|
onClick={() => Taro.navigateTo({url: `/app/appVersion/add?id=${item.id}`})}
|
||||||
|
>
|
||||||
|
<Tag type={STATUS_MAP[item.status || 'pending']?.color as any}>
|
||||||
|
{STATUS_MAP[item.status || 'pending']?.label}
|
||||||
|
</Tag>
|
||||||
|
</Cell>
|
||||||
|
))}
|
||||||
|
</CellGroup>
|
||||||
|
<Space direction="vertical" style={{padding: '16px'}}>
|
||||||
|
<Button onClick={() => Taro.navigateTo({url: '/app/appVersion/add'})}>
|
||||||
|
新增版本
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppVersionList;
|
||||||
|
|||||||
6
src/developer/app/[id]/analytics.config.ts
Normal file
6
src/developer/app/[id]/analytics.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 运营监控页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '运营监控',
|
||||||
|
})
|
||||||
457
src/developer/app/[id]/analytics.scss
Normal file
457
src/developer/app/[id]/analytics.scss
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
.analytics-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时数据卡片
|
||||||
|
.realtime-card {
|
||||||
|
background: linear-gradient(135deg, #1890ff, #52c41a);
|
||||||
|
padding: 24px;
|
||||||
|
margin: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.realtime-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日概览
|
||||||
|
.overview-stats {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 内容
|
||||||
|
.tab-content {
|
||||||
|
height: calc(100vh - 550px);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section,
|
||||||
|
.metrics-section,
|
||||||
|
.region-section,
|
||||||
|
.source-section,
|
||||||
|
.page-section,
|
||||||
|
.error-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图表占位
|
||||||
|
.chart-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 64px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-hint {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 饼图
|
||||||
|
.pie-chart {
|
||||||
|
.pie-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-color {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-value {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 性能指标
|
||||||
|
.metric-item {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-info {
|
||||||
|
.metric-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-unit {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误统计
|
||||||
|
.error-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
.error-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.error-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: #F44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: #FF9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户统计
|
||||||
|
.user-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.user-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区域分布
|
||||||
|
.region-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-rank {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-name {
|
||||||
|
width: 100px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 16px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #1890ff, #52c41a);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-value {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 来源分布
|
||||||
|
.source-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-rank {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-value {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面排行
|
||||||
|
.page-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-rank {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-path {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.page-stat {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空数据
|
||||||
|
.empty-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
425
src/developer/app/[id]/analytics.tsx
Normal file
425
src/developer/app/[id]/analytics.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* 运营监控页面 - 数据分析看板
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtTabs, AtTabsPane, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||||
|
import {
|
||||||
|
getOverviewStats,
|
||||||
|
getRealtimeData,
|
||||||
|
getPerformanceMetrics,
|
||||||
|
getDeviceStats,
|
||||||
|
getSourceStats,
|
||||||
|
getPageStats,
|
||||||
|
getRegionStats,
|
||||||
|
} from '../../../../api/analytics'
|
||||||
|
import type { OverviewStats, PerformanceMetric, DeviceStat, SourceStat, PageStat, RegionStat } from '../../../../types/analytics'
|
||||||
|
import './analytics.scss'
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
|
const formatNumber = (num?: number) => {
|
||||||
|
if (!num) return '0'
|
||||||
|
if (num >= 100000000) return (num / 100000000).toFixed(1) + '亿'
|
||||||
|
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化百分比
|
||||||
|
const formatPercent = (num?: number) => {
|
||||||
|
if (!num) return '0%'
|
||||||
|
return num.toFixed(1) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Analytics() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [realtime, setRealtime] = useState({
|
||||||
|
uv: 0,
|
||||||
|
pv: 0,
|
||||||
|
apiCalls: 0,
|
||||||
|
errors: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
})
|
||||||
|
const [overview, setOverview] = useState<OverviewStats>({})
|
||||||
|
const [metrics, setMetrics] = useState<PerformanceMetric[]>([])
|
||||||
|
const [devices, setDevices] = useState<DeviceStat[]>([])
|
||||||
|
const [sources, setSources] = useState<SourceStat[]>([])
|
||||||
|
const [pages, setPages] = useState<PageStat[]>([])
|
||||||
|
const [regions, setRegions] = useState<RegionStat[]>([])
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
|
const [appId, setAppId] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.appId) {
|
||||||
|
setAppId(current.options.appId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appId) {
|
||||||
|
fetchData()
|
||||||
|
// 实时数据每 30 秒刷新
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
fetchRealtimeData()
|
||||||
|
}, 30000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [appId])
|
||||||
|
|
||||||
|
// 获取所有数据
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
await Promise.all([
|
||||||
|
fetchRealtimeData(),
|
||||||
|
fetchOverviewData(),
|
||||||
|
fetchMetricsData(),
|
||||||
|
fetchDeviceData(),
|
||||||
|
fetchSourceData(),
|
||||||
|
fetchPageData(),
|
||||||
|
fetchRegionData(),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取数据失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实时数据
|
||||||
|
const fetchRealtimeData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getRealtimeData(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setRealtime(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取实时数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取概览数据
|
||||||
|
const fetchOverviewData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getOverviewStats(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setOverview(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取概览数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取性能指标
|
||||||
|
const fetchMetricsData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPerformanceMetrics(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setMetrics(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取性能指标失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备分布
|
||||||
|
const fetchDeviceData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDeviceStats(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setDevices(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取设备数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取来源分布
|
||||||
|
const fetchSourceData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getSourceStats(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setSources(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取来源数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取页面访问
|
||||||
|
const fetchPageData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPageStats({
|
||||||
|
websiteId: Number(appId),
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
if (res.data) {
|
||||||
|
setPages(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取页面数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取区域分布
|
||||||
|
const fetchRegionData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getRegionStats(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setRegions(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取区域数据失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ title: '概览' },
|
||||||
|
{ title: '性能' },
|
||||||
|
{ title: '用户' },
|
||||||
|
{ title: '页面' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="analytics-page">
|
||||||
|
{loading && realtime.uv === 0 ? (
|
||||||
|
<View className="loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 实时数据 */}
|
||||||
|
<View className="realtime-card">
|
||||||
|
<View className="realtime-header">
|
||||||
|
<Text className="title">实时数据</Text>
|
||||||
|
<View className="live-dot">
|
||||||
|
<View className="dot" />
|
||||||
|
<Text>LIVE</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="realtime-stats">
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-value">{formatNumber(realtime.uv)}</Text>
|
||||||
|
<Text className="stat-label">实时 UV</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-value">{formatNumber(realtime.pv)}</Text>
|
||||||
|
<Text className="stat-label">实时 PV</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-value">{formatNumber(realtime.apiCalls)}</Text>
|
||||||
|
<Text className="stat-label">API 调用</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className={`stat-value ${realtime.errors > 0 ? 'error' : ''}`}>
|
||||||
|
{formatNumber(realtime.errors)}
|
||||||
|
</Text>
|
||||||
|
<Text className="stat-label">错误数</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 今日概览 */}
|
||||||
|
<View className="overview-stats">
|
||||||
|
<View className="stat-card">
|
||||||
|
<Text className="stat-value">{formatNumber(overview.todayUv)}</Text>
|
||||||
|
<Text className="stat-label">今日 UV</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-card">
|
||||||
|
<Text className="stat-value">{formatNumber(overview.todayPv)}</Text>
|
||||||
|
<Text className="stat-label">今日 PV</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-card">
|
||||||
|
<Text className="stat-value">{formatNumber(overview.todayApiCalls)}</Text>
|
||||||
|
<Text className="stat-label">API 调用</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<AtTabs
|
||||||
|
current={activeTab}
|
||||||
|
tabList={tabs}
|
||||||
|
scroll
|
||||||
|
onClick={(index) => setActiveTab(index)}
|
||||||
|
>
|
||||||
|
<AtTabsPane current={activeTab} index={0}>
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{/* 趋势图表区 */}
|
||||||
|
<View className="chart-section">
|
||||||
|
<Text className="section-title">数据趋势</Text>
|
||||||
|
<View className="chart-placeholder">
|
||||||
|
<Text className="iconfont icon-chart" />
|
||||||
|
<Text className="placeholder-text">趋势图表</Text>
|
||||||
|
<Text className="placeholder-hint">可集成 ECharts 展示数据趋势</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 设备分布 */}
|
||||||
|
<View className="chart-section">
|
||||||
|
<Text className="section-title">设备分布</Text>
|
||||||
|
{devices.length > 0 ? (
|
||||||
|
<View className="pie-chart">
|
||||||
|
{devices.map((device, index) => (
|
||||||
|
<View className="pie-item" key={index}>
|
||||||
|
<View
|
||||||
|
className="pie-color"
|
||||||
|
style={{
|
||||||
|
background: ['#1890ff', '#52c41a', '#faad14', '#f5222d'][index % 4],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text className="pie-label">{device.device}</Text>
|
||||||
|
<Text className="pie-value">{formatPercent(device.percentage)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="empty-data">暂无数据</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
|
||||||
|
<AtTabsPane current={activeTab} index={1}>
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{/* 性能指标 */}
|
||||||
|
<View className="metrics-section">
|
||||||
|
<Text className="section-title">核心指标</Text>
|
||||||
|
{metrics.length > 0 ? (
|
||||||
|
metrics.map((metric, index) => (
|
||||||
|
<View className="metric-item" key={index}>
|
||||||
|
<View className="metric-info">
|
||||||
|
<Text className="metric-name">{metric.metric}</Text>
|
||||||
|
<View className="metric-value-row">
|
||||||
|
<Text className="metric-value">{metric.value?.toFixed(2)}</Text>
|
||||||
|
<Text className="metric-unit">{metric.unit}</Text>
|
||||||
|
<AtTag
|
||||||
|
size="small"
|
||||||
|
type={metric.trend === 'up' ? 'success' : metric.trend === 'down' ? 'error' : 'primary'}
|
||||||
|
>
|
||||||
|
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
||||||
|
{Math.abs(metric.change || 0)}%
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-data">暂无数据</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 错误统计 */}
|
||||||
|
<View className="error-section">
|
||||||
|
<Text className="section-title">错误统计</Text>
|
||||||
|
<View className="error-summary">
|
||||||
|
<View className="error-item">
|
||||||
|
<Text className="error-value error">{overview.todayErrors || 0}</Text>
|
||||||
|
<Text className="error-label">今日错误</Text>
|
||||||
|
</View>
|
||||||
|
<View className="error-item">
|
||||||
|
<Text className="error-value warning">0</Text>
|
||||||
|
<Text className="error-label">严重错误</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
|
||||||
|
<AtTabsPane current={activeTab} index={2}>
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{/* 用户统计 */}
|
||||||
|
<View className="user-stats">
|
||||||
|
<View className="user-card">
|
||||||
|
<Text className="user-value">{formatNumber(overview.activeUsers)}</Text>
|
||||||
|
<Text className="user-label">活跃用户</Text>
|
||||||
|
</View>
|
||||||
|
<View className="user-card">
|
||||||
|
<Text className="user-value">{formatNumber(overview.newUsers)}</Text>
|
||||||
|
<Text className="user-label">新增用户</Text>
|
||||||
|
</View>
|
||||||
|
<View className="user-card">
|
||||||
|
<Text className="user-value">{formatPercent(overview.retention)}</Text>
|
||||||
|
<Text className="user-label">留存率</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 区域分布 */}
|
||||||
|
<View className="region-section">
|
||||||
|
<Text className="section-title">地域分布</Text>
|
||||||
|
{regions.length > 0 ? (
|
||||||
|
regions.slice(0, 5).map((region, index) => (
|
||||||
|
<View className="region-item" key={index}>
|
||||||
|
<View className="region-rank">{index + 1}</View>
|
||||||
|
<Text className="region-name">{region.region}</Text>
|
||||||
|
<View className="region-bar">
|
||||||
|
<View
|
||||||
|
className="bar-fill"
|
||||||
|
style={{ width: (region.percentage || 0) + '%' }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="region-value">{formatPercent(region.percentage)}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-data">暂无数据</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 来源分布 */}
|
||||||
|
<View className="source-section">
|
||||||
|
<Text className="section-title">流量来源</Text>
|
||||||
|
{sources.length > 0 ? (
|
||||||
|
sources.map((source, index) => (
|
||||||
|
<View className="source-item" key={index}>
|
||||||
|
<View className="source-rank">{index + 1}</View>
|
||||||
|
<Text className="source-name">{source.source}</Text>
|
||||||
|
<Text className="source-value">{formatPercent(source.percentage)}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-data">暂无数据</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
|
||||||
|
<AtTabsPane current={activeTab} index={3}>
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{/* 页面排行 */}
|
||||||
|
<View className="page-section">
|
||||||
|
<Text className="section-title">页面访问排行</Text>
|
||||||
|
{pages.length > 0 ? (
|
||||||
|
pages.map((page, index) => (
|
||||||
|
<View className="page-item" key={index}>
|
||||||
|
<View className="page-rank">{index + 1}</View>
|
||||||
|
<View className="page-info">
|
||||||
|
<Text className="page-title">{page.title || page.path}</Text>
|
||||||
|
<Text className="page-path">{page.path}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="page-stats">
|
||||||
|
<View className="page-stat">
|
||||||
|
<Text className="stat-num">{formatNumber(page.pv)}</Text>
|
||||||
|
<Text className="stat-label">PV</Text>
|
||||||
|
</View>
|
||||||
|
<View className="page-stat">
|
||||||
|
<Text className="stat-num">{formatNumber(page.uv)}</Text>
|
||||||
|
<Text className="stat-label">UV</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-data">暂无数据</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
</AtTabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
src/developer/app/[id]/build/[id].config.ts
Normal file
6
src/developer/app/[id]/build/[id].config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 构建详情页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '构建详情',
|
||||||
|
})
|
||||||
261
src/developer/app/[id]/build/[id].scss
Normal file
261
src/developer/app/[id]/build/[id].scss
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
.build-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
&.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.build-no {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&.commit {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #2196F3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.at-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: #1890ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
height: calc(100vh - 600px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
.info-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-value {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&.commit {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #2196F3;
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-panel {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 500px;
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
.log-line {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
line-height: 1.8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 26px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifacts-panel {
|
||||||
|
.artifact-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.artifact-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.artifact-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-size {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-artifacts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 100px 0;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 96px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/developer/app/[id]/build/[id].tsx
Normal file
325
src/developer/app/[id]/build/[id].tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* 构建详情页面
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView, Button } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtButton, AtTag, AtActivityIndicator, AtTimeline } from 'taro-ui'
|
||||||
|
import { getBuild, getBuildLogs, triggerBuild } from '../../../../../api/cicd'
|
||||||
|
import type { Build, BuildStatus } from '../../../../../types/cicd'
|
||||||
|
import './build-detail.scss'
|
||||||
|
|
||||||
|
// 状态映射
|
||||||
|
const STATUS_MAP: Record<BuildStatus, { text: string; color: string }> = {
|
||||||
|
pending: { text: '等待中', color: '#FFC107' },
|
||||||
|
running: { text: '构建中', color: '#2196F3' },
|
||||||
|
success: { text: '构建成功', color: '#4CAF50' },
|
||||||
|
failed: { text: '构建失败', color: '#F44336' },
|
||||||
|
cancelled: { text: '已取消', color: '#9E9E9E' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildDetail() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [build, setBuild] = useState<Build>({})
|
||||||
|
const [logs, setLogs] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('info')
|
||||||
|
const [buildId, setBuildId] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.id) {
|
||||||
|
setBuildId(current.options.id)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (buildId) {
|
||||||
|
fetchBuildDetail()
|
||||||
|
}
|
||||||
|
}, [buildId])
|
||||||
|
|
||||||
|
// 获取构建详情
|
||||||
|
const fetchBuildDetail = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getBuild(Number(buildId))
|
||||||
|
if (res.data) {
|
||||||
|
setBuild(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取构建详情失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取构建日志
|
||||||
|
const fetchBuildLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getBuildLogs(Number(buildId))
|
||||||
|
if (res.data) {
|
||||||
|
setLogs(res.data.logs || '')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取构建日志失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 Tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'logs' && !logs) {
|
||||||
|
fetchBuildLogs()
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// 重新触发构建
|
||||||
|
const handleRetryBuild = async () => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要重新构建吗?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm && build.websiteId) {
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '重新构建中...' })
|
||||||
|
await triggerBuild({
|
||||||
|
websiteId: build.websiteId!,
|
||||||
|
commitId: build.commitId,
|
||||||
|
branch: build.branch,
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '构建已触发', icon: 'success' })
|
||||||
|
fetchBuildDetail()
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '触发失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部署
|
||||||
|
const handleDeploy = () => {
|
||||||
|
Taro.navigateTo({
|
||||||
|
url: `/developer/app/${build.websiteId}/deploys?buildId=${buildId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
const date = new Date(time)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
if (seconds < 60) return `${seconds} 秒`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins} 分 ${secs} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className="build-detail loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = STATUS_MAP[build.status || 'pending']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="build-detail">
|
||||||
|
{/* 构建状态 */}
|
||||||
|
<View className="status-card">
|
||||||
|
<View className="status-header">
|
||||||
|
<Text className="build-no">#{build.buildNo || build.id}</Text>
|
||||||
|
<AtTag type={statusInfo.color.includes('F44336') ? 'error' : statusInfo.color.includes('4CAF50') ? 'success' : 'primary'}>
|
||||||
|
{statusInfo.text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="status-info">
|
||||||
|
<View className="info-item">
|
||||||
|
<Text className="label">分支</Text>
|
||||||
|
<Text className="value">{build.branch || 'main'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-item">
|
||||||
|
<Text className="label">提交</Text>
|
||||||
|
<Text className="value commit">{build.commitId?.slice(0, 7)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-item">
|
||||||
|
<Text className="label">触发方式</Text>
|
||||||
|
<Text className="value">{build.trigger || 'manual'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-item">
|
||||||
|
<Text className="label">耗时</Text>
|
||||||
|
<Text className="value">{formatDuration(build.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="commit-message">
|
||||||
|
<Text className="iconfont icon-commit" />
|
||||||
|
<Text className="msg">{build.commitMessage}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="meta">
|
||||||
|
<Text className="author">{build.author}</Text>
|
||||||
|
<Text className="time">{formatTime(build.startTime)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View className="actions">
|
||||||
|
{build.status === 'failed' && (
|
||||||
|
<AtButton type="primary" onClick={handleRetryBuild}>
|
||||||
|
重新构建
|
||||||
|
</AtButton>
|
||||||
|
)}
|
||||||
|
{build.status === 'success' && (
|
||||||
|
<AtButton type="primary" onClick={handleDeploy}>
|
||||||
|
部署
|
||||||
|
</AtButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<View className="tabs">
|
||||||
|
<View
|
||||||
|
className={`tab ${activeTab === 'info' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('info')}
|
||||||
|
>
|
||||||
|
构建信息
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
构建日志
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`tab ${activeTab === 'artifacts' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('artifacts')}
|
||||||
|
>
|
||||||
|
构建产物
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<View className="info-panel">
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">基本信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">构建编号</Text>
|
||||||
|
<Text className="row-value">{build.buildNo}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">构建 ID</Text>
|
||||||
|
<Text className="row-value">{build.id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">项目 ID</Text>
|
||||||
|
<Text className="row-value">{build.websiteId}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">Git 信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">分支</Text>
|
||||||
|
<Text className="row-value">{build.branch}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">Commit ID</Text>
|
||||||
|
<Text className="row-value commit">{build.commitId}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">提交者</Text>
|
||||||
|
<Text className="row-value">{build.author}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">时间信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">开始时间</Text>
|
||||||
|
<Text className="row-value">{formatTime(build.startTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">结束时间</Text>
|
||||||
|
<Text className="row-value">{formatTime(build.endTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">总耗时</Text>
|
||||||
|
<Text className="row-value">{formatDuration(build.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<View className="logs-panel">
|
||||||
|
{logs ? (
|
||||||
|
<View className="logs-content">
|
||||||
|
{logs.split('\n').map((line, index) => (
|
||||||
|
<Text key={index} className="log-line">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="loading-logs">
|
||||||
|
<AtActivityIndicator size={24} />
|
||||||
|
<Text>加载日志中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'artifacts' && (
|
||||||
|
<View className="artifacts-panel">
|
||||||
|
{build.artifacts && build.artifacts.length > 0 ? (
|
||||||
|
build.artifacts.map((artifact, index) => (
|
||||||
|
<View className="artifact-item" key={index}>
|
||||||
|
<View className="artifact-info">
|
||||||
|
<Text className="artifact-name">{artifact.name}</Text>
|
||||||
|
<Text className="artifact-size">
|
||||||
|
{(artifact.size || 0) > 1024 * 1024
|
||||||
|
? `${((artifact.size || 0) / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
: `${((artifact.size || 0) / 1024).toFixed(2)} KB`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{artifact.downloadUrl && (
|
||||||
|
<AtButton
|
||||||
|
size="small"
|
||||||
|
type="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (artifact.downloadUrl) {
|
||||||
|
Taro.setClipboardData({ data: artifact.downloadUrl })
|
||||||
|
Taro.showToast({ title: '链接已复制', icon: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</AtButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-artifacts">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text>暂无构建产物</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
src/developer/app/[id]/builds.config.ts
Normal file
6
src/developer/app/[id]/builds.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 构建列表页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '构建历史',
|
||||||
|
})
|
||||||
154
src/developer/app/[id]/builds.scss
Normal file
154
src/developer/app/[id]/builds.scss
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
.builds-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 32px;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
margin-right: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-list {
|
||||||
|
height: calc(100vh - 280px);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.build-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.build-no {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.build-branch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-commit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-id {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-msg {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
|
||||||
|
.build-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap,
|
||||||
|
.empty-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100px 0;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 96px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
269
src/developer/app/[id]/builds.tsx
Normal file
269
src/developer/app/[id]/builds.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 构建列表页面
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView, Input } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtButton, AtTabs, AtTabsPane, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||||
|
import { pageBuild, triggerBuild, cancelBuild } from '../../../../api/cicd'
|
||||||
|
import type { Build, BuildStatus } from '../../../../types/cicd'
|
||||||
|
import './builds.scss'
|
||||||
|
|
||||||
|
// 状态映射
|
||||||
|
const STATUS_MAP: Record<BuildStatus, { text: string; color: string }> = {
|
||||||
|
pending: { text: '等待中', color: '#FFC107' },
|
||||||
|
running: { text: '构建中', color: '#2196F3' },
|
||||||
|
success: { text: '构建成功', color: '#4CAF50' },
|
||||||
|
failed: { text: '构建失败', color: '#F44336' },
|
||||||
|
cancelled: { text: '已取消', color: '#9E9E9E' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Builds() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [builds, setBuilds] = useState<Build[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [status, setStatus] = useState<BuildStatus | ''>('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [appId, setAppId] = useState('')
|
||||||
|
|
||||||
|
// 获取构建列表
|
||||||
|
const fetchBuilds = async (pageNum = 1, reset = false) => {
|
||||||
|
try {
|
||||||
|
const res = await pageBuild({
|
||||||
|
page: pageNum,
|
||||||
|
limit: 20,
|
||||||
|
websiteId: Number(appId),
|
||||||
|
status: status || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.data) {
|
||||||
|
if (reset) {
|
||||||
|
setBuilds(res.data.list || [])
|
||||||
|
} else {
|
||||||
|
setBuilds(prev => [...prev, ...(res.data?.list || [])])
|
||||||
|
}
|
||||||
|
setTotal(res.data.total || 0)
|
||||||
|
setHasMore((res.data.list || []).length === 20)
|
||||||
|
setPage(pageNum)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取构建列表失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.appId) {
|
||||||
|
setAppId(current.options.appId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appId) {
|
||||||
|
fetchBuilds(1, true)
|
||||||
|
}
|
||||||
|
}, [appId, status])
|
||||||
|
|
||||||
|
// 触发构建
|
||||||
|
const handleTriggerBuild = async () => {
|
||||||
|
if (!appId) return
|
||||||
|
|
||||||
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要触发新的构建吗?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '触发中...' })
|
||||||
|
await triggerBuild({ websiteId: Number(appId) })
|
||||||
|
Taro.showToast({ title: '构建已触发', icon: 'success' })
|
||||||
|
fetchBuilds(1, true)
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '触发失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消构建
|
||||||
|
const handleCancelBuild = async (buildId: number) => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要取消该构建吗?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '取消中...' })
|
||||||
|
await cancelBuild(buildId)
|
||||||
|
Taro.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
fetchBuilds(page, true)
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '取消失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看构建详情
|
||||||
|
const goToBuildDetail = (buildId: number) => {
|
||||||
|
Taro.navigateTo({
|
||||||
|
url: `/developer/app/${appId}/build/${buildId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
fetchBuilds(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
const date = new Date(time)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}m ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ title: '全部' },
|
||||||
|
{ title: '等待中' },
|
||||||
|
{ title: '构建中' },
|
||||||
|
{ title: '成功' },
|
||||||
|
{ title: '失败' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="builds-page">
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<View className="search-bar">
|
||||||
|
<View className="search-input-wrap">
|
||||||
|
<Text className="iconfont icon-search" />
|
||||||
|
<Input
|
||||||
|
className="search-input"
|
||||||
|
placeholder="搜索构建号/分支/提交信息"
|
||||||
|
value={keyword}
|
||||||
|
onInput={(e) => setKeyword(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<AtButton size="small" type="primary" onClick={handleTriggerBuild}>
|
||||||
|
触发构建
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 状态筛选 */}
|
||||||
|
<AtTabs
|
||||||
|
current={tabs.findIndex(t => {
|
||||||
|
if (status === '') return t.title === '全部'
|
||||||
|
return STATUS_MAP[status]?.text === t.title
|
||||||
|
})}
|
||||||
|
tabList={tabs}
|
||||||
|
scroll
|
||||||
|
onClick={(index) => {
|
||||||
|
const statusMap: (BuildStatus | '')[] = ['', 'pending', 'running', 'success', 'failed']
|
||||||
|
setStatus(statusMap[index])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AtTabsPane current={0} index={0}>
|
||||||
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className="build-list"
|
||||||
|
onScrollToLower={handleLoadMore}
|
||||||
|
>
|
||||||
|
{loading && builds.length === 0 ? (
|
||||||
|
<View className="loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : builds.length === 0 ? (
|
||||||
|
<View className="empty-wrap">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text className="empty-text">暂无构建记录</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
builds.map((build) => (
|
||||||
|
<View
|
||||||
|
className="build-item"
|
||||||
|
key={build.id}
|
||||||
|
onClick={() => build.id && goToBuildDetail(build.id)}
|
||||||
|
>
|
||||||
|
<View className="build-header">
|
||||||
|
<Text className="build-no">#{build.buildNo || build.id}</Text>
|
||||||
|
<AtTag
|
||||||
|
type={STATUS_MAP[build.status || 'pending'].color.includes('F44336') ? 'error' : 'primary'}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{STATUS_MAP[build.status || 'pending'].text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="build-info">
|
||||||
|
<View className="build-branch">
|
||||||
|
<Text className="iconfont icon-git-branch" />
|
||||||
|
<Text>{build.branch || 'main'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="build-commit">
|
||||||
|
<Text className="iconfont icon-commit" />
|
||||||
|
<Text className="commit-id">{build.commitId?.slice(0, 7)}</Text>
|
||||||
|
<Text className="commit-msg">{build.commitMessage}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="build-footer">
|
||||||
|
<View className="build-meta">
|
||||||
|
<Text className="author">{build.author}</Text>
|
||||||
|
<Text className="time">{formatTime(build.createTime)}</Text>
|
||||||
|
<Text className="duration">{formatDuration(build.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
{build.status === 'pending' || build.status === 'running' ? (
|
||||||
|
<AtButton
|
||||||
|
size="small"
|
||||||
|
type="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
build.id && handleCancelBuild(build.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</AtButton>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && builds.length > 0 && (
|
||||||
|
<View className="loading-more">
|
||||||
|
<AtActivityIndicator size={24} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && builds.length > 0 && (
|
||||||
|
<View className="no-more">没有更多了</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
</AtTabs>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/developer/app/[id]/config.config.ts
Normal file
3
src/developer/app/[id]/config.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '应用配置',
|
||||||
|
})
|
||||||
10
src/developer/app/[id]/config.scss
Normal file
10
src/developer/app/[id]/config.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.config-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/app/[id]/config.tsx
Normal file
15
src/developer/app/[id]/config.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './config.scss'
|
||||||
|
|
||||||
|
const ConfigPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="config-page">
|
||||||
|
<View className="config-page__content">
|
||||||
|
<Text>应用配置功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfigPage
|
||||||
6
src/developer/app/[id]/deploy/[id].config.ts
Normal file
6
src/developer/app/[id]/deploy/[id].config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 部署详情页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '部署详情',
|
||||||
|
})
|
||||||
237
src/developer/app/[id]/deploy/[id].scss
Normal file
237
src/developer/app/[id]/deploy/[id].scss
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
.deploy-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
&.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.version-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.version {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.step-dot {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #4CAF50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-line {
|
||||||
|
width: 100px;
|
||||||
|
height: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin: 0 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #4CAF50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollback-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #FFF3E0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #FF9800;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.at-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
background: #1890ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
height: calc(100vh - 600px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
.info-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-value {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-panel {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 500px;
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
.log-line {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
line-height: 1.8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 26px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/developer/app/[id]/deploy/[id].tsx
Normal file
304
src/developer/app/[id]/deploy/[id].tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* 部署详情页面
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtButton, AtTag, AtActivityIndicator } from 'taro-ui'
|
||||||
|
import { getDeploy, getDeployLogs, rollbackDeploy } from '../../../../../api/cicd'
|
||||||
|
import type { Deploy, DeployStatus, DeployEnv } from '../../../../../types/cicd'
|
||||||
|
import './deploy-detail.scss'
|
||||||
|
|
||||||
|
// 状态映射
|
||||||
|
const STATUS_MAP: Record<DeployStatus, { text: string; color: string }> = {
|
||||||
|
pending: { text: '等待中', color: '#FFC107' },
|
||||||
|
deploying: { text: '部署中', color: '#2196F3' },
|
||||||
|
success: { text: '部署成功', color: '#4CAF50' },
|
||||||
|
failed: { text: '部署失败', color: '#F44336' },
|
||||||
|
rollback: { text: '回滚中', color: '#FF9800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境映射
|
||||||
|
const ENV_MAP: Record<DeployEnv, { text: string; color: string }> = {
|
||||||
|
development: { text: '开发环境', color: '#9E9E9E' },
|
||||||
|
staging: { text: '预发布环境', color: '#FF9800' },
|
||||||
|
production: { text: '生产环境', color: '#4CAF50' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeployDetail() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [deploy, setDeploy] = useState<Deploy>({})
|
||||||
|
const [logs, setLogs] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('info')
|
||||||
|
const [deployId, setDeployId] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.id) {
|
||||||
|
setDeployId(current.options.id)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deployId) {
|
||||||
|
fetchDeployDetail()
|
||||||
|
}
|
||||||
|
}, [deployId])
|
||||||
|
|
||||||
|
// 获取部署详情
|
||||||
|
const fetchDeployDetail = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDeploy(Number(deployId))
|
||||||
|
if (res.data) {
|
||||||
|
setDeploy(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取部署详情失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取部署日志
|
||||||
|
const fetchDeployLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDeployLogs(Number(deployId))
|
||||||
|
if (res.data) {
|
||||||
|
setLogs(res.data.logs || '')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取部署日志失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 Tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'logs' && !logs) {
|
||||||
|
fetchDeployLogs()
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// 回滚部署
|
||||||
|
const handleRollback = async () => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '确认回滚',
|
||||||
|
content: `确定要回滚到版本 ${deploy.previousVersion || '上一个版本'} 吗?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '回滚中...' })
|
||||||
|
await rollbackDeploy(Number(deployId))
|
||||||
|
Taro.showToast({ title: '回滚成功', icon: 'success' })
|
||||||
|
fetchDeployDetail()
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '回滚失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
const date = new Date(time)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
if (seconds < 60) return `${seconds} 秒`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins} 分 ${secs} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className="deploy-detail loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = STATUS_MAP[deploy.status || 'pending']
|
||||||
|
const envInfo = ENV_MAP[deploy.env || 'staging']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="deploy-detail">
|
||||||
|
{/* 部署状态卡片 */}
|
||||||
|
<View className="status-card">
|
||||||
|
<View className="status-header">
|
||||||
|
<View className="version-info">
|
||||||
|
<Text className="version">v{deploy.version}</Text>
|
||||||
|
<AtTag type={envInfo.color.includes('4CAF50') ? 'success' : 'primary'}>
|
||||||
|
{envInfo.text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
<AtTag
|
||||||
|
type={statusInfo.color.includes('F44336') ? 'error' : statusInfo.color.includes('4CAF50') ? 'success' : 'primary'}
|
||||||
|
>
|
||||||
|
{statusInfo.text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="status-progress">
|
||||||
|
<View className="progress-step">
|
||||||
|
<View className={`step-dot ${deploy.status !== 'pending' ? 'active' : ''}`} />
|
||||||
|
<Text className="step-label">等待</Text>
|
||||||
|
</View>
|
||||||
|
<View className={`progress-line ${deploy.status === 'deploying' || deploy.status === 'success' ? 'active' : ''}`} />
|
||||||
|
<View className="progress-step">
|
||||||
|
<View className={`step-dot ${deploy.status === 'deploying' || deploy.status === 'success' ? 'active' : ''}`} />
|
||||||
|
<Text className="step-label">部署</Text>
|
||||||
|
</View>
|
||||||
|
<View className={`progress-line ${deploy.status === 'success' ? 'active' : ''}`} />
|
||||||
|
<View className="progress-step">
|
||||||
|
<View className={`step-dot ${deploy.status === 'success' ? 'active' : ''}`} />
|
||||||
|
<Text className="step-label">完成</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="deploy-meta">
|
||||||
|
<View className="meta-item">
|
||||||
|
<Text className="label">构建版本</Text>
|
||||||
|
<Text className="value">#{deploy.buildNo}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="meta-item">
|
||||||
|
<Text className="label">部署人</Text>
|
||||||
|
<Text className="value">{deploy.deployer}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="meta-item">
|
||||||
|
<Text className="label">开始时间</Text>
|
||||||
|
<Text className="value">{formatTime(deploy.startTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="meta-item">
|
||||||
|
<Text className="label">总耗时</Text>
|
||||||
|
<Text className="value">{formatDuration(deploy.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{deploy.previousVersion && (
|
||||||
|
<View className="rollback-info">
|
||||||
|
<Text className="iconfont icon-rollback" />
|
||||||
|
<Text>从 v{deploy.previousVersion} 回滚</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View className="actions">
|
||||||
|
{deploy.status === 'success' && (
|
||||||
|
<AtButton type="primary" onClick={handleRollback}>
|
||||||
|
回滚到此版本
|
||||||
|
</AtButton>
|
||||||
|
)}
|
||||||
|
<AtButton
|
||||||
|
type="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<View className="tabs">
|
||||||
|
<View
|
||||||
|
className={`tab ${activeTab === 'info' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('info')}
|
||||||
|
>
|
||||||
|
部署信息
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`tab ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
部署日志
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<ScrollView scrollY className="tab-content">
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<View className="info-panel">
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">基本信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">部署 ID</Text>
|
||||||
|
<Text className="row-value">{deploy.id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">项目 ID</Text>
|
||||||
|
<Text className="row-value">{deploy.websiteId}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">构建 ID</Text>
|
||||||
|
<Text className="row-value">{deploy.buildId}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">版本号</Text>
|
||||||
|
<Text className="row-value">v{deploy.version}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">时间信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">开始时间</Text>
|
||||||
|
<Text className="row-value">{formatTime(deploy.startTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">结束时间</Text>
|
||||||
|
<Text className="row-value">{formatTime(deploy.endTime)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">总耗时</Text>
|
||||||
|
<Text className="row-value">{formatDuration(deploy.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{deploy.previousVersion && (
|
||||||
|
<View className="info-section">
|
||||||
|
<Text className="section-title">回滚信息</Text>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">回滚源版本</Text>
|
||||||
|
<Text className="row-value">v{deploy.previousVersion}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="info-row">
|
||||||
|
<Text className="row-label">回滚至版本</Text>
|
||||||
|
<Text className="row-value">v{deploy.version}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<View className="logs-panel">
|
||||||
|
{logs ? (
|
||||||
|
<View className="logs-content">
|
||||||
|
{logs.split('\n').map((line, index) => (
|
||||||
|
<Text key={index} className="log-line">
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="loading-logs">
|
||||||
|
<AtActivityIndicator size={24} />
|
||||||
|
<Text>加载日志中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
src/developer/app/[id]/deploys.config.ts
Normal file
6
src/developer/app/[id]/deploys.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 部署列表页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '部署历史',
|
||||||
|
})
|
||||||
229
src/developer/app/[id]/deploys.scss
Normal file
229
src/developer/app/[id]/deploys.scss
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
.deploys-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.failed {
|
||||||
|
color: #F44336;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-list {
|
||||||
|
height: calc(100vh - 300px);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.deploy-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.deploy-version {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.version {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.deploy-build,
|
||||||
|
.rollback-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rollback-info .iconfont {
|
||||||
|
color: #FF9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
|
||||||
|
.deploy-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.deployer {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap,
|
||||||
|
.empty-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100px 0;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 96px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部署弹窗样式
|
||||||
|
.deploy-modal-content {
|
||||||
|
.select-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-select {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.env-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.env-option {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-select {
|
||||||
|
.build-list {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(24, 144, 255, 0.1);
|
||||||
|
border: 1px solid #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.build-no {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-branch {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-commit {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #2196F3;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-time {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/developer/app/[id]/deploys.tsx
Normal file
371
src/developer/app/[id]/deploys.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* 部署列表页面
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtButton, AtTabs, AtTabsPane, AtTag, AtActivityIndicator, AtModal } from 'taro-ui'
|
||||||
|
import { pageDeploy, triggerDeploy, rollbackDeploy } from '../../../../api/cicd'
|
||||||
|
import { pageBuild } from '../../../../api/cicd'
|
||||||
|
import type { Deploy, DeployStatus, DeployEnv, Build } from '../../../../types/cicd'
|
||||||
|
import './deploys.scss'
|
||||||
|
|
||||||
|
// 状态映射
|
||||||
|
const STATUS_MAP: Record<DeployStatus, { text: string; color: string }> = {
|
||||||
|
pending: { text: '等待中', color: '#FFC107' },
|
||||||
|
deploying: { text: '部署中', color: '#2196F3' },
|
||||||
|
success: { text: '部署成功', color: '#4CAF50' },
|
||||||
|
failed: { text: '部署失败', color: '#F44336' },
|
||||||
|
rollback: { text: '回滚中', color: '#FF9800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境映射
|
||||||
|
const ENV_MAP: Record<DeployEnv, { text: string; color: string }> = {
|
||||||
|
development: { text: '开发', color: '#9E9E9E' },
|
||||||
|
staging: { text: '预发布', color: '#FF9800' },
|
||||||
|
production: { text: '生产', color: '#4CAF50' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Deploys() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [deploys, setDeploys] = useState<Deploy[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [env, setEnv] = useState<DeployEnv | ''>('')
|
||||||
|
const [appId, setAppId] = useState('')
|
||||||
|
const [buildId, setBuildId] = useState('')
|
||||||
|
const [showDeployModal, setShowDeployModal] = useState(false)
|
||||||
|
const [selectBuild, setSelectBuild] = useState<Build | null>(null)
|
||||||
|
const [selectEnv, setSelectEnv] = useState<DeployEnv>('staging')
|
||||||
|
const [builds, setBuilds] = useState<Build[]>([])
|
||||||
|
|
||||||
|
// 获取部署列表
|
||||||
|
const fetchDeploys = async (pageNum = 1, reset = false) => {
|
||||||
|
try {
|
||||||
|
const res = await pageDeploy({
|
||||||
|
page: pageNum,
|
||||||
|
limit: 20,
|
||||||
|
websiteId: Number(appId),
|
||||||
|
env: env || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.data) {
|
||||||
|
if (reset) {
|
||||||
|
setDeploys(res.data.list || [])
|
||||||
|
} else {
|
||||||
|
setDeploys(prev => [...prev, ...(res.data?.list || [])])
|
||||||
|
}
|
||||||
|
setTotal(res.data.total || 0)
|
||||||
|
setHasMore((res.data.list || []).length === 20)
|
||||||
|
setPage(pageNum)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取部署列表失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取成功构建列表(用于部署选择)
|
||||||
|
const fetchBuilds = async () => {
|
||||||
|
try {
|
||||||
|
const res = await pageBuild({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
websiteId: Number(appId),
|
||||||
|
status: 'success',
|
||||||
|
})
|
||||||
|
if (res.data) {
|
||||||
|
setBuilds(res.data.list || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取构建列表失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.appId) {
|
||||||
|
setAppId(current.options.appId)
|
||||||
|
}
|
||||||
|
if (current?.options?.buildId) {
|
||||||
|
setBuildId(current.options.buildId)
|
||||||
|
// 如果有 buildId,自动打开部署弹窗
|
||||||
|
fetchBuilds().then(() => {
|
||||||
|
if (current.options?.buildId && builds.length > 0) {
|
||||||
|
const build = builds.find(b => b.id === Number(current.options.buildId))
|
||||||
|
if (build) {
|
||||||
|
setSelectBuild(build)
|
||||||
|
setShowDeployModal(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appId) {
|
||||||
|
fetchDeploys(1, true)
|
||||||
|
}
|
||||||
|
}, [appId, env])
|
||||||
|
|
||||||
|
// 触发部署
|
||||||
|
const handleTriggerDeploy = async () => {
|
||||||
|
if (!selectBuild || !appId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '部署中...' })
|
||||||
|
await triggerDeploy({
|
||||||
|
websiteId: Number(appId),
|
||||||
|
buildId: selectBuild.id!,
|
||||||
|
env: selectEnv,
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '部署已触发', icon: 'success' })
|
||||||
|
setShowDeployModal(false)
|
||||||
|
fetchDeploys(1, true)
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '部署失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回滚部署
|
||||||
|
const handleRollback = async (deployId: number) => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '确认回滚',
|
||||||
|
content: '确定要回滚到上一个版本吗?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
Taro.showLoading({ title: '回滚中...' })
|
||||||
|
await rollbackDeploy(deployId)
|
||||||
|
Taro.showToast({ title: '回滚成功', icon: 'success' })
|
||||||
|
fetchDeploys(page, true)
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '回滚失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看部署详情
|
||||||
|
const goToDeployDetail = (deployId: number) => {
|
||||||
|
Taro.navigateTo({
|
||||||
|
url: `/developer/app/${appId}/deploy/${deployId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
fetchDeploys(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
const date = new Date(time)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}m ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ title: '全部' },
|
||||||
|
{ title: '开发' },
|
||||||
|
{ title: '预发布' },
|
||||||
|
{ title: '生产' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const envList: (DeployEnv | '')[] = ['', 'development', 'staging', 'production']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="deploys-page">
|
||||||
|
{/* 顶部统计 */}
|
||||||
|
<View className="stats-bar">
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-num">{total}</Text>
|
||||||
|
<Text className="stat-label">总部署</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-num success">{deploys.filter(d => d.status === 'success').length}</Text>
|
||||||
|
<Text className="stat-label">成功</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stat-item">
|
||||||
|
<Text className="stat-num failed">{deploys.filter(d => d.status === 'failed').length}</Text>
|
||||||
|
<Text className="stat-label">失败</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 环境筛选 */}
|
||||||
|
<AtTabs
|
||||||
|
current={envList.indexOf(env)}
|
||||||
|
tabList={tabs}
|
||||||
|
scroll
|
||||||
|
onClick={(index) => setEnv(envList[index])}
|
||||||
|
>
|
||||||
|
<AtTabsPane current={0} index={0}>
|
||||||
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className="deploy-list"
|
||||||
|
onScrollToLower={handleLoadMore}
|
||||||
|
>
|
||||||
|
{loading && deploys.length === 0 ? (
|
||||||
|
<View className="loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : deploys.length === 0 ? (
|
||||||
|
<View className="empty-wrap">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text className="empty-text">暂无部署记录</Text>
|
||||||
|
<AtButton size="small" type="primary" onClick={() => {
|
||||||
|
fetchBuilds()
|
||||||
|
setShowDeployModal(true)
|
||||||
|
}}>
|
||||||
|
发起部署
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
deploys.map((deploy) => (
|
||||||
|
<View
|
||||||
|
className="deploy-item"
|
||||||
|
key={deploy.id}
|
||||||
|
onClick={() => deploy.id && goToDeployDetail(deploy.id)}
|
||||||
|
>
|
||||||
|
<View className="deploy-header">
|
||||||
|
<View className="deploy-version">
|
||||||
|
<Text className="version">v{deploy.version}</Text>
|
||||||
|
<AtTag
|
||||||
|
size="small"
|
||||||
|
type={ENV_MAP[deploy.env || 'staging'].color.includes('4CAF50') ? 'success' : 'primary'}
|
||||||
|
>
|
||||||
|
{ENV_MAP[deploy.env || 'staging'].text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
<AtTag
|
||||||
|
type={STATUS_MAP[deploy.status || 'pending'].color.includes('F44336') ? 'error' : STATUS_MAP[deploy.status || 'pending'].color.includes('4CAF50') ? 'success' : 'primary'}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{STATUS_MAP[deploy.status || 'pending'].text}
|
||||||
|
</AtTag>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="deploy-info">
|
||||||
|
<View className="deploy-build">
|
||||||
|
<Text className="iconfont icon-build" />
|
||||||
|
<Text>#{deploy.buildNo}</Text>
|
||||||
|
</View>
|
||||||
|
{deploy.previousVersion && (
|
||||||
|
<View className="rollback-info">
|
||||||
|
<Text className="iconfont icon-rollback" />
|
||||||
|
<Text>从 v{deploy.previousVersion} 回滚</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="deploy-footer">
|
||||||
|
<View className="deploy-meta">
|
||||||
|
<Text className="deployer">{deploy.deployer}</Text>
|
||||||
|
<Text className="time">{formatTime(deploy.startTime)}</Text>
|
||||||
|
<Text className="duration">{formatDuration(deploy.duration)}</Text>
|
||||||
|
</View>
|
||||||
|
{deploy.status === 'success' && (
|
||||||
|
<AtButton
|
||||||
|
size="small"
|
||||||
|
type="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deploy.id && handleRollback(deploy.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
回滚
|
||||||
|
</AtButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && deploys.length > 0 && (
|
||||||
|
<View className="loading-more">
|
||||||
|
<AtActivityIndicator size={24} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && deploys.length > 0 && (
|
||||||
|
<View className="no-more">没有更多了</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</AtTabsPane>
|
||||||
|
</AtTabs>
|
||||||
|
|
||||||
|
{/* 部署弹窗 */}
|
||||||
|
<AtModal
|
||||||
|
isOpened={showDeployModal}
|
||||||
|
title="选择构建版本"
|
||||||
|
cancelText="取消"
|
||||||
|
confirmText="部署"
|
||||||
|
onClose={() => setShowDeployModal(false)}
|
||||||
|
onCancel={() => setShowDeployModal(false)}
|
||||||
|
onConfirm={handleTriggerDeploy}
|
||||||
|
content={
|
||||||
|
<View className="deploy-modal-content">
|
||||||
|
{/* 环境选择 */}
|
||||||
|
<View className="env-select">
|
||||||
|
<Text className="select-label">部署环境</Text>
|
||||||
|
<View className="env-options">
|
||||||
|
{(['development', 'staging', 'production'] as DeployEnv[]).map((e) => (
|
||||||
|
<View
|
||||||
|
key={e}
|
||||||
|
className={`env-option ${selectEnv === e ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectEnv(e)}
|
||||||
|
>
|
||||||
|
{ENV_MAP[e].text}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 构建选择 */}
|
||||||
|
<View className="build-select">
|
||||||
|
<Text className="select-label">选择构建版本</Text>
|
||||||
|
<ScrollView scrollY className="build-list">
|
||||||
|
{builds.map((build) => (
|
||||||
|
<View
|
||||||
|
key={build.id}
|
||||||
|
className={`build-option ${selectBuild?.id === build.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectBuild(build)}
|
||||||
|
>
|
||||||
|
<View className="build-info">
|
||||||
|
<Text className="build-no">#{build.buildNo}</Text>
|
||||||
|
<Text className="build-branch">{build.branch}</Text>
|
||||||
|
<Text className="build-commit">{build.commitId?.slice(0, 7)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="build-time">
|
||||||
|
{formatTime(build.endTime)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/developer/app/[id]/index.config.ts
Normal file
3
src/developer/app/[id]/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '应用详情',
|
||||||
|
})
|
||||||
28
src/developer/app/[id]/index.scss
Normal file
28
src/developer/app/[id]/index.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/developer/app/[id]/index.tsx
Normal file
22
src/developer/app/[id]/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const AppDetail: React.FC = () => {
|
||||||
|
const id = Taro.getCurrentInstance()?.router?.params?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="app-detail-page">
|
||||||
|
<View className="app-detail-page__header">
|
||||||
|
<Text className="app-detail-page__title">📱 应用详情</Text>
|
||||||
|
</View>
|
||||||
|
<View className="app-detail-page__content">
|
||||||
|
<Text>应用 ID: {id}</Text>
|
||||||
|
<Text style={{ marginTop: '20rpx' }}>应用详情功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppDetail
|
||||||
6
src/developer/app/[id]/pipeline.config.ts
Normal file
6
src/developer/app/[id]/pipeline.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 流水线配置页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '流水线配置',
|
||||||
|
})
|
||||||
254
src/developer/app/[id]/pipeline.scss
Normal file
254
src/developer/app/[id]/pipeline.scss
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
.pipeline-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding-bottom: 120px;
|
||||||
|
|
||||||
|
&.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 300px;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-content {
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.item-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-desc {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分支规则
|
||||||
|
.rule-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.rule-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.rule-branch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-env {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境变量
|
||||||
|
.env-list {
|
||||||
|
.env-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.env-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.env-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.key {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-value {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-delete {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #F44336;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook
|
||||||
|
.webhook-list {
|
||||||
|
.webhook-item {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.webhook-info {
|
||||||
|
.webhook-url {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #2196F3;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-rules,
|
||||||
|
.empty-envs,
|
||||||
|
.empty-webhooks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存按钮
|
||||||
|
.save-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.at-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境变量弹窗
|
||||||
|
.env-modal-content {
|
||||||
|
.modal-item {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-switch {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hint {
|
||||||
|
font-size: 22px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
346
src/developer/app/[id]/pipeline.tsx
Normal file
346
src/developer/app/[id]/pipeline.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* 流水线配置页面
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView, Input, Switch } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { AtButton, AtSwitch, AtTag, AtActivityIndicator, AtInput, AtModal } from 'taro-ui'
|
||||||
|
import {
|
||||||
|
getPipelineConfig,
|
||||||
|
updatePipelineConfig,
|
||||||
|
addEnvVar,
|
||||||
|
deleteEnvVar,
|
||||||
|
getBranches,
|
||||||
|
} from '../../../../api/cicd'
|
||||||
|
import type { PipelineConfig, BranchRule, EnvVar } from '../../../../types/cicd'
|
||||||
|
import './pipeline.scss'
|
||||||
|
|
||||||
|
export default function Pipeline() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [config, setConfig] = useState<PipelineConfig>({})
|
||||||
|
const [branches, setBranches] = useState<string[]>([])
|
||||||
|
const [showEnvModal, setShowEnvModal] = useState(false)
|
||||||
|
const [newEnvKey, setNewEnvKey] = useState('')
|
||||||
|
const [newEnvValue, setNewEnvValue] = useState('')
|
||||||
|
const [newEnvSecret, setNewEnvSecret] = useState(false)
|
||||||
|
const [appId, setAppId] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
const current = pages[pages.length - 1]
|
||||||
|
if (current?.options?.appId) {
|
||||||
|
setAppId(current.options.appId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appId) {
|
||||||
|
fetchConfig()
|
||||||
|
fetchBranches()
|
||||||
|
}
|
||||||
|
}, [appId])
|
||||||
|
|
||||||
|
// 获取流水线配置
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPipelineConfig(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setConfig(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取流水线配置失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分支列表
|
||||||
|
const fetchBranches = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getBranches(Number(appId))
|
||||||
|
if (res.data) {
|
||||||
|
setBranches(res.data.branches || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取分支列表失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
const handleUpdateConfig = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
await updatePipelineConfig({
|
||||||
|
...config,
|
||||||
|
websiteId: Number(appId),
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加环境变量
|
||||||
|
const handleAddEnvVar = async () => {
|
||||||
|
if (!newEnvKey.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入变量名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addEnvVar(Number(appId), {
|
||||||
|
key: newEnvKey,
|
||||||
|
value: newEnvValue,
|
||||||
|
isSecret: newEnvSecret,
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '添加成功', icon: 'success' })
|
||||||
|
setShowEnvModal(false)
|
||||||
|
setNewEnvKey('')
|
||||||
|
setNewEnvValue('')
|
||||||
|
setNewEnvSecret(false)
|
||||||
|
fetchConfig()
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '添加失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除环境变量
|
||||||
|
const handleDeleteEnvVar = (key: string) => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除环境变量 ${key} 吗?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteEnvVar(Number(appId), key)
|
||||||
|
Taro.showToast({ title: '删除成功', icon: 'success' })
|
||||||
|
fetchConfig()
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制环境变量值
|
||||||
|
const handleCopyEnvValue = (value: string) => {
|
||||||
|
Taro.setClipboardData({ data: value })
|
||||||
|
Taro.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className="pipeline-page loading-wrap">
|
||||||
|
<AtActivityIndicator size={32} />
|
||||||
|
<Text className="loading-text">加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="pipeline-page">
|
||||||
|
<ScrollView scrollY className="pipeline-content">
|
||||||
|
{/* 基础配置 */}
|
||||||
|
<View className="config-section">
|
||||||
|
<Text className="section-title">基础配置</Text>
|
||||||
|
|
||||||
|
<View className="config-item">
|
||||||
|
<View className="item-info">
|
||||||
|
<Text className="item-label">启用流水线</Text>
|
||||||
|
<Text className="item-desc">开启后支持自动构建和部署</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onChange={(e) => setConfig({ ...config, enabled: e.detail.value })}
|
||||||
|
color="#1890ff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="config-item">
|
||||||
|
<View className="item-info">
|
||||||
|
<Text className="item-label">自动部署</Text>
|
||||||
|
<Text className="item-desc">构建成功后自动部署到对应环境</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
checked={config.autoDeploy}
|
||||||
|
onChange={(e) => setConfig({ ...config, autoDeploy: e.detail.value })}
|
||||||
|
color="#1890ff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 分支规则 */}
|
||||||
|
<View className="config-section">
|
||||||
|
<View className="section-header">
|
||||||
|
<Text className="section-title">分支规则</Text>
|
||||||
|
<AtButton size="small" type="primary">
|
||||||
|
添加规则
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{config.branchRules && config.branchRules.length > 0 ? (
|
||||||
|
config.branchRules.map((rule, index) => (
|
||||||
|
<View className="rule-item" key={index}>
|
||||||
|
<View className="rule-info">
|
||||||
|
<View className="rule-branch">
|
||||||
|
<Text className="iconfont icon-git-branch" />
|
||||||
|
<Text>{rule.branch || 'main'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="rule-tags">
|
||||||
|
{rule.autoBuild && <AtTag size="small">自动构建</AtTag>}
|
||||||
|
{rule.autoDeploy && <AtTag size="small" type="success">自动部署</AtTag>}
|
||||||
|
{rule.requireApproval && <AtTag size="small" type="warning">需要审批</AtTag>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="rule-env">
|
||||||
|
{rule.deployEnv === 'development' ? '开发' : rule.deployEnv === 'staging' ? '预发布' : '生产'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="empty-rules">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text>暂无分支规则</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 环境变量 */}
|
||||||
|
<View className="config-section">
|
||||||
|
<View className="section-header">
|
||||||
|
<Text className="section-title">环境变量</Text>
|
||||||
|
<AtButton size="small" type="primary" onClick={() => setShowEnvModal(true)}>
|
||||||
|
添加变量
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{config.environmentVars && config.environmentVars.length > 0 ? (
|
||||||
|
<View className="env-list">
|
||||||
|
{config.environmentVars.map((env, index) => (
|
||||||
|
<View className="env-item" key={index}>
|
||||||
|
<View className="env-info">
|
||||||
|
<View className="env-key">
|
||||||
|
<Text className="key">{env.key}</Text>
|
||||||
|
{env.isSecret && <AtTag size="small" type="warning">加密</AtTag>}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="env-value"
|
||||||
|
onClick={() => !env.isSecret && env.value && handleCopyEnvValue(env.value)}
|
||||||
|
>
|
||||||
|
{env.isSecret ? '******' : env.value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="env-delete iconfont icon-delete"
|
||||||
|
onClick={() => env.key && handleDeleteEnvVar(env.key)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="empty-envs">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text>暂无环境变量</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Webhook 配置 */}
|
||||||
|
<View className="config-section">
|
||||||
|
<View className="section-header">
|
||||||
|
<Text className="section-title">Webhook 配置</Text>
|
||||||
|
<AtButton size="small" type="primary">
|
||||||
|
添加 Webhook
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{config.webhooks && config.webhooks.length > 0 ? (
|
||||||
|
<View className="webhook-list">
|
||||||
|
{config.webhooks.map((webhook, index) => (
|
||||||
|
<View className="webhook-item" key={index}>
|
||||||
|
<View className="webhook-info">
|
||||||
|
<Text className="webhook-url">{webhook.url}</Text>
|
||||||
|
<View className="webhook-tags">
|
||||||
|
{webhook.enabled ? (
|
||||||
|
<AtTag size="small" type="success">启用</AtTag>
|
||||||
|
) : (
|
||||||
|
<AtTag size="small">禁用</AtTag>
|
||||||
|
)}
|
||||||
|
{webhook.events?.map((event, i) => (
|
||||||
|
<AtTag key={i} size="small" type="primary">{event}</AtTag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="empty-webhooks">
|
||||||
|
<Text className="iconfont icon-empty" />
|
||||||
|
<Text>暂无 Webhook</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<View className="save-bar">
|
||||||
|
<AtButton
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
onClick={handleUpdateConfig}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</AtButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 添加环境变量弹窗 */}
|
||||||
|
<AtModal
|
||||||
|
isOpened={showEnvModal}
|
||||||
|
title="添加环境变量"
|
||||||
|
cancelText="取消"
|
||||||
|
confirmText="添加"
|
||||||
|
onClose={() => setShowEnvModal(false)}
|
||||||
|
onCancel={() => setShowEnvModal(false)}
|
||||||
|
onConfirm={handleAddEnvVar}
|
||||||
|
content={
|
||||||
|
<View className="env-modal-content">
|
||||||
|
<View className="modal-item">
|
||||||
|
<Text className="modal-label">变量名 *</Text>
|
||||||
|
<Input
|
||||||
|
className="modal-input"
|
||||||
|
placeholder="如: API_KEY"
|
||||||
|
value={newEnvKey}
|
||||||
|
onInput={(e) => setNewEnvKey(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="modal-item">
|
||||||
|
<Text className="modal-label">变量值</Text>
|
||||||
|
<Input
|
||||||
|
className="modal-input"
|
||||||
|
placeholder="变量对应的值"
|
||||||
|
value={newEnvValue}
|
||||||
|
onInput={(e) => setNewEnvValue(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="modal-item">
|
||||||
|
<View className="modal-switch">
|
||||||
|
<Text className="modal-label">加密存储</Text>
|
||||||
|
<AtSwitch
|
||||||
|
checked={newEnvSecret}
|
||||||
|
onChange={(e) => setNewEnvSecret(e.detail.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="modal-hint">加密后值将不可见</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/developer/app/[id]/publish.config.ts
Normal file
3
src/developer/app/[id]/publish.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '发布管理',
|
||||||
|
})
|
||||||
10
src/developer/app/[id]/publish.scss
Normal file
10
src/developer/app/[id]/publish.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.publish-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/app/[id]/publish.tsx
Normal file
15
src/developer/app/[id]/publish.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './publish.scss'
|
||||||
|
|
||||||
|
const PublishPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="publish-page">
|
||||||
|
<View className="publish-page__content">
|
||||||
|
<Text>发布管理功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublishPage
|
||||||
335
src/developer/app/[id]/version.scss
Normal file
335
src/developer/app/[id]/version.scss
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
.version-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24rpx;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__publish-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境 Tab
|
||||||
|
&__tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 8rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tab {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-hint {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx !important;
|
||||||
|
color: #ccc !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__no-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本卡片
|
||||||
|
.version-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__env {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__current {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
width: 140rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__changelog {
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布弹窗
|
||||||
|
.version-page__modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-page__form {
|
||||||
|
&-item {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-radio {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-page__textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-page__modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-page__modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
src/developer/app/[id]/version.tsx
Normal file
364
src/developer/app/[id]/version.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Button, Input } from '@tarojs/components'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { usePullDownRefresh } from '@tarojs/taro'
|
||||||
|
import { pageVersion, createVersion } from '@/api/developer/developer'
|
||||||
|
import type { Version, VersionParam, VersionStatus, PublishEnv } from '@/types/developer'
|
||||||
|
import './version.scss'
|
||||||
|
|
||||||
|
// 状态配置
|
||||||
|
const STATUS_CONFIG: Record<VersionStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
|
0: { label: '构建中', color: '#faad14', bgColor: '#fffbe6' },
|
||||||
|
1: { label: '已发布', color: '#52c41a', bgColor: '#f6ffed' },
|
||||||
|
2: { label: '已回滚', color: '#ff4d4f', bgColor: '#fff1f0' },
|
||||||
|
3: { label: '构建失败', color: '#f5222d', bgColor: '#fff1f0' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境配置
|
||||||
|
const ENV_CONFIG: Record<PublishEnv, { label: string; color: string }> = {
|
||||||
|
development: { label: '开发环境', color: '#722ed1' },
|
||||||
|
staging: { label: '预发布环境', color: '#1890ff' },
|
||||||
|
production: { label: '生产环境', color: '#52c41a' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const appId = Number(router.params.id)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [list, setList] = useState<Version[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
versionName: '',
|
||||||
|
versionNo: '',
|
||||||
|
changelog: '',
|
||||||
|
env: 'staging' as PublishEnv,
|
||||||
|
})
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [currentTab, setCurrentTab] = useState<PublishEnv | 'all'>('all')
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (pageNum: number = 1, isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
if (isRefresh) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: VersionParam = {
|
||||||
|
page: pageNum,
|
||||||
|
limit: 20,
|
||||||
|
websiteId: appId,
|
||||||
|
env: currentTab === 'all' ? undefined : currentTab,
|
||||||
|
}
|
||||||
|
const data = await pageVersion(params)
|
||||||
|
|
||||||
|
if (pageNum === 1) {
|
||||||
|
setList(data?.records || [])
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...(data?.records || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data?.total || 0
|
||||||
|
const records = data?.records || []
|
||||||
|
setHasMore(records.length > 0 && (pageNum * 20) < total)
|
||||||
|
setPage(pageNum)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1)
|
||||||
|
}, [appId, currentTab])
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
usePullDownRefresh(() => {
|
||||||
|
loadData(1, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onReachBottom = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
loadData(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建版本
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createForm.versionName.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入版本名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!createForm.versionNo.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入版本号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await createVersion({
|
||||||
|
websiteId: appId,
|
||||||
|
versionName: createForm.versionName,
|
||||||
|
versionNo: createForm.versionNo,
|
||||||
|
changelog: createForm.changelog,
|
||||||
|
env: createForm.env,
|
||||||
|
} as Partial<Version>)
|
||||||
|
Taro.showToast({ title: '发布成功', icon: 'success' })
|
||||||
|
setShowCreate(false)
|
||||||
|
setCreateForm({ versionName: '', versionNo: '', changelog: '', env: 'staging' })
|
||||||
|
loadData(1)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('发布失败', err)
|
||||||
|
Taro.showToast({ title: '发布失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布新版本
|
||||||
|
const handlePublish = () => {
|
||||||
|
if (list.some(v => v.status === 0)) {
|
||||||
|
Taro.showToast({ title: '有版本正在构建中', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowCreate(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatSize = (size?: string) => {
|
||||||
|
if (!size) return '-'
|
||||||
|
if (size.length < 4) return size
|
||||||
|
const num = parseFloat(size)
|
||||||
|
if (num >= 1024 * 1024) {
|
||||||
|
return (num / (1024 * 1024)).toFixed(2) + ' MB'
|
||||||
|
} else if (num >= 1024) {
|
||||||
|
return (num / 1024).toFixed(2) + ' KB'
|
||||||
|
}
|
||||||
|
return size + ' B'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 配置
|
||||||
|
const tabs: { key: PublishEnv | 'all'; label: string }[] = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'development', label: '开发' },
|
||||||
|
{ key: 'staging', label: '预发布' },
|
||||||
|
{ key: 'production', label: '生产' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="version-page">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="version-page__header">
|
||||||
|
<Text className="version-page__title">📦 版本管理</Text>
|
||||||
|
<Button
|
||||||
|
className="version-page__publish-btn"
|
||||||
|
size="mini"
|
||||||
|
onClick={handlePublish}
|
||||||
|
>
|
||||||
|
+ 发布版本
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 环境 Tab */}
|
||||||
|
<View className="version-page__tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<View
|
||||||
|
key={tab.key}
|
||||||
|
className={`version-page__tab ${currentTab === tab.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<View className="version-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="version-page__empty">
|
||||||
|
<Text>暂无版本记录</Text>
|
||||||
|
<Text className="version-page__empty-hint">点击「发布版本」创建新版本</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((item) => (
|
||||||
|
<View key={item.id} className="version-card">
|
||||||
|
<View className="version-card__header">
|
||||||
|
<View className="version-card__info">
|
||||||
|
<Text className="version-card__name">
|
||||||
|
{item.versionName || `v${item.versionNo}`}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className="version-card__status"
|
||||||
|
style={{
|
||||||
|
color: STATUS_CONFIG[item.status as VersionStatus]?.color,
|
||||||
|
background: STATUS_CONFIG[item.status as VersionStatus]?.bgColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_CONFIG[item.status as VersionStatus]?.label}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="version-card__env"
|
||||||
|
style={{ color: ENV_CONFIG[item.env as PublishEnv]?.color }}
|
||||||
|
>
|
||||||
|
{ENV_CONFIG[item.env as PublishEnv]?.label}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.isCurrent && (
|
||||||
|
<View className="version-card__current">
|
||||||
|
<Text>当前版本</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="version-card__body">
|
||||||
|
{item.versionNo && (
|
||||||
|
<View className="version-card__row">
|
||||||
|
<Text className="version-card__label">版本号:</Text>
|
||||||
|
<Text className="version-card__value">{item.versionNo}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.packageSize && (
|
||||||
|
<View className="version-card__row">
|
||||||
|
<Text className="version-card__label">包大小:</Text>
|
||||||
|
<Text className="version-card__value">{formatSize(item.packageSize)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.publishBy && (
|
||||||
|
<View className="version-card__row">
|
||||||
|
<Text className="version-card__label">发布人:</Text>
|
||||||
|
<Text className="version-card__value">{item.publishBy}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.publishTime && (
|
||||||
|
<View className="version-card__row">
|
||||||
|
<Text className="version-card__label">发布时间:</Text>
|
||||||
|
<Text className="version-card__value">{formatDate(item.publishTime)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.changelog && (
|
||||||
|
<View className="version-card__changelog">
|
||||||
|
<Text className="version-card__changelog-title">更新日志:</Text>
|
||||||
|
<Text className="version-card__changelog-content">{item.changelog}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="version-card__footer">
|
||||||
|
<Text className="version-card__time">
|
||||||
|
创建于 {formatDate(item.createTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && list.length > 0 && (
|
||||||
|
<View className="version-page__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && list.length > 0 && (
|
||||||
|
<View className="version-page__no-more">
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 发布弹窗 */}
|
||||||
|
{showCreate && (
|
||||||
|
<View className="version-page__modal">
|
||||||
|
<View className="version-page__modal-mask" onClick={() => setShowCreate(false)} />
|
||||||
|
<View className="version-page__modal-content">
|
||||||
|
<Text className="version-page__modal-title">发布新版本</Text>
|
||||||
|
|
||||||
|
<View className="version-page__form">
|
||||||
|
<View className="version-page__form-item">
|
||||||
|
<Text className="version-page__form-label">版本名称 *</Text>
|
||||||
|
<Input
|
||||||
|
className="version-page__form-input"
|
||||||
|
placeholder="如:正式版 v1.0.0"
|
||||||
|
value={createForm.versionName}
|
||||||
|
onInput={(e) => setCreateForm(prev => ({ ...prev, versionName: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="version-page__form-item">
|
||||||
|
<Text className="version-page__form-label">版本号 *</Text>
|
||||||
|
<Input
|
||||||
|
className="version-page__form-input"
|
||||||
|
placeholder="如:1.0.0"
|
||||||
|
value={createForm.versionNo}
|
||||||
|
onInput={(e) => setCreateForm(prev => ({ ...prev, versionNo: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="version-page__form-item">
|
||||||
|
<Text className="version-page__form-label">发布环境</Text>
|
||||||
|
<View className="version-page__form-radio">
|
||||||
|
{(['staging', 'production'] as const).map((env) => (
|
||||||
|
<View
|
||||||
|
key={env}
|
||||||
|
className={`version-page__form-radio-item ${createForm.env === env ? 'active' : ''}`}
|
||||||
|
onClick={() => setCreateForm(prev => ({ ...prev, env }))}
|
||||||
|
>
|
||||||
|
{ENV_CONFIG[env].label}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="version-page__form-item">
|
||||||
|
<Text className="version-page__form-label">更新日志</Text>
|
||||||
|
<View className="version-page__form-textarea">
|
||||||
|
<textarea
|
||||||
|
className="version-page__textarea"
|
||||||
|
placeholder="请输入版本更新内容..."
|
||||||
|
value={createForm.changelog}
|
||||||
|
onInput={(e: any) => setCreateForm(prev => ({ ...prev, changelog: e.detail.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="version-page__modal-actions">
|
||||||
|
<Button
|
||||||
|
className="version-page__modal-btn version-page__modal-btn--cancel"
|
||||||
|
onClick={() => setShowCreate(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="version-page__modal-btn version-page__modal-btn--confirm"
|
||||||
|
loading={creating}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionPage
|
||||||
3
src/developer/app/api-keys/index.config.ts
Normal file
3
src/developer/app/api-keys/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: 'API Key 管理',
|
||||||
|
})
|
||||||
305
src/developer/app/api-keys/index.scss
Normal file
305
src/developer/app/api-keys/index.scss
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
.api-keys-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24rpx;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__create-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tips {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #ffe58f;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #d48806;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-hint {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx !important;
|
||||||
|
color: #ccc !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__no-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key 卡片
|
||||||
|
.api-key-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
width: 140rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&--monospace {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建弹窗
|
||||||
|
.api-keys-page__modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-page__form {
|
||||||
|
&-item {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-radio {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-page__modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-keys-page__modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/developer/app/api-keys/index.tsx
Normal file
335
src/developer/app/api-keys/index.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Input, Button, actionSheet } from '@tarojs/components'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { usePullDownRefresh } from '@tarojs/taro'
|
||||||
|
import { pageApiKey, createApiKey, deleteApiKey } from '@/api/developer/developer'
|
||||||
|
import type { ApiKey, ApiKeyParam } from '@/types/developer'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// API Key 状态映射
|
||||||
|
const STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
|
0: { text: '禁用', color: '#999' },
|
||||||
|
1: { text: '正常', color: '#52c41a' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, string> = {
|
||||||
|
server: '服务端',
|
||||||
|
client: '客户端',
|
||||||
|
webhook: 'Webhook',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeysPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const projectId = Number(router.params.projectId) || undefined
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [list, setList] = useState<ApiKey[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'server' as const,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (pageNum: number = 1, isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
if (isRefresh) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: ApiKeyParam = {
|
||||||
|
page: pageNum,
|
||||||
|
limit: 20,
|
||||||
|
websiteId: projectId,
|
||||||
|
}
|
||||||
|
const data = await pageApiKey(params)
|
||||||
|
|
||||||
|
if (pageNum === 1) {
|
||||||
|
setList(data?.records || [])
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...(data?.records || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data?.total || 0
|
||||||
|
const records = data?.records || []
|
||||||
|
setHasMore(records.length > 0 && (pageNum * 20) < total)
|
||||||
|
setPage(pageNum)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
usePullDownRefresh(() => {
|
||||||
|
loadData(1, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onReachBottom = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
loadData(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 API Key
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createForm.name.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await createApiKey({
|
||||||
|
name: createForm.name,
|
||||||
|
type: createForm.type,
|
||||||
|
remark: createForm.remark,
|
||||||
|
websiteId: projectId,
|
||||||
|
} as Partial<ApiKey>)
|
||||||
|
Taro.showToast({ title: '创建成功', icon: 'success' })
|
||||||
|
setShowCreate(false)
|
||||||
|
setCreateForm({ name: '', type: 'server', remark: '' })
|
||||||
|
loadData(1)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建失败', err)
|
||||||
|
Taro.showToast({ title: '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 API Key
|
||||||
|
const handleDelete = (item: ApiKey) => {
|
||||||
|
actionSheet({
|
||||||
|
alertText: `确定删除 "${item.name}" 吗?此操作不可恢复。`,
|
||||||
|
actions: [
|
||||||
|
{ name: '删除', color: '#ff4d4f', type: 'warn' as const },
|
||||||
|
],
|
||||||
|
confirmText: '取消',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
doDelete(item.id!)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteApiKey(id)
|
||||||
|
Taro.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
loadData(1)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除失败', err)
|
||||||
|
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制内容
|
||||||
|
const handleCopy = (text: string, label: string) => {
|
||||||
|
Taro.setClipboardData({
|
||||||
|
data: text,
|
||||||
|
success: () => {
|
||||||
|
Taro.showToast({ title: `${label}已复制`, icon: 'success' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="api-keys-page">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="api-keys-page__header">
|
||||||
|
<Text className="api-keys-page__title">🔑 API Key 管理</Text>
|
||||||
|
<Button
|
||||||
|
className="api-keys-page__create-btn"
|
||||||
|
size="mini"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
|
+ 创建
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<View className="api-keys-page__tips">
|
||||||
|
<Text>API Key 是用于调用接口的凭证,请妥善保管,切勿泄露。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<View className="api-keys-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="api-keys-page__empty">
|
||||||
|
<Text>暂无 API Key</Text>
|
||||||
|
<Text className="api-keys-page__empty-hint">点击上方「创建」按钮生成新的 Key</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((item) => (
|
||||||
|
<View key={item.id} className="api-key-card">
|
||||||
|
<View className="api-key-card__header">
|
||||||
|
<View className="api-key-card__info">
|
||||||
|
<Text className="api-key-card__name">{item.name}</Text>
|
||||||
|
<View
|
||||||
|
className="api-key-card__status"
|
||||||
|
style={{ color: STATUS_MAP[item.status || 1]?.color }}
|
||||||
|
>
|
||||||
|
{STATUS_MAP[item.status || 1]?.text}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="api-key-card__type"
|
||||||
|
>
|
||||||
|
{TYPE_MAP[item.type || 'server']}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-key-card__body">
|
||||||
|
<View className="api-key-card__row">
|
||||||
|
<Text className="api-key-card__label">AppId:</Text>
|
||||||
|
<Text
|
||||||
|
className="api-key-card__value api-key-card__value--monospace"
|
||||||
|
onClick={() => handleCopy(item.appId || '', 'AppId')}
|
||||||
|
>
|
||||||
|
{item.appId || '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="api-key-card__row">
|
||||||
|
<Text className="api-key-card__label">AppSecret:</Text>
|
||||||
|
<Text
|
||||||
|
className="api-key-card__value api-key-card__value--monospace"
|
||||||
|
onClick={() => handleCopy(item.appSecret || '', 'AppSecret')}
|
||||||
|
>
|
||||||
|
••••••••••••••••
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.remark && (
|
||||||
|
<View className="api-key-card__row">
|
||||||
|
<Text className="api-key-card__label">备注:</Text>
|
||||||
|
<Text className="api-key-card__value">{item.remark}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-key-card__footer">
|
||||||
|
<Text className="api-key-card__time">
|
||||||
|
创建于 {formatDate(item.createTime)}
|
||||||
|
</Text>
|
||||||
|
<View className="api-key-card__actions">
|
||||||
|
{item.appSecret && (
|
||||||
|
<Text
|
||||||
|
className="api-key-card__action api-key-card__action--primary"
|
||||||
|
onClick={() => handleCopy(item.appSecret || '', 'AppSecret')}
|
||||||
|
>
|
||||||
|
复制密钥
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className="api-key-card__action api-key-card__action--danger"
|
||||||
|
onClick={() => handleDelete(item)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && list.length > 0 && (
|
||||||
|
<View className="api-keys-page__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && list.length > 0 && (
|
||||||
|
<View className="api-keys-page__no-more">
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 创建弹窗 */}
|
||||||
|
{showCreate && (
|
||||||
|
<View className="api-keys-page__modal">
|
||||||
|
<View className="api-keys-page__modal-mask" onClick={() => setShowCreate(false)} />
|
||||||
|
<View className="api-keys-page__modal-content">
|
||||||
|
<Text className="api-keys-page__modal-title">创建 API Key</Text>
|
||||||
|
|
||||||
|
<View className="api-keys-page__form">
|
||||||
|
<View className="api-keys-page__form-item">
|
||||||
|
<Text className="api-keys-page__form-label">名称 *</Text>
|
||||||
|
<Input
|
||||||
|
className="api-keys-page__form-input"
|
||||||
|
placeholder="请输入 Key 名称"
|
||||||
|
value={createForm.name}
|
||||||
|
onInput={(e) => setCreateForm(prev => ({ ...prev, name: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-keys-page__form-item">
|
||||||
|
<Text className="api-keys-page__form-label">类型</Text>
|
||||||
|
<View className="api-keys-page__form-radio">
|
||||||
|
{(['server', 'client', 'webhook'] as const).map((type) => (
|
||||||
|
<View
|
||||||
|
key={type}
|
||||||
|
className={`api-keys-page__form-radio-item ${createForm.type === type ? 'active' : ''}`}
|
||||||
|
onClick={() => setCreateForm(prev => ({ ...prev, type }))}
|
||||||
|
>
|
||||||
|
{TYPE_MAP[type]}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-keys-page__form-item">
|
||||||
|
<Text className="api-keys-page__form-label">备注</Text>
|
||||||
|
<Input
|
||||||
|
className="api-keys-page__form-input"
|
||||||
|
placeholder="可选,简要描述用途"
|
||||||
|
value={createForm.remark}
|
||||||
|
onInput={(e) => setCreateForm(prev => ({ ...prev, remark: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-keys-page__modal-actions">
|
||||||
|
<Button
|
||||||
|
className="api-keys-page__modal-btn api-keys-page__modal-btn--cancel"
|
||||||
|
onClick={() => setShowCreate(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="api-keys-page__modal-btn api-keys-page__modal-btn--confirm"
|
||||||
|
loading={creating}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeysPage
|
||||||
75
src/developer/app/create.scss
Normal file
75
src/developer/app/create.scss
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-create-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.form {
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__labelText {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__textarea {
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
min-height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型选择网格 */
|
||||||
|
.type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-item {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/developer/app/create.tsx
Normal file
118
src/developer/app/create.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, ScrollView, Input, Textarea } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Button, Radio, RadioGroup, Picker } from '@nutui/nutui-react-taro'
|
||||||
|
import { createApp } from '@/api/developer'
|
||||||
|
import { APP_TYPE_NAME } from '@/types/developer'
|
||||||
|
import './create.scss'
|
||||||
|
|
||||||
|
const AppCreate: React.FC = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 20 as number, // 微信小程序
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const appTypes = [
|
||||||
|
{ value: 10, label: '网站' },
|
||||||
|
{ value: 20, label: '微信小程序' },
|
||||||
|
{ value: 30, label: '抖音小程序' },
|
||||||
|
{ value: 40, label: '百度小程序' },
|
||||||
|
{ value: 50, label: '支付宝小程序' },
|
||||||
|
{ value: 60, label: 'Android APP' },
|
||||||
|
{ value: 70, label: 'iOS APP' },
|
||||||
|
{ value: 100, label: '插件' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
const updateField = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入应用名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
await createApp(formData)
|
||||||
|
Taro.showToast({ title: '创建成功', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建失败', error)
|
||||||
|
Taro.showToast({ title: '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="app-create-page">
|
||||||
|
<ScrollView scrollY className="app-create-page__scroll">
|
||||||
|
<View className="form">
|
||||||
|
{/* 应用名称 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">应用名称 *</Text>
|
||||||
|
</View>
|
||||||
|
<Input
|
||||||
|
className="form__input"
|
||||||
|
placeholder="请输入应用名称"
|
||||||
|
value={formData.name}
|
||||||
|
onInput={(e) => updateField('name', e.detail.value)}
|
||||||
|
maxlength={50}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 应用类型 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">应用类型 *</Text>
|
||||||
|
</View>
|
||||||
|
<RadioGroup value={String(formData.type)} onChange={(v) => updateField('type', Number(v))}>
|
||||||
|
<View className="type-grid">
|
||||||
|
{appTypes.map((type) => (
|
||||||
|
<View key={type.value} className="type-item">
|
||||||
|
<Radio value={String(type.value)}>
|
||||||
|
<Text className="type-item__text">{type.label}</Text>
|
||||||
|
</Radio>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</RadioGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 应用描述 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">应用描述</Text>
|
||||||
|
</View>
|
||||||
|
<Textarea
|
||||||
|
className="form__textarea"
|
||||||
|
placeholder="请输入应用描述(选填)"
|
||||||
|
value={formData.description}
|
||||||
|
onInput={(e) => updateField('description', e.detail.value)}
|
||||||
|
maxlength={200}
|
||||||
|
autoHeight
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View className="form__footer">
|
||||||
|
<Button type="primary" block loading={loading} onClick={handleSubmit}>
|
||||||
|
创建应用
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppCreate
|
||||||
159
src/developer/app/index.scss
Normal file
159
src/developer/app/index.scss
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding-bottom: 140rpx;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
height: calc(100vh - 100rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-wrapper {
|
||||||
|
background: #ffffff;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用列表 */
|
||||||
|
.app-list {
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用卡片 */
|
||||||
|
.app-card {
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: #f3f4f6;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__iconImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__iconDefault {
|
||||||
|
font-size: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statusDot {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statusText {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999999;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 创建按钮 */
|
||||||
|
.create-btn-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部安全区域 */
|
||||||
|
.safe-area-bottom {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
149
src/developer/app/index.tsx
Normal file
149
src/developer/app/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Button, Empty, Tabs, PullToRefresh } from '@nutui/nutui-react-taro'
|
||||||
|
import { pageMyApp } from '@/api/developer'
|
||||||
|
import type { App } from '@/types/developer'
|
||||||
|
import { APP_TYPE_NAME, STATUS_NAME, STATUS_COLOR } from '@/types/developer'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const AppList: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
const [apps, setApps] = useState<App[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await pageMyApp({ current: 1, size: 20 })
|
||||||
|
if (result) {
|
||||||
|
setApps(result.list || [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载失败', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await loadData()
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 筛选应用
|
||||||
|
const filteredApps = activeTab === 'all'
|
||||||
|
? apps
|
||||||
|
: apps.filter((app) => {
|
||||||
|
if (activeTab === 'running') return app.status === 1
|
||||||
|
if (activeTab === 'stopped') return app.status !== 1
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 跳转创建页面
|
||||||
|
const handleCreate = () => {
|
||||||
|
Taro.navigateTo({ url: '/developer/app/create' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转应用详情
|
||||||
|
const handleAppClick = (id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/developer/app/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status?: number) => {
|
||||||
|
return STATUS_COLOR[status || 0] || '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status?: number) => {
|
||||||
|
return STATUS_NAME[status || 0] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="app-list-page">
|
||||||
|
<PullToRefresh refreshing={refreshing} onRefresh={onRefresh}>
|
||||||
|
{/* 标签页 */}
|
||||||
|
<View className="tabs-wrapper">
|
||||||
|
<Tabs value={activeTab} onChange={(v) => setActiveTab(v as string)}>
|
||||||
|
<Tabs.TabPane title="全部应用" value="all" />
|
||||||
|
<Tabs.TabPane title="运行中" value="running" />
|
||||||
|
<Tabs.TabPane title="已停止" value="stopped" />
|
||||||
|
</Tabs>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView scrollY className="app-list-page__scroll">
|
||||||
|
{/* 应用列表 */}
|
||||||
|
<View className="app-list">
|
||||||
|
{loading ? (
|
||||||
|
<View className="app-list__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : filteredApps.length === 0 ? (
|
||||||
|
<Empty description="暂无应用" />
|
||||||
|
) : (
|
||||||
|
<View className="app-list__content">
|
||||||
|
{filteredApps.map((app) => (
|
||||||
|
<View key={app.productId} className="app-card" onClick={() => handleAppClick(app.productId!)}>
|
||||||
|
<View className="app-card__icon">
|
||||||
|
{app.icon ? (
|
||||||
|
<Image src={app.icon} mode="aspectFill" className="app-card__iconImg" />
|
||||||
|
) : (
|
||||||
|
<Text className="app-card__iconDefault">📱</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="app-card__info">
|
||||||
|
<View className="app-card__header">
|
||||||
|
<Text className="app-card__name">{app.productName}</Text>
|
||||||
|
<View className="app-card__status" style={{ color: getStatusColor(app.status) }}>
|
||||||
|
<View className="app-card__statusDot" style={{ backgroundColor: getStatusColor(app.status) }} />
|
||||||
|
<Text className="app-card__statusText">{getStatusText(app.status)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="app-card__type">
|
||||||
|
{APP_TYPE_NAME[app.appType as keyof typeof APP_TYPE_NAME] || '未知'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="app-card__desc" numberOfLines={2}>
|
||||||
|
{app.description || '暂无描述'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="app-card__meta">
|
||||||
|
<Text className="app-card__version">v{app.version || '1.0.0'}</Text>
|
||||||
|
<Text className="app-card__time">
|
||||||
|
更新于 {app.updateTime ? app.updateTime.split(' ')[0] : '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部安全区域 */}
|
||||||
|
<View className="safe-area-bottom" />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 创建按钮 */}
|
||||||
|
<View className="create-btn-wrapper">
|
||||||
|
<Button type="primary" block onClick={handleCreate}>
|
||||||
|
创建新应用
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</PullToRefresh>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppList
|
||||||
223
src/developer/developer/apply.scss
Normal file
223
src/developer/developer/apply.scss
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
.apply-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
|
||||||
|
// Tab
|
||||||
|
&__tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tab {
|
||||||
|
flex: 1;
|
||||||
|
height: 96rpx;
|
||||||
|
line-height: 96rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 4rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单
|
||||||
|
&__form {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
padding-left: 16rpx;
|
||||||
|
border-left: 6rpx solid #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__agreement {
|
||||||
|
padding: 24rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
height: 96rpx;
|
||||||
|
line-height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
&__list {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
font-size: 120rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__no-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请卡片
|
||||||
|
.apply-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reason {
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time,
|
||||||
|
&__review {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remark {
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #fa8c16;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/developer/developer/apply.tsx
Normal file
328
src/developer/developer/apply.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Input, Button, Textarea } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { useReachBottom } from '@tarojs/taro'
|
||||||
|
import { pageMyApply, createApply } from '@/api/developer/developer'
|
||||||
|
import type { Apply, ApplyParam, ApplyStatus } from '@/types/developer'
|
||||||
|
import './apply.scss'
|
||||||
|
|
||||||
|
// 状态配置
|
||||||
|
const STATUS_CONFIG: Record<ApplyStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
|
pending: { label: '待审核', color: '#faad14', bgColor: '#fffbe6' },
|
||||||
|
approved: { label: '已通过', color: '#52c41a', bgColor: '#f6ffed' },
|
||||||
|
rejected: { label: '已拒绝', color: '#ff4d4f', bgColor: '#fff1f0' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeveloperApplyPage: React.FC = () => {
|
||||||
|
const [tab, setTab] = useState<'apply' | 'record'>('apply')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [list, setList] = useState<Apply[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
|
||||||
|
// 申请表单
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
position: '',
|
||||||
|
experience: '',
|
||||||
|
})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||||
|
|
||||||
|
// 加载申请记录
|
||||||
|
const loadData = async (pageNum: number = 1, isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
if (isRefresh) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: ApplyParam = { page: pageNum, limit: 20 }
|
||||||
|
const data = await pageMyApply(params)
|
||||||
|
|
||||||
|
if (pageNum === 1) {
|
||||||
|
setList(data?.records || [])
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...(data?.records || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data?.total || 0
|
||||||
|
const records = data?.records || []
|
||||||
|
setHasMore(records.length > 0 && (pageNum * 20) < total)
|
||||||
|
setPage(pageNum)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'record') {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}, [tab])
|
||||||
|
|
||||||
|
// 提交申请
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.realName.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入真实姓名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.phone.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入手机号码', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查隐私协议是否同意
|
||||||
|
if (!agreementChecked) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: '请先同意服务协议和隐私政策',
|
||||||
|
icon: 'none'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await createApply({
|
||||||
|
type: 'developer',
|
||||||
|
reason: JSON.stringify(form),
|
||||||
|
} as Partial<Apply>)
|
||||||
|
Taro.showToast({ title: '申请已提交', icon: 'success' })
|
||||||
|
setForm({
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
position: '',
|
||||||
|
experience: '',
|
||||||
|
})
|
||||||
|
setTab('record')
|
||||||
|
loadData(1)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('提交失败', err)
|
||||||
|
Taro.showToast({ title: '提交失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="apply-page">
|
||||||
|
{/* Tab */}
|
||||||
|
<View className="apply-page__tabs">
|
||||||
|
<View
|
||||||
|
className={`apply-page__tab ${tab === 'apply' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('apply')}
|
||||||
|
>
|
||||||
|
申请成为开发者
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`apply-page__tab ${tab === 'record' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('record')}
|
||||||
|
>
|
||||||
|
申请记录
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 申请表单 */}
|
||||||
|
{tab === 'apply' && (
|
||||||
|
<View className="apply-page__form">
|
||||||
|
<View className="apply-page__section">
|
||||||
|
<Text className="apply-page__section-title">基本信息</Text>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">真实姓名 *</Text>
|
||||||
|
<Input
|
||||||
|
className="apply-page__input"
|
||||||
|
placeholder="请输入真实姓名"
|
||||||
|
value={form.realName}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, realName: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">手机号码 *</Text>
|
||||||
|
<Input
|
||||||
|
className="apply-page__input"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
maxlength={11}
|
||||||
|
value={form.phone}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, phone: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">电子邮箱</Text>
|
||||||
|
<Input
|
||||||
|
className="apply-page__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入电子邮箱(选填)"
|
||||||
|
value={form.email}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, email: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__section">
|
||||||
|
<Text className="apply-page__section-title">职业信息</Text>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">公司/组织</Text>
|
||||||
|
<Input
|
||||||
|
className="apply-page__input"
|
||||||
|
placeholder="请输入公司或组织名称(选填)"
|
||||||
|
value={form.company}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, company: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">职位</Text>
|
||||||
|
<Input
|
||||||
|
className="apply-page__input"
|
||||||
|
placeholder="请输入您的职位(选填)"
|
||||||
|
value={form.position}
|
||||||
|
onInput={(e) => setForm(prev => ({ ...prev, position: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__field">
|
||||||
|
<Text className="apply-page__label">开发经验</Text>
|
||||||
|
<Textarea
|
||||||
|
className="apply-page__textarea"
|
||||||
|
placeholder="请简要描述您的开发经验和擅长的技术领域..."
|
||||||
|
value={form.experience}
|
||||||
|
onInput={(e: any) => setForm(prev => ({ ...prev, experience: e.detail.value }))}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="apply-page__agreement">
|
||||||
|
<View style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: '18px', height: '18px', borderRadius: '4px',
|
||||||
|
border: agreementChecked ? 'none' : '1px solid #ccc',
|
||||||
|
background: agreementChecked ? '#3b82f6' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginRight: '8px'
|
||||||
|
}}
|
||||||
|
onClick={() => setAgreementChecked(!agreementChecked)}
|
||||||
|
>
|
||||||
|
{agreementChecked && <Text style={{ color: '#fff', fontSize: '12px' }}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
<Text className="apply-page__agreement-text" style={{ fontSize: '12px' }}>
|
||||||
|
我已阅读并同意
|
||||||
|
<Text style={{ color: '#3b82f6' }} onClick={() => Taro.navigateTo({ url: '/passport/webview/index?url=' + encodeURIComponent('https://websopy.websoft.top/agreement') })}>《开发者协议》</Text>
|
||||||
|
和
|
||||||
|
<Text style={{ color: '#3b82f6' }} onClick={() => Taro.navigateTo({ url: '/passport/webview/index?url=' + encodeURIComponent('https://websopy.websoft.top/privacy') })}>《隐私政策》</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="apply-page__submit"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
提交申请
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 申请记录 */}
|
||||||
|
{tab === 'record' && (
|
||||||
|
<View className="apply-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="apply-page__empty">
|
||||||
|
<Text className="apply-page__empty-icon">📋</Text>
|
||||||
|
<Text className="apply-page__empty-text">暂无申请记录</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((item) => (
|
||||||
|
<View key={item.id} className="apply-card">
|
||||||
|
<View className="apply-card__header">
|
||||||
|
<View className="apply-card__type">
|
||||||
|
<Text>开发者申请</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="apply-card__status"
|
||||||
|
style={{
|
||||||
|
color: STATUS_CONFIG[item.status as ApplyStatus]?.color,
|
||||||
|
background: STATUS_CONFIG[item.status as ApplyStatus]?.bgColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_CONFIG[item.status as ApplyStatus]?.label}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.reason && (
|
||||||
|
<View className="apply-card__reason">
|
||||||
|
<Text className="apply-card__reason-label">申请信息:</Text>
|
||||||
|
<Text className="apply-card__reason-content" numberOfLines={3}>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(item.reason)
|
||||||
|
return `姓名: ${info.realName} | 手机: ${info.phone}`
|
||||||
|
} catch {
|
||||||
|
return item.reason
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="apply-card__footer">
|
||||||
|
<Text className="apply-card__time">
|
||||||
|
申请时间: {formatDate(item.createTime)}
|
||||||
|
</Text>
|
||||||
|
{item.reviewTime && (
|
||||||
|
<Text className="apply-card__review">
|
||||||
|
审核时间: {formatDate(item.reviewTime)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.reviewRemark && (
|
||||||
|
<View className="apply-card__remark">
|
||||||
|
<Text className="apply-card__remark-label">审核备注:</Text>
|
||||||
|
<Text className="apply-card__remark-content">{item.reviewRemark}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && list.length === 0 && (
|
||||||
|
<View className="apply-page__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && list.length > 0 && (
|
||||||
|
<View className="apply-page__no-more">
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeveloperApplyPage
|
||||||
3
src/developer/developer/profile.config.ts
Normal file
3
src/developer/developer/profile.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '开发者资料',
|
||||||
|
})
|
||||||
10
src/developer/developer/profile.scss
Normal file
10
src/developer/developer/profile.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.profile-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/developer/profile.tsx
Normal file
15
src/developer/developer/profile.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './profile.scss'
|
||||||
|
|
||||||
|
const ProfilePage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="profile-page">
|
||||||
|
<View className="profile-page__content">
|
||||||
|
<Text>开发者资料设置开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage
|
||||||
3
src/developer/docs/api-docs.config.ts
Normal file
3
src/developer/docs/api-docs.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: 'API 文档',
|
||||||
|
})
|
||||||
10
src/developer/docs/api-docs.scss
Normal file
10
src/developer/docs/api-docs.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.api-docs-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/docs/api-docs.tsx
Normal file
15
src/developer/docs/api-docs.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './api-docs.scss'
|
||||||
|
|
||||||
|
const ApiDocsPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="api-docs-page">
|
||||||
|
<View className="api-docs-page__content">
|
||||||
|
<Text>API 文档开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiDocsPage
|
||||||
3
src/developer/docs/index.config.ts
Normal file
3
src/developer/docs/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '开发者文档',
|
||||||
|
})
|
||||||
28
src/developer/docs/index.scss
Normal file
28
src/developer/docs/index.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/developer/docs/index.tsx
Normal file
18
src/developer/docs/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const DeveloperDocs: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="docs-page">
|
||||||
|
<View className="docs-page__header">
|
||||||
|
<Text className="docs-page__title">📖 开发者文档</Text>
|
||||||
|
</View>
|
||||||
|
<View className="docs-page__content">
|
||||||
|
<Text>开发者文档功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeveloperDocs
|
||||||
3
src/developer/docs/quickstart.config.ts
Normal file
3
src/developer/docs/quickstart.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '快速开始',
|
||||||
|
})
|
||||||
10
src/developer/docs/quickstart.scss
Normal file
10
src/developer/docs/quickstart.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.quickstart-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/docs/quickstart.tsx
Normal file
15
src/developer/docs/quickstart.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './quickstart.scss'
|
||||||
|
|
||||||
|
const QuickstartPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="quickstart-page">
|
||||||
|
<View className="quickstart-page__content">
|
||||||
|
<Text>快速开始文档开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickstartPage
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '配送中心'
|
navigationBarTitleText: '开发者中心',
|
||||||
|
usingComponents: {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.developer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部区域 */
|
||||||
|
.developer-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 40rpx 32rpx 60rpx;
|
||||||
|
background: linear-gradient(180deg, #1a56db 0%, #3b82f6 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__glow {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: -100rpx;
|
||||||
|
width: 500rpx;
|
||||||
|
height: 300rpx;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
filter: blur(60rpx);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titleText {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计卡片 */
|
||||||
|
.stats-card {
|
||||||
|
margin: -40rpx 24rpx 24rpx;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
display: block;
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #1a56db;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__divider {
|
||||||
|
width: 2rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷操作 */
|
||||||
|
.quick-actions {
|
||||||
|
margin: 0 24rpx 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titleText {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 20rpx 10rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon--blue {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon--green {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon--purple {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon--orange {
|
||||||
|
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用列表 */
|
||||||
|
.app-list {
|
||||||
|
margin: 0 24rpx 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__more {
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__moreText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 60rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用项 */
|
||||||
|
.app-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f9fafb;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111111;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statusDot {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statusText {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部安全区域 */
|
||||||
|
.safe-area-bottom {
|
||||||
|
height: calc(40rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,295 +1,223 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import {View, Text} from '@tarojs/components'
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro'
|
|
||||||
import {
|
|
||||||
User,
|
|
||||||
Shopping,
|
|
||||||
Dongdong,
|
|
||||||
ArrowRight,
|
|
||||||
Purse,
|
|
||||||
People
|
|
||||||
} from '@nutui/icons-react-taro'
|
|
||||||
import {useDealerUser} from '@/hooks/useDealerUser'
|
|
||||||
import { useThemeStyles } from '@/hooks/useTheme'
|
|
||||||
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
|
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Button, Empty } from '@nutui/nutui-react-taro'
|
||||||
|
import { useThemeStyles } from '@/hooks/useTheme'
|
||||||
|
import { pageMyApp, pageJoinedApp } from '@/api/developer'
|
||||||
|
import type { App } from '@/types/developer'
|
||||||
|
import { APP_TYPE_NAME } from '@/types/developer'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
const DealerIndex: React.FC = () => {
|
const DeveloperIndex: React.FC = () => {
|
||||||
const {
|
|
||||||
dealerUser,
|
|
||||||
error,
|
|
||||||
refresh,
|
|
||||||
} = useDealerUser()
|
|
||||||
|
|
||||||
// 使用主题样式
|
|
||||||
const themeStyles = useThemeStyles()
|
const themeStyles = useThemeStyles()
|
||||||
|
const [apps, setApps] = useState<App[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
totalApps: 0,
|
||||||
|
runningApps: 0,
|
||||||
|
totalCalls: 0,
|
||||||
|
})
|
||||||
|
|
||||||
// 导航到各个功能页面
|
// 加载数据 - 同时查询创建的应用和参与的应用
|
||||||
const navigateToPage = (url: string) => {
|
const loadData = async () => {
|
||||||
Taro.navigateTo({url})
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
// 同时调用两个接口:创建的应用 + 参与的应用(通过邀请加入)
|
||||||
|
const [myAppsResult, joinedAppsResult] = await Promise.all([
|
||||||
|
pageMyApp({ current: 1, size: 10 }),
|
||||||
|
pageJoinedApp({ current: 1, size: 10 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 合并两个列表,去重(根据 productId)
|
||||||
|
const myApps = myAppsResult?.records || []
|
||||||
|
const joinedApps = joinedAppsResult?.records || []
|
||||||
|
|
||||||
|
// 使用 Map 去重,优先保留创建的应用(排在前面)
|
||||||
|
const appMap = new Map<number, App>()
|
||||||
|
|
||||||
|
// 先加入创建的应用
|
||||||
|
myApps.forEach((app) => {
|
||||||
|
if (app.productId) {
|
||||||
|
appMap.set(app.productId, { ...app, isOwner: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 再加入参与的应用(如果已存在则跳过)
|
||||||
|
joinedApps.forEach((app) => {
|
||||||
|
if (app.productId && !appMap.has(app.productId)) {
|
||||||
|
appMap.set(app.productId, { ...app, isOwner: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergedApps = Array.from(appMap.values())
|
||||||
|
|
||||||
|
setApps(mergedApps)
|
||||||
|
setStats({
|
||||||
|
totalApps: mergedApps.length,
|
||||||
|
runningApps: mergedApps.filter((a) => a.status === 1).length,
|
||||||
|
totalCalls: 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载数据失败', error)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化金额
|
useEffect(() => {
|
||||||
const formatMoney = (money?: string) => {
|
loadData()
|
||||||
if (!money) return '0.00'
|
}, [])
|
||||||
return parseFloat(money).toFixed(2)
|
|
||||||
|
// 跳转页面
|
||||||
|
const navigateTo = (url: string) => {
|
||||||
|
Taro.navigateTo({ url })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化时间
|
// 获取状态颜色
|
||||||
const formatTime = (time?: string) => {
|
const getStatusColor = (status?: number) => {
|
||||||
if (!time) return '-'
|
const colors: Record<number, string> = {
|
||||||
return new Date(time).toLocaleDateString()
|
0: '#6b7280',
|
||||||
|
1: '#10b981',
|
||||||
|
2: '#f59e0b',
|
||||||
|
3: '#ef4444',
|
||||||
|
4: '#ef4444',
|
||||||
|
5: '#ef4444',
|
||||||
|
}
|
||||||
|
return colors[status || 0] || '#6b7280'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户主题
|
// 获取状态文本
|
||||||
const userTheme = gradientUtils.getThemeByUserId(dealerUser?.userId)
|
const getStatusText = (status?: number) => {
|
||||||
|
const texts: Record<number, string> = {
|
||||||
// 获取渐变背景
|
0: '未开通',
|
||||||
const getGradientBackground = (themeColor?: string) => {
|
1: '运行中',
|
||||||
if (themeColor) {
|
2: '维护中',
|
||||||
const darkerColor = gradientUtils.adjustColorBrightness(themeColor, -30)
|
3: '已关闭',
|
||||||
return gradientUtils.createGradient(themeColor, darkerColor)
|
4: '已欠费',
|
||||||
|
5: '违规停机',
|
||||||
}
|
}
|
||||||
return userTheme.background
|
return texts[status || 0] || '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(getGradientBackground(),'getGradientBackground()')
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4">
|
<View className="developer-page">
|
||||||
<View className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
<ScrollView scrollY className="developer-page__scroll">
|
||||||
<Text className="text-red-600">{error}</Text>
|
{/* 头部区域 */}
|
||||||
|
<View className="developer-header" style={themeStyles.primaryBackground}>
|
||||||
|
<View className="developer-header__glow" />
|
||||||
|
<View className="developer-header__content">
|
||||||
|
<View className="developer-header__title">
|
||||||
|
<Text className="developer-header__titleText">🛠️ 开发者中心</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button type="primary" onClick={refresh}>
|
<Text className="developer-header__subtitle">管理你的项目和应用</Text>
|
||||||
重试
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<View className="stats-card">
|
||||||
|
<View className="stats-card__row">
|
||||||
|
<View className="stats-card__item">
|
||||||
|
<Text className="stats-card__value">{stats.totalApps}</Text>
|
||||||
|
<Text className="stats-card__label">我的应用</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stats-card__divider" />
|
||||||
|
<View className="stats-card__item">
|
||||||
|
<Text className="stats-card__value">{stats.runningApps}</Text>
|
||||||
|
<Text className="stats-card__label">运行中</Text>
|
||||||
|
</View>
|
||||||
|
<View className="stats-card__divider" />
|
||||||
|
<View className="stats-card__item">
|
||||||
|
<Text className="stats-card__value">{stats.totalCalls}</Text>
|
||||||
|
<Text className="stats-card__label">API 调用</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<View className="quick-actions">
|
||||||
|
<View className="quick-actions__title">
|
||||||
|
<Text className="quick-actions__titleText">快捷操作</Text>
|
||||||
|
</View>
|
||||||
|
<View className="quick-actions__grid">
|
||||||
|
<View className="quick-actions__item" onClick={() => navigateTo('/developer/project/index')}>
|
||||||
|
<View className="quick-actions__icon quick-actions__icon--blue">
|
||||||
|
<Text>📁</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="quick-actions__text">项目管理</Text>
|
||||||
|
</View>
|
||||||
|
<View className="quick-actions__item" onClick={() => navigateTo('/developer/app/index')}>
|
||||||
|
<View className="quick-actions__icon quick-actions__icon--green">
|
||||||
|
<Text>📱</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="quick-actions__text">应用管理</Text>
|
||||||
|
</View>
|
||||||
|
<View className="quick-actions__item" onClick={() => navigateTo('/developer/app/api-keys')}>
|
||||||
|
<View className="quick-actions__icon quick-actions__icon--purple">
|
||||||
|
<Text>🔑</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="quick-actions__text">API Key</Text>
|
||||||
|
</View>
|
||||||
|
<View className="quick-actions__item" onClick={() => navigateTo('/developer/docs/index')}>
|
||||||
|
<View className="quick-actions__icon quick-actions__icon--orange">
|
||||||
|
<Text>📖</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="quick-actions__text">开发者文档</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 应用列表 */}
|
||||||
|
<View className="app-list">
|
||||||
|
<View className="app-list__header">
|
||||||
|
<Text className="app-list__title">我的应用</Text>
|
||||||
|
<View className="app-list__more" onClick={() => navigateTo('/developer/app/index')}>
|
||||||
|
<Text className="app-list__moreText">查看全部 ›</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="app-list__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : apps.length === 0 ? (
|
||||||
|
<Empty description="暂无应用" />
|
||||||
|
) : (
|
||||||
|
<View className="app-list__content">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<View key={app.productId} className="app-item" onClick={() => navigateTo(`/developer/app/${app.productId}`)}>
|
||||||
|
<View className="app-item__icon">
|
||||||
|
{app.icon ? (
|
||||||
|
<img src={app.icon} alt={app.productName} />
|
||||||
|
) : (
|
||||||
|
<Text>📱</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="app-item__info">
|
||||||
|
<Text className="app-item__name">{app.productName}</Text>
|
||||||
|
<Text className="app-item__type">{APP_TYPE_NAME[app.appType as keyof typeof APP_TYPE_NAME] || '未知'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="app-item__status" style={{ color: getStatusColor(app.status) }}>
|
||||||
|
<View className="app-item__statusDot" style={{ backgroundColor: getStatusColor(app.status) }} />
|
||||||
|
<Text className="app-item__statusText">{getStatusText(app.status)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="app-list__footer">
|
||||||
|
<Button type="primary" block onClick={() => navigateTo('/developer/app/create')}>
|
||||||
|
创建新应用
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="bg-gray-100 min-h-screen">
|
|
||||||
<View>
|
|
||||||
{/*头部信息*/}
|
|
||||||
{dealerUser && (
|
|
||||||
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
|
|
||||||
{/* 装饰性背景元素 - 小程序兼容版本 */}
|
|
||||||
<View className="absolute w-32 h-32 rounded-full" style={{
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
top: '-16px',
|
|
||||||
right: '-16px'
|
|
||||||
}}></View>
|
|
||||||
<View className="absolute w-24 h-24 rounded-full" style={{
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
||||||
bottom: '-12px',
|
|
||||||
left: '-12px'
|
|
||||||
}}></View>
|
|
||||||
<View className="absolute w-16 h-16 rounded-full" style={{
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
|
||||||
top: '60px',
|
|
||||||
left: '120px'
|
|
||||||
}}></View>
|
|
||||||
<View className="flex items-center justify-between relative z-10 mb-4">
|
|
||||||
<Avatar
|
|
||||||
size="50"
|
|
||||||
src={dealerUser?.qrcode}
|
|
||||||
icon={<User/>}
|
|
||||||
className="mr-4"
|
|
||||||
style={{
|
|
||||||
border: '2px solid rgba(255, 255, 255, 0.3)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="flex-1 flex-col">
|
|
||||||
<View className="text-white text-lg font-bold mb-1" style={{
|
|
||||||
}}>
|
|
||||||
{dealerUser?.realName || '分销商'}
|
|
||||||
</View>
|
|
||||||
<View className="text-sm" style={{
|
|
||||||
color: 'rgba(255, 255, 255, 0.8)'
|
|
||||||
}}>
|
|
||||||
ID: {dealerUser.userId}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="text-right hidden">
|
|
||||||
<Text className="text-xs" style={{
|
|
||||||
color: 'rgba(255, 255, 255, 0.9)'
|
|
||||||
}}>加入时间</Text>
|
|
||||||
<Text className="text-xs" style={{
|
|
||||||
color: 'rgba(255, 255, 255, 0.7)'
|
|
||||||
}}>
|
|
||||||
{formatTime(dealerUser.createTime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 佣金统计卡片 */}
|
|
||||||
{dealerUser && (
|
|
||||||
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}>
|
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="font-semibold text-gray-800">工资统计</Text>
|
|
||||||
</View>
|
|
||||||
<View className="grid grid-cols-3 gap-3">
|
|
||||||
<View className="text-center p-3 rounded-lg flex flex-col" style={{
|
|
||||||
background: businessGradients.money.available
|
|
||||||
}}>
|
|
||||||
<Text className="text-lg font-bold mb-1 text-white">
|
|
||||||
{formatMoney(dealerUser.money)}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>工资收入</Text>
|
|
||||||
</View>
|
|
||||||
<View className="text-center p-3 rounded-lg flex flex-col" style={{
|
|
||||||
background: businessGradients.money.frozen
|
|
||||||
}}>
|
|
||||||
<Text className="text-lg font-bold mb-1 text-white">
|
|
||||||
{formatMoney(dealerUser.freezeMoney)}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>桶数</Text>
|
|
||||||
</View>
|
|
||||||
<View className="text-center p-3 rounded-lg flex flex-col" style={{
|
|
||||||
background: businessGradients.money.total
|
|
||||||
}}>
|
|
||||||
<Text className="text-lg font-bold mb-1 text-white">
|
|
||||||
{formatMoney(dealerUser.totalMoney)}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>累计收入</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 团队统计 */}
|
|
||||||
{dealerUser && (
|
|
||||||
<View className="bg-white mx-4 mt-4 rounded-xl p-4 hidden">
|
|
||||||
<View className="flex items-center justify-between mb-4">
|
|
||||||
<Text className="font-semibold text-gray-800">我的邀请</Text>
|
|
||||||
<View
|
|
||||||
className="text-gray-400 text-sm flex items-center"
|
|
||||||
onClick={() => navigateToPage('/dealer/team/index')}
|
|
||||||
>
|
|
||||||
<Text>查看详情</Text>
|
|
||||||
<ArrowRight size="12"/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="grid grid-cols-3 gap-4">
|
|
||||||
<View className="text-center grid">
|
|
||||||
<Text className="text-xl font-bold text-purple-500 mb-1">
|
|
||||||
{dealerUser.firstNum || 0}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-gray-500">一级成员</Text>
|
|
||||||
</View>
|
|
||||||
<View className="text-center grid">
|
|
||||||
<Text className="text-xl font-bold text-indigo-500 mb-1">
|
|
||||||
{dealerUser.secondNum || 0}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-gray-500">二级成员</Text>
|
|
||||||
</View>
|
|
||||||
<View className="text-center grid">
|
|
||||||
<Text className="text-xl font-bold text-pink-500 mb-1">
|
|
||||||
{dealerUser.thirdNum || 0}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-gray-500">三级成员</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 功能导航 */}
|
|
||||||
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
|
|
||||||
<View className="font-semibold mb-4 text-gray-800">配送工具</View>
|
|
||||||
<ConfigProvider>
|
|
||||||
<Grid
|
|
||||||
columns={4}
|
|
||||||
className="no-border-grid"
|
|
||||||
style={{
|
|
||||||
'--nutui-grid-border-color': 'transparent',
|
|
||||||
'--nutui-grid-item-border-width': '0px',
|
|
||||||
border: 'none'
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
|
||||||
<View className="text-center">
|
|
||||||
<View className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mx-auto mb-2">
|
|
||||||
<Shopping color="#3b82f6" size="20"/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Grid.Item>
|
|
||||||
|
|
||||||
<Grid.Item text={'工资明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
|
|
||||||
<View className="text-center">
|
|
||||||
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
|
||||||
<Purse color="#10b981" size="20"/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Grid.Item>
|
|
||||||
|
|
||||||
<Grid.Item text={'配送小区'} onClick={() => navigateToPage('/rider/team/index')}>
|
|
||||||
<View className="text-center">
|
|
||||||
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
|
||||||
<People color="#8b5cf6" size="20"/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Grid.Item>
|
|
||||||
|
|
||||||
<Grid.Item text={'仓库地址'} onClick={() => navigateToPage('/rider/qrcode/index')}>
|
|
||||||
<View className="text-center">
|
|
||||||
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
|
||||||
<Dongdong color="#f59e0b" size="20"/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Grid.Item>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* 第二行功能 */}
|
|
||||||
{/*<Grid*/}
|
|
||||||
{/* columns={4}*/}
|
|
||||||
{/* className="no-border-grid mt-4"*/}
|
|
||||||
{/* style={{*/}
|
|
||||||
{/* '--nutui-grid-border-color': 'transparent',*/}
|
|
||||||
{/* '--nutui-grid-item-border-width': '0px',*/}
|
|
||||||
{/* border: 'none'*/}
|
|
||||||
{/* } as React.CSSProperties}*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* <Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* <Presentation color="#6366f1" size="20"/>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* /!* 预留其他功能位置 *!/*/}
|
|
||||||
{/* <Grid.Item text={''}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* <Grid.Item text={''}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
|
|
||||||
{/* <Grid.Item text={''}>*/}
|
|
||||||
{/* <View className="text-center">*/}
|
|
||||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </View>*/}
|
|
||||||
{/* </Grid.Item>*/}
|
|
||||||
{/*</Grid>*/}
|
|
||||||
</ConfigProvider>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 底部安全区域 */}
|
{/* 底部安全区域 */}
|
||||||
<View className="h-20"></View>
|
<View className="safe-area-bottom" />
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DealerIndex
|
export default DeveloperIndex
|
||||||
|
|||||||
3
src/developer/market/index.config.ts
Normal file
3
src/developer/market/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '开发者市场',
|
||||||
|
})
|
||||||
20
src/developer/market/index.scss
Normal file
20
src/developer/market/index.scss
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.market-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/developer/market/index.tsx
Normal file
18
src/developer/market/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const MarketPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="market-page">
|
||||||
|
<View className="market-page__header">
|
||||||
|
<Text className="market-page__title">🛒 开发者市场</Text>
|
||||||
|
</View>
|
||||||
|
<View className="market-page__content">
|
||||||
|
<Text>模板和插件市场开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarketPage
|
||||||
183
src/developer/notification/index.scss
Normal file
183
src/developer/notification/index.scss
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
.notification-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
min-width: 36rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mark-read {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab
|
||||||
|
&__tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 32rpx 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tab {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
padding-bottom: 8rpx;
|
||||||
|
border-bottom: 4rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
&__list {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
font-size: 120rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__no-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知卡片
|
||||||
|
.notification-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.read {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff4d4f;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
right: 16rpx;
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #ccc;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/developer/notification/index.tsx
Normal file
288
src/developer/notification/index.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ActionSheet } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { usePullDownRefresh } from '@tarojs/taro'
|
||||||
|
import {
|
||||||
|
pageNotification,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
} from '@/api/developer/developer'
|
||||||
|
import type { Notification, NotificationParam, NotificationType } from '@/types/developer'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// 通知类型配置
|
||||||
|
const TYPE_CONFIG: Record<NotificationType, { label: string; icon: string; color: string }> = {
|
||||||
|
system: { label: '系统通知', icon: '🔔', color: '#1890ff' },
|
||||||
|
app: { label: '应用通知', icon: '📱', color: '#52c41a' },
|
||||||
|
member: { label: '成员通知', icon: '👥', color: '#722ed1' },
|
||||||
|
order: { label: '订单通知', icon: '💰', color: '#fa8c16' },
|
||||||
|
developer: { label: '开发通知', icon: '🛠️', color: '#13c2c2' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [list, setList] = useState<Notification[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const [currentTab, setCurrentTab] = useState<NotificationType | 'all'>('all')
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (pageNum: number = 1, isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
if (isRefresh) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: NotificationParam = {
|
||||||
|
page: pageNum,
|
||||||
|
limit: 20,
|
||||||
|
type: currentTab === 'all' ? undefined : currentTab,
|
||||||
|
}
|
||||||
|
const data = await pageNotification(params)
|
||||||
|
|
||||||
|
if (pageNum === 1) {
|
||||||
|
setList(data?.records || [])
|
||||||
|
} else {
|
||||||
|
setList(prev => [...prev, ...(data?.records || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data?.total || 0
|
||||||
|
const records = data?.records || []
|
||||||
|
setHasMore(records.length > 0 && (pageNum * 20) < total)
|
||||||
|
setPage(pageNum)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载未读数
|
||||||
|
const loadUnreadCount = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getUnreadCount()
|
||||||
|
setUnreadCount(data?.count || 0)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取未读数失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(1)
|
||||||
|
loadUnreadCount()
|
||||||
|
}, [currentTab])
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
usePullDownRefresh(() => {
|
||||||
|
loadData(1, true)
|
||||||
|
loadUnreadCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onReachBottom = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
loadData(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记已读
|
||||||
|
const handleRead = async (item: Notification) => {
|
||||||
|
if (item.isRead) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markAsRead(item.id!)
|
||||||
|
setList(prev =>
|
||||||
|
prev.map(n => (n.id === item.id ? { ...n, isRead: true } : n))
|
||||||
|
)
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('标记已读失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部已读
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllAsRead()
|
||||||
|
setList(prev => prev.map(n => ({ ...n, isRead: true })))
|
||||||
|
setUnreadCount(0)
|
||||||
|
Taro.showToast({ title: '已全部已读', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('标记全部已读失败', err)
|
||||||
|
Taro.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (item: Notification) => {
|
||||||
|
ActionSheet({
|
||||||
|
alertText: '确定删除这条通知吗?',
|
||||||
|
actions: [{ name: '删除', color: '#ff4d4f', type: 'warn' as const }],
|
||||||
|
confirmText: '取消',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
doDelete(item)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async (item: Notification) => {
|
||||||
|
try {
|
||||||
|
await deleteNotification(item.id!)
|
||||||
|
setList(prev => prev.filter(n => n.id !== item.id))
|
||||||
|
if (!item.isRead) {
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||||
|
}
|
||||||
|
Taro.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除失败', err)
|
||||||
|
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击通知
|
||||||
|
const handleClick = (item: Notification) => {
|
||||||
|
handleRead(item)
|
||||||
|
|
||||||
|
// 根据通知类型和内容跳转
|
||||||
|
if (item.data?.url) {
|
||||||
|
Taro.navigateTo({ url: item.data.url })
|
||||||
|
} else if (item.data?.appId) {
|
||||||
|
Taro.navigateTo({ url: `/developer/app/${item.data.appId}/index` })
|
||||||
|
} else if (item.data?.projectId) {
|
||||||
|
Taro.navigateTo({ url: `/developer/project/${item.data.projectId}/index` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return `今天 ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
} else if (days === 1) {
|
||||||
|
return '昨天'
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}天前`
|
||||||
|
} else {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 配置
|
||||||
|
const tabs: { key: NotificationType | 'all'; label: string }[] = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'system', label: '系统' },
|
||||||
|
{ key: 'app', label: '应用' },
|
||||||
|
{ key: 'member', label: '成员' },
|
||||||
|
{ key: 'order', label: '订单' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="notification-page">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="notification-page__header">
|
||||||
|
<View className="notification-page__header-left">
|
||||||
|
<Text className="notification-page__title">🔔 消息通知</Text>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View className="notification-page__badge">
|
||||||
|
<Text>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Text
|
||||||
|
className="notification-page__mark-read"
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
>
|
||||||
|
全部已读
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab */}
|
||||||
|
<View className="notification-page__tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<View
|
||||||
|
key={tab.key}
|
||||||
|
className={`notification-page__tab ${currentTab === tab.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<View className="notification-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="notification-page__empty">
|
||||||
|
<Text className="notification-page__empty-icon">📭</Text>
|
||||||
|
<Text className="notification-page__empty-text">暂无通知</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className={`notification-card ${item.isRead ? 'read' : 'unread'}`}
|
||||||
|
onClick={() => handleClick(item)}
|
||||||
|
>
|
||||||
|
<View className="notification-card__icon">
|
||||||
|
<Text style={{ color: TYPE_CONFIG[item.type || 'system']?.color }}>
|
||||||
|
{TYPE_CONFIG[item.type || 'system']?.icon}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="notification-card__content">
|
||||||
|
<View className="notification-card__header">
|
||||||
|
<Text className="notification-card__title">{item.title}</Text>
|
||||||
|
{!item.isRead && <View className="notification-card__dot" />}
|
||||||
|
</View>
|
||||||
|
<Text className="notification-card__body" numberOfLines={2}>
|
||||||
|
{item.content}
|
||||||
|
</Text>
|
||||||
|
<Text className="notification-card__time">
|
||||||
|
{formatDate(item.createTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="notification-card__delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && list.length > 0 && (
|
||||||
|
<View className="notification-page__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && list.length > 0 && (
|
||||||
|
<View className="notification-page__no-more">
|
||||||
|
<Text>没有更多了</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationPage
|
||||||
3
src/developer/project/[id]/api-keys.config.ts
Normal file
3
src/developer/project/[id]/api-keys.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: 'API Key 管理',
|
||||||
|
})
|
||||||
3
src/developer/project/[id]/api-keys.scss
Normal file
3
src/developer/project/[id]/api-keys.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// 复用 developer/app/api-keys/index.scss 的样式
|
||||||
|
// 注意:使用 @use 替代 @import 是 Sass 的新推荐方式
|
||||||
|
@use '../../app/api-keys/index' as api-keys;
|
||||||
139
src/developer/project/[id]/api-keys.tsx
Normal file
139
src/developer/project/[id]/api-keys.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Input, Button, actionSheet } from '@tarojs/components'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { usePullDownRefresh } from '@tarojs/taro'
|
||||||
|
import { listApiKey, createApiKey, deleteApiKey } from '@/api/developer/developer'
|
||||||
|
import type { ApiKey, ApiKeyParam } from '@/types/developer'
|
||||||
|
import './api-keys.scss'
|
||||||
|
|
||||||
|
const ApiKeysPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const projectId = Number(router.params.id)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [list, setList] = useState<ApiKey[]>([])
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createForm, setCreateForm] = useState({ name: '', remark: '' })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: ApiKeyParam = { websiteId: projectId }
|
||||||
|
const data = await listApiKey(params)
|
||||||
|
setList(data || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [projectId])
|
||||||
|
usePullDownRefresh(() => { loadData() })
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createForm.name.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await createApiKey({ name: createForm.name, remark: createForm.remark, websiteId: projectId } as Partial<ApiKey>)
|
||||||
|
Taro.showToast({ title: '创建成功', icon: 'success' })
|
||||||
|
setShowCreate(false)
|
||||||
|
setCreateForm({ name: '', remark: '' })
|
||||||
|
loadData()
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (item: ApiKey) => {
|
||||||
|
actionSheet({
|
||||||
|
alertText: `确定删除 "${item.name}" 吗?`,
|
||||||
|
actions: [{ name: '删除', color: '#ff4d4f', type: 'warn' as const }],
|
||||||
|
confirmText: '取消',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
deleteApiKey(item.id!).then(() => {
|
||||||
|
Taro.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
Taro.setClipboardData({ data: text, success: () => Taro.showToast({ title: '已复制', icon: 'success' }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="api-keys-page">
|
||||||
|
<View className="api-keys-page__header">
|
||||||
|
<Text className="api-keys-page__title">🔑 API Key 管理</Text>
|
||||||
|
<Button className="api-keys-page__create-btn" size="mini" onClick={() => setShowCreate(true)}>+ 创建</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="api-keys-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="api-keys-page__empty"><Text>暂无 API Key</Text></View>
|
||||||
|
) : (
|
||||||
|
list.map((item) => (
|
||||||
|
<View key={item.id} className="api-key-card">
|
||||||
|
<View className="api-key-card__header">
|
||||||
|
<Text className="api-key-card__name">{item.name}</Text>
|
||||||
|
<Text className="api-key-card__status" style={{ color: item.status ? '#52c41a' : '#999' }}>
|
||||||
|
{item.status ? '正常' : '禁用'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="api-key-card__row">
|
||||||
|
<Text className="api-key-card__label">AppId:</Text>
|
||||||
|
<Text className="api-key-card__value api-key-card__value--monospace" onClick={() => handleCopy(item.appId || '')}>
|
||||||
|
{item.appId || '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="api-key-card__row">
|
||||||
|
<Text className="api-key-card__label">AppSecret:</Text>
|
||||||
|
<Text className="api-key-card__value api-key-card__value--monospace" onClick={() => handleCopy(item.appSecret || '')}>
|
||||||
|
••••••••••••••••
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="api-key-card__actions">
|
||||||
|
{item.appSecret && <Text className="api-key-card__action api-key-card__action--primary" onClick={() => handleCopy(item.appSecret || '')}>复制密钥</Text>}
|
||||||
|
<Text className="api-key-card__action api-key-card__action--danger" onClick={() => handleDelete(item)}>删除</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<View className="api-keys-page__modal">
|
||||||
|
<View className="api-keys-page__modal-mask" onClick={() => setShowCreate(false)} />
|
||||||
|
<View className="api-keys-page__modal-content">
|
||||||
|
<Text className="api-keys-page__modal-title">创建 API Key</Text>
|
||||||
|
<View className="api-keys-page__form">
|
||||||
|
<View className="api-keys-page__form-item">
|
||||||
|
<Text className="api-keys-page__form-label">名称 *</Text>
|
||||||
|
<Input className="api-keys-page__form-input" placeholder="请输入 Key 名称" value={createForm.name} onInput={(e) => setCreateForm(prev => ({ ...prev, name: e.detail.value }))} />
|
||||||
|
</View>
|
||||||
|
<View className="api-keys-page__form-item">
|
||||||
|
<Text className="api-keys-page__form-label">备注</Text>
|
||||||
|
<Input className="api-keys-page__form-input" placeholder="可选" value={createForm.remark} onInput={(e) => setCreateForm(prev => ({ ...prev, remark: e.detail.value }))} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="api-keys-page__modal-actions">
|
||||||
|
<Button className="api-keys-page__modal-btn api-keys-page__modal-btn--cancel" onClick={() => setShowCreate(false)}>取消</Button>
|
||||||
|
<Button className="api-keys-page__modal-btn api-keys-page__modal-btn--confirm" loading={creating} onClick={handleCreate}>创建</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeysPage
|
||||||
3
src/developer/project/[id]/index.config.ts
Normal file
3
src/developer/project/[id]/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '项目详情',
|
||||||
|
})
|
||||||
28
src/developer/project/[id]/index.scss
Normal file
28
src/developer/project/[id]/index.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/developer/project/[id]/index.tsx
Normal file
22
src/developer/project/[id]/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const ProjectDetail: React.FC = () => {
|
||||||
|
const id = Taro.getCurrentInstance()?.router?.params?.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="project-detail-page">
|
||||||
|
<View className="project-detail-page__header">
|
||||||
|
<Text className="project-detail-page__title">📁 项目详情</Text>
|
||||||
|
</View>
|
||||||
|
<View className="project-detail-page__content">
|
||||||
|
<Text>项目 ID: {id}</Text>
|
||||||
|
<Text style={{ marginTop: '20rpx' }}>项目详情功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectDetail
|
||||||
307
src/developer/project/[id]/members.scss
Normal file
307
src/developer/project/[id]/members.scss
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
.members-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24rpx;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__invite-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计卡片
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&-num {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-hint {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 26rpx !important;
|
||||||
|
color: #ccc !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员卡片
|
||||||
|
.member-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-left: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #667eea;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请弹窗
|
||||||
|
.members-page__modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
padding: 48rpx 32rpx;
|
||||||
|
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-page__form {
|
||||||
|
&-item {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-radio {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-page__modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-page__modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/developer/project/[id]/members.tsx
Normal file
324
src/developer/project/[id]/members.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Button, Input, actionSheet } from '@tarojs/components'
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
|
import { usePullDownRefresh } from '@tarojs/taro'
|
||||||
|
import { listProjectMember, addProjectMember, removeProjectMember } from '@/api/developer/developer'
|
||||||
|
import type { ProjectMember } from '@/types/developer'
|
||||||
|
import './members.scss'
|
||||||
|
|
||||||
|
// 角色配置
|
||||||
|
const ROLE_CONFIG: Record<string, { label: string; color: string; desc: string }> = {
|
||||||
|
owner: { label: '所有者', color: '#722ed1', desc: '拥有所有权限' },
|
||||||
|
admin: { label: '管理员', color: '#1890ff', desc: '管理项目设置和成员' },
|
||||||
|
developer: { label: '开发者', color: '#52c41a', desc: '开发和管理应用' },
|
||||||
|
viewer: { label: '查看者', color: '#999', desc: '仅可查看' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MembersPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const projectId = Number(router.params.id)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [list, setList] = useState<ProjectMember[]>([])
|
||||||
|
const [showInvite, setShowInvite] = useState(false)
|
||||||
|
const [inviteForm, setInviteForm] = useState({
|
||||||
|
username: '',
|
||||||
|
role: 'developer' as ProjectMember['role'],
|
||||||
|
})
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (isRefresh = false) => {
|
||||||
|
if (loading) return
|
||||||
|
setLoading(true)
|
||||||
|
if (isRefresh) setRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listProjectMember(projectId)
|
||||||
|
setList(data || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载失败', err)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
usePullDownRefresh(() => {
|
||||||
|
loadData(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 邀请成员
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (!inviteForm.username.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入用户名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setInviting(true)
|
||||||
|
try {
|
||||||
|
await addProjectMember(projectId, {
|
||||||
|
username: inviteForm.username,
|
||||||
|
role: inviteForm.role,
|
||||||
|
} as Partial<ProjectMember>)
|
||||||
|
Taro.showToast({ title: '邀请成功', icon: 'success' })
|
||||||
|
setShowInvite(false)
|
||||||
|
setInviteForm({ username: '', role: 'developer' })
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('邀请失败', err)
|
||||||
|
Taro.showToast({ title: '邀请失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setInviting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除成员
|
||||||
|
const handleRemove = (member: ProjectMember) => {
|
||||||
|
if (member.role === 'owner') {
|
||||||
|
Taro.showToast({ title: '无法移除项目所有者', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionSheet({
|
||||||
|
alertText: `确定将 "${member.username}" 从项目中移除吗?`,
|
||||||
|
actions: [
|
||||||
|
{ name: '移除', color: '#ff4d4f', type: 'warn' as const },
|
||||||
|
],
|
||||||
|
confirmText: '取消',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
doRemove(member.id!)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRemove = async (memberId: number) => {
|
||||||
|
try {
|
||||||
|
await removeProjectMember(projectId, memberId)
|
||||||
|
Taro.showToast({ title: '已移除', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('移除失败', err)
|
||||||
|
Taro.showToast({ title: '移除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改角色
|
||||||
|
const handleChangeRole = (member: ProjectMember) => {
|
||||||
|
if (member.role === 'owner') {
|
||||||
|
Taro.showToast({ title: '无法修改所有者角色', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = ['admin', 'developer', 'viewer']
|
||||||
|
const options = roles.map(role => ({
|
||||||
|
name: ROLE_CONFIG[role].label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
actionSheet({
|
||||||
|
title: `修改 "${member.username}" 的角色`,
|
||||||
|
actions: options,
|
||||||
|
confirmText: '取消',
|
||||||
|
}).then(res => {
|
||||||
|
if (res.confirm === false && res.errMsg?.includes('cancel')) return
|
||||||
|
const role = roles[res.confirm as number]
|
||||||
|
if (role) {
|
||||||
|
updateRole(member.id!, role as ProjectMember['role'])
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRole = async (memberId: number, role: ProjectMember['role']) => {
|
||||||
|
try {
|
||||||
|
await addProjectMember(projectId, { id: memberId, role } as Partial<ProjectMember>)
|
||||||
|
Taro.showToast({ title: '已更新角色', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('更新失败', err)
|
||||||
|
Taro.showToast({ title: '更新失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="members-page">
|
||||||
|
{/* 头部 */}
|
||||||
|
<View className="members-page__header">
|
||||||
|
<Text className="members-page__title">👥 项目成员</Text>
|
||||||
|
<Button
|
||||||
|
className="members-page__invite-btn"
|
||||||
|
size="mini"
|
||||||
|
onClick={() => setShowInvite(true)}
|
||||||
|
>
|
||||||
|
+ 邀请
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 统计 */}
|
||||||
|
<View className="members-page__stats">
|
||||||
|
<View className="members-page__stat">
|
||||||
|
<Text className="members-page__stat-num">{list.length}</Text>
|
||||||
|
<Text className="members-page__stat-label">总成员</Text>
|
||||||
|
</View>
|
||||||
|
<View className="members-page__stat">
|
||||||
|
<Text className="members-page__stat-num">
|
||||||
|
{list.filter(m => m.role === 'admin' || m.role === 'owner').length}
|
||||||
|
</Text>
|
||||||
|
<Text className="members-page__stat-label">管理员</Text>
|
||||||
|
</View>
|
||||||
|
<View className="members-page__stat">
|
||||||
|
<Text className="members-page__stat-num">
|
||||||
|
{list.filter(m => m.role === 'developer').length}
|
||||||
|
</Text>
|
||||||
|
<Text className="members-page__stat-label">开发者</Text>
|
||||||
|
</View>
|
||||||
|
<View className="members-page__stat">
|
||||||
|
<Text className="members-page__stat-num">
|
||||||
|
{list.filter(m => m.role === 'viewer').length}
|
||||||
|
</Text>
|
||||||
|
<Text className="members-page__stat-label">查看者</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 成员列表 */}
|
||||||
|
<View className="members-page__list">
|
||||||
|
{list.length === 0 && !loading ? (
|
||||||
|
<View className="members-page__empty">
|
||||||
|
<Text>暂无成员</Text>
|
||||||
|
<Text className="members-page__empty-hint">点击「邀请」添加项目成员</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
list.map((member) => (
|
||||||
|
<View key={member.id} className="member-card">
|
||||||
|
<View className="member-card__avatar">
|
||||||
|
{member.avatar ? (
|
||||||
|
<View
|
||||||
|
className="member-card__avatar-img"
|
||||||
|
style={{ backgroundImage: `url(${member.avatar})` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text className="member-card__avatar-text">
|
||||||
|
{(member.username || '?').charAt(0).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="member-card__info">
|
||||||
|
<View className="member-card__name-row">
|
||||||
|
<Text className="member-card__name">{member.username}</Text>
|
||||||
|
<View
|
||||||
|
className="member-card__role"
|
||||||
|
style={{ color: ROLE_CONFIG[member.role || 'viewer']?.color }}
|
||||||
|
>
|
||||||
|
{ROLE_CONFIG[member.role || 'viewer']?.label}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="member-card__desc">
|
||||||
|
{ROLE_CONFIG[member.role || 'viewer']?.desc}
|
||||||
|
</Text>
|
||||||
|
<Text className="member-card__time">
|
||||||
|
加入于 {formatDate(member.joinedAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{member.role !== 'owner' && (
|
||||||
|
<View className="member-card__actions">
|
||||||
|
<Text
|
||||||
|
className="member-card__action"
|
||||||
|
onClick={() => handleChangeRole(member)}
|
||||||
|
>
|
||||||
|
修改角色
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="member-card__action member-card__action--danger"
|
||||||
|
onClick={() => handleRemove(member)}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && list.length === 0 && (
|
||||||
|
<View className="members-page__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 邀请弹窗 */}
|
||||||
|
{showInvite && (
|
||||||
|
<View className="members-page__modal">
|
||||||
|
<View className="members-page__modal-mask" onClick={() => setShowInvite(false)} />
|
||||||
|
<View className="members-page__modal-content">
|
||||||
|
<Text className="members-page__modal-title">邀请项目成员</Text>
|
||||||
|
|
||||||
|
<View className="members-page__form">
|
||||||
|
<View className="members-page__form-item">
|
||||||
|
<Text className="members-page__form-label">用户名 *</Text>
|
||||||
|
<Input
|
||||||
|
className="members-page__form-input"
|
||||||
|
placeholder="请输入要邀请的用户名"
|
||||||
|
value={inviteForm.username}
|
||||||
|
onInput={(e) => setInviteForm(prev => ({ ...prev, username: e.detail.value }))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="members-page__form-item">
|
||||||
|
<Text className="members-page__form-label">角色</Text>
|
||||||
|
<View className="members-page__form-radio">
|
||||||
|
{(['admin', 'developer', 'viewer'] as const).map((role) => (
|
||||||
|
<View
|
||||||
|
key={role}
|
||||||
|
className={`members-page__form-radio-item ${inviteForm.role === role ? 'active' : ''}`}
|
||||||
|
onClick={() => setInviteForm(prev => ({ ...prev, role }))}
|
||||||
|
>
|
||||||
|
{ROLE_CONFIG[role].label}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="members-page__form-hint">
|
||||||
|
{ROLE_CONFIG[inviteForm.role]?.desc}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="members-page__modal-actions">
|
||||||
|
<Button
|
||||||
|
className="members-page__modal-btn members-page__modal-btn--cancel"
|
||||||
|
onClick={() => setShowInvite(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="members-page__modal-btn members-page__modal-btn--confirm"
|
||||||
|
loading={inviting}
|
||||||
|
onClick={handleInvite}
|
||||||
|
>
|
||||||
|
邀请
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MembersPage
|
||||||
3
src/developer/project/[id]/settings.config.ts
Normal file
3
src/developer/project/[id]/settings.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '项目设置',
|
||||||
|
})
|
||||||
10
src/developer/project/[id]/settings.scss
Normal file
10
src/developer/project/[id]/settings.scss
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.settings-page {
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/developer/project/[id]/settings.tsx
Normal file
15
src/developer/project/[id]/settings.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import './settings.scss'
|
||||||
|
|
||||||
|
const SettingsPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View className="settings-page">
|
||||||
|
<View className="settings-page__content">
|
||||||
|
<Text>项目设置功能开发中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage
|
||||||
88
src/developer/project/create.scss
Normal file
88
src/developer/project/create.scss
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-create-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.form {
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__labelText {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__textarea {
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
min-height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型选项 */
|
||||||
|
.type-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/developer/project/create.tsx
Normal file
133
src/developer/project/create.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, ScrollView, Input, Textarea } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Button, Radio, RadioGroup } from '@nutui/nutui-react-taro'
|
||||||
|
import { createProject } from '@/api/developer'
|
||||||
|
import './create.scss'
|
||||||
|
|
||||||
|
const ProjectCreate: React.FC = () => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'basic' as 'basic' | 'pro' | 'enterprise',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 更新表单数据
|
||||||
|
const updateField = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
Taro.showToast({ title: '请输入项目名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
await createProject(formData)
|
||||||
|
Taro.showToast({ title: '创建成功', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建失败', error)
|
||||||
|
Taro.showToast({ title: '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取类型说明
|
||||||
|
const getTypeDesc = (type: string) => {
|
||||||
|
const descs: Record<string, string> = {
|
||||||
|
basic: '免费版本,包含基础功能,适合个人开发者',
|
||||||
|
pro: '专业版本,功能完整,适合中小企业',
|
||||||
|
enterprise: '企业版本,全功能支持,适合大型企业',
|
||||||
|
}
|
||||||
|
return descs[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="project-create-page">
|
||||||
|
<ScrollView scrollY className="project-create-page__scroll">
|
||||||
|
<View className="form">
|
||||||
|
{/* 项目名称 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">项目名称 *</Text>
|
||||||
|
</View>
|
||||||
|
<Input
|
||||||
|
className="form__input"
|
||||||
|
placeholder="请输入项目名称"
|
||||||
|
value={formData.name}
|
||||||
|
onInput={(e) => updateField('name', e.detail.value)}
|
||||||
|
maxlength={50}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 项目类型 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">项目类型 *</Text>
|
||||||
|
</View>
|
||||||
|
<RadioGroup value={formData.type} onChange={(e) => updateField('type', e)}>
|
||||||
|
<View className="type-options">
|
||||||
|
<View className="type-option">
|
||||||
|
<Radio value="basic">
|
||||||
|
<View className="type-option__content">
|
||||||
|
<Text className="type-option__title">基础版</Text>
|
||||||
|
<Text className="type-option__desc">{getTypeDesc('basic')}</Text>
|
||||||
|
</View>
|
||||||
|
</Radio>
|
||||||
|
</View>
|
||||||
|
<View className="type-option">
|
||||||
|
<Radio value="pro">
|
||||||
|
<View className="type-option__content">
|
||||||
|
<Text className="type-option__title">专业版</Text>
|
||||||
|
<Text className="type-option__desc">{getTypeDesc('pro')}</Text>
|
||||||
|
</View>
|
||||||
|
</Radio>
|
||||||
|
</View>
|
||||||
|
<View className="type-option">
|
||||||
|
<Radio value="enterprise">
|
||||||
|
<View className="type-option__content">
|
||||||
|
<Text className="type-option__title">企业版</Text>
|
||||||
|
<Text className="type-option__desc">{getTypeDesc('enterprise')}</Text>
|
||||||
|
</View>
|
||||||
|
</Radio>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</RadioGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 项目描述 */}
|
||||||
|
<View className="form__group">
|
||||||
|
<View className="form__label">
|
||||||
|
<Text className="form__labelText">项目描述</Text>
|
||||||
|
</View>
|
||||||
|
<Textarea
|
||||||
|
className="form__textarea"
|
||||||
|
placeholder="请输入项目描述(选填)"
|
||||||
|
value={formData.description}
|
||||||
|
onInput={(e) => updateField('description', e.detail.value)}
|
||||||
|
maxlength={200}
|
||||||
|
autoHeight
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View className="form__footer">
|
||||||
|
<Button type="primary" block loading={loading} onClick={handleSubmit}>
|
||||||
|
创建项目
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectCreate
|
||||||
147
src/developer/project/index.scss
Normal file
147
src/developer/project/index.scss
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
page {
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f7;
|
||||||
|
padding-bottom: 140rpx;
|
||||||
|
|
||||||
|
&__scroll {
|
||||||
|
height: calc(100vh - 100rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-wrapper {
|
||||||
|
background: #ffffff;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 项目列表 */
|
||||||
|
.project-list {
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #999999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 项目卡片 */
|
||||||
|
.project-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badgeText {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-top: 2rpx solid #f3f4f6;
|
||||||
|
border-bottom: 2rpx solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statValue {
|
||||||
|
display: block;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #3b82f6;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__statLabel {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actionText {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 创建按钮 */
|
||||||
|
.create-btn-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部安全区域 */
|
||||||
|
.safe-area-bottom {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
178
src/developer/project/index.tsx
Normal file
178
src/developer/project/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Button, Empty, Tabs, PullToRefresh } from '@nutui/nutui-react-taro'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const ProjectList: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
const [projects, setProjects] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockProjects = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '我的企业官网',
|
||||||
|
type: 'pro',
|
||||||
|
description: '企业品牌展示官网',
|
||||||
|
appCount: 2,
|
||||||
|
memberCount: 3,
|
||||||
|
apiCallCount: 12580,
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: '2026-04-10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '电商小程序',
|
||||||
|
type: 'enterprise',
|
||||||
|
description: '多端电商解决方案',
|
||||||
|
appCount: 5,
|
||||||
|
memberCount: 8,
|
||||||
|
apiCallCount: 98650,
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: '2026-04-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '内部管理系统',
|
||||||
|
type: 'basic',
|
||||||
|
description: 'OA办公系统',
|
||||||
|
appCount: 1,
|
||||||
|
memberCount: 5,
|
||||||
|
apiCallCount: 3200,
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: '2026-04-08',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
// TODO: 替换为真实 API 调用
|
||||||
|
// const result = await pageMyProject({ type: activeTab === 'all' ? undefined : activeTab })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
setProjects(mockProjects)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载失败', error)
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await loadData()
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// 获取项目类型标签
|
||||||
|
const getTypeBadge = (type: string) => {
|
||||||
|
const badges: Record<string, { text: string; color: string }> = {
|
||||||
|
basic: { text: '基础', color: '#6b7280' },
|
||||||
|
pro: { text: '专业', color: '#3b82f6' },
|
||||||
|
enterprise: { text: '企业', color: '#f59e0b' },
|
||||||
|
}
|
||||||
|
return badges[type] || badges.basic
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转创建页面
|
||||||
|
const handleCreate = () => {
|
||||||
|
Taro.navigateTo({ url: '/developer/project/create' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转项目详情
|
||||||
|
const handleProjectClick = (id: number) => {
|
||||||
|
Taro.navigateTo({ url: `/developer/project/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="project-list-page">
|
||||||
|
<PullToRefresh refreshing={refreshing} onRefresh={onRefresh}>
|
||||||
|
{/* 标签页 */}
|
||||||
|
<View className="tabs-wrapper">
|
||||||
|
<Tabs value={activeTab} onChange={(v) => setActiveTab(v as string)}>
|
||||||
|
<Tabs.TabPane title="全部项目" value="all" />
|
||||||
|
<Tabs.TabPane title="基础版" value="basic" />
|
||||||
|
<Tabs.TabPane title="专业版" value="pro" />
|
||||||
|
<Tabs.TabPane title="企业版" value="enterprise" />
|
||||||
|
</Tabs>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView scrollY className="project-list-page__scroll">
|
||||||
|
{/* 项目列表 */}
|
||||||
|
<View className="project-list">
|
||||||
|
{loading ? (
|
||||||
|
<View className="project-list__loading">
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<Empty description="暂无项目" />
|
||||||
|
) : (
|
||||||
|
<View className="project-list__content">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const badge = getTypeBadge(project.type)
|
||||||
|
return (
|
||||||
|
<View key={project.id} className="project-card" onClick={() => handleProjectClick(project.id)}>
|
||||||
|
<View className="project-card__header">
|
||||||
|
<View className="project-card__title-row">
|
||||||
|
<Text className="project-card__name">{project.name}</Text>
|
||||||
|
<View className="project-card__badge" style={{ backgroundColor: `${badge.color}15`, color: badge.color }}>
|
||||||
|
<Text className="project-card__badgeText">{badge.text}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="project-card__desc">{project.description}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="project-card__stats">
|
||||||
|
<View className="project-card__stat">
|
||||||
|
<Text className="project-card__statValue">{project.appCount}</Text>
|
||||||
|
<Text className="project-card__statLabel">应用</Text>
|
||||||
|
</View>
|
||||||
|
<View className="project-card__stat">
|
||||||
|
<Text className="project-card__statValue">{project.memberCount}</Text>
|
||||||
|
<Text className="project-card__statLabel">成员</Text>
|
||||||
|
</View>
|
||||||
|
<View className="project-card__stat">
|
||||||
|
<Text className="project-card__statValue">{project.apiCallCount}</Text>
|
||||||
|
<Text className="project-card__statLabel">API调用</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="project-card__footer">
|
||||||
|
<Text className="project-card__time">更新于 {project.updatedAt}</Text>
|
||||||
|
<View className="project-card__action">
|
||||||
|
<Text className="project-card__actionText">查看详情 ›</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部安全区域 */}
|
||||||
|
<View className="safe-area-bottom" />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 创建按钮 */}
|
||||||
|
<View className="create-btn-wrapper">
|
||||||
|
<Button type="primary" block onClick={handleCreate}>
|
||||||
|
创建新项目
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</PullToRefresh>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectList
|
||||||
8
src/developer/sdk/index.config.ts
Normal file
8
src/developer/sdk/index.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* SDK 下载中心 - 页面配置
|
||||||
|
*/
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: 'SDK 下载中心',
|
||||||
|
enableShareAppMessage: true,
|
||||||
|
enableShareTimeline: true
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user