docs(server): 添加服务器信息记录和联调接口文档

- 创建信息记录文档,包含项目管理要求、产物范围、Git仓库、制品仓库信息
- 添加服务器部署信息,包括应用服务器、MySQL/Redis服务器、Jenkins服务配置
- 记录邮件服务、DNS/HTTPS证书配置及安全备注
- 创建API联调文档,包含线上入口、ID约定、初始化管理员账号信息
- 添加统一响应格式、常见错误码、鉴权规则说明
- 提供核心接口清单,涵盖tenant-service、im-service、push-service等服务
- 补充curl示例,包含运营平台登录、IM登录、会话管理等操作示例
- 实现会话控制器,支持置顶、免打扰、标记已读、草稿等功能
- 添加全局异常处理器,统一处理业务异常和参数校验错误
- 创建IM管理控制器,提供用户管理、好友请求、黑名单等管理功能
这个提交包含在:
XuqmGroup 2026-04-29 12:33:26 +08:00
父节点 de89297457
当前提交 9c1dc4fbd7
共有 21 个文件被更改,包括 2124 次插入393 次删除

查看文件

@ -13,6 +13,7 @@ export default defineConfig({
nav: [
{ text: '快速开始', link: '/guide/quickstart' },
{ text: '演示项目', link: '/demo/' },
{
text: 'SDK',
items: [
@ -33,6 +34,9 @@ export default defineConfig({
{ text: '平台概念', link: '/guide/concepts' },
{ text: '接入流程', link: '/guide/flow' },
],
'/demo/': [
{ text: '演示项目', link: '/demo/' },
],
'/android/': [
{ text: '概览', link: '/android/' },
{ text: '安装配置', link: '/android/setup' },

查看文件

@ -0,0 +1,81 @@
# 演示项目
下面这些入口对应当前仓库里的可用演示物料。
## 移动端
<div class="demo-grid">
<div class="demo-card">
<h2>Android SDK Sample App</h2>
<p>适合验证 Android SDK 的 IM、推送和更新能力。</p>
<img
alt="Android SDK Sample App 下载二维码"
src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=https%3A%2F%2Fdev.xuqinmin.com%2Fdocs%2Fdemo%2Fandroid-sdk-sample-app.apk"
/>
<p><a href="/demo/android-sdk-sample-app.apk">下载 APK</a></p>
</div>
<div class="demo-card">
<h2>RN Chat Demo</h2>
<p>适合验证 React Native 演示项目和服务端 demo 数据。</p>
<img
alt="RN Chat Demo 下载二维码"
src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=https%3A%2F%2Fdev.xuqinmin.com%2Fdocs%2Fdemo%2Frn-chat-demo.apk"
/>
<p><a href="/demo/rn-chat-demo.apk">下载 APK</a></p>
</div>
</div>
## Web
<div class="demo-grid">
<div class="demo-card">
<h2>tenant-platform</h2>
<p>租户开放平台,登录后可直接进入应用、IM、版本管理等页面。</p>
<p>
<a href="https://dev.xuqinmin.com" target="_blank" rel="noreferrer">打开控制台</a>
<span style="padding:0 8px;">·</span>
<a href="https://dev.xuqinmin.com/apps/ak_demo_chat/im" target="_blank" rel="noreferrer">打开 IM 演示页</a>
</p>
</div>
<div class="demo-card">
<h2>docs-site 快速入口</h2>
<p>先看快速开始,再按平台页接入。</p>
<p>
<a href="/guide/quickstart">快速开始</a>
<span style="padding:0 8px;">·</span>
<a href="/server/api">API 速查</a>
</p>
</div>
</div>
<style>
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin: 16px 0 28px;
}
.demo-card {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
background: var(--vp-c-bg-soft);
}
.demo-card h2 {
margin: 0 0 8px;
font-size: 18px;
}
.demo-card p {
margin: 8px 0;
line-height: 1.6;
}
.demo-card img {
display: block;
width: 220px;
height: 220px;
margin: 12px 0;
background: #fff;
}
</style>

查看文件

@ -29,7 +29,11 @@ WS 地址wss://dev.xuqinmin.com/ws/im
演示用户demo_alice / demo_bob
```
## 4. 接入流程
## 4. 演示项目
手机端演示包和 Web 演示入口单独放在 [演示项目](/demo/) 页面,便于直接扫码或跳转验证。
## 5. 接入流程
```
你的业务服务端

查看文件

@ -8,6 +8,9 @@ hero:
- theme: brand
text: 快速开始
link: /guide/quickstart
- theme: alt
text: 演示项目
link: /demo/
- theme: alt
text: 平台控制台
link: https://dev.xuqinmin.com
@ -37,4 +40,8 @@ features:
title: 服务端 API
details: 完整 REST API 速查,WebSocket STOMP 协议说明
link: /server/api
- icon: 📱
title: 演示项目
details: 手机端扫码下载演示包,Web 端直接跳转到对应页面
link: /demo/
---

二进制文件未显示。

查看文件

@ -0,0 +1 @@
This is a placeholder APK payload for demo update flow.

查看文件

@ -7,10 +7,13 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@ -28,6 +31,7 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']

查看文件

@ -29,6 +29,18 @@ export interface FeatureService {
createdAt: string
}
export interface ImServiceConfig {
allowStrangerMessage: boolean
allowFriendRequest: boolean
friendRequestMode: 'REQUIRE_CONFIRM' | 'DIRECT_ACCEPT' | 'DISALLOW'
allowGroupJoinRequest: boolean
blacklistSendSuccess: boolean
messageRecallMinutes: number
historyRetentionDays: number
conversationPullLimit: number
multiClientConversationDeleteSync: boolean
}
export const appApi = {
list: () => client.get<{ data: App[] }>('/apps'),
@ -48,9 +60,14 @@ export const appApi = {
params: { platform, serviceType, enable },
}),
updateServiceConfig: (appId: string, platform: string, serviceType: string, allowStrangerMessage: boolean) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, null, {
params: { platform, serviceType, allowStrangerMessage },
updateServiceConfig: (
appId: string,
platform: string,
serviceType: string,
config: Partial<ImServiceConfig>,
) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
params: { platform, serviceType },
}),
requestSecretVerify: (appId: string, purpose: 'REVEAL_SECRET' | 'RESET_SECRET') =>

查看文件

@ -114,6 +114,32 @@ export interface FriendRequest {
reviewedAt?: number | null
}
export interface BlacklistEntry {
id: string
appId: string
userId: string
blockedUserId: string
createdAt: number
}
export interface KeywordFilter {
id: string
appId: string
pattern: string
replacement?: string | null
action: 'REPLACE' | 'BLOCK'
enabled: boolean
createdAt: number
}
export interface GlobalMute {
id: string
appId: string
enabled: boolean
createdAt: number
updatedAt: number
}
export interface WebhookConfig {
id: string
appId: string
@ -201,9 +227,9 @@ export const imAdminApi = {
})
},
listFriendRequests(appId: string, direction: 'incoming' | 'outgoing' = 'incoming') {
return imClient.get<{ data: FriendRequest[] }>('/api/im/friend-requests', {
params: { appId, direction },
listFriendRequests(appId: string) {
return imClient.get<{ data: FriendRequest[] }>('/api/im/admin/friend-requests', {
params: { appId },
})
},
@ -221,9 +247,23 @@ export const imAdminApi = {
)
},
createFriendRequest(appId: string, fromUserId: string, toUserId: string, remark?: string) {
return imClient.post<{ data: FriendRequest }>(
'/api/im/admin/friend-requests',
{
fromUserId,
toUserId,
...(remark ? { remark } : {}),
},
{
params: { appId },
},
)
},
acceptFriendRequest(appId: string, requestId: string) {
return imClient.post<{ data: FriendRequest }>(
`/api/im/friend-requests/${encodeURIComponent(requestId)}/accept`,
`/api/im/admin/friend-requests/${encodeURIComponent(requestId)}/accept`,
null,
{ params: { appId } },
)
@ -231,15 +271,123 @@ export const imAdminApi = {
rejectFriendRequest(appId: string, requestId: string) {
return imClient.post<{ data: FriendRequest }>(
`/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`,
`/api/im/admin/friend-requests/${encodeURIComponent(requestId)}/reject`,
null,
{ params: { appId } },
)
},
listBlacklist(appId: string) {
return imClient.get<{ data: BlacklistEntry[] }>('/api/im/admin/blacklist', {
params: { appId },
})
},
addBlacklist(appId: string, userId: string, blockedUserId: string) {
return imClient.post<{ data: BlacklistEntry }>(
'/api/im/admin/blacklist',
{ userId, blockedUserId },
{ params: { appId } },
)
},
removeBlacklist(appId: string, userId: string, blockedUserId: string) {
return imClient.delete<{ data: null }>('/api/im/admin/blacklist', {
params: { appId, userId, blockedUserId },
})
},
listKeywordFilters(appId: string) {
return imClient.get<{ data: KeywordFilter[] }>('/api/im/admin/keyword-filters', {
params: { appId },
})
},
createKeywordFilter(
appId: string,
form: { pattern: string; replacement?: string; action: 'REPLACE' | 'BLOCK'; enabled: boolean },
) {
return imClient.post<{ data: KeywordFilter }>('/api/im/admin/keyword-filters', form, {
params: { appId },
})
},
updateKeywordFilter(
appId: string,
filterId: string,
form: { pattern: string; replacement?: string; action: 'REPLACE' | 'BLOCK'; enabled: boolean },
) {
return imClient.put<{ data: KeywordFilter }>(`/api/im/admin/keyword-filters/${encodeURIComponent(filterId)}`, form, {
params: { appId },
})
},
deleteKeywordFilter(appId: string, filterId: string) {
return imClient.delete<{ data: null }>(`/api/im/admin/keyword-filters/${encodeURIComponent(filterId)}`, {
params: { appId },
})
},
getGlobalMute(appId: string) {
return imClient.get<{ data: GlobalMute }>('/api/im/admin/global-mute', {
params: { appId },
})
},
setGlobalMute(appId: string, enabled: boolean) {
return imClient.put<{ data: GlobalMute }>('/api/im/admin/global-mute', null, {
params: { appId, enabled },
})
},
listGroupMembers(appId: string, groupId: string) {
return imClient.get<{ data: ImUser[] }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/members`,
{ params: { appId } },
)
},
searchGroupMembers(appId: string, groupId: string, keyword: string, size = 20) {
return imClient.get<{ data: ImUser[] }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/members/search`,
{ params: { appId, keyword, size } },
)
},
addGroupMember(appId: string, groupId: string, userId: string) {
return imClient.post<{ data: ImGroup }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/members`,
{ userId },
{ params: { appId } },
)
},
removeGroupMember(appId: string, groupId: string, userId: string) {
return imClient.delete<{ data: ImGroup }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}`,
{ params: { appId } },
)
},
setGroupRole(appId: string, groupId: string, userId: string, role: 'ADMIN' | 'MEMBER') {
return imClient.post<{ data: ImGroup }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/roles`,
{ userId, role },
{ params: { appId } },
)
},
muteGroupMember(appId: string, groupId: string, userId: string, minutes: number) {
return imClient.post<{ data: ImGroup }>(
`/api/im/admin/groups/${encodeURIComponent(groupId)}/mute`,
{ userId, minutes },
{ params: { appId } },
)
},
listGroupJoinRequests(appId: string, groupId: string) {
return imClient.get<{ data: GroupJoinRequest[] }>(
`/api/im/groups/${encodeURIComponent(groupId)}/join-requests`,
`/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests`,
{ params: { appId } },
)
},
@ -259,7 +407,7 @@ export const imAdminApi = {
acceptGroupJoinRequest(appId: string, groupId: string, requestId: string) {
return imClient.post<{ data: GroupJoinRequest }>(
`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`,
`/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`,
null,
{ params: { appId } },
)
@ -267,7 +415,7 @@ export const imAdminApi = {
rejectGroupJoinRequest(appId: string, groupId: string, requestId: string) {
return imClient.post<{ data: GroupJoinRequest }>(
`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`,
`/api/im/admin/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`,
null,
{ params: { appId } },
)

查看文件

@ -64,6 +64,15 @@ export interface AppVersion {
createdAt: string
}
export interface AppPackageInspectResult {
platform: 'ANDROID' | 'IOS'
packageName?: string
versionName?: string
versionCode?: number
fileName?: string
detected: boolean
}
export interface RnBundle {
id: string
appId: string
@ -79,6 +88,15 @@ export interface RnBundle {
createdAt: string
}
export interface RnBundleInspectResult {
moduleId?: string
platform?: 'ANDROID' | 'IOS'
version?: string
minCommonVersion?: string
fileName?: string
detected: boolean
}
export interface UnifiedAppUploadItem {
fileKey: string
platform: 'ANDROID' | 'IOS'
@ -129,6 +147,12 @@ export const updateAdminApi = {
})
},
inspectAppPackage(formData: FormData) {
return updateClient.post<{ data: AppPackageInspectResult }>('/api/v1/updates/app/inspect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
listRnBundles(appId: string, moduleId?: string, platform?: string) {
return updateClient.get<{ data: RnBundle[] }>('/api/v1/rn/list', {
params: { appId, ...(moduleId && { moduleId }), ...(platform && { platform }) },
@ -153,6 +177,12 @@ export const updateAdminApi = {
})
},
inspectRnBundle(formData: FormData) {
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
uploadUnifiedRelease(formData: FormData) {
return updateClient.post('/api/v1/updates/unified/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 1.6 MiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 223 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 106 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 102 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 89 KiB

查看文件

@ -37,6 +37,14 @@ const router = createRouter({
path: 'apps/:id',
component: () => import('@/views/apps/AppDetailView.vue'),
},
{
path: 'apps/:appId/im-config',
component: () => import('@/views/im/ImConfigView.vue'),
},
{
path: 'apps/:appId/im-webhooks',
component: () => import('@/views/im/ImWebhookView.vue'),
},
{
path: 'apps/:appId/im',
component: () => import('@/views/im/ImManagementView.vue'),

查看文件

@ -24,8 +24,38 @@
</el-descriptions>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>即时通讯服务</template>
<div class="service-grid">
<el-card class="service-card">
<div class="service-header">
<span class="service-name">{{ serviceLabel('IM') }}</span>
<el-switch :model-value="imEnabled" @change="(val: boolean) => onToggleImService(val)" />
</div>
<template v-if="imService">
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<!-- IM 管理页按 appKey 作用域查询不能把租户 app.id 直接传进去 -->
<el-button size="small" @click="$router.push({ path: `/apps/${route.params.id}/im`, query: { appKey: app.appKey } })">
即时通讯管理
</el-button>
<el-button size="small" type="primary" plain @click="$router.push(`/apps/${route.params.id}/im-config`)">
服务配置
</el-button>
</div>
</template>
<template v-else>
<div style="margin-top:10px">
<el-button size="small" type="primary" plain @click="openActivationRequest('ANDROID', 'IM')">
申请开通
</el-button>
</div>
</template>
</el-card>
</div>
</el-card>
<el-card>
<template #header>功能服务配置</template>
<template #header>离线推送与版本管理</template>
<el-tabs v-model="activePlatform">
<el-tab-pane label="Android" name="ANDROID" />
<el-tab-pane label="iOS" name="IOS" />
@ -33,7 +63,7 @@
</el-tabs>
<div class="service-grid">
<el-card v-for="svcType in ['IM', 'PUSH', 'UPDATE']" :key="svcType" class="service-card">
<el-card v-for="svcType in ['PUSH', 'UPDATE']" :key="svcType" class="service-card">
<div class="service-header">
<span class="service-name">{{ serviceLabel(svcType) }}</span>
<el-switch
@ -43,12 +73,6 @@
</div>
<template v-if="isEnabled(activePlatform, svcType)">
<div style="margin-top:10px">
<el-button
v-if="svcType === 'IM'"
size="small"
@click="$router.push(`/apps/${route.params.id}/im`)">
即时通讯管理
</el-button>
<el-button
v-if="svcType === 'UPDATE'"
size="small"
@ -56,13 +80,6 @@
版本管理
</el-button>
</div>
<div v-if="svcType === 'IM'" class="service-config-row">
<span>允许陌生人发消息</span>
<el-switch
:model-value="allowStrangerMessage(activePlatform)"
@change="(val: boolean) => onToggleStrangerMessage(activePlatform, val)"
/>
</div>
</template>
<template v-else>
<div style="margin-top:10px">
@ -105,7 +122,8 @@
<!-- 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="服务">{{ serviceLabel(activationForm.serviceType) }}</el-form-item>
<el-form-item v-if="activationForm.serviceType !== 'IM'" label="平台">{{ activationForm.platform }}</el-form-item>
<el-form-item label="申请理由">
<el-input v-model="activationForm.reason" type="textarea" :rows="3" placeholder="请描述您的业务场景" />
</el-form-item>
@ -119,7 +137,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View } from '@element-plus/icons-vue'
@ -130,6 +148,8 @@ const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const activePlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const imService = computed(() => services.value.find(s => s.serviceType === 'IM') ?? null)
const imEnabled = computed(() => imService.value?.enabled ?? false)
const revealedSecret = ref<string | null>(null)
@ -154,20 +174,14 @@ function getService(platform: string, svcType: string) {
return services.value.find(s => s.platform === platform && s.serviceType === svcType) ?? null
}
function allowStrangerMessage(platform: string) {
const service = getService(platform, 'IM')
if (!service?.config) return false
try {
return Boolean(JSON.parse(service.config).allowStrangerMessage)
} catch {
return false
}
}
function serviceLabel(type: string) {
return { IM: '即时通讯 (IM)', PUSH: '离线推送', UPDATE: '版本管理' }[type] ?? type
}
function imServicePlatform() {
return imService.value?.platform ?? 'ANDROID'
}
async function loadData() {
const id = route.params.id as string
const [appRes, svcRes] = await Promise.all([
@ -191,9 +205,16 @@ async function onToggleService(platform: string, svcType: string, enable: boolea
}
}
async function onToggleStrangerMessage(platform: string, allow: boolean) {
await appApi.updateServiceConfig(route.params.id as string, platform, 'IM', allow)
ElMessage.success(allow ? '已允许陌生人发消息' : '已关闭陌生人发消息')
async function onToggleImService(enable: boolean) {
if (enable) {
openActivationRequest(imServicePlatform(), 'IM')
return
}
await ElMessageBox.confirm('确认关闭 即时通讯 服务?', '关闭服务', {
type: 'warning', confirmButtonText: '确认关闭', cancelButtonText: '取消',
})
await appApi.toggleService(route.params.id as string, imServicePlatform(), 'IM', false)
ElMessage.success('已关闭')
loadData()
}
@ -278,5 +299,4 @@ onMounted(loadData)
.service-card { border: 1px solid #e8e8e8; }
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; }
.service-name { font-size: 15px; }
.service-config-row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 13px; color: #666; }
</style>

查看文件

@ -0,0 +1,257 @@
<template>
<div v-if="app">
<el-page-header @back="$router.back()" content="IM 服务配置" style="margin-bottom:24px" />
<el-card style="margin-bottom:16px">
<el-descriptions :column="2" border>
<el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">
<el-text class="mono">{{ app.appKey }}</el-text>
<el-button link @click="copy(app.appKey)"><el-icon><CopyDocument /></el-icon></el-button>
</el-descriptions-item>
<el-descriptions-item label="服务状态">
<el-tag :type="imEnabled ? 'success' : 'info'">{{ imEnabled ? '已开通' : '未开通' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>即时通讯服务</template>
<div class="service-header">
<span class="service-name">即时通讯 (IM)</span>
<el-switch :model-value="imEnabled" @change="(val: boolean) => onToggleImService(val)" />
</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<!-- 服务配置页使用的是租户应用主键 app.idIM 管理页才带 appKey -->
<el-button size="small" @click="$router.push({ path: `/apps/${route.params.appId}/im`, query: { appKey: app.appKey } })">
即时通讯管理
</el-button>
<el-button size="small" @click="$router.push(`/apps/${route.params.appId}`)">
返回详情
</el-button>
</div>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>登录与消息</template>
<div class="service-config-list">
<div class="service-config-row">
<div>
<div class="service-config-title">允许陌生人发消息</div>
<div class="service-config-desc">关闭后仅好友之间可以发送单聊消息</div>
</div>
<el-switch
:model-value="imConfig.allowStrangerMessage"
@change="(val: boolean) => onToggleImConfig({ allowStrangerMessage: val })"
/>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">黑名单检查</div>
<div class="service-config-desc">控制对方拉黑后发送方是否仍显示发送成功</div>
</div>
<el-switch
:model-value="imConfig.blacklistSendSuccess"
@change="(val: boolean) => onToggleImConfig({ blacklistSendSuccess: val })"
/>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">消息可撤回时长</div>
<div class="service-config-desc">单位分钟默认 2 分钟</div>
</div>
<el-input-number
:model-value="imConfig.messageRecallMinutes"
:min="0"
:max="1440"
controls-position="right"
@change="(val: number | undefined) => onToggleImConfig({ messageRecallMinutes: val ?? 2 })"
/>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">历史消息存储时长</div>
<div class="service-config-desc">单位天默认 7 </div>
</div>
<el-input-number
:model-value="imConfig.historyRetentionDays"
:min="1"
:max="3650"
controls-position="right"
@change="(val: number | undefined) => onToggleImConfig({ historyRetentionDays: val ?? 7 })"
/>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">会话列表拉取个数</div>
<div class="service-config-desc">云端会话拉取上限默认 100最大 500</div>
</div>
<el-input-number
:model-value="imConfig.conversationPullLimit"
:min="1"
:max="500"
controls-position="right"
@change="(val: number | undefined) => onToggleImConfig({ conversationPullLimit: val ?? 100 })"
/>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">删除会话后多端同步</div>
<div class="service-config-desc">一端删除会话后是否同步到其他端</div>
</div>
<el-switch
:model-value="imConfig.multiClientConversationDeleteSync"
@change="(val: boolean) => onToggleImConfig({ multiClientConversationDeleteSync: val })"
/>
</div>
</div>
</el-card>
<el-card>
<template #header>好友与关系链</template>
<div class="service-config-list">
<div class="service-config-row">
<div>
<div class="service-config-title">默认加好友验证方式</div>
<div class="service-config-desc">对应腾讯控制台的好友关系链验证方式设置</div>
</div>
<el-select
:model-value="imConfig.friendRequestMode"
style="width: 180px"
@change="(val: ImServiceConfig['friendRequestMode']) => onToggleImConfig({ friendRequestMode: val, allowFriendRequest: val !== 'DISALLOW' })"
>
<el-option label="需要验证" value="REQUIRE_CONFIRM" />
<el-option label="自动通过" value="DIRECT_ACCEPT" />
<el-option label="不允许加好友" value="DISALLOW" />
</el-select>
</div>
<div class="service-config-row">
<div>
<div class="service-config-title">允许群加入申请</div>
<div class="service-config-desc">关闭后用户不能向公开群发送入群申请</div>
</div>
<el-switch
:model-value="imConfig.allowGroupJoinRequest"
@change="(val: boolean) => onToggleImConfig({ allowGroupJoinRequest: val })"
/>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type App, type FeatureService, type ImServiceConfig } from '@/api/app'
const route = useRoute()
const app = ref<App | null>(null)
const services = ref<FeatureService[]>([])
const imService = computed(() => services.value.find(s => s.serviceType === 'IM') ?? null)
const imEnabled = computed(() => imService.value?.enabled ?? false)
const imConfig = computed<ImServiceConfig>(() => {
const service = imService.value
if (!service?.config) {
return defaultImConfig()
}
try {
const parsed = JSON.parse(service.config) as Partial<ImServiceConfig>
return {
allowStrangerMessage: parsed.allowStrangerMessage ?? false,
allowFriendRequest: parsed.allowFriendRequest ?? true,
friendRequestMode: normalizeFriendRequestMode(parsed.friendRequestMode, parsed.allowFriendRequest),
allowGroupJoinRequest: parsed.allowGroupJoinRequest ?? true,
blacklistSendSuccess: parsed.blacklistSendSuccess ?? true,
messageRecallMinutes: parsed.messageRecallMinutes ?? 2,
historyRetentionDays: parsed.historyRetentionDays ?? 7,
conversationPullLimit: parsed.conversationPullLimit ?? 100,
multiClientConversationDeleteSync: parsed.multiClientConversationDeleteSync ?? false,
}
} catch {
return defaultImConfig()
}
})
function defaultImConfig(): ImServiceConfig {
return {
allowStrangerMessage: false,
allowFriendRequest: true,
friendRequestMode: 'REQUIRE_CONFIRM',
allowGroupJoinRequest: true,
blacklistSendSuccess: true,
messageRecallMinutes: 2,
historyRetentionDays: 7,
conversationPullLimit: 100,
multiClientConversationDeleteSync: false,
}
}
function normalizeFriendRequestMode(
mode: ImServiceConfig['friendRequestMode'] | string | undefined,
allowFriendRequest: boolean | undefined,
): ImServiceConfig['friendRequestMode'] {
const normalized = (mode ?? '').toString().trim().toUpperCase()
if (normalized === 'DIRECT_ACCEPT' || normalized === 'DISALLOW' || normalized === 'REQUIRE_CONFIRM') {
return normalized
}
return allowFriendRequest === false ? 'DISALLOW' : 'REQUIRE_CONFIRM'
}
async function loadData() {
const id = route.params.appId as string
const [appRes, svcRes] = await Promise.all([
appApi.get(id),
appApi.getServices(id),
])
app.value = appRes.data.data
services.value = svcRes.data.data
}
function imServicePlatform() {
return imService.value?.platform ?? 'ANDROID'
}
async function onToggleImService(enable: boolean) {
if (enable) {
return ElMessage.info('请通过服务开通流程启用 IM')
}
await ElMessageBox.confirm('确认关闭 即时通讯 服务?', '关闭服务', {
type: 'warning',
confirmButtonText: '确认关闭',
cancelButtonText: '取消',
})
await appApi.toggleService(route.params.appId as string, imServicePlatform(), 'IM', false)
ElMessage.success('已关闭')
loadData()
}
async function onToggleImConfig(patch: Partial<ImServiceConfig>) {
const nextConfig: ImServiceConfig = {
...imConfig.value,
...patch,
}
await appApi.updateServiceConfig(route.params.appId as string, imServicePlatform(), 'IM', nextConfig)
ElMessage.success('IM 配置已更新')
loadData()
}
function copy(text: string) {
navigator.clipboard.writeText(text)
ElMessage.success('已复制')
}
onMounted(loadData)
</script>
<style scoped>
.mono { font-family: monospace; font-size: 12px; }
.service-header { display: flex; justify-content: space-between; align-items: center; font-weight: 500; }
.service-name { font-size: 15px; }
.service-config-list { display: flex; flex-direction: column; gap: 12px; }
.service-config-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 13px; color: #666; }
.service-config-title { font-size: 13px; font-weight: 500; color: #303133; line-height: 1.4; }
.service-config-desc { margin-top: 4px; font-size: 12px; color: #909399; line-height: 1.4; }
</style>

查看文件

@ -0,0 +1,227 @@
<template>
<div v-if="app">
<el-page-header @back="$router.back()" content="IM 回调配置" style="margin-bottom:24px" />
<el-card style="margin-bottom:16px">
<el-descriptions :column="2" border>
<el-descriptions-item label="应用名称">{{ app.name }}</el-descriptions-item>
<el-descriptions-item label="包名">{{ app.packageName }}</el-descriptions-item>
<el-descriptions-item label="AppKey">
<el-text class="mono">{{ app.appKey }}</el-text>
<el-button link @click="copy(app.appKey)">
<el-icon><CopyDocument /></el-icon>
</el-button>
</el-descriptions-item>
<el-descriptions-item label="说明">
只管理当前租户 IM 服务的回调地址和签名密钥
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card style="margin-bottom:16px">
<template #header>接入说明</template>
<el-alert
title="回调配置只针对当前租户的 IM 服务,不包含用户关系、黑名单、好友申请等用户域操作。"
type="info"
:closable="false"
show-icon
style="margin-bottom:16px"
/>
<el-descriptions :column="1" border>
<el-descriptions-item label="调用方式">服务端以 `POST` 方式推送到你配置的回调地址</el-descriptions-item>
<el-descriptions-item label="签名头">`X-App-Id``X-App-Timestamp``X-App-Nonce``X-App-Signature`</el-descriptions-item>
<el-descriptions-item label="验签公式">`HMAC-SHA256(appSecret, appId + '\\n' + timestamp + '\\n' + nonce + '\\n' + sha256(body))`</el-descriptions-item>
<el-descriptions-item label="幂等建议">接收方建议按 `callbackId` 去重</el-descriptions-item>
</el-descriptions>
<el-table :data="webhookEvents" border stripe style="margin-top:16px">
<el-table-column prop="event" label="事件" width="180" />
<el-table-column prop="payload" label="payload" width="220" />
<el-table-column prop="description" label="说明" min-width="320" />
</el-table>
</el-card>
<el-card>
<template #header>回调地址</template>
<div class="toolbar toolbar-space-between">
<el-button type="primary" @click="openCreateWebhookDialog">新增回调</el-button>
<el-button @click="loadWebhooks" :loading="loadingWebhooks">刷新</el-button>
</div>
<el-table :data="webhooks" v-loading="loadingWebhooks" border stripe>
<el-table-column prop="url" label="回调地址" min-width="260" show-overflow-tooltip />
<el-table-column prop="secret" label="密钥" width="120">
<template #default="{ row }">
{{ row.secret ? '******' : '-' }}
</template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEditWebhookDialog(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="deleteWebhook(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="showWebhookDialog"
:title="editingWebhookId ? '编辑回调配置' : '新增回调配置'"
width="520px"
@closed="resetWebhookForm"
>
<el-form :model="webhookForm" label-width="90px">
<el-form-item label="回调地址">
<el-input v-model="webhookForm.url" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="回调密钥">
<el-input v-model="webhookForm.secret" placeholder="留空则不修改" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="webhookForm.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showWebhookDialog = false">取消</el-button>
<el-button type="primary" :loading="submittingWebhook" @click="submitWebhookForm">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
import { appApi, type App } from '@/api/app'
import { imAdminApi, type WebhookConfig, type WebhookConfigForm } from '@/api/im'
const route = useRoute()
const app = ref<App | null>(null)
const webhooks = ref<WebhookConfig[]>([])
const loadingWebhooks = ref(false)
const showWebhookDialog = ref(false)
const submittingWebhook = ref(false)
const editingWebhookId = ref<string | null>(null)
const webhookForm = ref<WebhookConfigForm & { enabled: boolean }>({
url: '',
secret: '',
enabled: true,
})
const appId = computed(() => route.params.appId as string)
const webhookEvents = [
{ event: 'message.sent', payload: 'ImMessageEntity', description: '消息发送成功后触发。' },
{ event: 'message.revoked', payload: 'ImMessageEntity', description: '消息撤回后触发。' },
{ event: 'message.edited', payload: 'ImMessageEntity', description: '文本消息编辑后触发。' },
{ event: 'message.read', payload: 'MessageReadCallbackPayload', description: '已读回执同步后触发。' },
{ event: 'friend.request.sent', payload: 'FriendRequestCallbackPayload', description: '好友申请创建后触发。' },
{ event: 'friend.request.accepted', payload: 'FriendRequestCallbackPayload', description: '好友申请通过后触发。' },
{ event: 'friend.request.rejected', payload: 'FriendRequestCallbackPayload', description: '好友申请拒绝后触发。' },
{ event: 'group.join.request.sent', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请创建后触发。' },
{ event: 'group.join.request.accepted', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请通过后触发。' },
{ event: 'group.join.request.rejected', payload: 'GroupJoinRequestCallbackPayload', description: '入群申请拒绝后触发。' },
{ event: 'blacklist.added', payload: 'BlacklistCallbackPayload', description: '黑名单记录新增后触发。' },
{ event: 'blacklist.removed', payload: 'BlacklistCallbackPayload', description: '黑名单记录移除后触发。' },
]
async function loadApp() {
const res = await appApi.get(appId.value)
app.value = res.data.data
}
async function loadWebhooks() {
loadingWebhooks.value = true
try {
const res = await imAdminApi.listWebhooks(appId.value)
webhooks.value = res.data.data
} finally {
loadingWebhooks.value = false
}
}
function openCreateWebhookDialog() {
editingWebhookId.value = null
webhookForm.value = { url: '', secret: '', enabled: true }
showWebhookDialog.value = true
}
function openEditWebhookDialog(row: WebhookConfig) {
editingWebhookId.value = row.id
webhookForm.value = {
url: row.url,
secret: '',
enabled: row.enabled,
}
showWebhookDialog.value = true
}
function resetWebhookForm() {
editingWebhookId.value = null
webhookForm.value = { url: '', secret: '', enabled: true }
}
async function submitWebhookForm() {
if (!webhookForm.value.url.trim()) {
ElMessage.warning('请填写回调地址')
return
}
submittingWebhook.value = true
try {
const payload: WebhookConfigForm & { enabled: boolean } = {
url: webhookForm.value.url.trim(),
enabled: webhookForm.value.enabled,
}
const secret = webhookForm.value.secret.trim()
if (secret) {
payload.secret = secret
}
if (editingWebhookId.value) {
await imAdminApi.updateWebhook(appId.value, editingWebhookId.value, payload)
} else {
await imAdminApi.createWebhook(appId.value, payload)
}
ElMessage.success('回调配置已保存')
showWebhookDialog.value = false
await loadWebhooks()
} finally {
submittingWebhook.value = false
}
}
async function deleteWebhook(row: WebhookConfig) {
await ElMessageBox.confirm(`确认删除回调配置 ${row.url}`, '删除回调', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
await imAdminApi.deleteWebhook(appId.value, row.id)
ElMessage.success('已删除')
await loadWebhooks()
}
function formatTime(value: number) {
return new Date(value).toLocaleString()
}
function copy(text: string) {
navigator.clipboard.writeText(text)
ElMessage.success('已复制')
}
onMounted(async () => {
await Promise.all([loadApp(), loadWebhooks()])
})
</script>

查看文件

@ -3,9 +3,6 @@
<el-page-header @back="$router.back()" :content="`版本管理 — ${appId}`" style="margin-bottom:20px" />
<el-card>
<div class="toolbar" style="margin-bottom: 16px">
<el-button type="primary" @click="showUnifiedUpload = true">一键上传</el-button>
</div>
<el-tabs v-model="activeTab">
<!-- App Versions -->
<el-tab-pane label="App 整包版本" name="app">
@ -125,6 +122,13 @@
<!-- App Store Config -->
<el-tab-pane label="应用商店配置" name="store">
<el-alert
title="应用商店配置按渠道分别维护,页面只保留真实可用的凭据字段和提交说明。"
type="info"
show-icon
:closable="false"
style="margin-bottom:16px"
/>
<div class="store-grid">
<el-card
v-for="store in STORE_DEFS"
@ -144,6 +148,10 @@
<el-tag v-if="getStoreConfig(store.type)" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</div>
<div class="store-card-meta">
<div v-if="getStoreConfig(store.type)">更新于 {{ formatTime(getStoreConfig(store.type)?.updatedAt ?? '') }}</div>
<div v-else>请先补齐 {{ store.shortLabel }} 的凭据</div>
</div>
<div class="store-card-footer">
<el-button size="small" @click="openStoreConfigDialog(store)">
{{ getStoreConfig(store.type) ? '编辑凭据' : '配置凭据' }}
@ -211,9 +219,10 @@
<el-dialog
v-model="showStoreConfig"
:title="`配置 ${currentStoreDef?.label} 凭据`"
width="520px"
width="980px"
>
<el-form v-if="currentStoreDef" :model="storeConfigForm" label-width="160px">
<div v-if="currentStoreDef" class="store-config-layout">
<el-form :model="storeConfigForm" label-width="160px" class="store-config-form">
<el-form-item label="启用">
<el-switch v-model="storeConfigForm.enabled" />
</el-form-item>
@ -241,6 +250,23 @@
</el-form-item>
</template>
</el-form>
<div class="store-config-guide">
<div class="store-config-guide-title">填写指引</div>
<div class="store-config-guide-subtitle">{{ currentStoreDef.guideSubtitle }}</div>
<el-steps direction="vertical" :active="currentStoreDef.guideSteps.length" finish-status="success" style="margin:16px 0">
<el-step v-for="step in currentStoreDef.guideSteps" :key="step.title" :title="step.title" :description="step.description" />
</el-steps>
<el-link :href="currentStoreDef.guideUrl" target="_blank" type="primary">查看官方文档</el-link>
<el-divider />
<div class="store-config-guide-hint">{{ currentStoreDef.guideHint }}</div>
<img
v-if="currentStoreDef.guideImage"
:src="currentStoreDef.guideImage"
:alt="currentStoreDef.label + ' 配置截图'"
class="store-config-guide-image"
/>
</div>
</div>
<template #footer>
<el-button @click="showStoreConfig = false">取消</el-button>
<el-button type="primary" @click="saveStoreConfig" :loading="savingStoreConfig">保存</el-button>
@ -257,16 +283,24 @@
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="包名"><el-input v-model="appUploadForm.packageName" placeholder="com.example.app" /></el-form-item>
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" /></el-form-item>
<el-form-item label="包名 / Bundle ID">
<el-input v-model="appUploadForm.packageName" placeholder="选择文件后可自动填充" />
</el-form-item>
<el-form-item label="版本名称"><el-input v-model="appUploadForm.versionName" placeholder="选择文件后可自动填充" /></el-form-item>
<el-form-item label="版本码"><el-input-number v-model="appUploadForm.versionCode" :min="1" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="appUploadForm.forceUpdate" /></el-form-item>
<el-form-item label="更新说明"><el-input v-model="appUploadForm.changeLog" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => appUploadForm.file = f.raw ?? null" accept=".apk,.ipa">
<el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk,.ipa">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="选中 APK 后会自动读取包名、版本名和版本码;iOS 包若能解析到 Info.plist,也会自动填充。"
/>
<el-divider content-position="left">发版配置</el-divider>
<el-form-item label="定时发布">
<el-date-picker
@ -291,7 +325,8 @@
</el-checkbox-group>
</el-form-item>
<el-form-item label="审核后自动发布">
<el-switch v-model="appUploadForm.autoPublishAfterReview" />
<el-switch v-model="appUploadForm.autoPublishAfterReview" :disabled="!!appUploadForm.scheduledPublishAt" />
<span class="form-tip">与定时发布互斥</span>
</el-form-item>
</el-form>
<template #footer>
@ -303,21 +338,27 @@
<!-- Upload RN Bundle Dialog -->
<el-dialog v-model="showUploadRn" title="上传 RN Bundle" width="480px">
<el-form :model="rnUploadForm" label-width="120px">
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" /></el-form-item>
<el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onRnBundleChange" accept=".bundle,.js">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="推荐文件名格式moduleId__ANDROID__1.0.0__1.0.0.bundle,系统会按命名自动识别模块、平台、版本和最低 Common 版本。"
/>
<el-form-item label="模块ID"><el-input v-model="rnUploadForm.moduleId" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="平台">
<el-select v-model="rnUploadForm.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="如 1.0.1" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="如 1.0.0" /></el-form-item>
<el-form-item label="版本"><el-input v-model="rnUploadForm.version" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="rnUploadForm.minCommonVersion" placeholder="可由文件名自动识别" /></el-form-item>
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => rnUploadForm.file = f.raw ?? null" accept=".bundle,.js">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadRn = false">取消</el-button>
@ -325,118 +366,27 @@
</template>
</el-dialog>
<!-- Unified Release Dialog -->
<el-dialog v-model="showUnifiedUpload" title="一键上传" width="960px">
<el-form label-width="120px">
<el-divider content-position="left">Android / iOS 整包</el-divider>
<div class="unified-grid">
<div v-for="item in unifiedAppForms" :key="item.platform" class="unified-block">
<div class="unified-block-title">{{ item.platform === 'ANDROID' ? 'Android' : 'iOS' }}</div>
<el-form-item label="启用"><el-switch v-model="item.enabled" /></el-form-item>
<template v-if="item.enabled">
<el-form-item label="包名"><el-input v-model="item.packageName" placeholder="com.example.app" /></el-form-item>
<el-form-item label="版本名称"><el-input v-model="item.versionName" /></el-form-item>
<el-form-item label="版本码"><el-input-number v-model="item.versionCode" :min="1" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="item.forceUpdate" /></el-form-item>
<el-form-item label="更新说明"><el-input v-model="item.changeLog" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => item.file = f.raw ?? null"
:accept="item.platform === 'ANDROID' ? '.apk' : '.ipa'">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</template>
</div>
</div>
<el-divider content-position="left">发版配置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="定时发布">
<el-date-picker
v-model="unifiedReleaseOptions.scheduledPublishAt"
type="datetime"
placeholder="留空手动发布"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Webhook URL">
<el-input v-model="unifiedReleaseOptions.webhookUrl" placeholder="审核结果回调" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="自动提交市场">
<el-switch v-model="unifiedReleaseOptions.autoSubmitStore" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审核后自动发布">
<el-switch v-model="unifiedReleaseOptions.autoPublishAfterReview" />
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="unifiedReleaseOptions.autoSubmitStore" label="目标市场">
<el-checkbox-group v-model="unifiedReleaseOptions.storeTargets">
<el-checkbox v-for="s in enabledStores" :key="s.type" :value="s.type">{{ s.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-divider content-position="left">
Bundle 插件
<el-button link type="primary" @click="addUnifiedBundle" style="margin-left:8px">新增插件</el-button>
</el-divider>
<div v-for="(item, index) in unifiedBundleForms" :key="item.key" class="unified-bundle-row">
<div class="unified-bundle-head">
<div>插件 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeUnifiedBundle(index)" :disabled="unifiedBundleForms.length === 1">删除</el-button>
</div>
<div class="unified-grid">
<el-form-item label="模块ID"><el-input v-model="item.moduleId" /></el-form-item>
<el-form-item label="平台">
<el-select v-model="item.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
</el-select>
</el-form-item>
<el-form-item label="版本"><el-input v-model="item.version" /></el-form-item>
<el-form-item label="最低 Common 版本"><el-input v-model="item.minCommonVersion" /></el-form-item>
<el-form-item label="说明"><el-input v-model="item.note" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="Bundle 文件">
<el-upload :auto-upload="false" :limit="1" :on-change="f => item.file = f.raw ?? null" accept=".bundle,.js">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="showUnifiedUpload = false">取消</el-button>
<el-button type="primary" @click="submitUnifiedUpload" :loading="uploadingUnified">上传</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
updateAdminApi,
type AppPackageInspectResult,
type AppVersion,
type RnBundle,
type RnBundleInspectResult,
type StoreConfig,
type StoreType,
type StoreReviewState,
} from '@/api/update'
import huaweiGuideImage from '@/assets/update-store/huawei/01.png'
import miGuideImage from '@/assets/update-store/mi/01.png'
import oppoGuideImage from '@/assets/update-store/oppo/01.png'
import vivoGuideImage from '@/assets/update-store/vivo/01.png'
import honorGuideImage from '@/assets/update-store/honor/01.png'
const route = useRoute()
const appId = route.params.appId as string
@ -452,72 +402,148 @@ const rnBundles = ref<RnBundle[]>([])
const loadingRn = ref(false)
const storeConfigs = ref<StoreConfig[]>([])
// Store definitions
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
type GuideStep = { title: string; description: string }
type StoreDef = {
type: StoreType
label: string
shortLabel: string
fields: FieldDef[]
guideSubtitle: string
guideUrl: string
guideSteps: GuideStep[]
guideHint: string
guideImage?: string
}
const STORE_DEFS: { type: StoreType; label: string; fields: FieldDef[] }[] = [
const STORE_DEFS: StoreDef[] = [
{
type: 'HUAWEI',
label: '华为应用市场',
shortLabel: '华为',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password', placeholder: 'AppGallery Connect Client Secret' },
],
guideSubtitle: '在 AppGallery Connect 创建 Connect API 凭据',
guideUrl: 'https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114',
guideSteps: [
{ title: '创建应用或进入应用管理', description: '在 AppGallery Connect 中找到目标应用。' },
{ title: '进入开发工具 / Connect API', description: '创建服务端凭据并选择 APP 管理员角色。' },
{ title: '复制客户端 ID 和密钥', description: '把 Client ID / Client Secret 填到这里。' },
],
guideHint: '与后端提交服务对应字段一致clientId、clientSecret。',
guideImage: huaweiGuideImage,
},
{
type: 'MI',
label: '小米应用商店',
shortLabel: '小米',
fields: [
{ key: 'username', label: '用户名' },
{ key: 'privateKey', label: 'RSA 私钥', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
],
guideSubtitle: '在自动发布接口页面获取上传所需密钥',
guideUrl: 'https://dev.mi.com/distribute/doc/details?pId=1134',
guideSteps: [
{ title: '进入应用游戏管理', description: '在控制台选择目标应用。' },
{ title: '打开自动发布接口', description: '下载公钥文件并准备私钥。' },
{ title: '录入用户名和私钥', description: '这里保存的是服务端上传所需凭据。' },
],
guideHint: '当前字段保持为 username / privateKey,与后端服务一致。',
guideImage: miGuideImage,
},
{
type: 'OPPO',
label: 'OPPO 软件商店',
shortLabel: 'OPPO',
fields: [
{ key: 'clientId', label: 'Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
guideSubtitle: '在我的 API 里创建服务端应用',
guideUrl: 'https://open.oppomobile.com/new/developmentDoc/info?id=11119',
guideSteps: [
{ title: '进入“我的 API”', description: '确认当前应用拥有服务端应用能力。' },
{ title: '新建服务端应用', description: '按平台要求创建接口凭据。' },
{ title: '保存 client_id / client_secret', description: '这两个值对应这里的 Client ID / Client Secret。' },
],
guideHint: '字段与后端 submitToOppo 读取逻辑一致。',
guideImage: oppoGuideImage,
},
{
type: 'VIVO',
label: 'vivo 应用商店',
shortLabel: 'vivo',
fields: [
{ key: 'accessKey', label: 'Access Key' },
{ key: 'accessSecret', label: 'Access Secret', type: 'password' },
],
guideSubtitle: '在 API 管理中复制 access_key / access_secret',
guideUrl: 'https://dev.vivo.com.cn/documentCenter/doc/326',
guideSteps: [
{ title: '进入 api 管理', description: '找到当前应用对应的接口管理入口。' },
{ title: '激活后再读取密钥', description: '首次启用后可能需要刷新页面。' },
{ title: '填入 Access Key / Access Secret', description: '与后端提交服务字段保持一致。' },
],
guideHint: '提交服务读取 accessKey / accessSecret。',
guideImage: vivoGuideImage,
},
{
type: 'HONOR',
label: '荣耀应用市场',
shortLabel: '荣耀',
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: 'AppGallery Connect Client ID' },
{ key: 'clientSecret', label: 'Client Secret', type: 'password' },
],
guideSubtitle: '在管理中心申请凭证并复制 Client_id / 密钥',
guideUrl: 'https://developer.honor.com/cn',
guideSteps: [
{ title: '进入管理中心', description: '打开荣耀开发者后台并进入凭证页。' },
{ title: '申请凭证', description: '创建用于服务端上传的 API 凭据。' },
{ title: '保存 Client_id / 密钥', description: '与这里的 Client ID / Client Secret 对应。' },
],
guideHint: '与后端 Honor 提交流程完全一致。',
guideImage: honorGuideImage,
},
{
type: 'APP_STORE',
label: 'Apple App Store',
shortLabel: 'App Store',
fields: [
{ key: 'teamId', label: 'Team ID' },
{ key: 'keyId', label: 'Key ID' },
{ key: 'privateKey', label: 'P8 私钥内容', type: 'textarea', placeholder: '-----BEGIN PRIVATE KEY-----\n...' },
{ key: 'bundleId', label: 'Bundle ID', placeholder: 'com.example.app' },
],
guideSubtitle: '在 App Store Connect 创建 API Key',
guideUrl: 'https://developer.apple.com/documentation/appstoreconnectapi',
guideSteps: [
{ title: '进入 App Store Connect', description: '打开用户与访问或对应的 App 详情。' },
{ title: '生成 API Key', description: '保存 Team ID、Key ID 和 p8 私钥。' },
{ title: '补充 Bundle ID', description: '这里填写目标包名或 bundle identifier。' },
],
guideHint: 'App Store 提交与后端 Apple 提交流程读取的字段一致。',
},
{
type: 'GOOGLE_PLAY',
label: 'Google Play',
shortLabel: 'Google Play',
fields: [
{ key: 'serviceAccountJson', label: '服务账号 JSON', type: 'textarea', placeholder: '{ "type": "service_account", ... }' },
],
guideSubtitle: '使用服务账号 JSON 授权上传',
guideUrl: 'https://developer.android.com/google/play/developer-api',
guideSteps: [
{ title: '创建服务账号', description: '从 Google Cloud 获取服务账号 JSON。' },
{ title: '授予 Play 管理权限', description: '把该服务账号绑定到目标应用。' },
{ title: '粘贴 JSON 内容', description: '这里保存的是上传服务端所需内容。' },
],
guideHint: '目前字段以 JSON 方式保存,方便服务端直接读取。',
},
]
// Store config helpers
function getStoreConfig(type: StoreType): StoreConfig | undefined {
return storeConfigs.value.find(c => c.storeType === type)
}
@ -526,9 +552,7 @@ function isStoreEnabled(type: StoreType): boolean {
return getStoreConfig(type)?.enabled ?? false
}
const enabledStores = computed(() =>
STORE_DEFS.filter(s => isStoreEnabled(s.type))
)
const enabledStores = computed(() => STORE_DEFS.filter(s => isStoreEnabled(s.type)))
async function toggleStore(type: StoreType, enabled: boolean) {
const cfg = getStoreConfig(type)
@ -536,31 +560,38 @@ async function toggleStore(type: StoreType, enabled: boolean) {
try {
await updateAdminApi.saveStoreConfig(appId, type, cfg.configJson ?? '{}', enabled)
await loadStoreConfigs()
} catch { ElMessage.error('操作失败') }
} catch {
ElMessage.error('操作失败')
}
}
async function loadStoreConfigs() {
try {
const res = await updateAdminApi.getStoreConfigs(appId)
storeConfigs.value = res.data.data
} catch { /* silent */ }
} catch {
storeConfigs.value = []
}
}
// Store config dialog
const showStoreConfig = ref(false)
const savingStoreConfig = ref(false)
const currentStoreDef = ref<typeof STORE_DEFS[0] | null>(null)
const currentStoreDef = ref<StoreDef | null>(null)
const storeConfigForm = ref<{ enabled: boolean; values: Record<string, string> }>({
enabled: true, values: {},
enabled: true,
values: {},
})
function openStoreConfigDialog(store: typeof STORE_DEFS[0]) {
function openStoreConfigDialog(store: StoreDef) {
currentStoreDef.value = store
const existing = getStoreConfig(store.type)
let values: Record<string, string> = {}
if (existing?.configJson) {
try { values = JSON.parse(existing.configJson) } catch { /* ignore */ }
try {
values = JSON.parse(existing.configJson)
} catch {
values = {}
}
}
storeConfigForm.value = { enabled: existing?.enabled ?? true, values }
showStoreConfig.value = true
@ -579,8 +610,11 @@ async function saveStoreConfig() {
ElMessage.success('凭据已保存')
showStoreConfig.value = false
await loadStoreConfigs()
} catch { ElMessage.error('保存失败') }
finally { savingStoreConfig.value = false }
} catch {
ElMessage.error('保存失败')
} finally {
savingStoreConfig.value = false
}
}
async function removeStoreConfig(type: StoreType) {
@ -589,11 +623,11 @@ async function removeStoreConfig(type: StoreType) {
await updateAdminApi.deleteStoreConfig(appId, type)
ElMessage.success('已删除')
await loadStoreConfigs()
} catch { ElMessage.error('删除失败') }
} catch {
ElMessage.error('删除失败')
}
}
// Submit to stores
const showSubmitStore = ref(false)
const submittingToStores = ref(false)
const submitStoreVersion = ref<AppVersion | null>(null)
@ -612,13 +646,14 @@ async function confirmSubmitToStores() {
await updateAdminApi.executeSubmitToStores(submitStoreVersion.value.id, selectedStores.value)
ElMessage.success('已提交,服务端正在向应用市场上传,审核状态将通过 Webhook 或刷新页面查看')
showSubmitStore.value = false
loadAppVersions()
} catch { ElMessage.error('提交失败') }
finally { submittingToStores.value = false }
await loadAppVersions()
} catch {
ElMessage.error('提交失败')
} finally {
submittingToStores.value = false
}
}
// Gray release
const showGray = ref(false)
const submittingGray = ref(false)
const grayTarget = ref<{ id: string; type: 'app' | 'rn' } | null>(null)
@ -637,18 +672,18 @@ async function submitGray() {
const { id, type } = grayTarget.value
if (type === 'app') {
await updateAdminApi.grayAppVersion(id, grayForm.value.enabled, grayForm.value.percent)
loadAppVersions()
await loadAppVersions()
} else {
await updateAdminApi.grayRnBundle(id, grayForm.value.enabled, grayForm.value.percent)
loadRnBundles()
await loadRnBundles()
}
ElMessage.success('灰度配置已保存')
showGray.value = false
} finally { submittingGray.value = false }
} finally {
submittingGray.value = false
}
}
// Upload app
const showUploadApp = ref(false)
const uploadingApp = ref(false)
const appUploadForm = ref({
@ -666,9 +701,34 @@ const appUploadForm = ref({
autoPublishAfterReview: false,
})
async function onAppPackageChange(uploadFile: { raw?: File } | null) {
const file = uploadFile?.raw ?? null
appUploadForm.value.file = file
if (!file) return
const formData = new FormData()
formData.append('apkFile', file)
try {
const res = await updateAdminApi.inspectAppPackage(formData)
const inspected = res.data.data as AppPackageInspectResult
if (inspected.platform) appUploadForm.value.platform = inspected.platform
if (inspected.packageName) appUploadForm.value.packageName = inspected.packageName
if (inspected.versionName) appUploadForm.value.versionName = inspected.versionName
if (typeof inspected.versionCode === 'number') appUploadForm.value.versionCode = inspected.versionCode
} catch {
ElMessage.warning('已选择文件,但未能完整识别,请补全版本信息后上传')
}
}
async function submitAppUpload() {
const f = appUploadForm.value
if (!f.file) return ElMessage.warning('请先选择应用包文件')
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
if (f.scheduledPublishAt && f.autoPublishAfterReview) {
f.autoPublishAfterReview = false
ElMessage.warning('定时发布和审核后自动发布互斥,已按定时发布处理')
}
uploadingApp.value = true
try {
const fd = new FormData()
@ -683,123 +743,106 @@ async function submitAppUpload() {
if (f.webhookUrl) fd.append('webhookUrl', f.webhookUrl)
if (f.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(f.storeTargets))
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
if (f.file) fd.append('apkFile', f.file)
fd.append('apkFile', f.file)
const resp = await updateAdminApi.uploadAppVersion(fd)
if (f.autoSubmitStore && f.storeTargets.length) {
const versionId = resp.data.data.id
await updateAdminApi.executeSubmitToStores(versionId, f.storeTargets)
await updateAdminApi.executeSubmitToStores(resp.data.data.id, f.storeTargets)
}
ElMessage.success('上传成功')
showUploadApp.value = false
loadAppVersions()
} finally { uploadingApp.value = false }
await loadAppVersions()
} finally {
uploadingApp.value = false
}
}
// Upload RN bundle
const showUploadRn = ref(false)
const uploadingRn = ref(false)
const rnUploadForm = ref({
moduleId: '', platform: 'ANDROID' as 'ANDROID' | 'IOS',
version: '', minCommonVersion: '', note: '', file: null as File | null,
moduleId: '',
platform: 'ANDROID' as 'ANDROID' | 'IOS',
version: '',
minCommonVersion: '',
note: '',
file: null as File | null,
})
function parseRnBundleName(fileName: string): RnBundleInspectResult | null {
const baseName = fileName.replace(/\.[^.]+$/, '')
const parts = baseName.split('__')
if (parts.length >= 4) {
return {
moduleId: parts[0],
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS',
version: parts[2],
minCommonVersion: parts[3],
fileName,
detected: true,
}
}
return null
}
async function onRnBundleChange(uploadFile: { raw?: File } | null) {
const file = uploadFile?.raw ?? null
rnUploadForm.value.file = file
if (!file) return
const local = parseRnBundleName(file.name)
if (local) {
if (local.moduleId) rnUploadForm.value.moduleId = local.moduleId
if (local.platform) rnUploadForm.value.platform = local.platform
if (local.version) rnUploadForm.value.version = local.version
if (local.minCommonVersion) rnUploadForm.value.minCommonVersion = local.minCommonVersion
return
}
const formData = new FormData()
formData.append('bundle', file)
try {
const res = await updateAdminApi.inspectRnBundle(formData)
const inspected = res.data.data as RnBundleInspectResult
if (inspected.moduleId) rnUploadForm.value.moduleId = inspected.moduleId
if (inspected.platform) rnUploadForm.value.platform = inspected.platform
if (inspected.version) rnUploadForm.value.version = inspected.version
if (inspected.minCommonVersion) rnUploadForm.value.minCommonVersion = inspected.minCommonVersion
} catch {
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
}
}
async function submitRnUpload() {
const f = rnUploadForm.value
if (!f.moduleId || !f.version || !f.file) return ElMessage.warning('请填写模块ID、版本和 Bundle 文件')
uploadingRn.value = true
try {
const fd = new FormData()
fd.append('appId', appId); fd.append('moduleId', f.moduleId)
fd.append('platform', f.platform); fd.append('version', f.version)
fd.append('appId', appId)
fd.append('moduleId', f.moduleId)
fd.append('platform', f.platform)
fd.append('version', f.version)
if (f.minCommonVersion) fd.append('minCommonVersion', f.minCommonVersion)
if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd)
ElMessage.success('Bundle 上传成功')
showUploadRn.value = false
loadRnBundles()
} finally { uploadingRn.value = false }
}
// Unified upload
const showUnifiedUpload = ref(false)
const uploadingUnified = ref(false)
const unifiedAppForms = ref([
{ key: 'ANDROID', enabled: true, platform: 'ANDROID' as const, packageName: '', versionName: '', versionCode: 1, forceUpdate: false, changeLog: '', file: null as File | null },
{ key: 'IOS', enabled: true, platform: 'IOS' as const, packageName: '', versionName: '', versionCode: 1, forceUpdate: false, changeLog: '', file: null as File | null },
])
const unifiedBundleForms = ref([
{ key: 'bundle-0', moduleId: '', platform: 'ANDROID' as 'ANDROID' | 'IOS', version: '', minCommonVersion: '', note: '', file: null as File | null },
])
const unifiedReleaseOptions = ref({
scheduledPublishAt: '',
webhookUrl: '',
autoSubmitStore: false,
storeTargets: [] as StoreType[],
autoPublishAfterReview: false,
})
function addUnifiedBundle() {
unifiedBundleForms.value.push({ key: `bundle-${Date.now()}`, moduleId: '', platform: 'ANDROID', version: '', minCommonVersion: '', note: '', file: null })
}
function removeUnifiedBundle(index: number) {
unifiedBundleForms.value.splice(index, 1)
if (!unifiedBundleForms.value.length) addUnifiedBundle()
}
async function submitUnifiedUpload() {
const appItems = unifiedAppForms.value
.filter(i => i.enabled && i.file)
.map(i => ({ fileKey: i.key, platform: i.platform, versionName: i.versionName, versionCode: i.versionCode, changeLog: i.changeLog || undefined, forceUpdate: i.forceUpdate, packageName: i.packageName || undefined }))
const bundleItems = unifiedBundleForms.value
.filter(i => i.file)
.map(i => ({ fileKey: i.key, moduleId: i.moduleId, platform: i.platform, version: i.version, minCommonVersion: i.minCommonVersion || undefined, note: i.note || undefined }))
if (!appItems.length && !bundleItems.length) return ElMessage.warning('请至少选择一个包或 Bundle 文件')
for (const i of unifiedAppForms.value) {
if (i.enabled && i.file && (!i.versionName || !i.versionCode)) return ElMessage.warning('请填写整包版本信息')
await loadRnBundles()
} finally {
uploadingRn.value = false
}
const opts = unifiedReleaseOptions.value
uploadingUnified.value = true
try {
const fd = new FormData()
fd.append('appId', appId)
fd.append('manifest', JSON.stringify({ appVersions: appItems, rnBundles: bundleItems }))
if (opts.scheduledPublishAt) fd.append('scheduledPublishAt', opts.scheduledPublishAt)
if (opts.webhookUrl) fd.append('webhookUrl', opts.webhookUrl)
if (opts.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(opts.storeTargets))
fd.append('autoPublishAfterReview', String(opts.autoPublishAfterReview))
for (const i of unifiedAppForms.value) if (i.enabled && i.file) fd.append(i.key, i.file)
for (const i of unifiedBundleForms.value) if (i.file) fd.append(i.key, i.file)
const resp = await updateAdminApi.uploadUnifiedRelease(fd)
// If auto-submit to stores, trigger for each uploaded app version
if (opts.autoSubmitStore && opts.storeTargets.length) {
const versionIds: string[] = (resp.data as any)?.data?.appVersionIds ?? []
for (const vid of versionIds) {
await updateAdminApi.executeSubmitToStores(vid, opts.storeTargets).catch(() => {})
}
}
ElMessage.success('一键上传成功')
showUnifiedUpload.value = false
loadAppVersions(); loadRnBundles()
} finally { uploadingUnified.value = false }
}
// Version list actions
async function loadAppVersions() {
loadingApp.value = true
try {
const res = await updateAdminApi.listAppVersions(appId, appPlatform.value)
appVersions.value = res.data.data
} catch { ElMessage.error('加载失败') }
finally { loadingApp.value = false }
} catch {
ElMessage.error('加载失败')
} finally {
loadingApp.value = false
}
}
async function loadRnBundles() {
@ -807,30 +850,69 @@ async function loadRnBundles() {
try {
const res = await updateAdminApi.listRnBundles(appId, rnModuleFilter.value || undefined, rnPlatform.value || undefined)
rnBundles.value = res.data.data
} catch { ElMessage.error('加载失败') }
finally { loadingRn.value = false }
} catch {
ElMessage.error('加载失败')
} finally {
loadingRn.value = false
}
}
async function publishApp(id: string) { await updateAdminApi.publishAppVersion(id); ElMessage.success('已发布'); loadAppVersions() }
async function unpublishApp(id: string) { await updateAdminApi.unpublishAppVersion(id); ElMessage.success('已下架'); loadAppVersions() }
async function publishRn(id: string) { await updateAdminApi.publishRnBundle(id); ElMessage.success('已发布'); loadRnBundles() }
async function unpublishRn(id: string) { await updateAdminApi.unpublishRnBundle(id); ElMessage.success('已下架'); loadRnBundles() }
async function publishApp(id: string) {
await updateAdminApi.publishAppVersion(id)
ElMessage.success('已发布')
await loadAppVersions()
}
// Formatting helpers
async function unpublishApp(id: string) {
await updateAdminApi.unpublishAppVersion(id)
ElMessage.success('已下架')
await loadAppVersions()
}
function formatTime(t: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function statusLabel(row: { publishStatus: string }) { return { DRAFT: '草稿', PUBLISHED: '已发布', DEPRECATED: '已下架' }[row.publishStatus] ?? row.publishStatus }
function statusTagType(row: { publishStatus: string }) { return { DRAFT: '', PUBLISHED: 'success', DEPRECATED: 'info' }[row.publishStatus] ?? '' }
function storeLabel(type: string) { return STORE_DEFS.find(s => s.type === type)?.label ?? type }
function reviewLabel(state: string): string { return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state }
function reviewTagType(state: string): string { return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? '' }
async function publishRn(id: string) {
await updateAdminApi.publishRnBundle(id)
ElMessage.success('已发布')
await loadRnBundles()
}
async function unpublishRn(id: string) {
await updateAdminApi.unpublishRnBundle(id)
ElMessage.success('已下架')
await loadRnBundles()
}
function formatTime(t: string) {
return t ? new Date(t).toLocaleString('zh-CN') : '-'
}
function statusLabel(row: { publishStatus: string }) {
return { DRAFT: '草稿', PUBLISHED: '已发布', DEPRECATED: '已下架' }[row.publishStatus] ?? row.publishStatus
}
function statusTagType(row: { publishStatus: string }) {
return { DRAFT: '', PUBLISHED: 'success', DEPRECATED: 'info' }[row.publishStatus] ?? ''
}
function storeLabel(type: string) {
return STORE_DEFS.find(s => s.type === type)?.label ?? type
}
function reviewLabel(state: string): string {
return { PENDING: '待提交', UNDER_REVIEW: '审核中', APPROVED: '已通过', REJECTED: '已拒绝' }[state] ?? state
}
function reviewTagType(state: string): string {
return { PENDING: 'info', UNDER_REVIEW: 'warning', APPROVED: 'success', REJECTED: 'danger' }[state] ?? ''
}
function parseStoreReview(json?: string): { store: string; state: string }[] {
if (!json) return []
try {
const m = JSON.parse(json) as Record<string, string>
return Object.entries(m).map(([store, state]) => ({ store, state }))
} catch { return [] }
} catch {
return []
}
}
onMounted(() => {
@ -865,24 +947,41 @@ onMounted(() => {
/* Submit store checkbox */
.store-checkbox-row { padding: 6px 0; }
/* Unified upload */
.unified-grid {
/* Store config dialog */
.store-config-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
gap: 20px;
}
.unified-block, .unified-bundle-row {
.store-config-form {
min-width: 0;
}
.store-config-guide {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 12px;
background: var(--el-bg-color-page);
padding: 16px;
background: var(--el-fill-color-blank);
}
.unified-block-title, .unified-bundle-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.store-config-guide-title {
font-weight: 600;
margin-bottom: 4px;
}
.store-config-guide-subtitle {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.store-config-guide-hint {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.store-config-guide-image {
display: block;
width: 100%;
max-width: 320px;
margin-top: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
object-fit: cover;
}
.unified-bundle-row + .unified-bundle-row { margin-top: 12px; }
</style>