docs(server): 添加服务器信息记录和联调接口文档
- 创建信息记录文档,包含项目管理要求、产物范围、Git仓库、制品仓库信息 - 添加服务器部署信息,包括应用服务器、MySQL/Redis服务器、Jenkins服务配置 - 记录邮件服务、DNS/HTTPS证书配置及安全备注 - 创建API联调文档,包含线上入口、ID约定、初始化管理员账号信息 - 添加统一响应格式、常见错误码、鉴权规则说明 - 提供核心接口清单,涵盖tenant-service、im-service、push-service等服务 - 补充curl示例,包含运营平台登录、IM登录、会话管理等操作示例 - 实现会话控制器,支持置顶、免打扰、标记已读、草稿等功能 - 添加全局异常处理器,统一处理业务异常和参数校验错误 - 创建IM管理控制器,提供用户管理、好友请求、黑名单等管理功能
这个提交包含在:
父节点
de89297457
当前提交
9c1dc4fbd7
@ -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' },
|
||||
|
||||
81
docs-site/docs/demo/index.md
普通文件
81
docs-site/docs/demo/index.md
普通文件
@ -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.
|
||||
4
tenant-platform/components.d.ts
vendored
4
tenant-platform/components.d.ts
vendored
@ -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.id;IM 管理页才带 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,10 +623,10 @@ 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)
|
||||
@ -612,12 +646,13 @@ 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)
|
||||
@ -637,17 +672,17 @@ 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)
|
||||
@ -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('请填写整包版本信息')
|
||||
}
|
||||
|
||||
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(() => {})
|
||||
await loadRnBundles()
|
||||
} finally {
|
||||
uploadingRn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户