refactor(user): 将VIP身份统一改为开发者标记

- 审核通过时不再新增角色,而是更新用户isDeveloper布尔标记
- 后端UserService新增markAsDeveloper方法,通过updateById设置isDeveloper
- UserController新增PUT接口更新开发者标记,权限使用sys:userRole:save
- 前端api新增setUserDeveloper调用新的审核接口
- vip-review页面移除角色相关代码,改为调用setUserDeveloper方法
- 约定开发者身份即sys_user.is_developer字段,不再依赖角色表记录
- app.config.ts追加开发者中心与平台管理相关页面路由
- 新增开发者中心、平台管理的多个API模块(apikey、appProduct、appDomain、developer请求等)
- 添加公共工具方法和复用组件用于开发者中心及平台管理功能
- 统一品牌名称为“威数谱软件”,更新文案及界面展现一致性
This commit is contained in:
2026-07-22 14:57:03 +08:00
parent 2ea16e22cf
commit 39d3b59434
24 changed files with 2015 additions and 95 deletions

40
.idea/workspace.xml generated
View File

@@ -4,18 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="2de94e4f-0587-4c9b-8bfd-e87e0ae95b81" name="更改" comment="refactor(user): 删除门店信息输入框&#10;&#10;- 移除用户资料页面中的门店名称输入框&#10;- 移除用户资料页面中的门店地址输入框&#10;- 简化用户页面界面布局,提高界面整洁度"> <list default="true" id="2de94e4f-0587-4c9b-8bfd-e87e0ae95b81" name="更改" comment="refactor(user): 统一品牌名称为“威数谱软件”&#10;&#10;- 将项目中所有“威硕谱软件”名称修改为“威数谱软件”&#10;- 更新协议页面、欢迎消息、礼品卡等多处文本内容&#10;- 修改小程序配置文件中的项目名称和显示标题&#10;- 优化开发者申请页部分文案,明确开发者身份描述&#10;- 保证所有界面及配置中的品牌名称统一一致">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/env.js" beforeDir="false" afterPath="$PROJECT_DIR$/config/env.js" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.workbuddy/memory/2026-07-22.md" beforeDir="false" afterPath="$PROJECT_DIR$/.workbuddy/memory/2026-07-22.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/project.config.json" beforeDir="false" afterPath="$PROJECT_DIR$/project.config.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/api/system/user/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/system/user/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/detail.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/detail.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/app.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/app.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/gift-card/purchase/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/gift-card/purchase/index.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/pages/user/vip-review/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/user/vip-review/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/index/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/index/index.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/index/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/index/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/purchase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/purchase.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/user/vip-upgrade/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/user/vip-upgrade/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/passport/agreement.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/passport/agreement.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/passport/login.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/passport/login.tsx" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -127,15 +121,7 @@
<workItem from="1784131049625" duration="2304000" /> <workItem from="1784131049625" duration="2304000" />
<workItem from="1784133736973" duration="4614000" /> <workItem from="1784133736973" duration="4614000" />
<workItem from="1784585070286" duration="3047000" /> <workItem from="1784585070286" duration="3047000" />
<workItem from="1784638429525" duration="8483000" /> <workItem from="1784638429525" duration="10410000" />
</task>
<task id="LOCAL-00012" summary="refactor(api): 移除未使用的配置接口及相关Hook&#10;&#10;- 从cmsWebsiteField接口中删除configWebsiteField方法&#10;- 删除useConfig自定义Hook及其引用&#10;- 更新about页面去除对config对象和useConfig的使用&#10;- 修正invite模块请求URL统一加上/api前缀">
<option name="closed" value="true" />
<created>1775908253789</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1775908253789</updated>
</task> </task>
<task id="LOCAL-00013" summary="refactor(api): 移除未使用的配置接口及相关Hook&#10;&#10;- 从cmsWebsiteField接口中删除configWebsiteField方法&#10;- 删除useConfig自定义Hook及其引用&#10;- 更新about页面去除对config对象和useConfig的使用&#10;- 修正invite模块请求URL统一加上/api前缀"> <task id="LOCAL-00013" summary="refactor(api): 移除未使用的配置接口及相关Hook&#10;&#10;- 从cmsWebsiteField接口中删除configWebsiteField方法&#10;- 删除useConfig自定义Hook及其引用&#10;- 更新about页面去除对config对象和useConfig的使用&#10;- 修正invite模块请求URL统一加上/api前缀">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -521,7 +507,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1784697730666</updated> <updated>1784697730666</updated>
</task> </task>
<option name="localTasksCounter" value="61" /> <task id="LOCAL-00061" summary="refactor(user): 统一品牌名称为“威数谱软件”&#10;&#10;- 将项目中所有“威硕谱软件”名称修改为“威数谱软件”&#10;- 更新协议页面、欢迎消息、礼品卡等多处文本内容&#10;- 修改小程序配置文件中的项目名称和显示标题&#10;- 优化开发者申请页部分文案,明确开发者身份描述&#10;- 保证所有界面及配置中的品牌名称统一一致">
<option name="closed" value="true" />
<created>1784699171525</created>
<option name="number" value="00061" />
<option name="presentableId" value="LOCAL-00061" />
<option name="project" value="LOCAL" />
<updated>1784699171525</updated>
</task>
<option name="localTasksCounter" value="62" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -541,7 +535,6 @@
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" /> <option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" /> <option name="CHECK_NEW_TODO" value="false" />
<MESSAGE value="feat(passport): 优化已登录用户加入时协议勾选逻辑&#10;&#10;- 已登录用户点击「确认加入」时不再强制勾选协议&#10;- 移除 handleConfirmJoin 中对协议勾选的检查&#10;- 未注册用户仍需勾选协议后才能授权手机号&#10;- 确保登录状态异常时有提示信息显示" />
<MESSAGE value="feat(invite): 支持已登录用户直接加入应用&#10;&#10;- 后端accept接口code参数改为可选支持通过Authorization头识别登录用户&#10;- 已登录用户加入时不再弹手机号授权前端调用新增的doJoinAppForLoggedInUser方法&#10;- 未注册用户仍通过手机号授权码code加入调用doJoinAppForNewUser方法&#10;- 拆分前端加入逻辑新增handleConfirmJoin统一处理已登录加入流程&#10;- 优化按钮交互,已登录状态下按钮变为普通点击,移除协议强制勾选&#10;- 错误处理和状态提示完善,加入成功后自动跳转首页" /> <MESSAGE value="feat(invite): 支持已登录用户直接加入应用&#10;&#10;- 后端accept接口code参数改为可选支持通过Authorization头识别登录用户&#10;- 已登录用户加入时不再弹手机号授权前端调用新增的doJoinAppForLoggedInUser方法&#10;- 未注册用户仍通过手机号授权码code加入调用doJoinAppForNewUser方法&#10;- 拆分前端加入逻辑新增handleConfirmJoin统一处理已登录加入流程&#10;- 优化按钮交互,已登录状态下按钮变为普通点击,移除协议强制勾选&#10;- 错误处理和状态提示完善,加入成功后自动跳转首页" />
<MESSAGE value="feat(developer): 完成小程序开发者中心和企业控制台改造&#10;&#10;- 设计并实现了开发者中心与企业控制台两大模块&#10;- 按用户角色区分开发者和企业客户,支持多项目类型及成员管理&#10;- 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面&#10;- 实现应用版本发布、消息通知中心、权限审批与开发者申请流程&#10;- 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能&#10;- 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块&#10;- 优化后端API支持已登录和未注册用户不同加入应用流程&#10;- 前端按钮统一采用微信手机号授权,完善用户授权体验&#10;- 修复多个页面的JSX语法错误及依赖导入问题替换部分组件库&#10;- 增加详细的类型定义文件,提升项目类型安全&#10;- 新增超过55个页面及60个API接口扩展应用功能和服务体系&#10;- 完成全面的样式设计,实现一致的视觉风格和交互体验" /> <MESSAGE value="feat(developer): 完成小程序开发者中心和企业控制台改造&#10;&#10;- 设计并实现了开发者中心与企业控制台两大模块&#10;- 按用户角色区分开发者和企业客户,支持多项目类型及成员管理&#10;- 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面&#10;- 实现应用版本发布、消息通知中心、权限审批与开发者申请流程&#10;- 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能&#10;- 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块&#10;- 优化后端API支持已登录和未注册用户不同加入应用流程&#10;- 前端按钮统一采用微信手机号授权,完善用户授权体验&#10;- 修复多个页面的JSX语法错误及依赖导入问题替换部分组件库&#10;- 增加详细的类型定义文件,提升项目类型安全&#10;- 新增超过55个页面及60个API接口扩展应用功能和服务体系&#10;- 完成全面的样式设计,实现一致的视觉风格和交互体验" />
<MESSAGE value="feat(api-keys): 优化隐私协议同意逻辑及样式&#10;&#10;- 修正api-keys样式导入路径改为developer路径&#10;- 在开发者申请页面增加服务协议和隐私政策勾选框&#10;- 提交时校验协议是否已勾选,未勾选时提示用户&#10;- 优化申请页面协议区域样式及交互&#10;- 在用户验证页面添加隐私政策同意勾选框&#10;- 提交用户验证表单时检查协议同意状态&#10;- 增加打开服务协议和隐私政策的链接跳转&#10;- 更新项目配置增加passport/webview路由配置&#10;- 修复createTicket函数中日期格式正则表达式问题" /> <MESSAGE value="feat(api-keys): 优化隐私协议同意逻辑及样式&#10;&#10;- 修正api-keys样式导入路径改为developer路径&#10;- 在开发者申请页面增加服务协议和隐私政策勾选框&#10;- 提交时校验协议是否已勾选,未勾选时提示用户&#10;- 优化申请页面协议区域样式及交互&#10;- 在用户验证页面添加隐私政策同意勾选框&#10;- 提交用户验证表单时检查协议同意状态&#10;- 增加打开服务协议和隐私政策的链接跳转&#10;- 更新项目配置增加passport/webview路由配置&#10;- 修复createTicket函数中日期格式正则表达式问题" />
@@ -566,7 +559,8 @@
<MESSAGE value="feat(user): 动态查询VIP角色ID避免硬编码绑定问题&#10;&#10;- RoleParam新增tenantId字段支持按租户过滤角色&#10;- 新增getCurrentTenantId函数兼容登录态及默认租户回退&#10;- 实现getVipRoleId函数按roleCode与tenantId查询VIP角色ID&#10;- assignVipRole动态获取VIP角色ID移除写死的角色ID&#10;- 添加用户角色时传" /> <MESSAGE value="feat(user): 动态查询VIP角色ID避免硬编码绑定问题&#10;&#10;- RoleParam新增tenantId字段支持按租户过滤角色&#10;- 新增getCurrentTenantId函数兼容登录态及默认租户回退&#10;- 实现getVipRoleId函数按roleCode与tenantId查询VIP角色ID&#10;- assignVipRole动态获取VIP角色ID移除写死的角色ID&#10;- 添加用户角色时传" />
<MESSAGE value="feat(user): 统一VIP概念为开发者并优化开发者登录及申请&#10;&#10;- 将VIP相关文案、图标改为开发者涉及用户中心、门店中心、审核页等&#10;- 优化登录弹窗UI改为红色系提升视觉统一性和交互体验&#10;- 关于我们页面品牌和公司信息内容更新,展示最新企业介绍和业务范围&#10;- VIP升级页新增“所在城市”选择使用微信原生地区选择器完善地址信息&#10;- 用户申请表单中新增城市字段及校验,提交时携带城市信息&#10;- 优化审核页面文案,统称开发者身份,明确申请审核内容&#10;- 修复UserRoleController.save接口bug避免userId被无条件覆盖导致角色绑错&#10;- 动态查询VIP角色ID避免硬编码支持多租户环境正确绑定角色" /> <MESSAGE value="feat(user): 统一VIP概念为开发者并优化开发者登录及申请&#10;&#10;- 将VIP相关文案、图标改为开发者涉及用户中心、门店中心、审核页等&#10;- 优化登录弹窗UI改为红色系提升视觉统一性和交互体验&#10;- 关于我们页面品牌和公司信息内容更新,展示最新企业介绍和业务范围&#10;- VIP升级页新增“所在城市”选择使用微信原生地区选择器完善地址信息&#10;- 用户申请表单中新增城市字段及校验,提交时携带城市信息&#10;- 优化审核页面文案,统称开发者身份,明确申请审核内容&#10;- 修复UserRoleController.save接口bug避免userId被无条件覆盖导致角色绑错&#10;- 动态查询VIP角色ID避免硬编码支持多租户环境正确绑定角色" />
<MESSAGE value="refactor(user): 删除门店信息输入框&#10;&#10;- 移除用户资料页面中的门店名称输入框&#10;- 移除用户资料页面中的门店地址输入框&#10;- 简化用户页面界面布局,提高界面整洁度" /> <MESSAGE value="refactor(user): 删除门店信息输入框&#10;&#10;- 移除用户资料页面中的门店名称输入框&#10;- 移除用户资料页面中的门店地址输入框&#10;- 简化用户页面界面布局,提高界面整洁度" />
<option name="LAST_COMMIT_MESSAGE" value="refactor(user): 删除门店信息输入框&#10;&#10;- 移除用户资料页面中的门店名称输入框&#10;- 移除用户资料页面中的门店地址输入框&#10;- 简化用户页面界面布局,提高界面整洁度" /> <MESSAGE value="refactor(user): 统一品牌名称为“威数谱软件”&#10;&#10;- 将项目中所有“威硕谱软件”名称修改为“威数谱软件”&#10;- 更新协议页面、欢迎消息、礼品卡等多处文本内容&#10;- 修改小程序配置文件中的项目名称和显示标题&#10;- 优化开发者申请页部分文案,明确开发者身份描述&#10;- 保证所有界面及配置中的品牌名称统一一致" />
<option name="LAST_COMMIT_MESSAGE" value="refactor(user): 统一品牌名称为“威数谱软件”&#10;&#10;- 将项目中所有“威硕谱软件”名称修改为“威数谱软件”&#10;- 更新协议页面、欢迎消息、礼品卡等多处文本内容&#10;- 修改小程序配置文件中的项目名称和显示标题&#10;- 优化开发者申请页部分文案,明确开发者身份描述&#10;- 保证所有界面及配置中的品牌名称统一一致" />
<option name="NON_MODAL_COMMIT_POSTPONE_SLOW_CHECKS" value="false" /> <option name="NON_MODAL_COMMIT_POSTPONE_SLOW_CHECKS" value="false" />
</component> </component>
</project> </project>

View File

@@ -34,3 +34,14 @@
- `ShopDealerApply` 模型新增 `city?: string`(注释:省 市 区,空格分隔)。 - `ShopDealerApply` 模型新增 `city?: string`(注释:省 市 区,空格分隔)。
- 提交时 `addShopDealerApply` 增加 `city: cityText`;读取历史申请时 `setCityValue(latest.city.split(' '))`;保存校验增加「请选择所在城市」。 - 提交时 `addShopDealerApply` 增加 `city: cityText`;读取历史申请时 `setCityValue(latest.city.split(' '))`;保存校验增加「请选择所在城市」。
- 注意:仅前端扩展字段,后端是否落库 `city` 存疑(模型此前只有 address需后端 schema 支持才能真正持久化。 - 注意:仅前端扩展字段,后端是否落库 `city` 存疑(模型此前只有 address需后端 schema 支持才能真正持久化。
## vip-review 审核通过:改为「更新 user 为 developer」不新增角色
- 起因:用户要求 `pages/user/vip-review/index` 审核通过时直接把 user 标记为开发者,而不是往 `sys_user_role` 加 developer 角色。
- 后端(仓库 `/Users/gxwebsoft/JAVA/com.gxwebsoft.core`,已 `mvn -o compile` BUILD SUCCESS
- `User.isDeveloper``sys_user.is_developer`Boolean此前从未被写入正是开发者标记位。
- `UserService` 新增 `boolean markAsDeveloper(Integer userId)``UserServiceImpl` 用最小实体 `updateById``isDeveloper=true`,成功后再经 `syncMessageProducer` 同步 websopy复用 updateUser 的同步范式)。
- `UserController` 新增 `PUT /api/system/user/developer/{userId}``setDeveloper`),权限沿用 `sys:userRole:save`(与原 `addUserRole` 一致,避免店员 403 回归)。
- 前端:
- `@/api/system/user/index.ts` 新增 `setUserDeveloper(userId)``PUT /system/user/developer/{userId}`
- `vip-review/index.tsx`:移除 `addUserRole/listUserRole/listRoles/TenantId` 等角色相关 import 与 `VIP_ROLE_*``getCurrentTenantId``getVipRoleId``assignVipRole``formatDateTime``handleApprove``await assignVipRole(item.userId)` 替换为 `await setUserDeveloper(item.userId!)`
- 约定:**开发者身份 = `sys_user.is_developer` 布尔位,不是角色表记录**(已记项目 MEMORY.md

View File

@@ -0,0 +1,17 @@
# 项目长期记忆websopy-taro
## 核心约定
### 开发者身份的表示方式
- **开发者身份 = `sys_user.is_developer` 布尔位**(数据库列 `is_developer`),不是 `sys_user_role` 里的角色记录。
- 后端仓库:`/Users/gxwebsoft/JAVA/com.gxwebsoft.core`(单 Maven 模块,包名 `com.gxwebsoft` 分包)。
- 标记接口:`PUT /api/system/user/developer/{userId}``UserController.setDeveloper``UserService.markAsDeveloper`),成功后经 `syncMessageProducer` 同步 websopy。
- 读取点:`MainController.loginByDeveloperSms` 校验 `isDeveloper` 才允许开发者登录。
- 前端调用:`@/api/system/user``setUserDeveloper(userId)`
- 注意:前端 `vip-review` / `vip-upgrade` 等页面的变量名仍残留 `VIP_ROLE_*``pendingVipCount``dealerPrice` 等旧标识符(仅文案改成了「开发者」),后端角色与缓存 key 仍对应这些旧名,重命名需谨慎。
### 后端代码风格com.gxwebsoft.core
- Controller 继承 `BaseController`,统一返回 `ApiResult`(成功 `code=0`,失败 `code=1`),用 `success()/fail()`
- 「按 userId 置一个标志位」的范式:`new User(){{setUserId(); setXxx();}}``userService.updateById(u)`(参考 `updateStatus`/`updateRecommend`/`auditUser`)。
- 用户变更后如需同步 websopy`syncMessageProducer.sendUserSyncMessage("websopy","UPDATE",user)`(在 `UserServiceImpl` 内,`@Autowired(required=false)` 注入)。
- 用户相关代码都在 `com.gxwebsoft.common.system`entity/service/controller业务应用数据在 `com.gxwebsoft.websopy`(无 Controller

49
src/api/app/apikey.ts Normal file
View File

@@ -0,0 +1,49 @@
// API Key 管理(对齐 PC /developer/apikeys基址走 websopy-api
import { get, post, put, del } from '@/utils/request'
import { WebsopyBaseUrl } from '@/config/app'
const BASE = '/app/apikey'
export interface AppApiKey {
id: number
name: string
apiKey: string
keyPrefix?: string
status: number // 0正常 1禁用
scopes?: string
expireTime?: string
lastUsedAt?: string
usageCount?: number
remark?: string
createTime?: string
}
export async function pageAppApiKey(params: any) {
const res = await get<any>(BASE, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function createAppApiKey(data: any) {
const res = await post<any>(BASE, data, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '创建失败'))
}
export async function updateAppApiKeyStatus(id: number, status: number) {
const res = await put<any>(`${BASE}/${id}/status?status=${status}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function removeAppApiKey(id: number) {
const res = await del<any>(`${BASE}/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '删除失败'))
}
export async function getAppApiKeyStats() {
const res = await get<any>(`${BASE}/stats`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}

58
src/api/app/appDomain.ts Normal file
View File

@@ -0,0 +1,58 @@
// 个性域名绑定(对齐 PC /admin/domain-bindings基址走 websopy-api
import { get, post } from '@/utils/request'
import { WebsopyBaseUrl } from '@/config/app'
const BASE = '/app/domain'
export interface AppDomain {
id?: number
tenantId?: number
productId?: number
domain?: string
targetIp?: string
recordType?: string
status?: number // 0待解析 1校验中 2已生效 3已拒绝 4已停用
dnsStatus?: number
dnsIp?: string
proxyConfigured?: number
rejectReason?: string
tenantName?: string
createTime?: string
}
/** 管理端列表 */
export async function pageAppDomain(params: any) {
const res = await get<any>(`${BASE}/admin/list`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function verifyDomain(id: number) {
const res = await post<any>(`${BASE}/verify/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '校验失败'))
}
export async function setProxy(id: number) {
const res = await post<any>(`${BASE}/proxy/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function activeDomain(id: number) {
const res = await post<any>(`${BASE}/active/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function rejectDomain(id: number, reason: string) {
const res = await post<any>(`${BASE}/reject/${id}`, { reason }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function disableDomain(id: number) {
const res = await post<any>(`${BASE}/disable/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}

76
src/api/app/appProduct.ts Normal file
View File

@@ -0,0 +1,76 @@
// 应用产品(对齐 PC /developer/apps 与 /admin 应用管理,基址走 websopy-api
import { get, post } from '@/utils/request'
import { WebsopyBaseUrl } from '@/config/app'
const BASE = '/app/product'
export interface AppProduct {
productId?: number
productName?: string
productCode?: string
appType?: number
description?: string
logo?: string
publishStatus?: string
status?: number
adminUrl?: string
homeUrl?: string
createTime?: string
userId?: number
tenantId?: number
rejectReason?: string
[key: string]: any
}
/** 我的应用(创建者) */
export async function getMyApps(params: any) {
const res = await get<any>(`${BASE}/my/page`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
/** 开发者应用列表 */
export async function getDeveloperApps(params?: any) {
const res = await get<any>(`${BASE}/list`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
/** 创建应用 */
export async function addAppProduct(data: any) {
const res = await post<any>(`${BASE}/create`, data, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '创建失败'))
}
/** 发布审核列表管理端publishStatus=pending_review */
export async function pagePublishReviews(params: any) {
const res = await get<any>(`${BASE}/page`, { ...params, publishStatus: 'pending_review' }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
/** 全部应用(管理端) */
export async function pageAllApps(params: any) {
const res = await get<any>(`${BASE}/page`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
/** 审核通过 */
export async function approvePublishReview(id: number) {
const res = await post<any>(`${BASE}/approve/${id}`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
/** 审核拒绝 */
export async function rejectPublishReview(params: { productId: number; rejectReason: string }) {
const res = await post<any>(
`${BASE}/reject/${params.productId}?reason=${encodeURIComponent(params.rejectReason)}`,
{},
{ baseUrl: WebsopyBaseUrl }
)
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}

106
src/api/app/developer.ts Normal file
View File

@@ -0,0 +1,106 @@
// 开发者权限申请 & Git 账号(对齐 PC /developer/requests、/git 与 /admin/git-review基址走 websopy-api
import { get, post, put } from '@/utils/request'
import { WebsopyBaseUrl } from '@/config/app'
const BASE = '/app/developer'
export interface PermissionRequest {
id?: number
userId?: number
gitUsername?: string
repo?: string
repoName?: string
reason?: string
status?: string // pending / approved / rejected
createTime?: string
reviewedAt?: string
reviewerName?: string
rejectReason?: string
[key: string]: any
}
export interface GitAccount {
id?: number
userId?: number
username?: string
email?: string
status?: string // pending / verified / rejected
verificationNote?: string
tenantId?: number
[key: string]: any
}
// ============ 权限申请 ============
export async function pagePermissionRequests(params: any) {
const res = await get<any>(`${BASE}/permission-requests`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getPermissionRequestStats() {
const res = await get<any>(`${BASE}/permission-requests/stats`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getAvailableRepos() {
const res = await get<any>(`${BASE}/available-repos`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function submitPermissionRequest(data: any) {
const res = await post<any>(`${BASE}/permission-requests`, data, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '提交失败'))
}
export async function approvePermissionRequest(id: number) {
const res = await put<any>(`${BASE}/permission-requests/${id}/approve`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function rejectPermissionRequest(id: number, reason: string) {
const res = await put<any>(`${BASE}/permission-requests/${id}/reject`, { reason }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
// ============ Git 账号 ============
export async function saveGitAccount(data: any) {
const res = await post<any>(`${BASE}/git-account`, data, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '提交失败'))
}
export async function getGitAccountStatus() {
const res = await get<any>(`${BASE}/git-account/status`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getGiteaInfo() {
const res = await get<any>(`${BASE}/gitea-info`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
// ============ 管理端 ============
export async function pageGitAccounts(params: any) {
const res = await get<any>(`${BASE}/git-accounts`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function approveGitAccount(id: number) {
const res = await put<any>(`${BASE}/git-accounts/${id}/approve`, {}, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function rejectGitAccount(id: number, reason: string) {
const res = await put<any>(`${BASE}/git-accounts/${id}/reject`, { reason }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}

82
src/api/app/ticket.ts Normal file
View File

@@ -0,0 +1,82 @@
// 工单(对齐 PC /developer/tickets 与 /admin/tickets基址走 websopy-api
import { get, post } from '@/utils/request'
import { WebsopyBaseUrl } from '@/config/app'
const BASE = '/app/ticket'
export interface Ticket {
ticketId?: number
ticketNo?: string
title?: string
productId?: number
productName?: string
status?: string
priority?: string
category?: string
assigneeName?: string
submitUserName?: string
replyCount?: number
hasUnread?: boolean
createTime?: string
[key: string]: any
}
export async function getMyTickets(params: any) {
const res = await get<any>(`${BASE}/my`, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getAllTickets(params: any) {
const res = await get<any>(BASE, params, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getTicketDetail(id: number) {
const res = await get<any>(`${BASE}/${id}`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getTicketReplies(id: number) {
const res = await get<any>(`${BASE}/${id}/replies`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function submitTicket(data: any) {
const res = await post<any>(BASE, data, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '提交失败'))
}
export async function replyTicket(id: number, content: string) {
const res = await post<any>(`${BASE}/${id}/reply`, { content }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '回复失败'))
}
export async function updateTicketStatus(id: number, status: string) {
const res = await post<any>(`${BASE}/${id}/status`, { status }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function assignTicket(id: number, assigneeId: number) {
const res = await post<any>(`${BASE}/${id}/assign`, { assigneeId }, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.message
return Promise.reject(new Error(res?.message || '操作失败'))
}
export async function getTicketStats() {
const res = await get<any>(`${BASE}/stats`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}
export async function getTechStaffList() {
const res = await get<any>(`${BASE}/staffs`, undefined, { baseUrl: WebsopyBaseUrl })
if (res?.code === 0) return res.data
return Promise.reject(new Error(res?.message || '加载失败'))
}

View File

@@ -115,6 +115,20 @@ export async function updateUser(data: User) {
return Promise.reject(new Error(res.message)); return Promise.reject(new Error(res.message));
} }
/**
* 将用户标记为开发者(审核通过时调用,直接更新用户标记位,不新增角色)
*/
export async function setUserDeveloper(userId: number) {
const res = await request.put<ApiResult<unknown>>(
SERVER_API_URL + '/system/user/developer/' + userId,
{}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/** /**
* 删除用户 * 删除用户
*/ */

View File

@@ -117,6 +117,17 @@ export default {
'pages/statistics/dashboard/index', 'pages/statistics/dashboard/index',
'pages/statistics/sales/index', 'pages/statistics/sales/index',
'pages/statistics/users/index', 'pages/statistics/users/index',
// 开发者中心
'pages/developer/index/index',
'pages/developer/apps/index',
'pages/developer/apikeys/index',
'pages/developer/tickets/index',
// 平台管理
'pages/admin/index/index',
'pages/admin/app-review/index',
'pages/admin/git-review/index',
'pages/admin/domain-review/index',
'pages/admin/tickets/index',
], ],
"subpackages": [ "subpackages": [
{ {

View File

@@ -0,0 +1,47 @@
import { View, Text } from '@tarojs/components'
interface CellRowProps {
title: string
subtitle?: string
extra?: string
statusText?: string
statusColor?: string
showArrow?: boolean
onClick?: () => void
}
export default function CellRow({
title,
subtitle,
extra,
statusText,
statusColor,
showArrow = true,
onClick,
}: CellRowProps) {
return (
<View
className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'
onClick={onClick}
>
<View className='flex-1 mr-2'>
<View className='flex items-center gap-2 flex-wrap'>
<Text className='text-sm text-gray-800'>{title}</Text>
{statusText && (
<Text
className='text-xs px-1.5 py-0.5 rounded-full'
style={{ color: statusColor, background: (statusColor || '#999') + '22' }}
>
{statusText}
</Text>
)}
</View>
{subtitle && (
<Text className='text-xs text-gray-400 mt-1 block'>{subtitle}</Text>
)}
</View>
{extra && <Text className='text-sm text-gray-500 mr-2'>{extra}</Text>}
{showArrow && <Text className='text-gray-300'></Text>}
</View>
)
}

View File

@@ -0,0 +1,43 @@
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
interface EntryItem {
icon: string
label: string
url?: string
onClick?: () => void
badge?: string
}
interface EntryGridProps {
items: EntryItem[]
columns?: number
}
export default function EntryGrid({ items, columns = 3 }: EntryGridProps) {
return (
<View className='bg-white rounded-xl p-2'>
<View className='flex flex-wrap'>
{items.map((it, i) => (
<View
key={i}
className='flex flex-col items-center justify-center py-3 relative'
style={{ width: `${100 / columns}%` }}
onClick={() => {
if (it.onClick) it.onClick()
else if (it.url) Taro.navigateTo({ url: it.url })
}}
>
<Text className='text-2xl mb-1'>{it.icon}</Text>
<Text className='text-xs text-gray-600'>{it.label}</Text>
{it.badge && (
<View className='absolute top-1 right-6 px-1 rounded-full' style={{ background: '#ef4444' }}>
<Text className='text-white text-xs'>{it.badge}</Text>
</View>
)}
</View>
))}
</View>
</View>
)
}

View File

@@ -0,0 +1,25 @@
import { View, Text } from '@tarojs/components'
interface StatCardProps {
label: string
value: string | number
sub?: string
subColor?: string
color?: string
}
export default function StatCard({ label, value, sub, subColor, color = '#3b82f6' }: StatCardProps) {
return (
<View className='bg-white rounded-lg p-4'>
<Text className='text-sm text-gray-500 mb-2 block'>{label}</Text>
<Text className='text-2xl font-bold block' style={{ color }}>
{value}
</Text>
{sub && (
<Text className='text-xs mt-1 block' style={{ color: subColor || '#52c41a' }}>
{sub}
</Text>
)}
</View>
)
}

View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { pagePublishReviews, approvePublishReview, rejectPublishReview } from '@/api/app/appProduct'
import { getPageList, APP_TYPE_NAME, PUBLISH_STATUS_NAME, publishStatusColor } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '应用审核' })
const AppReviewPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [detail, setDetail] = useState<any>(null)
const [rejectReason, setRejectReason] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const data = await pagePublishReviews({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const approve = async (item: any) => {
try {
await approvePublishReview(item.productId)
Taro.showToast({ title: '已通过', icon: 'success' })
setDetail(null)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
const reject = async (item: any) => {
if (!rejectReason.trim()) {
Taro.showToast({ title: '请填写拒绝原因', icon: 'none' })
return
}
try {
await rejectPublishReview({ productId: item.productId, rejectReason })
Taro.showToast({ title: '已拒绝', icon: 'none' })
setDetail(null)
setRejectReason('')
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='应用审核' />
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((app) => (
<CellRow
key={app.productId}
title={app.productName || '未命名'}
subtitle={`${APP_TYPE_NAME[app.appType as number] || ''} · ${app.developer || ''}`}
statusText={PUBLISH_STATUS_NAME[app.publishStatus as string] || '待审核'}
statusColor={publishStatusColor(app.publishStatus)}
onClick={() => {
setRejectReason('')
setDetail(app)
}}
/>
))}
</View>
)}
</ScrollView>
{detail && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }} onClick={() => setDetail(null)}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4' onClick={(e) => e.stopPropagation()}>
<Text className='text-base font-medium block mb-1'>{detail.productName}</Text>
<Text className='text-xs text-gray-500 block mb-3'>{detail.description || '暂无描述'}</Text>
<View className='mb-4'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='填写拒绝原因'
value={rejectReason}
onInput={(e: any) => setRejectReason(e.detail.value)}
style={{ height: '70px' }}
/>
</View>
<View className='flex gap-3'>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#16a34a' }} onClick={() => approve(detail)}>
<Text className='text-sm text-white'></Text>
</View>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#dc2626' }} onClick={() => reject(detail)}>
<Text className='text-sm text-white'></Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}
export default AppReviewPage

View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { pageAppDomain, verifyDomain, activeDomain, rejectDomain, disableDomain } from '@/api/app/appDomain'
import { getPageList, DOMAIN_STATUS_NAME, domainStatusColor } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '域名绑定审核' })
const DomainReviewPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [detail, setDetail] = useState<any>(null)
const [rejectReason, setRejectReason] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await pageAppDomain({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const act = async (fn: () => Promise<any>, okMsg: string) => {
setBusy(true)
try {
await fn()
Taro.showToast({ title: okMsg, icon: 'none' })
setDetail(null)
setRejectReason('')
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
} finally {
setBusy(false)
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='域名绑定审核' />
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((d) => (
<CellRow
key={d.id}
title={d.domain || '域名'}
subtitle={`${d.tenantName || ''} · ${d.targetIp || ''}`}
statusText={DOMAIN_STATUS_NAME[d.status as number]}
statusColor={domainStatusColor(d.status)}
onClick={() => {
setRejectReason('')
setDetail(d)
}}
/>
))}
</View>
)}
</ScrollView>
{detail && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }} onClick={() => setDetail(null)}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4' onClick={(e) => e.stopPropagation()}>
<Text className='text-base font-medium block mb-1'>{detail.domain}</Text>
<Text className='text-xs text-gray-500 block mb-3'>
{detail.tenantName} · IP {detail.targetIp}
</Text>
<View className='flex flex-wrap gap-2 mb-3'>
<View className='px-3 py-1.5 rounded-lg text-sm text-white' style={{ background: '#0ea5e9' }} onClick={() => act(() => verifyDomain(detail.id), '已触发校验')}>
<Text></Text>
</View>
<View className='px-3 py-1.5 rounded-lg text-sm text-white' style={{ background: '#16a34a' }} onClick={() => act(() => activeDomain(detail.id), '已生效')}>
<Text></Text>
</View>
<View className='px-3 py-1.5 rounded-lg text-sm text-white' style={{ background: '#6b7280' }} onClick={() => act(() => disableDomain(detail.id), '已停用')}>
<Text></Text>
</View>
</View>
<View className='mb-2'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='填写拒绝原因'
value={rejectReason}
onInput={(e: any) => setRejectReason(e.detail.value)}
style={{ height: '60px' }}
/>
</View>
<View
className='py-2.5 rounded-lg text-center'
style={{ background: '#dc2626' }}
onClick={() => {
if (!rejectReason.trim()) {
Taro.showToast({ title: '请填写原因', icon: 'none' })
return
}
act(() => rejectDomain(detail.id, rejectReason), '已拒绝')
}}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
</View>
)}
</View>
)
}
export default DomainReviewPage

View File

@@ -0,0 +1,147 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import {
pageGitAccounts,
approveGitAccount,
rejectGitAccount,
pagePermissionRequests,
approvePermissionRequest,
rejectPermissionRequest,
} from '@/api/app/developer'
import { getPageList } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: 'Git / 权限审核' })
const GitReviewPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [tab, setTab] = useState<'git' | 'perm'>('git')
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [detail, setDetail] = useState<any>(null)
const [rejectReason, setRejectReason] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const data = tab === 'git' ? await pageGitAccounts({ page: 1, limit: 50 }) : await pagePermissionRequests({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [tab])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const approve = async (item: any) => {
try {
if (tab === 'git') await approveGitAccount(item.id)
else await approvePermissionRequest(item.id)
Taro.showToast({ title: '已通过', icon: 'success' })
setDetail(null)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
const reject = async (item: any) => {
if (!rejectReason.trim()) {
Taro.showToast({ title: '请填写拒绝原因', icon: 'none' })
return
}
try {
if (tab === 'git') await rejectGitAccount(item.id, rejectReason)
else await rejectPermissionRequest(item.id, rejectReason)
Taro.showToast({ title: '已拒绝', icon: 'none' })
setDetail(null)
setRejectReason('')
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
const titleOf = (item: any) =>
tab === 'git' ? item.username || item.gitUsername || 'Git 账号' : item.gitUsername || item.repoName || '权限申请'
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='Git / 权限审核' />
<View className='flex bg-white'>
<View
className={`flex-1 text-center py-3 text-sm ${tab === 'git' ? 'font-medium border-b-2 text-blue-500' : 'text-gray-500'}`}
style={tab === 'git' ? { borderBottomColor: '#2563eb' } : {}}
onClick={() => setTab('git')}
>
<Text>Git </Text>
</View>
<View
className={`flex-1 text-center py-3 text-sm ${tab === 'perm' ? 'font-medium border-b-2 text-blue-500' : 'text-gray-500'}`}
style={tab === 'perm' ? { borderBottomColor: '#2563eb' } : {}}
onClick={() => setTab('perm')}
>
<Text></Text>
</View>
</View>
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((item) => (
<CellRow
key={item.id}
title={titleOf(item)}
subtitle={tab === 'git' ? `状态:${item.status || 'pending'}` : `${item.repoName || item.repo || ''} · ${item.reason || ''}`}
onClick={() => {
setRejectReason('')
setDetail(item)
}}
/>
))}
</View>
)}
</ScrollView>
{detail && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }} onClick={() => setDetail(null)}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4' onClick={(e) => e.stopPropagation()}>
<Text className='text-base font-medium block mb-1'>{titleOf(detail)}</Text>
<Text className='text-xs text-gray-500 block mb-3'>{tab === 'git' ? detail.email || '' : detail.reason || ''}</Text>
<View className='mb-4'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='填写拒绝原因'
value={rejectReason}
onInput={(e: any) => setRejectReason(e.detail.value)}
style={{ height: '70px' }}
/>
</View>
<View className='flex gap-3'>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#16a34a' }} onClick={() => approve(detail)}>
<Text className='text-sm text-white'></Text>
</View>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#dc2626' }} onClick={() => reject(detail)}>
<Text className='text-sm text-white'></Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}
export default GitReviewPage

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import StatCard from '@/components/common/StatCard'
import EntryGrid from '@/components/common/EntryGrid'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { pageAllApps, pagePublishReviews } from '@/api/app/appProduct'
import { getAllTickets } from '@/api/app/ticket'
import { pageUsers } from '@/api/system/user'
import { getPageTotal } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '平台管理' })
const AdminCenterPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const isAdmin = !!(user as any)?.isAdmin
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState({ apps: 0, pending: 0, tickets: 0, users: 0 })
const load = useCallback(async () => {
setLoading(true)
try {
const [appsR, pendR, tickR, usersR] = await Promise.allSettled([
pageAllApps({ page: 1, limit: 1 }),
pagePublishReviews({ page: 1, limit: 1 }),
getAllTickets({ page: 1, limit: 1 }),
pageUsers({ page: 1, limit: 1 } as any),
])
const apps = appsR.status === 'fulfilled' ? getPageTotal(appsR.value) : 0
const pending = pendR.status === 'fulfilled' ? getPageTotal(pendR.value) : 0
const tickets = tickR.status === 'fulfilled' ? getPageTotal(tickR.value) : 0
const users = usersR.status === 'fulfilled' ? getPageTotal(usersR.value) : 0
setStats({ apps, pending, tickets, users })
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
if (!isLoggedIn) {
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='平台管理' />
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
</View>
)
}
if (!isAdmin) {
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='平台管理' />
<View className='flex flex-col items-center justify-center py-20 px-10'>
<Text className='text-5xl mb-3'>🛡</Text>
<Text className='text-base text-gray-700 mb-2'>访</Text>
<Text className='text-xs text-gray-400 text-center'></Text>
</View>
</View>
)
}
const entries = [
{ icon: '📝', label: '应用审核', url: '/pages/admin/app-review/index' },
{ icon: '🐙', label: 'Git 审核', url: '/pages/admin/git-review/index' },
{ icon: '🌐', label: '域名审核', url: '/pages/admin/domain-review/index' },
{ icon: '🎫', label: '工单处理', url: '/pages/admin/tickets/index' },
]
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='平台管理' />
<ScrollView scrollY>
{loading ? (
<Loading />
) : (
<View className='p-4'>
<View className='grid grid-cols-2 gap-3 mb-3'>
<StatCard label='应用总数' value={stats.apps} color='#3b82f6' />
<StatCard label='待审核应用' value={stats.pending} color='#f59e0b' sub={stats.pending > 0 ? '需处理' : undefined} subColor='#f59e0b' />
</View>
<View className='grid grid-cols-2 gap-3 mb-3'>
<StatCard label='工单总数' value={stats.tickets} color='#10b981' />
<StatCard label='用户总数' value={stats.users} color='#8b5cf6' />
</View>
<Text className='text-base font-medium mb-2 block'></Text>
<EntryGrid items={entries} columns={4} />
<View className='h-6' />
</View>
)}
</ScrollView>
</View>
)
}
export default AdminCenterPage

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { getAllTickets, getTicketReplies, replyTicket, updateTicketStatus } from '@/api/app/ticket'
import { getPageList, TICKET_STATUS_NAME, TICKET_PRIORITY_NAME, TICKET_CATEGORY_NAME, ticketStatusColor } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '工单处理' })
const AdminTicketsPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [detail, setDetail] = useState<any>(null)
const [replies, setReplies] = useState<any[]>([])
const [replyText, setReplyText] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getAllTickets({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const openDetail = async (item: any) => {
setDetail(item)
setReplyText('')
try {
const r = await getTicketReplies(item.ticketId)
setReplies(Array.isArray(r) ? r : getPageList(r))
} catch (e) {
setReplies([])
}
}
const sendReply = async () => {
if (!replyText.trim() || !detail) return
try {
await replyTicket(detail.ticketId, replyText)
Taro.showToast({ title: '已回复', icon: 'none' })
setReplyText('')
openDetail(detail)
} catch (e: any) {
Taro.showToast({ title: e?.message || '回复失败', icon: 'none' })
}
}
const setStatus = async (status: string) => {
if (!detail) return
try {
await updateTicketStatus(detail.ticketId, status)
Taro.showToast({ title: '已更新', icon: 'none' })
setDetail(null)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='工单处理' />
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((t) => (
<CellRow
key={t.ticketId}
title={t.title || '工单'}
subtitle={`#${t.ticketNo || t.ticketId} · ${TICKET_CATEGORY_NAME[t.category as string] || ''} · ${t.submitUserName || ''}`}
statusText={TICKET_STATUS_NAME[t.status as string]}
statusColor={ticketStatusColor(t.status)}
onClick={() => openDetail(t)}
/>
))}
</View>
)}
</ScrollView>
{detail && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }} onClick={() => setDetail(null)}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4' onClick={(e) => e.stopPropagation()}>
<Text className='text-base font-medium block mb-1'>{detail.title}</Text>
<Text className='text-xs text-gray-500 block mb-3'>
{TICKET_STATUS_NAME[detail.status as string]} · {TICKET_PRIORITY_NAME[detail.priority as string]} ·{' '}
{detail.assigneeName ? '处理人 ' + detail.assigneeName : '未分配'}
</Text>
<ScrollView scrollY style={{ maxHeight: '35vh' }}>
{replies.length === 0 ? (
<Text className='text-xs text-gray-400'></Text>
) : (
replies.map((r, i) => (
<View key={i} className='bg-gray-100 rounded-lg p-2 mb-2'>
<Text className='text-sm text-gray-700'>{r.content || r.replyContent || ''}</Text>
<Text className='text-xs text-gray-400 mt-1 block'>
{r.userName || r.replierName || ''} · {(r.createTime || '').slice(0, 16)}
</Text>
</View>
))
)}
</ScrollView>
<View className='flex items-center gap-2 mt-3'>
<Input
className='flex-1 bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='输入回复...'
value={replyText}
onInput={(e: any) => setReplyText(e.detail.value)}
/>
<View className='px-4 py-2 rounded-lg' style={{ background: '#2563eb' }} onClick={sendReply}>
<Text className='text-white text-sm'></Text>
</View>
</View>
<View className='flex gap-3 mt-3'>
<View className='flex-1 py-2 rounded-lg text-center' style={{ background: '#16a34a' }} onClick={() => setStatus('resolved')}>
<Text className='text-sm text-white'></Text>
</View>
<View className='flex-1 py-2 rounded-lg text-center' style={{ background: '#6b7280' }} onClick={() => setStatus('closed')}>
<Text className='text-sm text-white'></Text>
</View>
</View>
<View className='mt-2 text-center' onClick={() => setDetail(null)}>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
)}
</View>
)
}
export default AdminTicketsPage

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Input, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { pageAppApiKey, createAppApiKey, updateAppApiKeyStatus, removeAppApiKey } from '@/api/app/apikey'
import { getPageList, maskKey } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: 'API Key 管理' })
const ApiKeysPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({ name: '', remark: '' })
const [submitting, setSubmitting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await pageAppApiKey({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const toggle = async (item: any) => {
try {
await updateAppApiKeyStatus(item.id, item.status === 0 ? 1 : 0)
Taro.showToast({ title: '已更新', icon: 'none' })
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '操作失败', icon: 'none' })
}
}
const remove = (item: any) => {
Taro.showModal({
title: '删除密钥',
content: '删除后无法恢复,确定删除?',
success: async (r) => {
if (r.confirm) {
try {
await removeAppApiKey(item.id)
Taro.showToast({ title: '已删除', icon: 'success' })
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '删除失败', icon: 'none' })
}
}
},
})
}
const copy = (item: any) => {
Taro.setClipboardData({ data: item.apiKey || '' })
}
const submit = async () => {
if (!form.name.trim()) {
Taro.showToast({ title: '请填写名称', icon: 'none' })
return
}
setSubmitting(true)
try {
await createAppApiKey({ name: form.name, scopes: 'read,write', remark: form.remark })
Taro.showToast({ title: '创建成功', icon: 'success' })
setShowModal(false)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '创建失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar
title='API Key 管理'
rightText='创建'
onRightClick={() => {
setForm({ name: '', remark: '' })
setShowModal(true)
}}
/>
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'> API Key</Text>
</View>
) : (
<View className='mt-3'>
{list.map((item) => (
<View key={item.id} className='bg-white px-4 py-3 border-b border-gray-50'>
<View className='flex items-center justify-between'>
<Text className='text-sm text-gray-800'>{item.name}</Text>
<Text
className='text-xs px-1.5 py-0.5 rounded-full'
style={{
color: item.status === 0 ? '#16a34a' : '#6b7280',
background: (item.status === 0 ? '#16a34a' : '#6b7280') + '22',
}}
>
{item.status === 0 ? '正常' : '禁用'}
</Text>
</View>
<Text className='text-xs text-gray-400 mt-1 block'>{maskKey(item.apiKey)}</Text>
<View className='flex gap-4 mt-2'>
<Text className='text-xs' style={{ color: '#2563eb' }} onClick={() => copy(item)}>
</Text>
<Text className='text-xs' style={{ color: '#d97706' }} onClick={() => toggle(item)}>
{item.status === 0 ? '禁用' : '启用'}
</Text>
<Text className='text-xs' style={{ color: '#dc2626' }} onClick={() => remove(item)}>
</Text>
</View>
</View>
))}
</View>
)}
</ScrollView>
{showModal && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4'>
<Text className='text-base font-medium block mb-3'> API Key</Text>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Input
className='bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='如:生产环境'
value={form.name}
onInput={(e) => setForm({ ...form, name: e.detail.value })}
/>
</View>
<View className='mb-4'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='可选'
value={form.remark}
onInput={(e) => setForm({ ...form, remark: e.detail.value })}
style={{ height: '70px' }}
/>
</View>
<View className='flex gap-3'>
<View className='flex-1 py-2.5 rounded-lg bg-gray-100 text-center' onClick={() => setShowModal(false)}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#2563eb' }} onClick={submit}>
<Text className='text-sm text-white'>{submitting ? '提交中...' : '创建'}</Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}
export default ApiKeysPage

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Input, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { getMyApps, addAppProduct } from '@/api/app/appProduct'
import { getPageList, APP_TYPE_NAME } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '我的应用' })
const APP_TYPE_OPTIONS = [
{ type: 20, name: '微信小程序' },
{ type: 10, name: '网站' },
{ type: 60, name: 'Android' },
{ type: 70, name: 'iOS' },
{ type: 110, name: '平台应用' },
]
const MyAppsPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [showModal, setShowModal] = useState(false)
const [form, setForm] = useState({ productName: '', productCode: '', appType: 20, description: '' })
const [submitting, setSubmitting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getMyApps({ page: 1, limit: 20 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const openCreate = () => {
setForm({ productName: '', productCode: '', appType: 20, description: '' })
setShowModal(true)
}
const submit = async () => {
if (!form.productName.trim()) {
Taro.showToast({ title: '请填写应用名称', icon: 'none' })
return
}
if (!/^[a-z][a-z0-9]*$/.test(form.productCode)) {
Taro.showToast({ title: '标识须为小写字母/数字', icon: 'none' })
return
}
setSubmitting(true)
try {
await addAppProduct(form)
Taro.showToast({ title: '创建成功', icon: 'success' })
setShowModal(false)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '创建失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='我的应用' rightText='创建' onRightClick={openCreate} />
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((app) => (
<CellRow
key={app.productId}
title={app.productName || '未命名应用'}
subtitle={`${APP_TYPE_NAME[app.appType as number] || '未知类型'} · ${app.productCode || ''}`}
onClick={() => {
if (app.adminUrl) Taro.setClipboardData({ data: app.adminUrl })
}}
/>
))}
</View>
)}
</ScrollView>
{showModal && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4'>
<Text className='text-base font-medium block mb-3'></Text>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Input
className='bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='如:威硕商城'
value={form.productName}
onInput={(e) => setForm({ ...form, productName: e.detail.value })}
/>
</View>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Input
className='bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='如weishop'
value={form.productCode}
onInput={(e) => setForm({ ...form, productCode: e.detail.value })}
/>
</View>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<View className='flex flex-wrap gap-2'>
{APP_TYPE_OPTIONS.map((opt) => (
<View
key={opt.type}
className='px-3 py-1.5 rounded-full text-sm'
style={form.appType === opt.type ? { background: '#2563eb', color: '#fff' } : { background: '#f3f4f6', color: '#374151' }}
onClick={() => setForm({ ...form, appType: opt.type })}
>
<Text>{opt.name}</Text>
</View>
))}
</View>
</View>
<View className='mb-4'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='简单描述应用用途'
value={form.description}
onInput={(e) => setForm({ ...form, description: e.detail.value })}
style={{ height: '80px' }}
/>
</View>
<View className='flex gap-3'>
<View className='flex-1 py-2.5 rounded-lg bg-gray-100 text-center' onClick={() => setShowModal(false)}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#2563eb' }} onClick={submit}>
<Text className='text-sm text-white'>{submitting ? '提交中...' : '创建'}</Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}
export default MyAppsPage

View File

@@ -0,0 +1,114 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import StatCard from '@/components/common/StatCard'
import EntryGrid from '@/components/common/EntryGrid'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { getMyApps } from '@/api/app/appProduct'
import { getAppApiKeyStats } from '@/api/app/apikey'
import { getMyTickets } from '@/api/app/ticket'
import { getPageTotal } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '开发者中心' })
const DeveloperCenterPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const isDeveloper = !!(user as any)?.isDeveloper
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState({ apps: 0, keys: 0, tickets: 0 })
const load = useCallback(async () => {
setLoading(true)
try {
const [appsR, keysR, ticketsR] = await Promise.allSettled([
getMyApps({ page: 1, limit: 1 }),
getAppApiKeyStats(),
getMyTickets({ page: 1, limit: 1 }),
])
const apps = appsR.status === 'fulfilled' ? getPageTotal(appsR.value) : 0
const keys = keysR.status === 'fulfilled' ? getPageTotal(keysR.value) : 0
const tickets = ticketsR.status === 'fulfilled' ? getPageTotal(ticketsR.value) : 0
setStats({ apps, keys, tickets })
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
if (!isLoggedIn) {
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='开发者中心' />
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
<View
className='mt-4 px-6 py-2 rounded-full'
style={{ background: '#0e932e' }}
onClick={() => Taro.navigateTo({ url: '/passport/login' })}
>
<Text className='text-white text-sm'></Text>
</View>
</View>
</View>
)
}
if (!isDeveloper) {
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='开发者中心' />
<View className='flex flex-col items-center justify-center py-20 px-10'>
<Text className='text-5xl mb-3'>🛠</Text>
<Text className='text-base text-gray-700 mb-2'></Text>
<Text className='text-xs text-gray-400 text-center mb-5 leading-relaxed'>
API Key
</Text>
<View
className='px-6 py-2 rounded-full'
style={{ background: '#2563eb' }}
onClick={() => Taro.navigateTo({ url: '/pages/user/vip-upgrade/index' })}
>
<Text className='text-white text-sm'></Text>
</View>
</View>
</View>
)
}
const entries = [
{ icon: '📦', label: '我的应用', url: '/pages/developer/apps/index' },
{ icon: '🔑', label: 'API Key', url: '/pages/developer/apikeys/index' },
{ icon: '🎫', label: '我的工单', url: '/pages/developer/tickets/index' },
]
return (
<View className='min-h-screen bg-gray-100'>
<NavBar title='开发者中心' />
<ScrollView scrollY>
{loading ? (
<Loading />
) : (
<View className='p-4'>
<View className='grid grid-cols-3 gap-3 mb-3'>
<StatCard label='我的应用' value={stats.apps} color='#3b82f6' />
<StatCard label='API Key' value={stats.keys} color='#10b981' />
<StatCard label='我的工单' value={stats.tickets} color='#f59e0b' />
</View>
<Text className='text-base font-medium mb-2 block'></Text>
<EntryGrid items={entries} columns={3} />
<View className='h-6' />
</View>
)}
</ScrollView>
</View>
)
}
export default DeveloperCenterPage

View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, ScrollView, Input, Textarea } from '@tarojs/components'
import Taro from '@tarojs/taro'
import NavBar from '@/components/NavBar'
import CellRow from '@/components/common/CellRow'
import Loading from '@/components/common/Loading'
import { useUser } from '@/hooks/useUser'
import { getMyTickets, getTicketReplies, submitTicket, replyTicket } from '@/api/app/ticket'
import { getPageList, TICKET_STATUS_NAME, TICKET_PRIORITY_NAME, TICKET_CATEGORY_NAME, ticketStatusColor } from '@/utils/devcenter'
definePageConfig({ navigationBarTitleText: '我的工单' })
const CATEGORIES = ['bug', 'feature', 'consultation', 'complaint', 'other']
const PRIORITIES = ['urgent', 'high', 'normal', 'low']
const MyTicketsPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [loading, setLoading] = useState(true)
const [list, setList] = useState<any[]>([])
const [showCreate, setShowCreate] = useState(false)
const [detail, setDetail] = useState<any>(null)
const [replies, setReplies] = useState<any[]>([])
const [replyText, setReplyText] = useState('')
const [createForm, setCreateForm] = useState({ title: '', category: 'bug', priority: 'normal', description: '' })
const [submitting, setSubmitting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await getMyTickets({ page: 1, limit: 50 })
setList(getPageList(data))
} catch (e) {
// ignore
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (isLoggedIn) load()
}, [isLoggedIn, load])
const openDetail = async (item: any) => {
setDetail(item)
setReplyText('')
try {
const r = await getTicketReplies(item.ticketId)
setReplies(Array.isArray(r) ? r : getPageList(r))
} catch (e) {
setReplies([])
}
}
const sendReply = async () => {
if (!replyText.trim() || !detail) return
try {
await replyTicket(detail.ticketId, replyText)
Taro.showToast({ title: '已回复', icon: 'none' })
setReplyText('')
openDetail(detail)
} catch (e: any) {
Taro.showToast({ title: e?.message || '回复失败', icon: 'none' })
}
}
const submit = async () => {
if (!createForm.title.trim()) {
Taro.showToast({ title: '请填写标题', icon: 'none' })
return
}
setSubmitting(true)
try {
await submitTicket({ ...createForm, content: createForm.description })
Taro.showToast({ title: '提交成功', icon: 'success' })
setShowCreate(false)
load()
} catch (e: any) {
Taro.showToast({ title: e?.message || '提交失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
return (
<View className='min-h-screen bg-gray-100'>
<NavBar
title='我的工单'
rightText='提交'
onRightClick={() => {
setCreateForm({ title: '', category: 'bug', priority: 'normal', description: '' })
setShowCreate(true)
}}
/>
<ScrollView scrollY>
{loading ? (
<Loading />
) : list.length === 0 ? (
<View className='flex flex-col items-center justify-center py-24'>
<Text className='text-gray-400 text-sm'></Text>
</View>
) : (
<View className='mt-3'>
{list.map((t) => (
<CellRow
key={t.ticketId}
title={t.title || '工单'}
subtitle={`#${t.ticketNo || t.ticketId} · ${TICKET_CATEGORY_NAME[t.category as string] || ''} · ${(t.createTime || '').slice(0, 10)}`}
statusText={TICKET_STATUS_NAME[t.status as string]}
statusColor={ticketStatusColor(t.status)}
onClick={() => openDetail(t)}
/>
))}
</View>
)}
</ScrollView>
{detail && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }} onClick={() => setDetail(null)}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4' onClick={(e) => e.stopPropagation()}>
<Text className='text-base font-medium block mb-2'>{detail.title}</Text>
<Text className='text-xs text-gray-500 block mb-3'>
{TICKET_STATUS_NAME[detail.status as string]} · {TICKET_PRIORITY_NAME[detail.priority as string]}
</Text>
<ScrollView scrollY style={{ maxHeight: '40vh' }}>
{replies.length === 0 ? (
<Text className='text-xs text-gray-400'></Text>
) : (
replies.map((r, i) => (
<View key={i} className='bg-gray-100 rounded-lg p-2 mb-2'>
<Text className='text-sm text-gray-700'>{r.content || r.replyContent || ''}</Text>
<Text className='text-xs text-gray-400 mt-1 block'>
{r.userName || r.replierName || ''} · {(r.createTime || '').slice(0, 16)}
</Text>
</View>
))
)}
</ScrollView>
<View className='flex items-center gap-2 mt-3'>
<Input
className='flex-1 bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='输入回复...'
value={replyText}
onInput={(e) => setReplyText(e.detail.value)}
/>
<View className='px-4 py-2 rounded-lg' style={{ background: '#2563eb' }} onClick={sendReply}>
<Text className='text-white text-sm'></Text>
</View>
</View>
<View className='mt-2 text-center' onClick={() => setDetail(null)}>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
</View>
)}
{showCreate && (
<View style={{ position: 'fixed', left: 0, top: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.4)', zIndex: 999 }}>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4'>
<Text className='text-base font-medium block mb-3'></Text>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Input
className='bg-gray-100 rounded-lg px-3 py-2 text-sm'
placeholder='一句话描述问题'
value={createForm.title}
onInput={(e) => setCreateForm({ ...createForm, title: e.detail.value })}
/>
</View>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<View className='flex flex-wrap gap-2'>
{CATEGORIES.map((c) => (
<View
key={c}
className='px-3 py-1 rounded-full text-xs'
style={createForm.category === c ? { background: '#2563eb', color: '#fff' } : { background: '#f3f4f6', color: '#374151' }}
onClick={() => setCreateForm({ ...createForm, category: c })}
>
<Text>{TICKET_CATEGORY_NAME[c]}</Text>
</View>
))}
</View>
</View>
<View className='mb-3'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<View className='flex flex-wrap gap-2'>
{PRIORITIES.map((p) => (
<View
key={p}
className='px-3 py-1 rounded-full text-xs'
style={createForm.priority === p ? { background: '#2563eb', color: '#fff' } : { background: '#f3f4f6', color: '#374151' }}
onClick={() => setCreateForm({ ...createForm, priority: p })}
>
<Text>{TICKET_PRIORITY_NAME[p]}</Text>
</View>
))}
</View>
</View>
<View className='mb-4'>
<Text className='text-xs text-gray-500 block mb-1'></Text>
<Textarea
className='bg-gray-100 rounded-lg px-3 py-2 text-sm w-full'
placeholder='详细描述'
value={createForm.description}
onInput={(e) => setCreateForm({ ...createForm, description: e.detail.value })}
style={{ height: '80px' }}
/>
</View>
<View className='flex gap-3'>
<View className='flex-1 py-2.5 rounded-lg bg-gray-100 text-center' onClick={() => setShowCreate(false)}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View className='flex-1 py-2.5 rounded-lg text-center' style={{ background: '#2563eb' }} onClick={submit}>
<Text className='text-sm text-white'>{submitting ? '提交中...' : '提交'}</Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}
export default MyTicketsPage

View File

@@ -3,9 +3,7 @@ import { View, Text, ScrollView } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { listShopDealerApply, updateShopDealerApply } from '@/api/shop/shopDealerApply' import { listShopDealerApply, updateShopDealerApply } from '@/api/shop/shopDealerApply'
import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model' import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model'
import { addUserRole, listUserRole } from '@/api/system/userRole' import { setUserDeveloper } from '@/api/system/user'
import { listRoles } from '@/api/system/role'
import { TenantId } from '@/config/app'
import { getMyClerk } from '@/api/shop/shopStoreUser' import { getMyClerk } from '@/api/shop/shopStoreUser'
definePageConfig({ definePageConfig({
@@ -21,26 +19,6 @@ const TABS: { key: TabKey; label: string; status: number }[] = [
{ key: 'rejected', label: '已驳回', status: 30 }, { key: 'rejected', label: '已驳回', status: 30 },
] ]
// VIP 角色信息(对应 system/role 表)
// 角色 ID 不写死,按 roleCode + 当前租户ID 动态查询
const VIP_ROLE_CODE = 'developer'
const VIP_ROLE_NAME = '开发者'
// 获取当前租户 ID登录时写入 storage未登录时回退到默认租户
function getCurrentTenantId(): number | string | undefined {
return Taro.getStorageSync('TenantId') || TenantId
}
/**
* 按 roleCode=vip + 当前租户ID 动态查询 VIP 角色,返回 roleId
*/
async function getVipRoleId(): Promise<number | undefined> {
const tenantId = getCurrentTenantId()
const roles = await listRoles({ roleCode: VIP_ROLE_CODE, tenantId })
const vip = (roles || []).find(r => r.roleCode === VIP_ROLE_CODE)
return vip?.roleId
}
// 格式化时间戳 // 格式化时间戳
function formatTime(time?: number | string): string { function formatTime(time?: number | string): string {
if (!time) return '-' if (!time) return '-'
@@ -60,53 +38,6 @@ function formatTime(time?: number | string): string {
return `${y}-${m}-${d} ${h}:${mi}` return `${y}-${m}-${d} ${h}:${mi}`
} }
// 格式化日期为 Spring Boot LocalDateTime 格式
function formatDateTime(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const h = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${h}:${mi}:${s}`
}
/**
* 给用户添加 VIP 角色
* 角色 ID 按 roleCode=vip + 当前租户ID 动态查询,避免写死;
* 先查询是否已有 VIP 角色,没有则新增,避免重复绑定
*/
async function assignVipRole(userId?: number): Promise<void> {
if (!userId) return
// 动态查询当前租户下的 VIP 角色
const vipRoleId = await getVipRoleId()
if (!vipRoleId) {
Taro.showToast({ title: '未找到 VIP 角色配置', icon: 'none' })
return
}
// 查询用户已有角色
const userRoles = await listUserRole({ userId })
const hasVip = (userRoles || []).some(
r => r.roleId === vipRoleId || r.roleCode === VIP_ROLE_CODE
)
if (hasVip) {
// 已拥有 VIP 角色,跳过
return
}
// 新增 VIP 角色绑定
await addUserRole({
userId,
roleId: vipRoleId,
roleCode: VIP_ROLE_CODE,
roleName: VIP_ROLE_NAME,
tenantId: getCurrentTenantId(),
})
}
const VipReviewPage: React.FC = () => { const VipReviewPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>('pending') const [activeTab, setActiveTab] = useState<TabKey>('pending')
const [list, setList] = useState<ShopDealerApply[]>([]) const [list, setList] = useState<ShopDealerApply[]>([])
@@ -198,8 +129,8 @@ const VipReviewPage: React.FC = () => {
auditTime: Date.now(), auditTime: Date.now(),
} as ShopDealerApply) } as ShopDealerApply)
// 审核通过后,用户添加 VIP 角色 // 审核通过后,用户标记为开发者(直接更新用户标记位,不新增角色
await assignVipRole(item.userId) await setUserDeveloper(item.userId!)
Taro.showToast({ title: '已通过', icon: 'success' }) Taro.showToast({ title: '已通过', icon: 'success' })
loadList(activeTab) loadList(activeTab)

138
src/utils/devcenter.ts Normal file
View File

@@ -0,0 +1,138 @@
// 开发者中心 / 平台管理 共用工具:分页提取、枚举中文、脱敏
/** 从后端分页结果中提取列表(兼容 list / records / 纯数组) */
export function getPageList(data: any): any[] {
if (!data) return []
if (Array.isArray(data.list)) return data.list
if (Array.isArray(data.records)) return data.records
if (Array.isArray(data)) return data
return []
}
/** 从后端分页结果中提取总数 */
export function getPageTotal(data: any): number {
if (!data) return 0
return data.total ?? data.count ?? getPageList(data).length
}
export const APP_TYPE_NAME: Record<number, string> = {
10: '网站',
20: '微信小程序',
30: '抖音小程序',
40: '百度小程序',
50: '支付宝小程序',
60: 'Android',
70: 'iOS',
80: 'macOS',
90: 'Windows',
100: '插件',
110: '平台应用',
}
export const PUBLISH_STATUS_NAME: Record<string, string> = {
developing: '开发中',
pending_review: '待审核',
published: '已上架',
rejected: '未通过',
deprecated: '已下架',
}
export const STATUS_NAME: Record<number, string> = {
0: '未开通',
1: '运行中',
2: '维护中',
3: '已关闭',
4: '已欠费',
5: '违规停机',
}
export const DOMAIN_STATUS_NAME: Record<number, string> = {
0: '待解析',
1: '校验中',
2: '已生效',
3: '已拒绝',
4: '已停用',
}
export const TICKET_STATUS_NAME: Record<string, string> = {
pending: '待处理',
assigned: '已分配',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
rejected: '已拒绝',
}
export const TICKET_PRIORITY_NAME: Record<string, string> = {
urgent: '紧急',
high: '高',
normal: '普通',
low: '低',
}
export const TICKET_CATEGORY_NAME: Record<string, string> = {
bug: '缺陷',
feature: '需求',
consultation: '咨询',
complaint: '投诉',
other: '其他',
}
/** API Key 脱敏保留前缀与后4位 */
export function maskKey(key?: string): string {
if (!key) return ''
if (key.length <= 12) return key
return key.slice(0, 8) + '••••••••' + key.slice(-4)
}
/** 颜色映射:根据状态返回主题色 */
export const STATUS_COLORS: Record<string, string> = {
green: '#16a34a',
red: '#dc2626',
amber: '#d97706',
blue: '#2563eb',
gray: '#6b7280',
}
export function publishStatusColor(status?: string): string {
switch (status) {
case 'published':
return STATUS_COLORS.green
case 'pending_review':
return STATUS_COLORS.amber
case 'rejected':
return STATUS_COLORS.red
default:
return STATUS_COLORS.gray
}
}
export function domainStatusColor(status?: number): string {
switch (status) {
case 2:
return STATUS_COLORS.green
case 0:
case 1:
return STATUS_COLORS.amber
case 3:
case 4:
return STATUS_COLORS.red
default:
return STATUS_COLORS.gray
}
}
export function ticketStatusColor(status?: string): string {
switch (status) {
case 'resolved':
case 'closed':
return STATUS_COLORS.green
case 'pending':
return STATUS_COLORS.red
case 'processing':
case 'assigned':
return STATUS_COLORS.blue
default:
return STATUS_COLORS.gray
}
}