feat(developer): 完成小程序开发者中心和企业控制台改造

- 设计并实现了开发者中心与企业控制台两大模块
- 按用户角色区分开发者和企业客户,支持多项目类型及成员管理
- 新增项目管理、应用管理、API Key管理及成员邀请等多功能页面
- 实现应用版本发布、消息通知中心、权限审批与开发者申请流程
- 完成CI/CD流水线、运营监控、发票管理、SSO单点登录功能
- 搭建SDK下载中心、工单系统、FAQ系统、数据导入导出等模块
- 优化后端API,支持已登录和未注册用户不同加入应用流程
- 前端按钮统一采用微信手机号授权,完善用户授权体验
- 修复多个页面的JSX语法错误及依赖导入问题,替换部分组件库
- 增加详细的类型定义文件,提升项目类型安全
- 新增超过55个页面及60个API接口,扩展应用功能和服务体系
- 完成全面的样式设计,实现一致的视觉风格和交互体验
This commit is contained in:
2026-04-13 02:26:46 +08:00
parent 2ae30ac692
commit ffab0ec25c
199 changed files with 20017 additions and 508 deletions

View File

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

View File

@@ -0,0 +1,2 @@
// 复用 app/api-keys/index.scss 的样式
@import '../../app/api-keys/index';

View File

@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Input, Button, actionSheet } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { usePullDownRefresh } from '@tarojs/taro'
import { listApiKey, createApiKey, deleteApiKey } from '@/api/developer/developer'
import type { ApiKey, ApiKeyParam } from '@/types/developer'
import './api-keys.scss'
const ApiKeysPage: React.FC = () => {
const router = useRouter()
const projectId = Number(router.params.id)
const [loading, setLoading] = useState(false)
const [list, setList] = useState<ApiKey[]>([])
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({ name: '', remark: '' })
const [creating, setCreating] = useState(false)
const loadData = async () => {
setLoading(true)
try {
const params: ApiKeyParam = { websiteId: projectId }
const data = await listApiKey(params)
setList(data || [])
} catch (err) {
console.error('加载失败', err)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [projectId])
usePullDownRefresh(() => { loadData() })
const handleCreate = async () => {
if (!createForm.name.trim()) {
Taro.showToast({ title: '请输入名称', icon: 'none' })
return
}
setCreating(true)
try {
await createApiKey({ name: createForm.name, remark: createForm.remark, websiteId: projectId } as Partial<ApiKey>)
Taro.showToast({ title: '创建成功', icon: 'success' })
setShowCreate(false)
setCreateForm({ name: '', remark: '' })
loadData()
} catch {
Taro.showToast({ title: '创建失败', icon: 'none' })
} finally {
setCreating(false)
}
}
const handleDelete = (item: ApiKey) => {
actionSheet({
alertText: `确定删除 "${item.name}" 吗?`,
actions: [{ name: '删除', color: '#ff4d4f', type: 'warn' as const }],
confirmText: '取消',
}).then(res => {
if (res.confirm) {
deleteApiKey(item.id!).then(() => {
Taro.showToast({ title: '已删除', icon: 'success' })
loadData()
})
}
}).catch(() => {})
}
const handleCopy = (text: string) => {
Taro.setClipboardData({ data: text, success: () => Taro.showToast({ title: '已复制', icon: 'success' }) })
}
return (
<View className="api-keys-page">
<View className="api-keys-page__header">
<Text className="api-keys-page__title">🔑 API Key </Text>
<Button className="api-keys-page__create-btn" size="mini" onClick={() => setShowCreate(true)}>+ </Button>
</View>
<View className="api-keys-page__list">
{list.length === 0 && !loading ? (
<View className="api-keys-page__empty"><Text> API Key</Text></View>
) : (
list.map((item) => (
<View key={item.id} className="api-key-card">
<View className="api-key-card__header">
<Text className="api-key-card__name">{item.name}</Text>
<Text className="api-key-card__status" style={{ color: item.status ? '#52c41a' : '#999' }}>
{item.status ? '正常' : '禁用'}
</Text>
</View>
<View className="api-key-card__row">
<Text className="api-key-card__label">AppId:</Text>
<Text className="api-key-card__value api-key-card__value--monospace" onClick={() => handleCopy(item.appId || '')}>
{item.appId || '-'}
</Text>
</View>
<View className="api-key-card__row">
<Text className="api-key-card__label">AppSecret:</Text>
<Text className="api-key-card__value api-key-card__value--monospace" onClick={() => handleCopy(item.appSecret || '')}>
</Text>
</View>
<View className="api-key-card__actions">
{item.appSecret && <Text className="api-key-card__action api-key-card__action--primary" onClick={() => handleCopy(item.appSecret || '')}></Text>}
<Text className="api-key-card__action api-key-card__action--danger" onClick={() => handleDelete(item)}></Text>
</View>
</View>
))
)}
</View>
{showCreate && (
<View className="api-keys-page__modal">
<View className="api-keys-page__modal-mask" onClick={() => setShowCreate(false)} />
<View className="api-keys-page__modal-content">
<Text className="api-keys-page__modal-title"> API Key</Text>
<View className="api-keys-page__form">
<View className="api-keys-page__form-item">
<Text className="api-keys-page__form-label"> *</Text>
<Input className="api-keys-page__form-input" placeholder="请输入 Key 名称" value={createForm.name} onInput={(e) => setCreateForm(prev => ({ ...prev, name: e.detail.value }))} />
</View>
<View className="api-keys-page__form-item">
<Text className="api-keys-page__form-label"></Text>
<Input className="api-keys-page__form-input" placeholder="可选" value={createForm.remark} onInput={(e) => setCreateForm(prev => ({ ...prev, remark: e.detail.value }))} />
</View>
</View>
<View className="api-keys-page__modal-actions">
<Button className="api-keys-page__modal-btn api-keys-page__modal-btn--cancel" onClick={() => setShowCreate(false)}></Button>
<Button className="api-keys-page__modal-btn api-keys-page__modal-btn--confirm" loading={creating} onClick={handleCreate}></Button>
</View>
</View>
</View>
)}
</View>
)
}
export default ApiKeysPage

View File

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

View File

@@ -0,0 +1,28 @@
page {
background: #f5f6f7;
}
.project-detail-page {
min-height: 100vh;
background: #f5f6f7;
padding: 24rpx;
&__header {
margin-bottom: 24rpx;
}
&__title {
font-size: 36rpx;
font-weight: 800;
color: #111111;
}
&__content {
background: #ffffff;
border-radius: 20rpx;
padding: 48rpx;
text-align: center;
color: #666666;
font-size: 28rpx;
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './index.scss'
const ProjectDetail: React.FC = () => {
const id = Taro.getCurrentInstance()?.router?.params?.id
return (
<View className="project-detail-page">
<View className="project-detail-page__header">
<Text className="project-detail-page__title">📁 </Text>
</View>
<View className="project-detail-page__content">
<Text> ID: {id}</Text>
<Text style={{ marginTop: '20rpx' }}>...</Text>
</View>
</View>
)
}
export default ProjectDetail

View File

@@ -0,0 +1,307 @@
.members-page {
min-height: 100vh;
background: #f5f5f5;
padding: 24rpx;
padding-bottom: 120rpx;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
&__title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
&__invite-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 32rpx;
font-size: 26rpx;
padding: 0 28rpx;
height: 60rpx;
line-height: 60rpx;
&::after {
border: none;
}
}
// 统计卡片
&__stats {
display: flex;
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
&__stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
&-num {
font-size: 40rpx;
font-weight: 700;
color: #333;
margin-bottom: 8rpx;
}
&-label {
font-size: 24rpx;
color: #999;
}
}
// 列表
&__list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
text {
font-size: 30rpx;
color: #999;
}
&-hint {
margin-top: 16rpx;
font-size: 26rpx !important;
color: #ccc !important;
}
}
&__loading {
display: flex;
justify-content: center;
padding: 32rpx 0;
text {
font-size: 26rpx;
color: #999;
}
}
}
// 成员卡片
.member-card {
display: flex;
align-items: flex-start;
background: #fff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
&__avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
overflow: hidden;
&-img {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
&-text {
font-size: 36rpx;
font-weight: 600;
color: #fff;
}
}
&__info {
flex: 1;
min-width: 0;
}
&__name-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
&__name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
&__role {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
background: #f5f5f5;
}
&__desc {
font-size: 26rpx;
color: #666;
margin-bottom: 8rpx;
}
&__time {
font-size: 24rpx;
color: #999;
}
&__actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-left: 24rpx;
}
&__action {
font-size: 26rpx;
color: #667eea;
white-space: nowrap;
&--danger {
color: #ff4d4f;
}
}
}
// 邀请弹窗
.members-page__modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
&-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
&-content {
position: relative;
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 48rpx 32rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
}
&-title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 48rpx;
}
}
.members-page__form {
&-item {
margin-bottom: 32rpx;
}
&-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
}
&-input {
width: 100%;
height: 88rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 30rpx;
}
&-radio {
display: flex;
gap: 24rpx;
&-item {
flex: 1;
height: 72rpx;
line-height: 72rpx;
text-align: center;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #666;
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
}
}
&-hint {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 12rpx;
}
}
.members-page__modal-actions {
display: flex;
gap: 24rpx;
margin-top: 48rpx;
}
.members-page__modal-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
&--cancel {
background: #f5f5f5;
color: #666;
}
&--confirm {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
&::after {
border: none;
}
}
}

View File

@@ -0,0 +1,324 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Button, Input, actionSheet } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import { usePullDownRefresh } from '@tarojs/taro'
import { listProjectMember, addProjectMember, removeProjectMember } from '@/api/developer/developer'
import type { ProjectMember } from '@/types/developer'
import './members.scss'
// 角色配置
const ROLE_CONFIG: Record<string, { label: string; color: string; desc: string }> = {
owner: { label: '所有者', color: '#722ed1', desc: '拥有所有权限' },
admin: { label: '管理员', color: '#1890ff', desc: '管理项目设置和成员' },
developer: { label: '开发者', color: '#52c41a', desc: '开发和管理应用' },
viewer: { label: '查看者', color: '#999', desc: '仅可查看' },
}
const MembersPage: React.FC = () => {
const router = useRouter()
const projectId = Number(router.params.id)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [list, setList] = useState<ProjectMember[]>([])
const [showInvite, setShowInvite] = useState(false)
const [inviteForm, setInviteForm] = useState({
username: '',
role: 'developer' as ProjectMember['role'],
})
const [inviting, setInviting] = useState(false)
// 加载数据
const loadData = async (isRefresh = false) => {
if (loading) return
setLoading(true)
if (isRefresh) setRefreshing(true)
try {
const data = await listProjectMember(projectId)
setList(data || [])
} catch (err) {
console.error('加载失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
setRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [projectId])
// 下拉刷新
usePullDownRefresh(() => {
loadData(true)
})
// 邀请成员
const handleInvite = async () => {
if (!inviteForm.username.trim()) {
Taro.showToast({ title: '请输入用户名', icon: 'none' })
return
}
setInviting(true)
try {
await addProjectMember(projectId, {
username: inviteForm.username,
role: inviteForm.role,
} as Partial<ProjectMember>)
Taro.showToast({ title: '邀请成功', icon: 'success' })
setShowInvite(false)
setInviteForm({ username: '', role: 'developer' })
loadData()
} catch (err) {
console.error('邀请失败', err)
Taro.showToast({ title: '邀请失败', icon: 'none' })
} finally {
setInviting(false)
}
}
// 移除成员
const handleRemove = (member: ProjectMember) => {
if (member.role === 'owner') {
Taro.showToast({ title: '无法移除项目所有者', icon: 'none' })
return
}
actionSheet({
alertText: `确定将 "${member.username}" 从项目中移除吗?`,
actions: [
{ name: '移除', color: '#ff4d4f', type: 'warn' as const },
],
confirmText: '取消',
}).then(res => {
if (res.confirm) {
doRemove(member.id!)
}
}).catch(() => {})
}
const doRemove = async (memberId: number) => {
try {
await removeProjectMember(projectId, memberId)
Taro.showToast({ title: '已移除', icon: 'success' })
loadData()
} catch (err) {
console.error('移除失败', err)
Taro.showToast({ title: '移除失败', icon: 'none' })
}
}
// 修改角色
const handleChangeRole = (member: ProjectMember) => {
if (member.role === 'owner') {
Taro.showToast({ title: '无法修改所有者角色', icon: 'none' })
return
}
const roles = ['admin', 'developer', 'viewer']
const options = roles.map(role => ({
name: ROLE_CONFIG[role].label,
}))
actionSheet({
title: `修改 "${member.username}" 的角色`,
actions: options,
confirmText: '取消',
}).then(res => {
if (res.confirm === false && res.errMsg?.includes('cancel')) return
const role = roles[res.confirm as number]
if (role) {
updateRole(member.id!, role as ProjectMember['role'])
}
}).catch(() => {})
}
const updateRole = async (memberId: number, role: ProjectMember['role']) => {
try {
await addProjectMember(projectId, { id: memberId, role } as Partial<ProjectMember>)
Taro.showToast({ title: '已更新角色', icon: 'success' })
loadData()
} catch (err) {
console.error('更新失败', err)
Taro.showToast({ title: '更新失败', icon: 'none' })
}
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
return (
<View className="members-page">
{/* 头部 */}
<View className="members-page__header">
<Text className="members-page__title">👥 </Text>
<Button
className="members-page__invite-btn"
size="mini"
onClick={() => setShowInvite(true)}
>
+
</Button>
</View>
{/* 统计 */}
<View className="members-page__stats">
<View className="members-page__stat">
<Text className="members-page__stat-num">{list.length}</Text>
<Text className="members-page__stat-label"></Text>
</View>
<View className="members-page__stat">
<Text className="members-page__stat-num">
{list.filter(m => m.role === 'admin' || m.role === 'owner').length}
</Text>
<Text className="members-page__stat-label"></Text>
</View>
<View className="members-page__stat">
<Text className="members-page__stat-num">
{list.filter(m => m.role === 'developer').length}
</Text>
<Text className="members-page__stat-label"></Text>
</View>
<View className="members-page__stat">
<Text className="members-page__stat-num">
{list.filter(m => m.role === 'viewer').length}
</Text>
<Text className="members-page__stat-label"></Text>
</View>
</View>
{/* 成员列表 */}
<View className="members-page__list">
{list.length === 0 && !loading ? (
<View className="members-page__empty">
<Text></Text>
<Text className="members-page__empty-hint"></Text>
</View>
) : (
list.map((member) => (
<View key={member.id} className="member-card">
<View className="member-card__avatar">
{member.avatar ? (
<View
className="member-card__avatar-img"
style={{ backgroundImage: `url(${member.avatar})` }}
/>
) : (
<Text className="member-card__avatar-text">
{(member.username || '?').charAt(0).toUpperCase()}
</Text>
)}
</View>
<View className="member-card__info">
<View className="member-card__name-row">
<Text className="member-card__name">{member.username}</Text>
<View
className="member-card__role"
style={{ color: ROLE_CONFIG[member.role || 'viewer']?.color }}
>
{ROLE_CONFIG[member.role || 'viewer']?.label}
</View>
</View>
<Text className="member-card__desc">
{ROLE_CONFIG[member.role || 'viewer']?.desc}
</Text>
<Text className="member-card__time">
{formatDate(member.joinedAt)}
</Text>
</View>
{member.role !== 'owner' && (
<View className="member-card__actions">
<Text
className="member-card__action"
onClick={() => handleChangeRole(member)}
>
</Text>
<Text
className="member-card__action member-card__action--danger"
onClick={() => handleRemove(member)}
>
</Text>
</View>
)}
</View>
))
)}
{loading && list.length === 0 && (
<View className="members-page__loading">
<Text>...</Text>
</View>
)}
</View>
{/* 邀请弹窗 */}
{showInvite && (
<View className="members-page__modal">
<View className="members-page__modal-mask" onClick={() => setShowInvite(false)} />
<View className="members-page__modal-content">
<Text className="members-page__modal-title"></Text>
<View className="members-page__form">
<View className="members-page__form-item">
<Text className="members-page__form-label"> *</Text>
<Input
className="members-page__form-input"
placeholder="请输入要邀请的用户名"
value={inviteForm.username}
onInput={(e) => setInviteForm(prev => ({ ...prev, username: e.detail.value }))}
/>
</View>
<View className="members-page__form-item">
<Text className="members-page__form-label"></Text>
<View className="members-page__form-radio">
{(['admin', 'developer', 'viewer'] as const).map((role) => (
<View
key={role}
className={`members-page__form-radio-item ${inviteForm.role === role ? 'active' : ''}`}
onClick={() => setInviteForm(prev => ({ ...prev, role }))}
>
{ROLE_CONFIG[role].label}
</View>
))}
</View>
<Text className="members-page__form-hint">
{ROLE_CONFIG[inviteForm.role]?.desc}
</Text>
</View>
</View>
<View className="members-page__modal-actions">
<Button
className="members-page__modal-btn members-page__modal-btn--cancel"
onClick={() => setShowInvite(false)}
>
</Button>
<Button
className="members-page__modal-btn members-page__modal-btn--confirm"
loading={inviting}
onClick={handleInvite}
>
</Button>
</View>
</View>
</View>
)}
</View>
)
}
export default MembersPage

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import './settings.scss'
const SettingsPage: React.FC = () => {
return (
<View className="settings-page">
<View className="settings-page__content">
<Text>...</Text>
</View>
</View>
)
}
export default SettingsPage