diff --git a/docs-site/docs/rn/index.md b/docs-site/docs/rn/index.md index 1898be5..82619e4 100644 --- a/docs-site/docs/rn/index.md +++ b/docs-site/docs/rn/index.md @@ -1,8 +1,8 @@ # React Native SDK 接入指南 -**包名**:`@xuqm/rn-sdk` · **版本**:0.3.x(v0.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` | 单聊、群聊、消息收发、本地 DB(WatermelonDB)| | `@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() diff --git a/tenant-platform/components.d.ts b/tenant-platform/components.d.ts index a4bd537..03b9d96 100644 --- a/tenant-platform/components.d.ts +++ b/tenant-platform/components.d.ts @@ -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'] diff --git a/tenant-platform/src/api/app.ts b/tenant-platform/src/api/app.ts index f3b7451..f63cdb0 100644 --- a/tenant-platform/src/api/app.ts +++ b/tenant-platform/src/api/app.ts @@ -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, + config: Partial & Partial, ) => client.put<{ data: FeatureService }>(`/apps/${appId}/services/config`, config, { params: { platform, serviceType }, diff --git a/tenant-platform/src/api/file.ts b/tenant-platform/src/api/file.ts new file mode 100644 index 0000000..d09cb99 --- /dev/null +++ b/tenant-platform/src/api/file.ts @@ -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) + }, +} diff --git a/tenant-platform/src/api/update.ts b/tenant-platform/src/api/update.ts index 0657eb4..0b5c8c0 100644 --- a/tenant-platform/src/api/update.ts +++ b/tenant-platform/src/api/update.ts @@ -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 ──────────────────────────────────────────────────────── diff --git a/tenant-platform/src/env.d.ts b/tenant-platform/src/env.d.ts index 13d4005..f9fc82a 100644 --- a/tenant-platform/src/env.d.ts +++ b/tenant-platform/src/env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL: string + readonly VITE_FILE_SERVICE_URL: string readonly BASE_URL: string } diff --git a/tenant-platform/src/views/update/VersionManagementView.vue b/tenant-platform/src/views/update/VersionManagementView.vue index bb4aa09..6d381f9 100644 --- a/tenant-platform/src/views/update/VersionManagementView.vue +++ b/tenant-platform/src/views/update/VersionManagementView.vue @@ -167,6 +167,94 @@ + + + + +
+ + Android + iOS + Harmony + + 刷新 + 保存配置 +
+ + + + + + + + + + + + + + + + 与默认定时发布互斥 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ {{ store.label }} +
+
+ +
+
+
+
@@ -310,7 +398,7 @@ type="info" :closable="false" show-icon - title="选中 APK / IPA 后会自动读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。" + title="选中 APK / IPA 后会先上传到文件服务,再读取包名、版本名和版本码;若识别到的包名与当前填写不一致,会提示你确认。" /> 发版配置 @@ -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([]) const loadingRn = ref(false) const storeConfigs = ref([]) +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({ + 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 | 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) + } 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(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() }) @@ -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; +}