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:
40
.idea/workspace.xml
generated
40
.idea/workspace.xml
generated
@@ -4,18 +4,12 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="2de94e4f-0587-4c9b-8bfd-e87e0ae95b81" name="更改" comment="refactor(user): 删除门店信息输入框 - 移除用户资料页面中的门店名称输入框 - 移除用户资料页面中的门店地址输入框 - 简化用户页面界面布局,提高界面整洁度">
|
||||
<list default="true" id="2de94e4f-0587-4c9b-8bfd-e87e0ae95b81" name="更改" comment="refactor(user): 统一品牌名称为“威数谱软件” - 将项目中所有“威硕谱软件”名称修改为“威数谱软件” - 更新协议页面、欢迎消息、礼品卡等多处文本内容 - 修改小程序配置文件中的项目名称和显示标题 - 优化开发者申请页部分文案,明确开发者身份描述 - 保证所有界面及配置中的品牌名称统一一致">
|
||||
<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$/project.config.json" beforeDir="false" afterPath="$PROJECT_DIR$/project.config.json" 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/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/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" />
|
||||
<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$/src/api/system/user/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/system/user/index.ts" 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/user/vip-review/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/user/vip-review/index.tsx" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -127,15 +121,7 @@
|
||||
<workItem from="1784131049625" duration="2304000" />
|
||||
<workItem from="1784133736973" duration="4614000" />
|
||||
<workItem from="1784585070286" duration="3047000" />
|
||||
<workItem from="1784638429525" duration="8483000" />
|
||||
</task>
|
||||
<task id="LOCAL-00012" summary="refactor(api): 移除未使用的配置接口及相关Hook - 从cmsWebsiteField接口中删除configWebsiteField方法 - 删除useConfig自定义Hook及其引用 - 更新about页面,去除对config对象和useConfig的使用 - 修正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>
|
||||
<workItem from="1784638429525" duration="10410000" />
|
||||
</task>
|
||||
<task id="LOCAL-00013" summary="refactor(api): 移除未使用的配置接口及相关Hook - 从cmsWebsiteField接口中删除configWebsiteField方法 - 删除useConfig自定义Hook及其引用 - 更新about页面,去除对config对象和useConfig的使用 - 修正invite模块请求URL,统一加上/api前缀">
|
||||
<option name="closed" value="true" />
|
||||
@@ -521,7 +507,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1784697730666</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="61" />
|
||||
<task id="LOCAL-00061" summary="refactor(user): 统一品牌名称为“威数谱软件” - 将项目中所有“威硕谱软件”名称修改为“威数谱软件” - 更新协议页面、欢迎消息、礼品卡等多处文本内容 - 修改小程序配置文件中的项目名称和显示标题 - 优化开发者申请页部分文案,明确开发者身份描述 - 保证所有界面及配置中的品牌名称统一一致">
|
||||
<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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -541,7 +535,6 @@
|
||||
<component name="VcsManagerConfiguration">
|
||||
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||
<option name="CHECK_NEW_TODO" value="false" />
|
||||
<MESSAGE value="feat(passport): 优化已登录用户加入时协议勾选逻辑 - 已登录用户点击「确认加入」时不再强制勾选协议 - 移除 handleConfirmJoin 中对协议勾选的检查 - 未注册用户仍需勾选协议后才能授权手机号 - 确保登录状态异常时有提示信息显示" />
|
||||
<MESSAGE value="feat(invite): 支持已登录用户直接加入应用 - 后端accept接口code参数改为可选,支持通过Authorization头识别登录用户 - 已登录用户加入时不再弹手机号授权,前端调用新增的doJoinAppForLoggedInUser方法 - 未注册用户仍通过手机号授权码code加入,调用doJoinAppForNewUser方法 - 拆分前端加入逻辑,新增handleConfirmJoin统一处理已登录加入流程 - 优化按钮交互,已登录状态下按钮变为普通点击,移除协议强制勾选 - 错误处理和状态提示完善,加入成功后自动跳转首页" />
|
||||
<MESSAGE value="feat(developer): 完成小程序开发者中心和企业控制台改造 - 设计并实现了开发者中心与企业控制台两大模块 - 按用户角色区分开发者和企业客户,支持多项目类型及成员管理 - 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面 - 实现应用版本发布、消息通知中心、权限审批与开发者申请流程 - 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能 - 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块 - 优化后端API,支持已登录和未注册用户不同加入应用流程 - 前端按钮统一采用微信手机号授权,完善用户授权体验 - 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库 - 增加详细的类型定义文件,提升项目类型安全 - 新增超过55个页面及60个API接口,扩展应用功能和服务体系 - 完成全面的样式设计,实现一致的视觉风格和交互体验" />
|
||||
<MESSAGE value="feat(api-keys): 优化隐私协议同意逻辑及样式 - 修正api-keys样式导入路径,改为developer路径 - 在开发者申请页面增加服务协议和隐私政策勾选框 - 提交时校验协议是否已勾选,未勾选时提示用户 - 优化申请页面协议区域样式及交互 - 在用户验证页面添加隐私政策同意勾选框 - 提交用户验证表单时检查协议同意状态 - 增加打开服务协议和隐私政策的链接跳转 - 更新项目配置,增加passport/webview路由配置 - 修复createTicket函数中日期格式正则表达式问题" />
|
||||
@@ -566,7 +559,8 @@
|
||||
<MESSAGE value="feat(user): 动态查询VIP角色ID,避免硬编码绑定问题 - RoleParam新增tenantId字段,支持按租户过滤角色 - 新增getCurrentTenantId函数,兼容登录态及默认租户回退 - 实现getVipRoleId函数,按roleCode与tenantId查询VIP角色ID - assignVipRole动态获取VIP角色ID,移除写死的角色ID - 添加用户角色时传" />
|
||||
<MESSAGE value="feat(user): 统一VIP概念为开发者并优化开发者登录及申请 - 将VIP相关文案、图标改为开发者,涉及用户中心、门店中心、审核页等 - 优化登录弹窗UI,改为红色系,提升视觉统一性和交互体验 - 关于我们页面品牌和公司信息内容更新,展示最新企业介绍和业务范围 - VIP升级页新增“所在城市”选择,使用微信原生地区选择器,完善地址信息 - 用户申请表单中新增城市字段及校验,提交时携带城市信息 - 优化审核页面文案,统称开发者身份,明确申请审核内容 - 修复UserRoleController.save接口bug,避免userId被无条件覆盖导致角色绑错 - 动态查询VIP角色ID,避免硬编码,支持多租户环境正确绑定角色" />
|
||||
<MESSAGE value="refactor(user): 删除门店信息输入框 - 移除用户资料页面中的门店名称输入框 - 移除用户资料页面中的门店地址输入框 - 简化用户页面界面布局,提高界面整洁度" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="refactor(user): 删除门店信息输入框 - 移除用户资料页面中的门店名称输入框 - 移除用户资料页面中的门店地址输入框 - 简化用户页面界面布局,提高界面整洁度" />
|
||||
<MESSAGE value="refactor(user): 统一品牌名称为“威数谱软件” - 将项目中所有“威硕谱软件”名称修改为“威数谱软件” - 更新协议页面、欢迎消息、礼品卡等多处文本内容 - 修改小程序配置文件中的项目名称和显示标题 - 优化开发者申请页部分文案,明确开发者身份描述 - 保证所有界面及配置中的品牌名称统一一致" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="refactor(user): 统一品牌名称为“威数谱软件” - 将项目中所有“威硕谱软件”名称修改为“威数谱软件” - 更新协议页面、欢迎消息、礼品卡等多处文本内容 - 修改小程序配置文件中的项目名称和显示标题 - 优化开发者申请页部分文案,明确开发者身份描述 - 保证所有界面及配置中的品牌名称统一一致" />
|
||||
<option name="NON_MODAL_COMMIT_POSTPONE_SLOW_CHECKS" value="false" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -34,3 +34,14 @@
|
||||
- `ShopDealerApply` 模型新增 `city?: string`(注释:省 市 区,空格分隔)。
|
||||
- 提交时 `addShopDealerApply` 增加 `city: cityText`;读取历史申请时 `setCityValue(latest.city.split(' '))`;保存校验增加「请选择所在城市」。
|
||||
- 注意:仅前端扩展字段,后端是否落库 `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)。
|
||||
|
||||
17
.workbuddy/memory/MEMORY.md
Normal file
17
.workbuddy/memory/MEMORY.md
Normal 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
49
src/api/app/apikey.ts
Normal 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
58
src/api/app/appDomain.ts
Normal 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
76
src/api/app/appProduct.ts
Normal 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
106
src/api/app/developer.ts
Normal 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
82
src/api/app/ticket.ts
Normal 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 || '加载失败'))
|
||||
}
|
||||
@@ -115,6 +115,20 @@ export async function updateUser(data: User) {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
|
||||
@@ -117,6 +117,17 @@ export default {
|
||||
'pages/statistics/dashboard/index',
|
||||
'pages/statistics/sales/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": [
|
||||
{
|
||||
|
||||
47
src/components/common/CellRow/index.tsx
Normal file
47
src/components/common/CellRow/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/components/common/EntryGrid/index.tsx
Normal file
43
src/components/common/EntryGrid/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/common/StatCard/index.tsx
Normal file
25
src/components/common/StatCard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
src/pages/admin/app-review/index.tsx
Normal file
120
src/pages/admin/app-review/index.tsx
Normal 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
|
||||
128
src/pages/admin/domain-review/index.tsx
Normal file
128
src/pages/admin/domain-review/index.tsx
Normal 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
|
||||
147
src/pages/admin/git-review/index.tsx
Normal file
147
src/pages/admin/git-review/index.tsx
Normal 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
|
||||
104
src/pages/admin/index/index.tsx
Normal file
104
src/pages/admin/index/index.tsx
Normal 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
|
||||
146
src/pages/admin/tickets/index.tsx
Normal file
146
src/pages/admin/tickets/index.tsx
Normal 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
|
||||
175
src/pages/developer/apikeys/index.tsx
Normal file
175
src/pages/developer/apikeys/index.tsx
Normal 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
|
||||
160
src/pages/developer/apps/index.tsx
Normal file
160
src/pages/developer/apps/index.tsx
Normal 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
|
||||
114
src/pages/developer/index/index.tsx
Normal file
114
src/pages/developer/index/index.tsx
Normal 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
|
||||
224
src/pages/developer/tickets/index.tsx
Normal file
224
src/pages/developer/tickets/index.tsx
Normal 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
|
||||
@@ -3,9 +3,7 @@ import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { listShopDealerApply, updateShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
import type { ShopDealerApply } from '@/api/shop/shopDealerApply/model'
|
||||
import { addUserRole, listUserRole } from '@/api/system/userRole'
|
||||
import { listRoles } from '@/api/system/role'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { setUserDeveloper } from '@/api/system/user'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
|
||||
definePageConfig({
|
||||
@@ -21,26 +19,6 @@ const TABS: { key: TabKey; label: string; status: number }[] = [
|
||||
{ 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 {
|
||||
if (!time) return '-'
|
||||
@@ -60,53 +38,6 @@ function formatTime(time?: number | string): string {
|
||||
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 [activeTab, setActiveTab] = useState<TabKey>('pending')
|
||||
const [list, setList] = useState<ShopDealerApply[]>([])
|
||||
@@ -198,8 +129,8 @@ const VipReviewPage: React.FC = () => {
|
||||
auditTime: Date.now(),
|
||||
} as ShopDealerApply)
|
||||
|
||||
// 审核通过后,给用户添加 VIP 角色
|
||||
await assignVipRole(item.userId)
|
||||
// 审核通过后,将用户标记为开发者(直接更新用户标记位,不新增角色)
|
||||
await setUserDeveloper(item.userId!)
|
||||
|
||||
Taro.showToast({ title: '已通过', icon: 'success' })
|
||||
loadList(activeTab)
|
||||
|
||||
138
src/utils/devcenter.ts
Normal file
138
src/utils/devcenter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user