docs(sdk): 添加 Android SDK 文档和 API 设计规范

- 新增 Android SDK 使用文档,包含模块结构、集成方式和快速开始指南
- 添加 SDK API 重设计规范,统一初始化和登录接口设计
- 补充安全设计规范,完善 UserSig 鉴权和敏感数据处理方案
- 创建平台 REST API 规范,定义服务端到服务端的调用接口
- 添加离线推送架构设计,集成各大厂商推送服务与 IM 联动方案
这个提交包含在:
XuqmGroup 2026-04-29 15:46:40 +08:00
父节点 9c1dc4fbd7
当前提交 fb82e4b8b6
共有 13 个文件被更改,包括 289 次插入459 次删除

查看文件

@ -39,13 +39,13 @@ dependencies {
### 2. 初始化
只需传入 `appId`,服务器地址由 SDK 内置,**无需传 `serverUrl`**。
只需传入 `appKey`,服务器地址由 SDK 内置,**无需传 `serverUrl`**。
```kotlin
// Application.onCreate()
XuqmSDK.initialize(
context = this,
appId = "your_app_id", // 在租户平台创建应用后获得
appKey = "your_app_key", // 在租户平台创建应用后获得
logLevel = LogLevel.WARN, // 可选,DEBUG 开启详细日志
)
```

查看文件

@ -6,7 +6,7 @@
1. 访问 [XuqmGroup 控制台](https://dev.xuqinmin.com)
2. 注册租户账号,创建应用
3. 记录 `appId`(即 `appKey`
3. 记录 `appKey`
## 2. 选择你的平台
@ -25,7 +25,7 @@
```
API 地址https://dev.xuqinmin.com
WS 地址wss://dev.xuqinmin.com/ws/im
演示 AppIdak_demo_chat
演示 AppKeyak_demo_chat
演示用户demo_alice / demo_bob
```
@ -37,8 +37,9 @@ WS 地址wss://dev.xuqinmin.com/ws/im
```
你的业务服务端
→ 持有 appId/appSecret
→ 调用 POST /api/im/auth/login?appId=&userId=&nickname= 换取 IM Token
→ 持有 appKey/appSecret
→ 调用 IM 登录接口换取 IM Token
→ 平台内部协议字段由 SDK 和后端自动处理,业务方无需感知
→ 返回 Token 给客户端
客户端 SDK

查看文件

@ -44,11 +44,7 @@ import { XuqmSDK } from '@xuqm/harmony-sdk'
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
XuqmSDK.init({
appId: 'your_app_id',
appKey: 'your_app_id',
appSecret: 'your_app_secret',
apiBaseUrl: 'https://dev.xuqinmin.com',
imWsUrl: 'wss://dev.xuqinmin.com/ws/im',
appKey: 'your_app_key',
debug: true,
})
}
@ -113,8 +109,8 @@ const sent = await ImSDK.sendMessage({ toId: group.id, chatType: 'GROUP', msgTyp
import { UpdateSDK } from '@xuqm/harmony-sdk'
const appInfo = await UpdateSDK.checkAppUpdate(1)
if (appInfo?.forceUpdate) {
// 跳转应用市场
if (appInfo?.marketUrl) {
await UpdateSDK.openAppMarket(getContext(this), appInfo.marketUrl)
}
const bundle = await UpdateSDK.checkRNUpdate('home', '1.0.0')
@ -124,6 +120,8 @@ if (bundle) {
}
```
Harmony 的整包更新只提供应用市场跳转,不提供本地安装包下载。
## ArkUI 示例
```typescript

查看文件

@ -52,10 +52,8 @@ dependencies: [
import XuqmCore
XuqmSDK.shared.initialize(
appKey: "your_app_id",
appKey: "your_app_key",
appSecret: "your_app_secret",
apiBaseUrl: "https://dev.xuqinmin.com",
imWsUrl: "wss://dev.xuqinmin.com/ws/im",
debug: false
)
```
@ -65,7 +63,7 @@ XuqmSDK.shared.initialize(
```swift
import XuqmIM
// 登录appId 已在 init 时指定)
// 登录appKey 已在 init 时指定)
try await ImSDK.shared.login(userId: "user_001", nickname: "张三")
// 监听事件

查看文件

@ -2,7 +2,7 @@
**包名**`@xuqm/rn-sdk` · **版本**0.3.xv0.4.0 规划中,将引入 UserSig 鉴权)
> **注意**v0.4.0 将是 Breaking 版本。`initialize()` 将移除 `serverUrl` 参数,`login()` 将改为 UserSig 鉴权模式,详见[迁移指南](#迁移指南-v03x--v04x)。
> **注意**v0.4.0 将是 Breaking 版本。`initialize()` 将只保留 `appKey`,`login()` 将改为 UserSig 鉴权模式,详见[迁移指南](#迁移指南-v03x--v04x)。
---
@ -38,14 +38,14 @@ yarn add @xuqm/rn-sdk
### 1. 初始化
初始化只需传入 `appId`,服务器地址由 SDK 内置,**不需要传 `serverUrl`**
初始化只需传入 `appKey`,平台地址由 SDK 内置,开发者无需额外配置
```ts
import { XuqmSDK } from '@xuqm/rn-sdk'
// App 入口(如 App.tsx 的顶层)
await XuqmSDK.initialize({
appId: 'your_app_id', // 在租户平台创建应用后获得
appKey: 'your_app_key', // 在租户平台创建应用后获得
logLevel: __DEV__ ? 'debug' : 'warn', // 可选
})
```
@ -247,14 +247,14 @@ v0.4.0 预计改动(**Breaking Changes**
```diff
// 初始化
- await XuqmSDK.initialize({ appId: 'xxx', serverUrl: 'https://...' })
+ await XuqmSDK.initialize({ appId: 'xxx' }) // serverUrl 内置,无需传入
- await XuqmSDK.initialize({ appKey: 'xxx', /* 平台地址参数已移除 */ })
+ await XuqmSDK.initialize({ appKey: 'xxx' }) // 平台地址内置,无需传入
// IM 登录(改为 UserSig 鉴权,密码不再传入 SDK
- await ImSDK.login(userId, nickname, avatar, dbName)
+ const userSig = await yourServer.getUserSig(userId) // 您的服务端签发
+ await ImSDK.login(userId, userSig, { nickname, avatar })
// dbName 自动由 appId + userId 派生,无需传入
// dbName 自动由 appKey + userId 派生,无需传入
// loginWithToken 废弃
- await ImSDK.loginWithToken(userId, token, dbName)
@ -267,14 +267,14 @@ UserSig 生成方式见[安全设计文档](../../design/02-security-design.md)
## 常见问题
**Q: 如何获取 appId?**
在 [租户平台](https://dev.xuqinmin.com) 注册账号后,创建应用即可获得 AppId
**Q: 如何获取 appKey?**
在 [租户平台](https://dev.xuqinmin.com) 注册账号后,创建应用即可获得 AppKey
**Q: 为什么不需要传 serverUrl?**
**Q: 为什么不需要传平台地址参数?**
XuqmGroup 是托管平台,服务地址统一管理,与腾讯云 IM 等平台的设计一致,开发者只需关心业务逻辑。
**Q: UserSig 是什么?v0.4.0**
UserSig 是您的业务服务端用 AppSecret 为用户签发的安全凭证,有效期可配置。AppSecret 绝不下发到客户端。
**Q: 本地消息存储在哪里?**
使用 WatermelonDBSQLite,按 `appId + userId` 自动隔离,多账号切换安全。
使用 WatermelonDBSQLite,按 `appKey + userId` 自动隔离,多账号切换安全。

查看文件

@ -35,11 +35,7 @@ import App from './App.vue'
const app = createApp(App)
app.use(createXuqm({
appId: 'your_app_id',
appKey: 'your_app_id',
appSecret: 'your_app_secret',
apiBaseUrl: 'https://dev.xuqinmin.com',
imWsUrl: 'wss://dev.xuqinmin.com/ws/im',
appKey: 'your_app_key',
debug: import.meta.env.DEV,
}))

查看文件

@ -21,6 +21,7 @@ declare module 'vue' {
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']

查看文件

@ -103,25 +103,6 @@ export interface OperationLog {
createdAt: number
}
export interface FriendRequest {
id: string
appId: string
fromUserId: string
toUserId: string
remark?: string | null
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'
createdAt: number
reviewedAt?: number | null
}
export interface BlacklistEntry {
id: string
appId: string
userId: string
blockedUserId: string
createdAt: number
}
export interface KeywordFilter {
id: string
appId: string
@ -227,76 +208,6 @@ export const imAdminApi = {
})
},
listFriendRequests(appId: string) {
return imClient.get<{ data: FriendRequest[] }>('/api/im/admin/friend-requests', {
params: { appId },
})
},
sendFriendRequest(appId: string, toUserId: string, remark?: string) {
return imClient.post<{ data: FriendRequest }>(
'/api/im/friend-requests',
null,
{
params: {
appId,
toUserId,
...(remark ? { remark } : {}),
},
},
)
},
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/admin/friend-requests/${encodeURIComponent(requestId)}/accept`,
null,
{ params: { appId } },
)
},
rejectFriendRequest(appId: string, requestId: string) {
return imClient.post<{ data: FriendRequest }>(
`/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 },

查看文件

@ -44,7 +44,7 @@ export interface StoreConfig {
export interface AppVersion {
id: string
appId: string
platform: 'ANDROID' | 'IOS'
platform: 'ANDROID' | 'IOS' | 'HARMONY'
versionName: string
versionCode: number
packageName?: string
@ -65,7 +65,7 @@ export interface AppVersion {
}
export interface AppPackageInspectResult {
platform: 'ANDROID' | 'IOS'
platform: 'ANDROID' | 'IOS' | 'HARMONY'
packageName?: string
versionName?: string
versionCode?: number
@ -77,10 +77,11 @@ export interface RnBundle {
id: string
appId: string
moduleId: string
platform: 'ANDROID' | 'IOS'
platform: 'ANDROID' | 'IOS' | 'HARMONY'
version: string
md5: string
minCommonVersion?: string
packageName?: string
note?: string
publishStatus: 'DRAFT' | 'PUBLISHED' | 'DEPRECATED'
grayEnabled: boolean
@ -90,30 +91,34 @@ export interface RnBundle {
export interface RnBundleInspectResult {
moduleId?: string
platform?: 'ANDROID' | 'IOS'
platform?: 'ANDROID' | 'IOS' | 'HARMONY'
version?: string
minCommonVersion?: string
packageName?: string
fileName?: string
detected: boolean
}
export interface UnifiedAppUploadItem {
fileKey: string
platform: 'ANDROID' | 'IOS'
platform: 'ANDROID' | 'IOS' | 'HARMONY'
versionName: string
versionCode: number
changeLog?: string
forceUpdate: boolean
packageName?: string
appStoreUrl?: string
marketUrl?: string
publishImmediately: boolean
}
export interface UnifiedRnUploadItem {
fileKey: string
moduleId: string
platform: 'ANDROID' | 'IOS'
platform: 'ANDROID' | 'IOS' | 'HARMONY'
version: string
minCommonVersion?: string
packageName?: string
note?: string
}
@ -123,7 +128,7 @@ export interface UnifiedReleaseManifest {
}
export const updateAdminApi = {
listAppVersions(appId: string, platform: 'ANDROID' | 'IOS') {
listAppVersions(appId: string, platform: 'ANDROID' | 'IOS' | 'HARMONY') {
return updateClient.get<{ data: AppVersion[] }>('/api/v1/updates/app/list', {
params: { appId, platform },
})

查看文件

@ -53,6 +53,10 @@ const router = createRouter({
path: 'apps/:appId/update',
component: () => import('@/views/update/VersionManagementView.vue'),
},
{
path: 'apps/:appId/update-guide',
component: () => import('@/views/update/StoreGuideView.vue'),
},
{
path: 'accounts',
component: () => import('@/views/accounts/SubAccountView.vue'),

查看文件

@ -117,86 +117,6 @@
</el-table>
</el-tab-pane>
<el-tab-pane label="好友申请" name="friendRequests">
<div class="toolbar toolbar-space-between">
<div class="toolbar-group">
<el-button type="primary" @click="openFriendRequestDialog">发起申请</el-button>
<el-button @click="loadFriendRequests" :loading="loadingFriendRequests">刷新</el-button>
</div>
<el-select v-model="friendRequestStatusFilter" style="width: 140px">
<el-option label="全部" value="ALL" />
<el-option label="待处理" value="PENDING" />
<el-option label="已同意" value="ACCEPTED" />
<el-option label="已拒绝" value="REJECTED" />
</el-select>
</div>
<el-table :data="filteredFriendRequests" v-loading="loadingFriendRequests" border stripe>
<el-table-column prop="fromUserId" label="申请人" width="160" />
<el-table-column prop="toUserId" label="接收人" width="160" />
<el-table-column prop="remark" label="备注" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="friendRequestTagType(row.status)" size="small">
{{ friendRequestStatusLabel(row.status) }}
</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 prop="reviewedAt" label="处理时间" width="180">
<template #default="{ row }">{{ formatTime(row.reviewedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 'PENDING'"
@click="approveFriend(row)"
>
同意
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="row.status !== 'PENDING'"
@click="declineFriend(row)"
>
拒绝
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="黑名单" name="blacklist">
<div class="toolbar toolbar-space-between">
<div class="toolbar-group">
<el-button type="primary" @click="openBlacklistDialog">新增黑名单</el-button>
<el-button @click="loadBlacklist" :loading="loadingBlacklist">刷新</el-button>
</div>
</div>
<el-table :data="blacklist" v-loading="loadingBlacklist" border stripe>
<el-table-column prop="userId" label="拉黑人" width="160" />
<el-table-column prop="blockedUserId" label="被拉黑人" width="160" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeBlacklist(row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="内容治理" name="governance">
<div class="section-block">
<div class="section-head">
@ -719,50 +639,6 @@
</template>
</el-dialog>
<el-dialog
v-model="showSendFriendRequest"
title="发起好友申请"
width="520px"
@closed="resetSendFriendRequestForm"
>
<el-form :model="sendFriendRequestForm" label-width="88px">
<el-form-item label="接收人ID">
<el-input v-model="sendFriendRequestForm.toUserId" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="sendFriendRequestForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSendFriendRequest = false">取消</el-button>
<el-button type="primary" :loading="submittingSendFriendRequest" @click="submitFriendRequest">
发送
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showBlacklistDialog"
title="新增黑名单"
width="520px"
@closed="resetBlacklistForm"
>
<el-form :model="blacklistForm" label-width="96px">
<el-form-item label="拉黑人ID">
<el-input v-model="blacklistForm.userId" />
</el-form-item>
<el-form-item label="被拉黑人ID">
<el-input v-model="blacklistForm.blockedUserId" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showBlacklistDialog = false">取消</el-button>
<el-button type="primary" :loading="submittingBlacklist" @click="submitBlacklist">
保存
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showKeywordFilterDialog"
:title="editingKeywordFilterId ? '编辑过滤规则' : '新增过滤规则'"
@ -803,9 +679,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
imAdminApi,
type BlacklistEntry,
type GlobalMute,
type FriendRequest,
type GroupJoinRequest,
type ImGroup,
type ImMessage,
@ -867,14 +741,9 @@ const webhookEvents = [
{ 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: '黑名单记录移除后触发。' },
]
const loadingWebhooks = ref(false)
const showWebhookGuideDialog = ref(false)
@ -883,19 +752,6 @@ const submittingWebhook = ref(false)
const editingWebhookId = ref<string | null>(null)
const webhookForm = ref({ url: '', secret: '', enabled: true })
const friendRequests = ref<FriendRequest[]>([])
const loadingFriendRequests = ref(false)
const friendRequestStatusFilter = ref<'ALL' | 'PENDING' | 'ACCEPTED' | 'REJECTED'>('ALL')
const showSendFriendRequest = ref(false)
const submittingSendFriendRequest = ref(false)
const sendFriendRequestForm = ref({ toUserId: '', remark: '' })
const blacklist = ref<BlacklistEntry[]>([])
const loadingBlacklist = ref(false)
const showBlacklistDialog = ref(false)
const submittingBlacklist = ref(false)
const blacklistForm = ref({ userId: '', blockedUserId: '' })
const keywordFilters = ref<KeywordFilter[]>([])
const loadingKeywordFilters = ref(false)
const showKeywordFilterDialog = ref(false)
@ -980,13 +836,6 @@ const statCards = computed(() => [
{ label: '今日消息', value: stats.value?.todayMessages ?? '-' },
])
const filteredFriendRequests = computed(() => {
if (friendRequestStatusFilter.value === 'ALL') {
return friendRequests.value
}
return friendRequests.value.filter(item => item.status === friendRequestStatusFilter.value)
})
const globalMuteEnabled = computed({
get: () => globalMute.value?.enabled ?? false,
set: (enabled: boolean) => {
@ -1145,19 +994,6 @@ function resetEditGroupForm() {
}
}
function resetSendFriendRequestForm() {
sendFriendRequestForm.value = { toUserId: '', remark: '' }
}
function resetBlacklistForm() {
blacklistForm.value = { userId: '', blockedUserId: '' }
}
function openBlacklistDialog() {
resetBlacklistForm()
showBlacklistDialog.value = true
}
function resetKeywordFilterForm() {
editingKeywordFilterId.value = null
keywordFilterForm.value = {
@ -1307,30 +1143,6 @@ async function loadWebhooks() {
}
}
async function loadFriendRequests() {
loadingFriendRequests.value = true
try {
const res = await imAdminApi.listFriendRequests(appKey.value)
friendRequests.value = res.data.data
} catch {
ElMessage.error('加载好友申请失败')
} finally {
loadingFriendRequests.value = false
}
}
async function loadBlacklist() {
loadingBlacklist.value = true
try {
const res = await imAdminApi.listBlacklist(appKey.value)
blacklist.value = res.data.data
} catch {
ElMessage.error('加载黑名单失败')
} finally {
loadingBlacklist.value = false
}
}
async function loadKeywordFilters() {
loadingKeywordFilters.value = true
try {
@ -1492,11 +1304,6 @@ async function muteManagedGroupMember(user: ImUser) {
ElMessage.success('成员已禁言')
}
function openFriendRequestDialog() {
resetSendFriendRequestForm()
showSendFriendRequest.value = true
}
async function toggleGlobalMute() {
savingGlobalMute.value = true
try {
@ -1511,40 +1318,6 @@ async function toggleGlobalMute() {
}
}
async function submitBlacklist() {
if (!blacklistForm.value.userId.trim() || !blacklistForm.value.blockedUserId.trim()) {
ElMessage.warning('请填写双方用户ID')
return
}
submittingBlacklist.value = true
try {
await imAdminApi.addBlacklist(
appKey.value,
blacklistForm.value.userId.trim(),
blacklistForm.value.blockedUserId.trim(),
)
ElMessage.success('黑名单已添加')
showBlacklistDialog.value = false
resetBlacklistForm()
loadBlacklist()
} catch {
ElMessage.error('添加黑名单失败')
} finally {
submittingBlacklist.value = false
}
}
async function removeBlacklist(row: BlacklistEntry) {
await ElMessageBox.confirm(`确认移除 ${row.userId} -> ${row.blockedUserId} 的黑名单?`, '移除黑名单', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
})
await imAdminApi.removeBlacklist(appKey.value, row.userId, row.blockedUserId)
ElMessage.success('黑名单已移除')
loadBlacklist()
}
async function submitKeywordFilter() {
if (!keywordFilterForm.value.pattern.trim()) {
ElMessage.warning('请填写命中词')
@ -1586,29 +1359,6 @@ async function deleteKeywordFilter(row: KeywordFilter) {
loadKeywordFilters()
}
async function submitFriendRequest() {
if (!sendFriendRequestForm.value.toUserId.trim()) {
ElMessage.warning('请填写接收人ID')
return
}
submittingSendFriendRequest.value = true
try {
await imAdminApi.sendFriendRequest(
appKey.value,
sendFriendRequestForm.value.toUserId.trim(),
sendFriendRequestForm.value.remark.trim() || undefined,
)
ElMessage.success('好友申请已发送')
showSendFriendRequest.value = false
resetSendFriendRequestForm()
loadFriendRequests()
} catch {
ElMessage.error('发送失败')
} finally {
submittingSendFriendRequest.value = false
}
}
async function searchMessages() {
await loadMessages(0)
}
@ -1692,28 +1442,6 @@ async function revokeMessage(message: ImMessage) {
loadMessages(historyPage.value)
}
async function approveFriend(request: FriendRequest) {
await ElMessageBox.confirm(`确认同意好友申请 ${request.fromUserId} -> ${request.toUserId}`, '同意好友申请', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
})
await imAdminApi.acceptFriendRequest(appKey.value, request.id)
ElMessage.success('已同意好友申请')
loadFriendRequests()
}
async function declineFriend(request: FriendRequest) {
await ElMessageBox.confirm(`确认拒绝好友申请 ${request.fromUserId} -> ${request.toUserId}`, '拒绝好友申请', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
})
await imAdminApi.rejectFriendRequest(appKey.value, request.id)
ElMessage.success('已拒绝好友申请')
loadFriendRequests()
}
async function approveGroupJoin(request: GroupJoinRequest) {
const groupId = managedGroup.value?.id || groupRequestGroupId.value.trim()
if (!groupId) {
@ -1872,10 +1600,6 @@ async function submitEditGroup() {
function handleTabChange(tab: string) {
if (tab === 'groups' && groups.value.length === 0) {
loadGroups()
} else if (tab === 'friendRequests' && friendRequests.value.length === 0) {
loadFriendRequests()
} else if (tab === 'blacklist' && blacklist.value.length === 0) {
loadBlacklist()
} else if (tab === 'governance') {
if (keywordFilters.value.length === 0) {
loadKeywordFilters()

查看文件

@ -0,0 +1,157 @@
<template>
<div class="store-guide-page">
<el-page-header @back="$router.back()" :content="`应用配置指引 — ${appId}`" />
<el-alert
title="这里是应用商店配置的独立指引页。建议先完成凭据配置,再回到版本管理页提交审核。Harmony 应用仅跳转应用市场,不做本地安装。"
type="info"
show-icon
:closable="false"
style="margin: 16px 0;"
/>
<el-row :gutter="16">
<el-col v-for="store in STORE_GUIDES" :key="store.type" :xs="24" :md="12" :lg="8" style="margin-bottom: 16px;">
<el-card shadow="hover" class="guide-card">
<div class="guide-card-title">
<span>{{ store.label }}</span>
<el-tag size="small" type="success" v-if="store.enabled">已接入</el-tag>
</div>
<p class="guide-subtitle">{{ store.subtitle }}</p>
<el-link :href="store.url" target="_blank" type="primary">{{ store.urlLabel }}</el-link>
<el-steps direction="vertical" :active="store.steps.length" finish-status="success" style="margin: 16px 0;">
<el-step v-for="step in store.steps" :key="step.title" :title="step.title" :description="step.description" />
</el-steps>
<p class="guide-hint">{{ store.hint }}</p>
<img v-if="store.image" :src="store.image" :alt="store.label + ' 配置截图'" class="guide-image" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
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
const STORE_GUIDES = [
{
type: 'HUAWEI',
label: '华为应用市场',
subtitle: 'AppGallery Connect Connect API 凭据',
url: 'https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114',
urlLabel: '查看华为官方文档',
steps: [
{ title: '创建应用', description: '在 AppGallery Connect 中打开目标应用。' },
{ title: '创建 Connect API 凭据', description: '保存 Client ID 和 Client Secret。' },
{ title: '回到版本管理页保存', description: '配置凭据后即可提交审核。' },
],
hint: '适合上传 APK 并提交审核;支持回调跟踪审核状态。',
image: huaweiGuideImage,
enabled: true,
},
{
type: 'MI',
label: '小米应用商店',
subtitle: '使用自动发布接口的账号与私钥',
url: 'https://dev.mi.com/distribute/doc/details?pId=1134',
urlLabel: '查看小米官方文档',
steps: [
{ title: '进入应用管理', description: '定位到目标应用。' },
{ title: '准备自动发布接口密钥', description: '记录用户名和 RSA 私钥。' },
{ title: '保存到租户平台', description: '完成后可提交审核。' },
],
hint: '字段按 username / privateKey 保存。',
image: miGuideImage,
enabled: true,
},
{
type: 'OPPO',
label: 'OPPO 软件商店',
subtitle: '我的 API 服务端应用凭据',
url: 'https://open.oppomobile.com/new/developmentDoc/info?id=11119',
urlLabel: '查看 OPPO 官方文档',
steps: [
{ title: '进入我的 API', description: '确认当前应用可创建服务端应用。' },
{ title: '创建 client_id / client_secret', description: '完成服务端凭据申请。' },
{ title: '保存并提交审核', description: '配置完成后返回版本管理页。' },
],
hint: '与后端 submitToOppo 的字段一致。',
image: oppoGuideImage,
enabled: true,
},
{
type: 'VIVO',
label: 'vivo 应用商店',
subtitle: 'API 管理中的 access_key / access_secret',
url: 'https://dev.vivo.com.cn/documentCenter/doc/326',
urlLabel: '查看 vivo 官方文档',
steps: [
{ title: '进入 API 管理', description: '找到当前应用对应入口。' },
{ title: '复制 access key 和 secret', description: '按服务端要求保存。' },
{ title: '保存后提交审核', description: '审核状态会通过 Webhook 回传。' },
],
hint: '注意请求频率限制,状态拉取需做节流。',
image: vivoGuideImage,
enabled: true,
},
{
type: 'HONOR',
label: '荣耀应用市场',
subtitle: '管理中心 API 凭据',
url: 'https://developer.honor.com/cn',
urlLabel: '查看荣耀官方文档',
steps: [
{ title: '进入管理中心', description: '打开荣耀开发者后台。' },
{ title: '申请服务端凭据', description: '保存 Client ID / Client Secret。' },
{ title: '提交审核', description: '与华为同类流程接入。' },
],
hint: 'Harmony 这条线仅跳转应用市场,不提供本地安装包。',
image: honorGuideImage,
enabled: true,
},
{
type: 'APP_STORE',
label: 'Apple App Store',
subtitle: 'App Store Connect API Key',
url: 'https://developer.apple.com/documentation/appstoreconnectapi',
urlLabel: '查看 Apple 官方文档',
steps: [
{ title: '创建 API Key', description: '保存 Team ID / Key ID / p8 私钥。' },
{ title: '补充 Bundle ID', description: '回到版本管理页保存包名与链接。' },
{ title: '提交审核', description: '支持审核后自动发布。' },
],
hint: 'iOS 可填写 App Store 链接。',
enabled: true,
},
{
type: 'GOOGLE_PLAY',
label: 'Google Play',
subtitle: '服务账号 JSON 凭据',
url: 'https://developer.android.com/google/play/developer-api',
urlLabel: '查看 Google Play 官方文档',
steps: [
{ title: '创建服务账号', description: '从 Google Cloud 获取 JSON。' },
{ title: '绑定 Play 权限', description: '授予对应应用的发布权限。' },
{ title: '保存服务账号 JSON', description: '回到版本管理页保存凭据。' },
],
hint: '适合由服务端或脚本自动提交审核。',
enabled: true,
},
]
</script>
<style scoped>
.guide-card { min-height: 520px; }
.guide-card-title { display: flex; justify-content: space-between; align-items: center; font-weight: 600; margin-bottom: 8px; }
.guide-subtitle { margin: 0 0 8px; color: var(--el-text-color-secondary); }
.guide-hint { color: var(--el-text-color-secondary); font-size: 13px; margin-top: 8px; }
.guide-image { width: 100%; border-radius: 12px; margin-top: 12px; border: 1px solid var(--el-border-color-light); }
</style>

查看文件

@ -10,8 +10,10 @@
<el-radio-group v-model="appPlatform" @change="loadAppVersions" style="margin-right:12px">
<el-radio-button value="ANDROID">Android</el-radio-button>
<el-radio-button value="IOS">iOS</el-radio-button>
<el-radio-button value="HARMONY">Harmony</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="showUploadApp = true">上传新版本</el-button>
<el-button @click="goToStoreGuide">应用配置指引</el-button>
<el-button @click="loadAppVersions" :loading="loadingApp">刷新</el-button>
</div>
@ -281,25 +283,34 @@
<el-select v-model="appUploadForm.platform">
<el-option value="ANDROID" label="Android" />
<el-option value="IOS" label="iOS" />
<el-option value="HARMONY" label="Harmony" />
</el-select>
</el-form-item>
<el-form-item label="包名 / Bundle ID">
<el-input v-model="appUploadForm.packageName" placeholder="选择文件后可自动填充" />
<el-input v-model="appUploadForm.packageName" placeholder="选择文件后可自动填充,或手动填写 Harmony bundleName" />
</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-form-item v-if="appUploadForm.platform !== 'HARMONY'" label="包文件">
<el-upload :auto-upload="false" :limit="1" :on-change="onAppPackageChange" accept=".apk,.ipa">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-alert
v-if="appUploadForm.platform === 'HARMONY'"
type="warning"
:closable="false"
show-icon
title="Harmony 应用更新只提交市场配置与版本信息,不需要本地安装包。"
/>
<el-alert
v-if="appUploadForm.platform !== 'HARMONY'"
type="info"
:closable="false"
show-icon
title="选中 APK 后会自动读取包名、版本名和版本码;iOS 包若能解析到 Info.plist,也会自动填充。"
title="选中 APK / IPA 后会自动读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。"
/>
<el-divider content-position="left">发版配置</el-divider>
<el-form-item label="定时发布">
@ -315,19 +326,16 @@
<el-form-item label="Webhook 通知">
<el-input v-model="appUploadForm.webhookUrl" placeholder="审核状态变更时回调此 URL" />
</el-form-item>
<el-form-item label="自动提交市场">
<el-switch v-model="appUploadForm.autoSubmitStore" />
<span class="form-tip">上传后立即让服务端提交已配置的应用商店</span>
</el-form-item>
<el-form-item v-if="appUploadForm.autoSubmitStore" label="目标市场">
<el-checkbox-group v-model="appUploadForm.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-form-item label="审核后自动发布">
<el-switch v-model="appUploadForm.autoPublishAfterReview" :disabled="!!appUploadForm.scheduledPublishAt" />
<span class="form-tip">与定时发布互斥</span>
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'IOS'" label="App Store 链接">
<el-input v-model="appUploadForm.appStoreUrl" placeholder="可填写 App Store 链接,便于审核跳转" />
</el-form-item>
<el-form-item v-if="appUploadForm.platform === 'HARMONY'" label="应用市场链接">
<el-input v-model="appUploadForm.marketUrl" placeholder="Harmony 应用市场详情页链接" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadApp = false">取消</el-button>
@ -347,17 +355,21 @@
type="info"
:closable="false"
show-icon
title="推荐文件名格式moduleId__ANDROID__1.0.0__1.0.0.bundle,系统会按命名自动识别模块、平台、版本和最低 Common 版本。"
title="推荐文件名格式moduleId__ANDROID__1.0.0__1.0.0__com.example.app.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-option value="HARMONY" label="Harmony" />
</el-select>
</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="包名 / Bundle">
<el-input v-model="rnUploadForm.packageName" placeholder="可选,建议与应用包名一致" />
</el-form-item>
<el-form-item label="说明"><el-input v-model="rnUploadForm.note" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
@ -371,7 +383,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
updateAdminApi,
@ -389,11 +401,12 @@ import vivoGuideImage from '@/assets/update-store/vivo/01.png'
import honorGuideImage from '@/assets/update-store/honor/01.png'
const route = useRoute()
const router = useRouter()
const appId = route.params.appId as string
const activeTab = ref('app')
const appPlatform = ref<'ANDROID' | 'IOS'>('ANDROID')
const rnPlatform = ref<'ANDROID' | 'IOS' | ''>('')
const appPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const rnPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY' | ''>('')
const rnModuleFilter = ref('')
const appVersions = ref<AppVersion[]>([])
@ -687,7 +700,7 @@ async function submitGray() {
const showUploadApp = ref(false)
const uploadingApp = ref(false)
const appUploadForm = ref({
platform: 'ANDROID' as 'ANDROID' | 'IOS',
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
packageName: '',
versionName: '',
versionCode: 1,
@ -696,9 +709,9 @@ const appUploadForm = ref({
file: null as File | null,
scheduledPublishAt: '',
webhookUrl: '',
autoSubmitStore: false,
storeTargets: [] as StoreType[],
autoPublishAfterReview: false,
appStoreUrl: '',
marketUrl: '',
})
async function onAppPackageChange(uploadFile: { raw?: File } | null) {
@ -712,7 +725,20 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
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.packageName && appUploadForm.value.packageName && appUploadForm.value.packageName !== inspected.packageName) {
try {
await ElMessageBox.confirm(
`检测到文件包名为 ${inspected.packageName},当前填写为 ${appUploadForm.value.packageName}。是否强制使用文件识别结果?`,
'包名不一致',
{ type: 'warning', confirmButtonText: '使用文件包名', cancelButtonText: '保留当前填写' },
)
appUploadForm.value.packageName = inspected.packageName
} catch {
// keep current manual input
}
} else 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 {
@ -722,7 +748,9 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
async function submitAppUpload() {
const f = appUploadForm.value
if (!f.file) return ElMessage.warning('请先选择应用包文件')
if (f.platform !== 'HARMONY' && !f.file) return ElMessage.warning('请先选择应用包文件')
if (f.platform === 'HARMONY' && !f.marketUrl) return ElMessage.warning('Harmony 版本请填写应用市场链接')
if (f.platform === 'HARMONY' && !f.packageName) return ElMessage.warning('Harmony 版本请填写 bundleName / 包名')
if (!f.versionName || !f.versionCode) return ElMessage.warning('请填写版本信息')
if (f.scheduledPublishAt && f.autoPublishAfterReview) {
f.autoPublishAfterReview = false
@ -741,13 +769,11 @@ async function submitAppUpload() {
if (f.changeLog) fd.append('changeLog', f.changeLog)
if (f.scheduledPublishAt) fd.append('scheduledPublishAt', f.scheduledPublishAt)
if (f.webhookUrl) fd.append('webhookUrl', f.webhookUrl)
if (f.storeTargets.length) fd.append('storeSubmitTargets', JSON.stringify(f.storeTargets))
if (f.appStoreUrl) fd.append('appStoreUrl', f.appStoreUrl)
if (f.marketUrl) fd.append('marketUrl', f.marketUrl)
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
fd.append('apkFile', f.file)
const resp = await updateAdminApi.uploadAppVersion(fd)
if (f.autoSubmitStore && f.storeTargets.length) {
await updateAdminApi.executeSubmitToStores(resp.data.data.id, f.storeTargets)
}
if (f.file) fd.append('apkFile', f.file)
await updateAdminApi.uploadAppVersion(fd)
ElMessage.success('上传成功')
showUploadApp.value = false
await loadAppVersions()
@ -760,9 +786,10 @@ const showUploadRn = ref(false)
const uploadingRn = ref(false)
const rnUploadForm = ref({
moduleId: '',
platform: 'ANDROID' as 'ANDROID' | 'IOS',
platform: 'ANDROID' as 'ANDROID' | 'IOS' | 'HARMONY',
version: '',
minCommonVersion: '',
packageName: '',
note: '',
file: null as File | null,
})
@ -773,9 +800,10 @@ function parseRnBundleName(fileName: string): RnBundleInspectResult | null {
if (parts.length >= 4) {
return {
moduleId: parts[0],
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS',
platform: parts[1].toUpperCase() as 'ANDROID' | 'IOS' | 'HARMONY',
version: parts[2],
minCommonVersion: parts[3],
packageName: parts[4],
fileName,
detected: true,
}
@ -794,6 +822,7 @@ async function onRnBundleChange(uploadFile: { raw?: File } | null) {
if (local.platform) rnUploadForm.value.platform = local.platform
if (local.version) rnUploadForm.value.version = local.version
if (local.minCommonVersion) rnUploadForm.value.minCommonVersion = local.minCommonVersion
if (local.packageName) rnUploadForm.value.packageName = local.packageName
return
}
@ -806,6 +835,7 @@ async function onRnBundleChange(uploadFile: { raw?: File } | null) {
if (inspected.platform) rnUploadForm.value.platform = inspected.platform
if (inspected.version) rnUploadForm.value.version = inspected.version
if (inspected.minCommonVersion) rnUploadForm.value.minCommonVersion = inspected.minCommonVersion
if (inspected.packageName) rnUploadForm.value.packageName = inspected.packageName
} catch {
ElMessage.warning('已选择文件,但未能从文件名识别出 RN Bundle 元数据,请补全后上传')
}
@ -822,6 +852,7 @@ async function submitRnUpload() {
fd.append('platform', f.platform)
fd.append('version', f.version)
if (f.minCommonVersion) fd.append('minCommonVersion', f.minCommonVersion)
if (f.packageName) fd.append('packageName', f.packageName)
if (f.note) fd.append('note', f.note)
fd.append('bundle', f.file)
await updateAdminApi.uploadRnBundle(fd)
@ -915,6 +946,10 @@ function parseStoreReview(json?: string): { store: string; state: string }[] {
}
}
function goToStoreGuide() {
router.push(`/apps/${appId}/update-guide`)
}
onMounted(() => {
loadAppVersions()
loadRnBundles()