diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 484912e..3569fc2 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,18 +4,12 @@
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
@@ -127,15 +121,7 @@
-
-
-
-
- 1775908253789
-
-
-
- 1775908253789
+
@@ -521,7 +507,15 @@
1784697730666
-
+
+
+ 1784699171525
+
+
+
+ 1784699171525
+
+
@@ -541,7 +535,6 @@
-
@@ -566,7 +559,8 @@
-
+
+
\ No newline at end of file
diff --git a/.workbuddy/memory/2026-07-22.md b/.workbuddy/memory/2026-07-22.md
index 69145d2..2d1eef2 100644
--- a/.workbuddy/memory/2026-07-22.md
+++ b/.workbuddy/memory/2026-07-22.md
@@ -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)。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
new file mode 100644
index 0000000..07ea116
--- /dev/null
+++ b/.workbuddy/memory/MEMORY.md
@@ -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)。
diff --git a/src/api/app/apikey.ts b/src/api/app/apikey.ts
new file mode 100644
index 0000000..0f7843b
--- /dev/null
+++ b/src/api/app/apikey.ts
@@ -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(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(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(`${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(`${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(`${BASE}/stats`, undefined, { baseUrl: WebsopyBaseUrl })
+ if (res?.code === 0) return res.data
+ return Promise.reject(new Error(res?.message || '加载失败'))
+}
diff --git a/src/api/app/appDomain.ts b/src/api/app/appDomain.ts
new file mode 100644
index 0000000..410a1fb
--- /dev/null
+++ b/src/api/app/appDomain.ts
@@ -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(`${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(`${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(`${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(`${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(`${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(`${BASE}/disable/${id}`, {}, { baseUrl: WebsopyBaseUrl })
+ if (res?.code === 0) return res.message
+ return Promise.reject(new Error(res?.message || '操作失败'))
+}
diff --git a/src/api/app/appProduct.ts b/src/api/app/appProduct.ts
new file mode 100644
index 0000000..746c63b
--- /dev/null
+++ b/src/api/app/appProduct.ts
@@ -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(`${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(`${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(`${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(`${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(`${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(`${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(
+ `${BASE}/reject/${params.productId}?reason=${encodeURIComponent(params.rejectReason)}`,
+ {},
+ { baseUrl: WebsopyBaseUrl }
+ )
+ if (res?.code === 0) return res.message
+ return Promise.reject(new Error(res?.message || '操作失败'))
+}
diff --git a/src/api/app/developer.ts b/src/api/app/developer.ts
new file mode 100644
index 0000000..bf3fcd6
--- /dev/null
+++ b/src/api/app/developer.ts
@@ -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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${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(`${BASE}/git-accounts/${id}/reject`, { reason }, { baseUrl: WebsopyBaseUrl })
+ if (res?.code === 0) return res.message
+ return Promise.reject(new Error(res?.message || '操作失败'))
+}
diff --git a/src/api/app/ticket.ts b/src/api/app/ticket.ts
new file mode 100644
index 0000000..2cac285
--- /dev/null
+++ b/src/api/app/ticket.ts
@@ -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(`${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(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(`${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(`${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(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(`${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(`${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(`${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(`${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(`${BASE}/staffs`, undefined, { baseUrl: WebsopyBaseUrl })
+ if (res?.code === 0) return res.data
+ return Promise.reject(new Error(res?.message || '加载失败'))
+}
diff --git a/src/api/system/user/index.ts b/src/api/system/user/index.ts
index 05a8e02..b965cec 100644
--- a/src/api/system/user/index.ts
+++ b/src/api/system/user/index.ts
@@ -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>(
+ SERVER_API_URL + '/system/user/developer/' + userId,
+ {}
+ );
+ if (res.code === 0) {
+ return res.message;
+ }
+ return Promise.reject(new Error(res.message));
+}
+
/**
* 删除用户
*/
diff --git a/src/app.config.ts b/src/app.config.ts
index c82bf32..f62ccb8 100644
--- a/src/app.config.ts
+++ b/src/app.config.ts
@@ -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": [
{
diff --git a/src/components/common/CellRow/index.tsx b/src/components/common/CellRow/index.tsx
new file mode 100644
index 0000000..cabd0d8
--- /dev/null
+++ b/src/components/common/CellRow/index.tsx
@@ -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 (
+
+
+
+ {title}
+ {statusText && (
+
+ {statusText}
+
+ )}
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+ {extra && {extra}}
+ {showArrow && →}
+
+ )
+}
diff --git a/src/components/common/EntryGrid/index.tsx b/src/components/common/EntryGrid/index.tsx
new file mode 100644
index 0000000..297100f
--- /dev/null
+++ b/src/components/common/EntryGrid/index.tsx
@@ -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 (
+
+
+ {items.map((it, i) => (
+ {
+ if (it.onClick) it.onClick()
+ else if (it.url) Taro.navigateTo({ url: it.url })
+ }}
+ >
+ {it.icon}
+ {it.label}
+ {it.badge && (
+
+ {it.badge}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/common/StatCard/index.tsx b/src/components/common/StatCard/index.tsx
new file mode 100644
index 0000000..fd1e681
--- /dev/null
+++ b/src/components/common/StatCard/index.tsx
@@ -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 (
+
+ {label}
+
+ {value}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+ )
+}
diff --git a/src/pages/admin/app-review/index.tsx b/src/pages/admin/app-review/index.tsx
new file mode 100644
index 0000000..2d24dca
--- /dev/null
+++ b/src/pages/admin/app-review/index.tsx
@@ -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([])
+ const [detail, setDetail] = useState(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 (
+
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无待审核应用
+
+ ) : (
+
+ {list.map((app) => (
+ {
+ setRejectReason('')
+ setDetail(app)
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {detail && (
+ setDetail(null)}>
+ e.stopPropagation()}>
+ {detail.productName}
+ {detail.description || '暂无描述'}
+
+ 拒绝原因
+
+
+ approve(detail)}>
+ 通过
+
+ reject(detail)}>
+ 拒绝
+
+
+
+
+ )}
+
+ )
+}
+
+export default AppReviewPage
diff --git a/src/pages/admin/domain-review/index.tsx b/src/pages/admin/domain-review/index.tsx
new file mode 100644
index 0000000..70e8f58
--- /dev/null
+++ b/src/pages/admin/domain-review/index.tsx
@@ -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([])
+ const [detail, setDetail] = useState(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, 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 (
+
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无域名申请
+
+ ) : (
+
+ {list.map((d) => (
+ {
+ setRejectReason('')
+ setDetail(d)
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {detail && (
+ setDetail(null)}>
+ e.stopPropagation()}>
+ {detail.domain}
+
+ {detail.tenantName} · 目标IP {detail.targetIp}
+
+
+ act(() => verifyDomain(detail.id), '已触发校验')}>
+ 检测解析
+
+ act(() => activeDomain(detail.id), '已生效')}>
+ 通过生效
+
+ act(() => disableDomain(detail.id), '已停用')}>
+ 停用
+
+
+
+ 拒绝原因
+
+ {
+ if (!rejectReason.trim()) {
+ Taro.showToast({ title: '请填写原因', icon: 'none' })
+ return
+ }
+ act(() => rejectDomain(detail.id, rejectReason), '已拒绝')
+ }}
+ >
+ 拒绝
+
+
+
+ )}
+
+ )
+}
+
+export default DomainReviewPage
diff --git a/src/pages/admin/git-review/index.tsx b/src/pages/admin/git-review/index.tsx
new file mode 100644
index 0000000..28abc02
--- /dev/null
+++ b/src/pages/admin/git-review/index.tsx
@@ -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([])
+ const [detail, setDetail] = useState(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 (
+
+
+
+ setTab('git')}
+ >
+ Git 账号
+
+ setTab('perm')}
+ >
+ 权限申请
+
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无待处理
+
+ ) : (
+
+ {list.map((item) => (
+ {
+ setRejectReason('')
+ setDetail(item)
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {detail && (
+ setDetail(null)}>
+ e.stopPropagation()}>
+ {titleOf(detail)}
+ {tab === 'git' ? detail.email || '' : detail.reason || ''}
+
+ 拒绝原因
+
+
+ approve(detail)}>
+ 通过
+
+ reject(detail)}>
+ 拒绝
+
+
+
+
+ )}
+
+ )
+}
+
+export default GitReviewPage
diff --git a/src/pages/admin/index/index.tsx b/src/pages/admin/index/index.tsx
new file mode 100644
index 0000000..4f1cd35
--- /dev/null
+++ b/src/pages/admin/index/index.tsx
@@ -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 (
+
+
+
+ 请先登录
+
+
+ )
+ }
+
+ if (!isAdmin) {
+ return (
+
+
+
+ 🛡️
+ 无访问权限
+ 平台管理仅对管理员开放
+
+
+ )
+ }
+
+ 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 (
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+ 0 ? '需处理' : undefined} subColor='#f59e0b' />
+
+
+
+
+
+ 审核与处理
+
+
+
+ )}
+
+
+ )
+}
+
+export default AdminCenterPage
diff --git a/src/pages/admin/tickets/index.tsx b/src/pages/admin/tickets/index.tsx
new file mode 100644
index 0000000..c37ecaf
--- /dev/null
+++ b/src/pages/admin/tickets/index.tsx
@@ -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([])
+ const [detail, setDetail] = useState(null)
+ const [replies, setReplies] = useState([])
+ 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 (
+
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无工单
+
+ ) : (
+
+ {list.map((t) => (
+ openDetail(t)}
+ />
+ ))}
+
+ )}
+
+
+ {detail && (
+ setDetail(null)}>
+ e.stopPropagation()}>
+ {detail.title}
+
+ {TICKET_STATUS_NAME[detail.status as string]} · {TICKET_PRIORITY_NAME[detail.priority as string]} ·{' '}
+ {detail.assigneeName ? '处理人 ' + detail.assigneeName : '未分配'}
+
+
+ {replies.length === 0 ? (
+ 暂无回复
+ ) : (
+ replies.map((r, i) => (
+
+ {r.content || r.replyContent || ''}
+
+ {r.userName || r.replierName || ''} · {(r.createTime || '').slice(0, 16)}
+
+
+ ))
+ )}
+
+
+ setReplyText(e.detail.value)}
+ />
+
+ 回复
+
+
+
+ setStatus('resolved')}>
+ 标记已解决
+
+ setStatus('closed')}>
+ 关闭
+
+
+ setDetail(null)}>
+ 关闭
+
+
+
+ )}
+
+ )
+}
+
+export default AdminTicketsPage
diff --git a/src/pages/developer/apikeys/index.tsx b/src/pages/developer/apikeys/index.tsx
new file mode 100644
index 0000000..c73fac2
--- /dev/null
+++ b/src/pages/developer/apikeys/index.tsx
@@ -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([])
+ 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 (
+
+ {
+ setForm({ name: '', remark: '' })
+ setShowModal(true)
+ }}
+ />
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无 API Key
+
+ ) : (
+
+ {list.map((item) => (
+
+
+ {item.name}
+
+ {item.status === 0 ? '正常' : '禁用'}
+
+
+ {maskKey(item.apiKey)}
+
+ copy(item)}>
+ 复制
+
+ toggle(item)}>
+ {item.status === 0 ? '禁用' : '启用'}
+
+ remove(item)}>
+ 删除
+
+
+
+ ))}
+
+ )}
+
+
+ {showModal && (
+
+
+ 创建 API Key
+
+ 名称
+ setForm({ ...form, name: e.detail.value })}
+ />
+
+
+ 备注
+
+
+ setShowModal(false)}>
+ 取消
+
+
+ {submitting ? '提交中...' : '创建'}
+
+
+
+
+ )}
+
+ )
+}
+
+export default ApiKeysPage
diff --git a/src/pages/developer/apps/index.tsx b/src/pages/developer/apps/index.tsx
new file mode 100644
index 0000000..d7b1664
--- /dev/null
+++ b/src/pages/developer/apps/index.tsx
@@ -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([])
+ 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 (
+
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 还没有应用,点击右上角创建
+
+ ) : (
+
+ {list.map((app) => (
+ {
+ if (app.adminUrl) Taro.setClipboardData({ data: app.adminUrl })
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {showModal && (
+
+
+ 创建应用
+
+ 应用名称
+ setForm({ ...form, productName: e.detail.value })}
+ />
+
+
+ 应用标识
+ setForm({ ...form, productCode: e.detail.value })}
+ />
+
+
+ 应用类型
+
+ {APP_TYPE_OPTIONS.map((opt) => (
+ setForm({ ...form, appType: opt.type })}
+ >
+ {opt.name}
+
+ ))}
+
+
+
+ 描述
+
+
+ setShowModal(false)}>
+ 取消
+
+
+ {submitting ? '提交中...' : '创建'}
+
+
+
+
+ )}
+
+ )
+}
+
+export default MyAppsPage
diff --git a/src/pages/developer/index/index.tsx b/src/pages/developer/index/index.tsx
new file mode 100644
index 0000000..0324aaf
--- /dev/null
+++ b/src/pages/developer/index/index.tsx
@@ -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 (
+
+
+
+ 请先登录后查看
+ Taro.navigateTo({ url: '/passport/login' })}
+ >
+ 去登录
+
+
+
+ )
+ }
+
+ if (!isDeveloper) {
+ return (
+
+
+
+ 🛠️
+ 尚未加入开发者中心
+
+ 开发者中心提供应用管理、API Key、工单等能力,请先申请开发者资质
+
+ Taro.navigateTo({ url: '/pages/user/vip-upgrade/index' })}
+ >
+ 申请成为开发者
+
+
+
+ )
+ }
+
+ 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 (
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ 常用功能
+
+
+
+ )}
+
+
+ )
+}
+
+export default DeveloperCenterPage
diff --git a/src/pages/developer/tickets/index.tsx b/src/pages/developer/tickets/index.tsx
new file mode 100644
index 0000000..b760128
--- /dev/null
+++ b/src/pages/developer/tickets/index.tsx
@@ -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([])
+ const [showCreate, setShowCreate] = useState(false)
+ const [detail, setDetail] = useState(null)
+ const [replies, setReplies] = useState([])
+ 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 (
+
+ {
+ setCreateForm({ title: '', category: 'bug', priority: 'normal', description: '' })
+ setShowCreate(true)
+ }}
+ />
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
+ 暂无工单
+
+ ) : (
+
+ {list.map((t) => (
+ openDetail(t)}
+ />
+ ))}
+
+ )}
+
+
+ {detail && (
+ setDetail(null)}>
+ e.stopPropagation()}>
+ {detail.title}
+
+ {TICKET_STATUS_NAME[detail.status as string]} · {TICKET_PRIORITY_NAME[detail.priority as string]}
+
+
+ {replies.length === 0 ? (
+ 暂无回复
+ ) : (
+ replies.map((r, i) => (
+
+ {r.content || r.replyContent || ''}
+
+ {r.userName || r.replierName || ''} · {(r.createTime || '').slice(0, 16)}
+
+
+ ))
+ )}
+
+
+ setReplyText(e.detail.value)}
+ />
+
+ 发送
+
+
+ setDetail(null)}>
+ 关闭
+
+
+
+ )}
+
+ {showCreate && (
+
+
+ 提交工单
+
+ 标题
+ setCreateForm({ ...createForm, title: e.detail.value })}
+ />
+
+
+ 类型
+
+ {CATEGORIES.map((c) => (
+ setCreateForm({ ...createForm, category: c })}
+ >
+ {TICKET_CATEGORY_NAME[c]}
+
+ ))}
+
+
+
+ 优先级
+
+ {PRIORITIES.map((p) => (
+ setCreateForm({ ...createForm, priority: p })}
+ >
+ {TICKET_PRIORITY_NAME[p]}
+
+ ))}
+
+
+
+ 描述
+
+
+ setShowCreate(false)}>
+ 取消
+
+
+ {submitting ? '提交中...' : '提交'}
+
+
+
+
+ )}
+
+ )
+}
+
+export default MyTicketsPage
diff --git a/src/pages/user/vip-review/index.tsx b/src/pages/user/vip-review/index.tsx
index 115b28b..9aa8c16 100644
--- a/src/pages/user/vip-review/index.tsx
+++ b/src/pages/user/vip-review/index.tsx
@@ -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 {
- 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 {
- 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('pending')
const [list, setList] = useState([])
@@ -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)
diff --git a/src/utils/devcenter.ts b/src/utils/devcenter.ts
new file mode 100644
index 0000000..ab0af0c
--- /dev/null
+++ b/src/utils/devcenter.ts
@@ -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 = {
+ 10: '网站',
+ 20: '微信小程序',
+ 30: '抖音小程序',
+ 40: '百度小程序',
+ 50: '支付宝小程序',
+ 60: 'Android',
+ 70: 'iOS',
+ 80: 'macOS',
+ 90: 'Windows',
+ 100: '插件',
+ 110: '平台应用',
+}
+
+export const PUBLISH_STATUS_NAME: Record = {
+ developing: '开发中',
+ pending_review: '待审核',
+ published: '已上架',
+ rejected: '未通过',
+ deprecated: '已下架',
+}
+
+export const STATUS_NAME: Record = {
+ 0: '未开通',
+ 1: '运行中',
+ 2: '维护中',
+ 3: '已关闭',
+ 4: '已欠费',
+ 5: '违规停机',
+}
+
+export const DOMAIN_STATUS_NAME: Record = {
+ 0: '待解析',
+ 1: '校验中',
+ 2: '已生效',
+ 3: '已拒绝',
+ 4: '已停用',
+}
+
+export const TICKET_STATUS_NAME: Record = {
+ pending: '待处理',
+ assigned: '已分配',
+ processing: '处理中',
+ resolved: '已解决',
+ closed: '已关闭',
+ rejected: '已拒绝',
+}
+
+export const TICKET_PRIORITY_NAME: Record = {
+ urgent: '紧急',
+ high: '高',
+ normal: '普通',
+ low: '低',
+}
+
+export const TICKET_CATEGORY_NAME: Record = {
+ 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 = {
+ 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
+ }
+}