docs(sdk): 添加 React Native SDK 文档和 Android/HarmonyOS 发版脚本

- 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明
- 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务
- 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能
- 实现多平台版本检查、自动重连、灰度发布等发版流程自动化
- 集成商店提交、定时发布、Webhook 回调等发布后处理功能
这个提交包含在:
XuqmGroup 2026-04-29 17:35:52 +08:00
父节点 5c93a14941
当前提交 470521c3a8
共有 7 个文件被更改,包括 317 次插入37 次删除

查看文件

@ -1,8 +1,8 @@
# React Native SDK 接入指南
**包名**`@xuqm/rn-sdk` · **版本**0.3.xv0.4.0 规划中,将引入 UserSig 鉴权
**包名**`@xuqm/rn-sdk` · **版本**0.3.x内部基础包,业务方不直接引用
> **注意**v0.4.0 将是 Breaking 版本。`initialize()` 将只保留 `appKey`,`login()` 将改为 UserSig 鉴权模式,详见[迁移指南](#迁移指南-v03x--v04x)
> **注意**`rn-sdk` 作为内部基础包存在,业务方正常接入时使用 `@xuqm/rn-common` 和各业务模块即可
---
@ -10,11 +10,11 @@
| 包 | 功能 |
|----|------|
| `@xuqm/rn-common` | 初始化、网络、设备信息(必须) |
| `@xuqm/rn-common` | 初始化、网络、设备信息,可独立使用 |
| `@xuqm/rn-im` | 单聊、群聊、消息收发、本地 DBWatermelonDB|
| `@xuqm/rn-push` | 推送设备 Token 上报 |
| `@xuqm/rn-update` | App 版本检查、RN Bundle 热更新 |
| `@xuqm/rn-sdk` | 以上所有模块的 meta 包(推荐直接使用) |
| `@xuqm/rn-sdk` | 内部基础包,随 IM / Push / Update 自动安装,不建议业务方直接引用 |
---
@ -26,12 +26,20 @@
@xuqm:registry=https://nexus.xuqinmin.com/repository/npm/
```
安装 meta 包(包含全部功能)
只使用基础能力时,直接安装 `rn-common`,不会带入 IM / Push / Update
```bash
yarn add @xuqm/rn-sdk
yarn add @xuqm/rn-common
```
按需安装模块时,`rn-im` / `rn-push` / `rn-update` 都会自动带上 `rn-common``rn-sdk`
```bash
yarn add @xuqm/rn-common @xuqm/rn-im
```
`rn-sdk` 不作为业务方直接安装入口;它会随着 `rn-im` / `rn-push` / `rn-update` 自动进入依赖树。
---
## 快速接入(当前 v0.3.x
@ -41,7 +49,7 @@ yarn add @xuqm/rn-sdk
初始化只需传入 `appKey`,平台地址由 SDK 内置,开发者无需额外配置。
```ts
import { XuqmSDK } from '@xuqm/rn-sdk'
import { XuqmSDK } from '@xuqm/rn-common'
// App 入口(如 App.tsx 的顶层)
await XuqmSDK.initialize({
@ -55,7 +63,7 @@ await XuqmSDK.initialize({
### 2. IM 登录
```ts
import { ImSDK } from '@xuqm/rn-sdk'
import { ImSDK } from '@xuqm/rn-im'
// 登录userId + 用户信息)
// v0.3.x传入用户信息,本地 DB 按 userId 自动隔离
@ -150,8 +158,8 @@ await ImSDK.removeFriend('user_002')
### 7. 消息搜索(本地)
```ts
import { ImSDK } from '@xuqm/rn-sdk'
import type { MessageSearchParams } from '@xuqm/rn-sdk'
import { ImSDK } from '@xuqm/rn-im'
import type { MessageSearchParams } from '@xuqm/rn-im'
const params: MessageSearchParams = {
keyword: '会议', // 关键词搜索
@ -166,7 +174,7 @@ const results = await ImSDK.searchMessages(params)
### 8. 推送 SDK
```ts
import { PushSDK } from '@xuqm/rn-sdk'
import { PushSDK } from '@xuqm/rn-push'
// 初始化并注册设备(登录后调用)
await PushSDK.initialize({ userId: 'user_001' })
@ -175,7 +183,7 @@ await PushSDK.initialize({ userId: 'user_001' })
### 9. 版本更新 SDK
```ts
import { UpdateSDK } from '@xuqm/rn-sdk'
import { UpdateSDK } from '@xuqm/rn-update'
// 检查 App 原生更新
const appUpdate = await UpdateSDK.checkAppUpdate()

查看文件

@ -21,7 +21,6 @@ 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']

查看文件

@ -41,6 +41,21 @@ export interface ImServiceConfig {
multiClientConversationDeleteSync: boolean
}
export interface UpdateServiceConfig {
defaultStoreTargets?: string[]
defaultPublishMode?: 'MANUAL' | 'NOW' | 'SCHEDULED' | 'AUTO_REVIEW'
defaultPublishImmediately?: boolean
defaultScheduledPublishAt?: string
defaultAutoPublishAfterReview?: boolean
defaultWebhookUrl?: string
defaultForceUpdate?: boolean
defaultGrayEnabled?: boolean
defaultGrayPercent?: number
defaultPackageName?: string
defaultAppStoreUrl?: string
defaultMarketUrl?: string
}
export const appApi = {
list: () => client.get<{ data: App[] }>('/apps'),
@ -55,6 +70,11 @@ export const appApi = {
getServices: (appId: string) =>
client.get<{ data: FeatureService[] }>(`/apps/${appId}/services`),
getService: (appId: string, platform: string, serviceType: string) =>
client.get<{ data: FeatureService }>(`/apps/${appId}/services/item`, {
params: { platform, serviceType },
}),
toggleService: (appId: string, platform: string, serviceType: string, enable: boolean) =>
client.post<{ data: FeatureService }>(`/apps/${appId}/services/toggle`, null, {
params: { platform, serviceType, enable },
@ -64,7 +84,7 @@ export const appApi = {
appId: string,
platform: string,
serviceType: string,
config: Partial<ImServiceConfig>,
config: Partial<ImServiceConfig> & Partial<UpdateServiceConfig>,
) =>
client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, {
params: { platform, serviceType },

查看文件

@ -0,0 +1,53 @@
import axios from 'axios'
const fileClient = axios.create({
baseURL: import.meta.env.VITE_FILE_SERVICE_URL ?? 'http://192.168.116.9:8086',
timeout: 30000,
})
if (import.meta.env.DEV) {
fileClient.interceptors.request.use((config) => {
console.debug('[tenant-platform][FILE] request', {
method: config.method?.toUpperCase(),
url: config.baseURL ? `${config.baseURL}${config.url ?? ''}` : config.url,
params: config.params,
})
return config
})
fileClient.interceptors.response.use((res) => {
console.debug('[tenant-platform][FILE] response', {
status: res.status,
url: res.config.url,
})
return res
})
}
fileClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export interface FileUploadResult {
url: string
thumbnailUrl?: string
hash: string
size: number
originalName: string
mimeType?: string
ext?: string
}
export const fileApi = {
uploadFile(file: File, thumbnail?: File | null) {
const formData = new FormData()
formData.append('file', file)
if (thumbnail) {
formData.append('thumbnail', thumbnail)
}
return fileClient.post<{ data: FileUploadResult }>('/api/file/upload', formData)
},
}

查看文件

@ -24,8 +24,18 @@ if (import.meta.env.DEV) {
}
updateClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
const url = config.url ?? ''
const skipAuth = (
url.startsWith('/api/v1/updates/app/check') ||
url.startsWith('/api/v1/updates/app/inspect') ||
url.startsWith('/api/v1/rn/update/check') ||
url.startsWith('/api/v1/rn/inspect') ||
url.startsWith('/api/v1/rn/files/')
)
if (!skipAuth) {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
}
return config
})
@ -147,14 +157,12 @@ export const updateAdminApi = {
},
uploadAppVersion(formData: FormData) {
return updateClient.post<{ data: AppVersion }>('/api/v1/updates/app/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return updateClient.post<{ data: AppVersion }>('/api/v1/updates/app/upload', formData)
},
inspectAppPackage(formData: FormData) {
return updateClient.post<{ data: AppPackageInspectResult }>('/api/v1/updates/app/inspect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
inspectAppPackage(apkUrl: string) {
return updateClient.get<{ data: AppPackageInspectResult }>('/api/v1/updates/app/inspect', {
params: { apkUrl },
})
},
@ -177,21 +185,15 @@ export const updateAdminApi = {
},
uploadRnBundle(formData: FormData) {
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return updateClient.post<{ data: RnBundle }>('/api/v1/rn/upload', formData)
},
inspectRnBundle(formData: FormData) {
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return updateClient.post<{ data: RnBundleInspectResult }>('/api/v1/rn/inspect', formData)
},
uploadUnifiedRelease(formData: FormData) {
return updateClient.post('/api/v1/updates/unified/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return updateClient.post('/api/v1/updates/unified/upload', formData)
},
// ── Store config ────────────────────────────────────────────────────────

查看文件

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_FILE_SERVICE_URL: string
readonly BASE_URL: string
}

查看文件

@ -167,6 +167,94 @@
</el-card>
</div>
</el-tab-pane>
<!-- Release Defaults -->
<el-tab-pane label="发版默认配置" name="release-config">
<el-alert
title="这里配置的是当前租户应用的更新默认值,脚本运行时会自动读取并优先使用这些设置。它们不会替代每次发版时的最终确认。"
type="info"
show-icon
:closable="false"
style="margin-bottom:16px"
/>
<div class="toolbar">
<el-radio-group v-model="releaseConfigPlatform" @change="loadReleaseConfig" 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 @click="loadReleaseConfig" :loading="loadingReleaseConfig">刷新</el-button>
<el-button type="primary" @click="saveReleaseConfig" :loading="savingReleaseConfig">保存配置</el-button>
</div>
<el-form :model="releaseConfigForm" label-width="150px" class="release-config-form">
<el-form-item label="默认发布模式">
<el-select v-model="releaseConfigForm.defaultPublishMode" style="width:240px">
<el-option value="MANUAL" label="手动发布" />
<el-option value="NOW" label="上传即发布" />
<el-option value="SCHEDULED" label="定时发布" />
<el-option value="AUTO_REVIEW" label="审核通过后自动发布" />
</el-select>
</el-form-item>
<el-form-item label="默认计划发布时间">
<el-date-picker
v-model="releaseConfigForm.defaultScheduledPublishAt"
type="datetime"
placeholder="留空则不默认定时"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
/>
</el-form-item>
<el-form-item label="默认审核后自动发布">
<el-switch v-model="releaseConfigForm.defaultAutoPublishAfterReview" :disabled="!!releaseConfigForm.defaultScheduledPublishAt" />
<span class="form-tip">与默认定时发布互斥</span>
</el-form-item>
<el-form-item label="默认立即发布">
<el-switch v-model="releaseConfigForm.defaultPublishImmediately" :disabled="!!releaseConfigForm.defaultScheduledPublishAt" />
</el-form-item>
<el-form-item label="默认 Webhook URL">
<el-input v-model="releaseConfigForm.defaultWebhookUrl" placeholder="审核状态变更时回调的 URL" />
</el-form-item>
<el-form-item label="默认强制更新">
<el-switch v-model="releaseConfigForm.defaultForceUpdate" />
</el-form-item>
<el-form-item label="默认灰度开关">
<el-switch v-model="releaseConfigForm.defaultGrayEnabled" />
</el-form-item>
<el-form-item v-if="releaseConfigForm.defaultGrayEnabled" label="默认灰度比例">
<el-slider v-model="releaseConfigForm.defaultGrayPercent" :min="1" :max="100" show-input />
</el-form-item>
<el-form-item label="默认包名 / Bundle ID">
<el-input v-model="releaseConfigForm.defaultPackageName" placeholder="脚本可自动读取,也可在这里预置" />
</el-form-item>
<el-form-item v-if="releaseConfigPlatform === 'IOS'" label="默认 App Store 链接">
<el-input v-model="releaseConfigForm.defaultAppStoreUrl" placeholder="iOS 默认跳转链接" />
</el-form-item>
<el-form-item v-if="releaseConfigPlatform === 'HARMONY'" label="默认应用市场链接">
<el-input v-model="releaseConfigForm.defaultMarketUrl" placeholder="Harmony 默认市场链接" />
</el-form-item>
<el-form-item label="默认市场提交目标">
<el-checkbox-group
v-model="releaseConfigForm.defaultStoreTargets"
:disabled="releaseStoreDefs.length === 0"
>
<div v-if="releaseStoreDefs.length" class="store-checkbox-grid">
<div v-for="store in releaseStoreDefs" :key="store.type" class="release-store-checkbox-row">
<el-checkbox :value="store.type">{{ store.label }}</el-checkbox>
</div>
</div>
<el-alert
v-else
type="info"
:closable="false"
show-icon
title="当前平台没有需要默认勾选的市场目标,仍可手动在发版时选择。"
/>
</el-checkbox-group>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
@ -310,7 +398,7 @@
type="info"
:closable="false"
show-icon
title="选中 APK / IPA 后会自动读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。"
title="选中 APK / IPA 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。"
/>
<el-divider content-position="left">发版配置</el-divider>
<el-form-item label="定时发布">
@ -385,6 +473,8 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type UpdateServiceConfig } from '@/api/app'
import { fileApi } from '@/api/file'
import {
updateAdminApi,
type AppPackageInspectResult,
@ -414,6 +504,33 @@ const loadingApp = ref(false)
const rnBundles = ref<RnBundle[]>([])
const loadingRn = ref(false)
const storeConfigs = ref<StoreConfig[]>([])
const releaseConfigPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
const loadingReleaseConfig = ref(false)
const savingReleaseConfig = ref(false)
const releaseStoreDefs = computed(() => {
if (releaseConfigPlatform.value === 'ANDROID') {
return STORE_DEFS.filter(store => store.type !== 'APP_STORE')
}
if (releaseConfigPlatform.value === 'IOS') {
return STORE_DEFS.filter(store => store.type === 'APP_STORE')
}
return []
})
const releaseConfigForm = ref<UpdateServiceConfig>({
defaultStoreTargets: [],
defaultPublishMode: 'MANUAL',
defaultPublishImmediately: false,
defaultScheduledPublishAt: '',
defaultAutoPublishAfterReview: false,
defaultWebhookUrl: '',
defaultForceUpdate: false,
defaultGrayEnabled: false,
defaultGrayPercent: 0,
defaultPackageName: '',
defaultAppStoreUrl: '',
defaultMarketUrl: '',
})
type FieldDef = { key: string; label: string; type?: 'password' | 'textarea'; placeholder?: string }
type GuideStep = { title: string; description: string }
@ -641,6 +758,66 @@ async function removeStoreConfig(type: StoreType) {
}
}
function normalizeReleaseConfig(raw: Partial<UpdateServiceConfig> | null | undefined): UpdateServiceConfig {
return {
defaultStoreTargets: raw?.defaultStoreTargets ?? [],
defaultPublishMode: raw?.defaultPublishMode ?? 'MANUAL',
defaultPublishImmediately: raw?.defaultPublishImmediately ?? false,
defaultScheduledPublishAt: raw?.defaultScheduledPublishAt ?? '',
defaultAutoPublishAfterReview: raw?.defaultAutoPublishAfterReview ?? false,
defaultWebhookUrl: raw?.defaultWebhookUrl ?? '',
defaultForceUpdate: raw?.defaultForceUpdate ?? false,
defaultGrayEnabled: raw?.defaultGrayEnabled ?? false,
defaultGrayPercent: raw?.defaultGrayPercent ?? 0,
defaultPackageName: raw?.defaultPackageName ?? '',
defaultAppStoreUrl: raw?.defaultAppStoreUrl ?? '',
defaultMarketUrl: raw?.defaultMarketUrl ?? '',
}
}
function parseReleaseConfig(config?: string | null): UpdateServiceConfig {
if (!config) return normalizeReleaseConfig({})
try {
return normalizeReleaseConfig(JSON.parse(config) as Partial<UpdateServiceConfig>)
} catch {
return normalizeReleaseConfig({})
}
}
async function loadReleaseConfig() {
loadingReleaseConfig.value = true
try {
const res = await appApi.getService(appId, releaseConfigPlatform.value, 'UPDATE')
releaseConfigForm.value = parseReleaseConfig(res.data.data.config)
const cfg = releaseConfigForm.value
if (cfg.defaultScheduledPublishAt) {
cfg.defaultPublishImmediately = false
cfg.defaultAutoPublishAfterReview = false
}
} catch {
releaseConfigForm.value = normalizeReleaseConfig({})
} finally {
loadingReleaseConfig.value = false
}
}
async function saveReleaseConfig() {
savingReleaseConfig.value = true
try {
const cfg = normalizeReleaseConfig(releaseConfigForm.value)
if (cfg.defaultScheduledPublishAt) {
cfg.defaultPublishImmediately = false
cfg.defaultAutoPublishAfterReview = false
}
await appApi.updateServiceConfig(appId, releaseConfigPlatform.value, 'UPDATE', cfg)
ElMessage.success('发版默认配置已保存')
} catch {
ElMessage.error('保存失败')
} finally {
savingReleaseConfig.value = false
}
}
const showSubmitStore = ref(false)
const submittingToStores = ref(false)
const submitStoreVersion = ref<AppVersion | null>(null)
@ -707,6 +884,7 @@ const appUploadForm = ref({
forceUpdate: false,
changeLog: '',
file: null as File | null,
fileUrl: '',
scheduledPublishAt: '',
webhookUrl: '',
autoPublishAfterReview: false,
@ -717,12 +895,14 @@ const appUploadForm = ref({
async function onAppPackageChange(uploadFile: { raw?: File } | null) {
const file = uploadFile?.raw ?? null
appUploadForm.value.file = file
appUploadForm.value.fileUrl = ''
if (!file) return
const formData = new FormData()
formData.append('apkFile', file)
try {
const res = await updateAdminApi.inspectAppPackage(formData)
const uploaded = await fileApi.uploadFile(file)
const fileInfo = uploaded.data.data
appUploadForm.value.fileUrl = fileInfo.url
const res = await updateAdminApi.inspectAppPackage(fileInfo.url)
const inspected = res.data.data as AppPackageInspectResult
if (inspected.platform) appUploadForm.value.platform = inspected.platform
if (inspected.packageName && appUploadForm.value.packageName && appUploadForm.value.packageName !== inspected.packageName) {
@ -748,7 +928,7 @@ async function onAppPackageChange(uploadFile: { raw?: File } | null) {
async function submitAppUpload() {
const f = appUploadForm.value
if (f.platform !== 'HARMONY' && !f.file) return ElMessage.warning('请先选择应用包文件')
if (f.platform !== 'HARMONY' && !f.fileUrl) 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('请填写版本信息')
@ -772,7 +952,7 @@ async function submitAppUpload() {
if (f.appStoreUrl) fd.append('appStoreUrl', f.appStoreUrl)
if (f.marketUrl) fd.append('marketUrl', f.marketUrl)
fd.append('autoPublishAfterReview', String(f.autoPublishAfterReview))
if (f.file) fd.append('apkFile', f.file)
if (f.fileUrl) fd.append('apkUrl', f.fileUrl)
await updateAdminApi.uploadAppVersion(fd)
ElMessage.success('上传成功')
showUploadApp.value = false
@ -954,6 +1134,7 @@ onMounted(() => {
loadAppVersions()
loadRnBundles()
loadStoreConfigs()
loadReleaseConfig()
})
</script>
@ -1019,4 +1200,20 @@ onMounted(() => {
border-radius: 8px;
object-fit: cover;
}
.release-config-form {
max-width: 920px;
}
.store-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px 12px;
width: 100%;
}
.release-store-checkbox-row {
display: flex;
align-items: center;
}
</style>