Compare commits

...

7 Commits

Author SHA1 Message Date
611b488af3 refactor(api): 规范前端API接口地址并统一路径前缀
- 修正开发者、小程序和企业相关API的基础URL,避免重复添加/api前缀
- 统一调整developer、enterprise、invite等模块接口路径,保持与后端Controller一致
- 新增权限申请及消息通知相关API,并完善相关函数实现
- 规范请求参数传递,移除多余包装,改用直接传递对象方式
- 新增API Key重置、版本发布/回滚等接口支持

feat(invite): 重构邀请登录流程支持未注册快速加入

- 完整重写invite页,分离已注册用户和未注册用户的按钮显示和交互
- 实现未注册用户微信手机号授权后自动注册登录及自动加入应用
- 已注册用户直接确认加入应用,无需手机号授权弹窗
- 统一使用getPhoneNumber按钮处理两种状态并修复“授权码不能为空”报错
- 前端配合后端改造,支持已登录用户可直接通过Authorization头加入应用

fix(developer): 修复开发者中心应用加载问题

- 修复只查询创建的应用导致邀请加入的应用不显示的问题
- 同时请求创建应用和参与应用接口,合并并去重应用列表
- 新增pageJoinedApp接口调用及合并逻辑,提高应用列表完整性和体验
2026-04-13 15:30:32 +08:00
d11d64469c feat(userVerify): 添加阿里云身份证二要素核验功能
- 在接口层新增 verifyIdCard 接口,实现姓名与身份证号核验
- userVerify 页面提交时增加身份核验逻辑,核验失败阻止提交
- 调用核验接口时显示加载状态,并根据结果提示用户
- 后端依赖 aliyun cloudauth,提供实人认证相关服务接口
- 完成基本的错误捕获与用户提示,提升实名认证流程安全性
2026-04-13 03:22:43 +08:00
d4e7a163f7 feat(userVerify): 添加阿里云身份证二要素核验功能
- 在接口层新增 verifyIdCard 接口,实现姓名与身份证号核验
- userVerify 页面提交时增加身份核验逻辑,核验失败阻止提交
- 调用核验接口时显示加载状态,并根据结果提示用户
- 后端依赖 aliyun cloudauth,提供实人认证相关服务接口
- 完成基本的错误捕获与用户提示,提升实名认证流程安全性
2026-04-13 03:20:05 +08:00
a8688c0f4a refactor(config): 移除开发者中心和企业控制台的自定义导航样式
- 删除了开发者中心页面配置中的 navigationStyle: 'custom'
- 删除了企业控制台页面配置中的 navigationStyle: 'custom'
- 简化了页面配置,保持默认导航样式
- 有助于提升页面兼容性和维护性
2026-04-13 02:50:57 +08:00
16b7e2fb61 feat(api-keys): 优化隐私协议同意逻辑及样式
- 修正api-keys样式导入路径,改为developer路径
- 在开发者申请页面增加服务协议和隐私政策勾选框
- 提交时校验协议是否已勾选,未勾选时提示用户
- 优化申请页面协议区域样式及交互
- 在用户验证页面添加隐私政策同意勾选框
- 提交用户验证表单时检查协议同意状态
- 增加打开服务协议和隐私政策的链接跳转
- 更新项目配置,增加passport/webview路由配置
- 修复createTicket函数中日期格式正则表达式问题
2026-04-13 02:48:31 +08:00
f2d6b029f1 feat(api-keys): 优化隐私协议同意逻辑及样式
- 修正api-keys样式导入路径,改为developer路径
- 在开发者申请页面增加服务协议和隐私政策勾选框
- 提交时校验协议是否已勾选,未勾选时提示用户
- 优化申请页面协议区域样式及交互
- 在用户验证页面添加隐私政策同意勾选框
- 提交用户验证表单时检查协议同意状态
- 增加打开服务协议和隐私政策的链接跳转
- 更新项目配置,增加passport/webview路由配置
- 修复createTicket函数中日期格式正则表达式问题
2026-04-13 02:40:27 +08:00
ffab0ec25c feat(developer): 完成小程序开发者中心和企业控制台改造
- 设计并实现了开发者中心与企业控制台两大模块
- 按用户角色区分开发者和企业客户,支持多项目类型及成员管理
- 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面
- 实现应用版本发布、消息通知中心、权限审批与开发者申请流程
- 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能
- 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块
- 优化后端API,支持已登录和未注册用户不同加入应用流程
- 前端按钮统一采用微信手机号授权,完善用户授权体验
- 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库
- 增加详细的类型定义文件,提升项目类型安全
- 新增超过55个页面及60个API接口,扩展应用功能和服务体系
- 完成全面的样式设计,实现一致的视觉风格和交互体验
2026-04-13 02:26:46 +08:00
203 changed files with 20465 additions and 437 deletions

View File

@@ -33,7 +33,29 @@
"usedAt": 1775921148885,
"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": 1775966075231
"lastUpdated": 1776023218479
}

View File

@@ -1,28 +1,458 @@
# 2026-04-12 工作记录
# 2026-04-12 工作日志
## 任务:优化邀请加入应用按钮逻辑
## 项目:小程序改造规划
### 需求描述
loginByOpenId 返回有用户数据(已登录)时,不显示手机号授权按钮,直接显示「确认加入」普通按钮;
loginByOpenId 返回未注册时才走 getPhoneNumber 授权分支。
### 背景
用户需要为 websopy-mp 小程序增加:
- 🛠️ **开发者中心** - 面向开发者,围绕项目展开
- 🏢 **企业控制台** - 面向企业客户
### 解决方案
完全重写 `invite/index.tsx`,核心逻辑
### 关键决策
1. **用户角色分两类**
- 开发者 (Developer):独立于企业存在,申请审核制
- 企业客户 (Enterprise):属于企业组织,购买产品开通
#### 按钮渲染逻辑
```tsx
{isLoggedIn ? (
// 已登录:普通按钮,直接加入,携带 Authorization 头
<Button onClick={handleConfirmJoin}>确认加入</Button>
) : (
// 未注册:手机号授权按钮(兜底,实际大多已被重定向到 login 页)
<Button open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>微信手机号快速加入</Button>
)}
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 # 类型定义
```
#### handleJoinApp 统一入口
- `useToken` 参数:已登录用户,请求头加 `Authorization: Bearer xxx`
- `phoneCode` 参数:未注册用户,请求体加 code/encryptedData/iv
#### 新增页面
- 首页角色入口卡片(开发者中心/企业控制台)
- 开发者工作台 + 统计卡片 + 快捷操作
- 项目列表/创建/详情
- 应用列表/创建/详情
- 企业工作台 + 统计卡片 + 快捷操作
- 企业应用列表/购买/详情
- 成员列表/邀请
- 订单列表/详情(占位)
- 费用中心/企业设置(占位)
#### 新增 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` - 完整重写,区分已登录/未注册两种按钮状态
@@ -52,13 +482,12 @@ loginByOpenId 返回未注册时才走 getPhoneNumber 授权分支。
## 修复:「授权码不能为空」报错
### 问题
已登录用户点「确认加入」时,后端报 `授权码不能为空`
后端 `/api/_app/developer/invite/accept` 接口不管是否登录,都要求传 `code`(微信授权码)。
后端 `/api/_app/developer/invite/accept` 接口强制要求传 `code`(微信授权码),不传就报「授权码不能为空
### 解决
统一用一个 `getPhoneNumber` 按钮处理两种场景:
- **已注册**:文字「确认加入」→ 触发 getPhoneNumber → `doJoinApp(code, accessToken)`
- **未注册**:文字「微信手机号快速加入」→ 触发 getPhoneNumber → 先 `loginByMpWxPhone` 注册 → 再 `wx.login()`取新 code → `doJoinApp(newCode, access_token)`
- **未注册**:文字「微信手机号快速加入」→ 触发 getPhoneNumber → 先 `loginByMpWxPhone` 注册 → 再 `wx.login()` 获 code → `doJoinApp(newCode, access_token)`
### doJoinApp 参数
```ts
@@ -85,6 +514,38 @@ doJoinApp(wxCode: string, accessToken: string)
---
## 优化:已登录用户不强制勾选协议
### 改动
- 已登录用户点击「确认加入」时,不再检查 `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 按钮
---
## 任务:后端改造支持已登录用户直接加入
### 问题
@@ -131,32 +592,24 @@ if (StrUtil.isBlank(code)) {
---
## 优化:已登录用户不强制勾选协议
### 改动
- 已登录用户点击「确认加入」时,不再检查 `agreementChecked`
- 未注册用户仍需勾选协议后才能授权手机号
### 文件修改
- `src/passport/invite/index.tsx` - `handleConfirmJoin` 移除协议检查
---
## 修复:后端需要手机号授权码
## 修复:开发者中心加载不到应用
### 问题
后端 `invite/accept` 接口只接受 `getPhoneNumber` 获取的手机号授权码,用 `wx.login()` 的 code 会报「获取手机号失败」
用户通过邀请加入应用后,开发者中心显示「加载中...」,无法显示应用列表
### 原因
- 前端 `developer/index.tsx` 只调用了 `pageMyApp` 接口(查询用户**创建**的应用)
- 用户通过邀请加入的应用属于**参与**的应用,不是创建的应用
- 后端 `loginByOpenId` 返回了应用列表,但前端没有使用这个数据
### 解决
两种用户状态都走 `getPhoneNumber` 按钮
- 已登录:文字「确认加入」,回调 `handleConfirmJoinGetPhoneNumber`
- 未注册:文字「微信手机号快速加入」,回调 `handleGetPhoneNumber`
1. **前端改造**`loadData` 同时调用两个接口
- `pageMyApp` - 查询创建的应用
- `pageJoinedApp` - 查询参与的应用(新增 API
- 合并两个列表,根据 `productId` 去重
### 差异
| 用户状态 | 回调 | 行为 |
|------|------|------|
| 已登录 | `handleConfirmJoinGetPhoneNumber` | 直接用 `code + access_token` 调加入接口 |
| 未注册 | `handleGetPhoneNumber` | 先 `loginByMpWxPhone` 注册登录,再调加入接口 |
2. **新增 API**`src/api/developer/developer.ts` 添加 `pageJoinedApp` 方法
### 文件修改
- `src/passport/invite/index.tsx` - 两种状态都用 getPhoneNumber 按钮
- `src/developer/index.tsx` - `loadData` 同时查询创建和参与的应用
- `src/api/developer/developer.ts` - 新增 `pageJoinedApp` 方法

View 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
View 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

View File

@@ -4,12 +4,19 @@
"condition": {
"miniprogram": {
"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": "",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "passport/invite/index",

226
src/api/analytics.ts Normal file
View 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
View 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',
})
}

View 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))
}

View 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))
}

View File

@@ -0,0 +1,5 @@
/**
* 开发者 API 模块
*/
export * from './developer'
export * from './enterprise'

331
src/api/import.ts Normal file
View 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
View 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
View 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
View 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,
})
}

View File

@@ -128,3 +128,24 @@ export async function submit(data: UserVerify) {
}
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
View 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++;
}
}

View File

@@ -117,6 +117,62 @@ export default {
"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: {

View File

@@ -2,9 +2,8 @@ import {useEffect, useState, useRef} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
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 {params} = useRouter();
@@ -21,17 +20,14 @@ const AddAppCredential = () => {
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
if (params.id) {
// 编辑模式
await updateAppCredential({
...values,
id: Number(params.id)
})
} else {
// 新增模式
await addAppCredential(values)
}
@@ -96,3 +92,21 @@ const AddAppCredential = () => {
>
<CellGroup style={{padding: '4px 0'}}>
<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;

View File

@@ -2,17 +2,14 @@ import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/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 {View} from '@tarojs/components'
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 [list, setList] = useState<AppCredential[]>([])
const reload = () => {
listAppCredential({
// 添加查询条件
})
listAppCredential({})
.then(data => {
setList(data || [])
})
@@ -24,7 +21,6 @@ const AppCredentialList = () => {
})
}
const onDel = async (id?: number) => {
await removeAppCredential(id)
Taro.showToast({
@@ -59,6 +55,26 @@ const AppCredentialList = () => {
}
return (
<>
{list.map((item, _) => (
<Cell.Group key={item.
<ConfigProvider>
<CellGroup>
{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;

View File

@@ -1,10 +1,17 @@
import {useEffect, useState, useRef} from "react";
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 {View} from '@tarojs/components'
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 {params} = useRouter();
@@ -21,17 +28,14 @@ const AddAppEvent = () => {
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
if (params.id) {
// 编辑模式
await updateAppEvent({
...values,
id: Number(params.id)
})
} else {
// 新增模式
await addAppEvent(values)
}
@@ -95,4 +99,21 @@ const AddAppEvent = () => {
}
>
<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;

View File

@@ -1,18 +1,15 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/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 {View} from '@tarojs/components'
import {ArrowRight} from '@nutui/icons-react-taro'
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 [list, setList] = useState<AppEvent[]>([])
const reload = () => {
listAppEvent({
// 添加查询条件
})
listAppEvent({})
.then(data => {
setList(data || [])
})
@@ -24,7 +21,6 @@ const AppEventList = () => {
})
}
const onDel = async (id?: number) => {
await removeAppEvent(id)
Taro.showToast({
@@ -51,7 +47,7 @@ const AppEventList = () => {
description="暂无数据"
/>
<Space>
<Button onClick={() => Taro.navigateTo({url: '/app/appEvent/add'})}></Button>
<Button onClick={() => Taro.navigateTo({url: '/app/appEvent/add'})}></Button>
</Space>
</div>
</ConfigProvider>
@@ -59,6 +55,26 @@ const AppEventList = () => {
}
return (
<>
{list.map((item, _) => (
<Cell.Group key={item.
<ConfigProvider>
<CellGroup>
{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;

View File

@@ -1,10 +1,9 @@
import {useEffect, useState, useRef} from "react";
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 {View} from '@tarojs/components'
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 {params} = useRouter();
@@ -21,18 +20,13 @@ const AddAppUser = () => {
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
if (params.id) {
// 编辑模式
await updateAppUser({
...values,
id: Number(params.id)
})
} else {
// 新增模式
await addAppUser(values)
}
Taro.showToast({
@@ -89,10 +83,25 @@ const AddAppUser = () => {
className={'w-full'}
block
>
{params.id ? '更新' : '保存'}
</Button>
</div>
}
>
<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;

View File

@@ -1,18 +1,15 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/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 {View} from '@tarojs/components'
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Avatar} from '@nutui/nutui-react-taro'
import {ArrowRight} from '@nutui/icons-react-taro'
import {AppUser} from "@/api/app/appUser/model";
import {listAppUser, removeAppUser, updateAppUser} from "@/api/app/appUser";
import {listAppUser} from "@/api/app/appUser";
const AppUserList = () => {
const [list, setList] = useState<AppUser[]>([])
const reload = () => {
listAppUser({
// 添加查询条件
})
listAppUser({})
.then(data => {
setList(data || [])
})
@@ -24,16 +21,6 @@ const AppUserList = () => {
})
}
const onDel = async (id?: number) => {
await removeAppUser(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload();
}
useDidShow(() => {
reload()
});
@@ -50,15 +37,27 @@ const AppUserList = () => {
}}
description="暂无数据"
/>
<Space>
<Button onClick={() => Taro.navigateTo({url: '/app/appUser/add'})}></Button>
</Space>
</div>
</ConfigProvider>
)
}
return (
<>
{list.map((item, _) => (
<Cell.Group key={item.
<ConfigProvider>
<CellGroup>
{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;

View File

@@ -2,9 +2,8 @@ import {useEffect, useState, useRef} from "react";
import {useRouter} from '@tarojs/taro'
import {Button, Loading, CellGroup, Input, TextArea, Form} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import {View} from '@tarojs/components'
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 {params} = useRouter();
@@ -21,18 +20,13 @@ const AddAppVersion = () => {
}
}
// 提交表单
const submitSucceed = async (values: any) => {
try {
if (params.id) {
// 编辑模式
await updateAppVersion({
...values,
id: Number(params.id)
})
} else {
// 新增模式
await addAppVersion(values)
}
Taro.showToast({
@@ -89,10 +83,25 @@ const AddAppVersion = () => {
className={'w-full'}
block
>
{params.id ? '更新' : '保存'}
</Button>
</div>
}
>
<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;

View File

@@ -1,18 +1,21 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/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 {View} from '@tarojs/components'
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Tag} from '@nutui/nutui-react-taro'
import {ArrowRight} from '@nutui/icons-react-taro'
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 [list, setList] = useState<AppVersion[]>([])
const reload = () => {
listAppVersion({
// 添加查询条件
})
listAppVersion({})
.then(data => {
setList(data || [])
})
@@ -24,16 +27,6 @@ const AppVersionList = () => {
})
}
const onDel = async (id?: number) => {
await removeAppVersion(id)
Taro.showToast({
title: '删除成功',
icon: 'success'
});
reload();
}
useDidShow(() => {
reload()
});
@@ -48,10 +41,10 @@ const AppVersionList = () => {
style={{
backgroundColor: 'transparent'
}}
description="暂无数据"
description="暂无版本"
/>
<Space>
<Button onClick={() => Taro.navigateTo({url: '/app/appVersion/add'})}></Button>
<Button onClick={() => Taro.navigateTo({url: '/app/appVersion/add'})}></Button>
</Space>
</div>
</ConfigProvider>
@@ -59,6 +52,30 @@ const AppVersionList = () => {
}
return (
<>
{list.map((item, _) => (
<Cell.Group key={item.
<ConfigProvider>
<CellGroup>
{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;

View File

@@ -0,0 +1,6 @@
/**
* 运营监控页面配置
*/
export default definePageConfig({
navigationBarTitleText: '运营监控',
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 构建详情页面配置
*/
export default definePageConfig({
navigationBarTitleText: '构建详情',
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 构建列表页面配置
*/
export default definePageConfig({
navigationBarTitleText: '构建历史',
})

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '应用配置',
})

View File

@@ -0,0 +1,10 @@
.config-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View File

@@ -0,0 +1,6 @@
/**
* 部署详情页面配置
*/
export default definePageConfig({
navigationBarTitleText: '部署详情',
})

View 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;
}
}

View 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>
)
}

View File

@@ -0,0 +1,6 @@
/**
* 部署列表页面配置
*/
export default definePageConfig({
navigationBarTitleText: '部署历史',
})

View 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;
}
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '应用详情',
})

View 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;
}
}

View 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

View File

@@ -0,0 +1,6 @@
/**
* 流水线配置页面配置
*/
export default definePageConfig({
navigationBarTitleText: '流水线配置',
})

View 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;
}
}
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '发布管理',
})

View File

@@ -0,0 +1,10 @@
.publish-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View 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;
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'API Key 管理',
})

View 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;
}
}
}

View 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

View 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;
}
}

View 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

View 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
View 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

View 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;
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '开发者资料',
})

View File

@@ -0,0 +1,10 @@
.profile-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'API 文档',
})

View File

@@ -0,0 +1,10 @@
.api-docs-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '开发者文档',
})

View 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;
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '快速开始',
})

View File

@@ -0,0 +1,10 @@
.quickstart-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View File

@@ -1,3 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '配送中心'
navigationBarTitleText: '开发者中心',
usingComponents: {},
})

View File

@@ -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));
}

View File

@@ -1,295 +1,223 @@
import React from 'react'
import {View, Text} 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 React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
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 {
dealerUser,
error,
refresh,
} = useDealerUser()
// 使用主题样式
const DeveloperIndex: React.FC = () => {
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 () => {
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(() => {
loadData()
}, [])
// 跳转页面
const navigateTo = (url: string) => {
Taro.navigateTo({ url })
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
// 获取状态颜色
const getStatusColor = (status?: number) => {
const colors: Record<number, string> = {
0: '#6b7280',
1: '#10b981',
2: '#f59e0b',
3: '#ef4444',
4: '#ef4444',
5: '#ef4444',
}
return colors[status || 0] || '#6b7280'
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleDateString()
// 获取状态文本
const getStatusText = (status?: number) => {
const texts: Record<number, string> = {
0: '未开通',
1: '运行中',
2: '维护中',
3: '已关闭',
4: '已欠费',
5: '违规停机',
}
return texts[status || 0] || '未知'
}
// 获取用户主题
const userTheme = gradientUtils.getThemeByUserId(dealerUser?.userId)
// 获取渐变背景
const getGradientBackground = (themeColor?: string) => {
if (themeColor) {
const darkerColor = gradientUtils.adjustColorBrightness(themeColor, -30)
return gradientUtils.createGradient(themeColor, darkerColor)
}
return userTheme.background
}
console.log(getGradientBackground(),'getGradientBackground()')
if (error) {
return (
<View className="p-4">
<View className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<Text className="text-red-600">{error}</Text>
<View className="developer-page">
<ScrollView scrollY className="developer-page__scroll">
{/* 头部区域 */}
<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>
<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>
</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 className="h-20"></View>
<View className="safe-area-bottom" />
</ScrollView>
</View>
)
}
export default DealerIndex
export default DeveloperIndex

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '开发者市场',
})

View 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;
}
}

View 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

View 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;
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'API Key 管理',
})

View File

@@ -0,0 +1,3 @@
// 复用 developer/app/api-keys/index.scss 的样式
// 注意:使用 @use 替代 @import 是 Sass 的新推荐方式
@use '../../app/api-keys/index' as api-keys;

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '项目详情',
})

View 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;
}
}

View 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

View 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;
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '项目设置',
})

View File

@@ -0,0 +1,10 @@
.settings-page {
padding: 32rpx;
&__content {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: #999;
}
}

View 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

View 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;
}
}

View 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

View 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;
}

View 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

View File

@@ -0,0 +1,8 @@
/**
* SDK 下载中心 - 页面配置
*/
export default definePageConfig({
navigationBarTitleText: 'SDK 下载中心',
enableShareAppMessage: true,
enableShareTimeline: true
});

View File

@@ -0,0 +1,342 @@
// SDK 下载中心样式
.sdk-center {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: env(safe-area-inset-bottom);
}
// 搜索栏
.search-bar {
display: flex;
align-items: center;
padding: 24px;
background: #fff;
.search-input {
flex: 1;
height: 72px;
padding: 0 24px;
background: #f5f5f5;
border-radius: 36px;
font-size: 28px;
}
.search-btn {
margin-left: 20px;
padding: 0 32px;
height: 72px;
line-height: 72px;
background: #007aff;
color: #fff;
border-radius: 36px;
font-size: 28px;
&::after { border: none; }
}
}
// 分类滚动
.category-scroll {
background: #fff;
white-space: nowrap;
.category-tabs {
display: inline-flex;
padding: 0 16px 24px;
}
.category-tab {
display: inline-flex;
align-items: center;
padding: 16px 32px;
margin-right: 16px;
background: #f5f5f5;
border-radius: 32px;
font-size: 26px;
color: #666;
white-space: nowrap;
&.active {
background: #007aff;
color: #fff;
}
}
}
// 统计栏
.stats-bar {
padding: 24px;
background: #fff;
.stats-text {
font-size: 26px;
color: #999;
}
}
// SDK 列表
.sdk-list {
height: calc(100vh - 400px);
padding: 24px;
.loading, .empty {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
color: #999;
font-size: 28px;
}
}
.sdk-card {
background: #fff;
border-radius: 24px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
.sdk-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.sdk-icon-wrap {
width: 88px;
height: 88px;
background: #f5f5f5;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
.sdk-icon {
font-size: 48px;
}
}
.sdk-info {
flex: 1;
margin-left: 24px;
.sdk-name {
font-size: 32px;
font-weight: 600;
color: #333;
display: block;
}
.sdk-version {
font-size: 24px;
color: #999;
margin-top: 8px;
display: block;
}
}
.sdk-actions {
.action-btn {
padding: 16px 32px;
font-size: 26px;
border-radius: 24px;
background: #f5f5f5;
color: #333;
&.primary {
background: #007aff;
color: #fff;
}
&::after { border: none; }
}
}
.sdk-desc {
font-size: 26px;
color: #666;
line-height: 1.6;
display: block;
margin-bottom: 20px;
}
.sdk-footer {
display: flex;
justify-content: space-between;
align-items: center;
.sdk-tags {
display: flex;
.tag {
padding: 8px 16px;
background: #f0f7ff;
color: #007aff;
border-radius: 8px;
font-size: 22px;
margin-right: 12px;
}
}
.sdk-stats {
display: flex;
.stat {
font-size: 24px;
color: #999;
margin-left: 20px;
}
}
}
}
// SDK 详情弹窗
.sdk-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
.sdk-modal-content {
width: 100%;
max-height: 85vh;
background: #fff;
border-radius: 32px 32px 0 0;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32px;
border-bottom: 1px solid #eee;
.modal-title {
font-size: 36px;
font-weight: 600;
}
.modal-close {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
color: #999;
}
}
.modal-body {
flex: 1;
padding: 32px;
max-height: calc(85vh - 200px);
}
.modal-section {
margin-bottom: 40px;
.section-title {
font-size: 30px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: block;
}
.section-content {
font-size: 28px;
color: #666;
line-height: 1.6;
&.changelog {
white-space: pre-wrap;
background: #f5f5f5;
padding: 24px;
border-radius: 16px;
}
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
.info-item {
.info-label {
font-size: 24px;
color: #999;
display: block;
margin-bottom: 8px;
}
.info-value {
font-size: 28px;
color: #333;
font-weight: 500;
}
}
}
.code-block {
background: #1e1e1e;
padding: 24px;
border-radius: 16px;
.code-text {
color: #a5d6ff;
font-size: 26px;
font-family: monospace;
}
}
.deps-list {
display: flex;
flex-wrap: wrap;
.dep-tag {
padding: 8px 16px;
background: #f0f7ff;
color: #007aff;
border-radius: 8px;
font-size: 24px;
margin-right: 12px;
margin-bottom: 12px;
}
}
.modal-footer {
display: flex;
padding: 32px;
padding-bottom: calc(32px + env(safe-area-inset-bottom));
border-top: 1px solid #eee;
.modal-btn {
flex: 1;
height: 88px;
line-height: 88px;
font-size: 32px;
border-radius: 44px;
margin: 0 12px;
background: #f5f5f5;
color: #333;
&.primary {
background: #007aff;
color: #fff;
}
&:first-child { margin-left: 0; }
&:last-child { margin-right: 0; }
&::after { border: none; }
}
}
}

Some files were not shown because too many files have changed in this diff Show More