feat: appSecret security UX, IM admin management, remove SecretKey, CI pipeline
- AppDetailView: appSecret hidden by default, email verification to reveal/reset - AppDetailView: remove per-service SecretKey, service enable requires ops approval request - ImManagementView: add register user and create group dialogs - app.ts/im.ts: update API types and add new endpoints - Add Jenkinsfile for Web apps (yarn build → Docker → ACR → deploy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
d4c13a5f76
当前提交
fdc9ae833b
76
Jenkinsfile
vendored
普通文件
76
Jenkinsfile
vendored
普通文件
@ -0,0 +1,76 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'APP', choices: ['tenant-platform', 'ops-platform'], description: '要构建的 Web 应用')
|
||||||
|
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag')
|
||||||
|
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
ACR_REGISTRY = 'registry.cn-hangzhou.aliyuncs.com' // 替换为你的 ACR 地址
|
||||||
|
ACR_NAMESPACE = 'xuqmgroup'
|
||||||
|
ACR_USERNAME = 'your-acr-username'
|
||||||
|
PROD_HOST = '106.54.23.149'
|
||||||
|
PROD_USER = 'ubuntu'
|
||||||
|
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps { checkout scm }
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Install & Build') {
|
||||||
|
steps {
|
||||||
|
dir("${params.APP}") {
|
||||||
|
sh '''
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
yarn build
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Docker Build & Push') {
|
||||||
|
steps {
|
||||||
|
withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) {
|
||||||
|
script {
|
||||||
|
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/web-${params.APP}:${params.IMAGE_TAG}"
|
||||||
|
sh """
|
||||||
|
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p \${ACR_PASS}
|
||||||
|
docker build -f ${params.APP}/Dockerfile -t ${imageName} .
|
||||||
|
docker push ${imageName}
|
||||||
|
docker rmi ${imageName}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to Production') {
|
||||||
|
when { expression { return params.DEPLOY } }
|
||||||
|
steps {
|
||||||
|
withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) {
|
||||||
|
script {
|
||||||
|
def svcName = params.APP == 'tenant-platform' ? 'web' : 'ops-web'
|
||||||
|
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/web-${params.APP}:${params.IMAGE_TAG}"
|
||||||
|
sh """
|
||||||
|
ssh -i \${SSH_KEY} -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_HOST} "
|
||||||
|
docker pull ${imageName} &&
|
||||||
|
cd /opt/xuqm/deploy &&
|
||||||
|
docker compose -f ${COMPOSE_FILE} up -d --no-deps ${svcName} &&
|
||||||
|
docker image prune -f
|
||||||
|
"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success { echo "✅ ${params.APP}:${params.IMAGE_TAG} 构建部署成功" }
|
||||||
|
failure { echo "❌ 构建失败,请检查日志" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,6 @@ export interface FeatureService {
|
|||||||
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
platform: 'ANDROID' | 'IOS' | 'HARMONY'
|
||||||
serviceType: 'IM' | 'PUSH' | 'UPDATE'
|
serviceType: 'IM' | 'PUSH' | 'UPDATE'
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
secretKey: string
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +47,14 @@ export const appApi = {
|
|||||||
params: { platform, serviceType, enable },
|
params: { platform, serviceType, enable },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
regenerateKey: (appId: string, serviceId: string) =>
|
requestSecretVerify: (appId: string, purpose: 'REVEAL_SECRET' | 'RESET_SECRET') =>
|
||||||
client.post<{ data: FeatureService }>(`/apps/${appId}/services/${serviceId}/regenerate-key`),
|
client.post<{ data: null }>(`/apps/${appId}/request-secret-verify`, null, {
|
||||||
|
params: { purpose },
|
||||||
|
}),
|
||||||
|
|
||||||
|
revealSecret: (appId: string, code: string) =>
|
||||||
|
client.post<{ data: { appSecret: string } }>(`/apps/${appId}/reveal-secret`, { code }),
|
||||||
|
|
||||||
|
resetSecret: (appId: string, code: string) =>
|
||||||
|
client.post<{ data: { appSecret: string } }>(`/apps/${appId}/reset-secret`, { code }),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,4 +57,20 @@ export const imAdminApi = {
|
|||||||
getStats(appId: string) {
|
getStats(appId: string) {
|
||||||
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
|
return imClient.get<{ data: ImStats }>('/api/im/admin/stats', { params: { appId } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerUser(appId: string, userId: string, nickname?: string, avatar?: string) {
|
||||||
|
return imClient.post<{ data: ImUser }>(
|
||||||
|
'/api/im/admin/users',
|
||||||
|
{ userId, nickname, avatar },
|
||||||
|
{ params: { appId } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
createGroup(appId: string, name: string, creatorId: string, memberIds: string[]) {
|
||||||
|
return imClient.post<{ data: ImGroup }>(
|
||||||
|
'/api/im/admin/groups',
|
||||||
|
{ name, creatorId, memberIds },
|
||||||
|
{ params: { appId } },
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,14 @@
|
|||||||
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
|
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="AppSecret">
|
<el-descriptions-item label="AppSecret">
|
||||||
<el-text class="mono">{{ app.appSecret }}</el-text>
|
<el-text class="mono">{{ revealedSecret ?? '••••••••••••••••' }}</el-text>
|
||||||
<el-button link @click="copy(app.appSecret)"><el-icon><CopyDocument /></el-icon></el-button>
|
<el-button link @click="openVerifyDialog('REVEAL_SECRET')" title="查看">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="revealedSecret" link @click="copy(revealedSecret!)">
|
||||||
|
<el-icon><CopyDocument /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="warning" @click="openVerifyDialog('RESET_SECRET')">重置</el-button>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="简述" :span="2">{{ app.description ?? '-' }}</el-descriptions-item>
|
<el-descriptions-item label="简述" :span="2">{{ app.description ?? '-' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@ -32,23 +38,10 @@
|
|||||||
<span class="service-name">{{ serviceLabel(svcType) }}</span>
|
<span class="service-name">{{ serviceLabel(svcType) }}</span>
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="isEnabled(activePlatform, svcType)"
|
:model-value="isEnabled(activePlatform, svcType)"
|
||||||
@change="(val: boolean) => toggleService(activePlatform, svcType, val)"
|
@change="(val: boolean) => onToggleService(activePlatform, svcType, val)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isEnabled(activePlatform, svcType)">
|
<template v-if="isEnabled(activePlatform, svcType)">
|
||||||
<div class="key-row">
|
|
||||||
<span class="key-label">SecretKey</span>
|
|
||||||
<el-text class="mono key-value" size="small">
|
|
||||||
{{ getService(activePlatform, svcType)?.secretKey }}
|
|
||||||
</el-text>
|
|
||||||
<el-button link size="small" @click="copy(getService(activePlatform, svcType)?.secretKey ?? '')">
|
|
||||||
<el-icon><CopyDocument /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button link size="small" type="warning"
|
|
||||||
@click="regenerate(getService(activePlatform, svcType)?.id ?? '')">
|
|
||||||
重新生成
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:10px">
|
<div style="margin-top:10px">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="svcType === 'IM'"
|
v-if="svcType === 'IM'"
|
||||||
@ -64,33 +57,92 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div style="margin-top:10px">
|
||||||
|
<el-button size="small" type="primary" plain @click="openActivationRequest(activePlatform, svcType)">
|
||||||
|
申请开通
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Email Verify Dialog (reveal or reset) -->
|
||||||
|
<el-dialog v-model="showVerifyDialog" :title="verifyPurpose === 'REVEAL_SECRET' ? '查看 AppSecret' : '重置 AppSecret'" width="420px">
|
||||||
|
<div v-if="!codeSent">
|
||||||
|
<p style="color:#555;margin-bottom:16px">
|
||||||
|
{{ verifyPurpose === 'REVEAL_SECRET'
|
||||||
|
? '为保护账号安全,查看 AppSecret 需要通过邮箱验证。'
|
||||||
|
: '重置后旧 AppSecret 立即失效,请确认后继续。' }}
|
||||||
|
</p>
|
||||||
|
<el-button type="primary" :loading="sendingCode" @click="sendVerifyCode">
|
||||||
|
发送验证码到邮箱
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p style="color:#555;margin-bottom:16px">验证码已发送至您的注册邮箱,请查收并输入:</p>
|
||||||
|
<el-input v-model="verifyCode" placeholder="6位验证码" maxlength="6" style="margin-bottom:12px" />
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-button @click="sendVerifyCode" :loading="sendingCode" size="small">重新发送</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showVerifyDialog = false">取消</el-button>
|
||||||
|
<el-button v-if="codeSent" type="primary" :loading="submittingVerify" @click="submitVerify">
|
||||||
|
{{ verifyPurpose === 'REVEAL_SECRET' ? '查看' : '确认重置' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Activation Request Dialog -->
|
||||||
|
<el-dialog v-model="showActivationDialog" title="申请开通服务" width="420px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="服务">{{ activationForm.platform }} / {{ serviceLabel(activationForm.serviceType) }}</el-form-item>
|
||||||
|
<el-form-item label="申请理由">
|
||||||
|
<el-input v-model="activationForm.reason" type="textarea" :rows="3" placeholder="请描述您的业务场景" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showActivationDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submittingActivation" @click="submitActivationRequest">提交申请</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { View } from '@element-plus/icons-vue'
|
||||||
import { appApi, type App, type FeatureService } from '@/api/app'
|
import { appApi, type App, type FeatureService } from '@/api/app'
|
||||||
|
import client from '@/api/client'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const app = ref<App | null>(null)
|
const app = ref<App | null>(null)
|
||||||
const services = ref<FeatureService[]>([])
|
const services = ref<FeatureService[]>([])
|
||||||
const activePlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
const activePlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||||
|
|
||||||
|
const revealedSecret = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showVerifyDialog = ref(false)
|
||||||
|
const verifyPurpose = ref<'REVEAL_SECRET' | 'RESET_SECRET'>('REVEAL_SECRET')
|
||||||
|
const codeSent = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const verifyCode = ref('')
|
||||||
|
const submittingVerify = ref(false)
|
||||||
|
|
||||||
|
const showActivationDialog = ref(false)
|
||||||
|
const submittingActivation = ref(false)
|
||||||
|
const activationForm = ref({ platform: '', serviceType: '', reason: '' })
|
||||||
|
|
||||||
function isEnabled(platform: string, svcType: string) {
|
function isEnabled(platform: string, svcType: string) {
|
||||||
return services.value.some(
|
return services.value.some(
|
||||||
s => s.platform === platform && s.serviceType === svcType && s.enabled
|
s => s.platform === platform && s.serviceType === svcType && s.enabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getService(platform: string, svcType: string) {
|
|
||||||
return services.value.find(s => s.platform === platform && s.serviceType === svcType)
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceLabel(type: string) {
|
function serviceLabel(type: string) {
|
||||||
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
|
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
|
||||||
}
|
}
|
||||||
@ -102,18 +154,87 @@ async function loadData() {
|
|||||||
])
|
])
|
||||||
app.value = appRes.data.data
|
app.value = appRes.data.data
|
||||||
services.value = svcRes.data.data
|
services.value = svcRes.data.data
|
||||||
|
revealedSecret.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleService(platform: string, svcType: string, enable: boolean) {
|
async function onToggleService(platform: string, svcType: string, enable: boolean) {
|
||||||
await appApi.toggleService(route.params.id as string, platform, svcType, enable)
|
if (enable) {
|
||||||
ElMessage.success(enable ? '已开启' : '已关闭')
|
openActivationRequest(platform, svcType)
|
||||||
loadData()
|
} else {
|
||||||
|
await ElMessageBox.confirm(`确认关闭 ${platform} 平台的 ${serviceLabel(svcType)} 服务?`, '关闭服务', {
|
||||||
|
type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await appApi.toggleService(route.params.id as string, platform, svcType, false)
|
||||||
|
ElMessage.success('已关闭')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function regenerate(serviceId: string) {
|
function openActivationRequest(platform: string, svcType: string) {
|
||||||
await appApi.regenerateKey(route.params.id as string, serviceId)
|
activationForm.value = { platform, serviceType: svcType, reason: '' }
|
||||||
ElMessage.success('密钥已重新生成')
|
showActivationDialog.value = true
|
||||||
loadData()
|
}
|
||||||
|
|
||||||
|
async function submitActivationRequest() {
|
||||||
|
if (!activationForm.value.reason.trim()) {
|
||||||
|
return ElMessage.warning('请填写申请理由')
|
||||||
|
}
|
||||||
|
submittingActivation.value = true
|
||||||
|
try {
|
||||||
|
await client.post(`/apps/${route.params.id}/services/request-activation`, null, {
|
||||||
|
params: {
|
||||||
|
platform: activationForm.value.platform,
|
||||||
|
serviceType: activationForm.value.serviceType,
|
||||||
|
applyReason: activationForm.value.reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ElMessage.success('申请已提交,等待运营审核')
|
||||||
|
showActivationDialog.value = false
|
||||||
|
} finally {
|
||||||
|
submittingActivation.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVerifyDialog(purpose: 'REVEAL_SECRET' | 'RESET_SECRET') {
|
||||||
|
verifyPurpose.value = purpose
|
||||||
|
codeSent.value = false
|
||||||
|
verifyCode.value = ''
|
||||||
|
showVerifyDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendVerifyCode() {
|
||||||
|
sendingCode.value = true
|
||||||
|
try {
|
||||||
|
await appApi.requestSecretVerify(route.params.id as string, verifyPurpose.value)
|
||||||
|
codeSent.value = true
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('发送失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
sendingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVerify() {
|
||||||
|
if (verifyCode.value.length !== 6) return ElMessage.warning('请输入6位验证码')
|
||||||
|
submittingVerify.value = true
|
||||||
|
try {
|
||||||
|
const id = route.params.id as string
|
||||||
|
if (verifyPurpose.value === 'REVEAL_SECRET') {
|
||||||
|
const res = await appApi.revealSecret(id, verifyCode.value)
|
||||||
|
revealedSecret.value = res.data.data.appSecret
|
||||||
|
ElMessage.success('AppSecret 已显示,请妥善保管')
|
||||||
|
} else {
|
||||||
|
const res = await appApi.resetSecret(id, verifyCode.value)
|
||||||
|
revealedSecret.value = res.data.data.appSecret
|
||||||
|
ElMessage.success('AppSecret 已重置,旧密钥立即失效')
|
||||||
|
}
|
||||||
|
showVerifyDialog.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('验证码错误或已过期')
|
||||||
|
} finally {
|
||||||
|
submittingVerify.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
@ -128,9 +249,6 @@ onMounted(loadData)
|
|||||||
.mono { font-family: monospace; font-size: 12px; }
|
.mono { font-family: monospace; font-size: 12px; }
|
||||||
.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 16px; }
|
.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 16px; }
|
||||||
.service-card { border: 1px solid #e8e8e8; }
|
.service-card { border: 1px solid #e8e8e8; }
|
||||||
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; margin-bottom: 12px; }
|
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; }
|
||||||
.service-name { font-size: 15px; }
|
.service-name { font-size: 15px; }
|
||||||
.key-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
||||||
.key-label { font-size: 12px; color: #888; }
|
|
||||||
.key-value { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
<!-- Users Tab -->
|
<!-- Users Tab -->
|
||||||
<el-tab-pane label="注册用户" name="users">
|
<el-tab-pane label="注册用户" name="users">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="showRegisterUser = true">注册用户</el-button>
|
||||||
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
|
<el-button @click="loadUsers" :loading="loadingUsers">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="users" v-loading="loadingUsers" border stripe>
|
<el-table :data="users" v-loading="loadingUsers" border stripe>
|
||||||
@ -65,6 +66,7 @@
|
|||||||
<!-- Groups Tab -->
|
<!-- Groups Tab -->
|
||||||
<el-tab-pane label="群组列表" name="groups">
|
<el-tab-pane label="群组列表" name="groups">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="showCreateGroup = true">创建群组</el-button>
|
||||||
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
|
<el-button @click="loadGroups" :loading="loadingGroups">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="groups" v-loading="loadingGroups" border stripe>
|
<el-table :data="groups" v-loading="loadingGroups" border stripe>
|
||||||
@ -81,6 +83,34 @@
|
|||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Register User Dialog -->
|
||||||
|
<el-dialog v-model="showRegisterUser" title="注册用户" width="400px">
|
||||||
|
<el-form :model="registerForm" label-width="80px">
|
||||||
|
<el-form-item label="用户ID"><el-input v-model="registerForm.userId" placeholder="全局唯一标识" /></el-form-item>
|
||||||
|
<el-form-item label="昵称"><el-input v-model="registerForm.nickname" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showRegisterUser = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submittingRegister" @click="submitRegisterUser">注册</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Create Group Dialog -->
|
||||||
|
<el-dialog v-model="showCreateGroup" title="创建群组" width="420px">
|
||||||
|
<el-form :model="createGroupForm" label-width="90px">
|
||||||
|
<el-form-item label="群名称"><el-input v-model="createGroupForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="创建者ID"><el-input v-model="createGroupForm.creatorId" /></el-form-item>
|
||||||
|
<el-form-item label="初始成员">
|
||||||
|
<el-input v-model="createGroupForm.memberIdsRaw" type="textarea" :rows="3"
|
||||||
|
placeholder="每行一个用户ID" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateGroup = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submittingCreateGroup" @click="submitCreateGroup">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -105,6 +135,14 @@ const userTotal = ref(0)
|
|||||||
const groups = ref<ImGroup[]>([])
|
const groups = ref<ImGroup[]>([])
|
||||||
const loadingGroups = ref(false)
|
const loadingGroups = ref(false)
|
||||||
|
|
||||||
|
const showRegisterUser = ref(false)
|
||||||
|
const submittingRegister = ref(false)
|
||||||
|
const registerForm = ref({ userId: '', nickname: '' })
|
||||||
|
|
||||||
|
const showCreateGroup = ref(false)
|
||||||
|
const submittingCreateGroup = ref(false)
|
||||||
|
const createGroupForm = ref({ name: '', creatorId: '', memberIdsRaw: '' })
|
||||||
|
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
|
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' },
|
||||||
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
|
{ label: '群组数', value: stats.value?.totalGroups ?? '-' },
|
||||||
@ -165,6 +203,44 @@ function handleTabChange(tab: string) {
|
|||||||
if (tab === 'groups' && groups.value.length === 0) loadGroups()
|
if (tab === 'groups' && groups.value.length === 0) loadGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRegisterUser() {
|
||||||
|
if (!registerForm.value.userId.trim()) return ElMessage.warning('请填写用户ID')
|
||||||
|
submittingRegister.value = true
|
||||||
|
try {
|
||||||
|
await imAdminApi.registerUser(appId, registerForm.value.userId, registerForm.value.nickname)
|
||||||
|
ElMessage.success('用户注册成功')
|
||||||
|
showRegisterUser.value = false
|
||||||
|
registerForm.value = { userId: '', nickname: '' }
|
||||||
|
loadUsers()
|
||||||
|
loadStats()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('注册失败,用户ID可能已存在')
|
||||||
|
} finally {
|
||||||
|
submittingRegister.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateGroup() {
|
||||||
|
if (!createGroupForm.value.name.trim() || !createGroupForm.value.creatorId.trim()) {
|
||||||
|
return ElMessage.warning('请填写群名称和创建者ID')
|
||||||
|
}
|
||||||
|
submittingCreateGroup.value = true
|
||||||
|
try {
|
||||||
|
const memberIds = createGroupForm.value.memberIdsRaw
|
||||||
|
.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
await imAdminApi.createGroup(appId, createGroupForm.value.name, createGroupForm.value.creatorId, memberIds)
|
||||||
|
ElMessage.success('群组创建成功')
|
||||||
|
showCreateGroup.value = false
|
||||||
|
createGroupForm.value = { name: '', creatorId: '', memberIdsRaw: '' }
|
||||||
|
loadGroups()
|
||||||
|
loadStats()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('创建失败')
|
||||||
|
} finally {
|
||||||
|
submittingCreateGroup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
loadUsers()
|
loadUsers()
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户