/** * App 下载逻辑(请求对齐 bili-landpage-client,接口域名见 api/btn.ts) * * - 进页面:bottonlist 拿按钮开关(iosDown / iosStore / iosTF / iosH5 / androidButton),不含下载地址 * - 点下载:bottonclick 返回 data.link(地址数组),跳 link[index];同一请求后端记点击 * - iOS 轻量版:点击直接调 h5/config 下载描述文件(不看开关);社媒内置浏览器装不了描述文件,只弹「复制到 Safari」引导 * - iOS 商店版:iosStore(packageType 2) 走 bottonclick 拿 link * - 下载前把 { ...query, tid } 写入剪贴板供 App 首启归因 * * 组件接入: * const { init, download, guide, isSocialInApp } = useAppDownload() * onMounted(init) // 拉取按钮开关 * 轻量版 / 主包 * 商店包 */ import { ref, computed } from 'vue' import { useRoute } from 'vue-router' import { bottonclick, downloadH5, getButtonList } from '~/api/btn' import type { donwloadParam, H5param } from '~/api/interface' import { APP_ID } from '~/config/app' import { copyToClipboard } from '~/utils/clipboard' import { isSocialInAppBrowser } from '~/utils/isSocialInAppBrowser' import { renewSessionTraceId } from '~/utils/traceId' export interface ButtonList { iosDown?: boolean iosStore?: boolean iosTF?: boolean iosH5?: boolean iosLink?: string androidButton?: number hasChannelApk?: boolean [key: string]: unknown } export type DownloadGuide = 'ios' | 'android' | null export type Terminal = 'ios' | 'android' // 模块级单例:PC / 手机组件共用同一份按钮开关,init 只请求一次 const buttonList = ref({}) const ip = ref('') const ready = ref(false) let initPromise: Promise | null = null const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent) const isWeiXin = /MicroMessenger/i.test(navigator.userAgent) // 去掉空值后序列化(bili filterChannel) function filterChannel(query: Record) { const result: Record = {} Object.entries(query).forEach(([key, value]) => { if (value) result[key] = value }) return JSON.stringify(result) } function jumpToLink(link: string) { // 微信内加时间戳,避免命中缓存 window.location.href = isWeiXin ? link + '?time=' + Date.now() : link } async function fetchIp() { try { const res = await fetch('https://api.ipify.org?format=json') const data = await res.json() ip.value = data.ip || '' } catch (err) { console.log('获取外部IP失败' + err) } } export function useAppDownload() { const route = useRoute() // 当前要展示的安装引导弹层,由页面组件绑定到 IosInstallGuide / AndroidInstallTip const guide = ref(null) async function fetchButtonList() { try { const { data } = await getButtonList({ appId: APP_ID, ip: ip.value, channel: JSON.stringify(route.query), }) buttonList.value = (data as ButtonList) || {} ready.value = true } catch (err) { console.log('获取按钮列表失败' + JSON.stringify(err)) } } function init() { if (!initPromise) { initPromise = (async () => { await fetchIp() await fetchButtonList() })() } return initPromise } // bottonclick 返回的下载地址数组 async function fetchLinks(param: donwloadParam): Promise { const { data } = await bottonclick(param) const link = (data as { link?: string[] } | undefined)?.link return Array.isArray(link) ? link.filter(Boolean) : [] } async function downloadMobileConfig() { const paramData: H5param = {} if (route.query.dc) paramData.dc = String(route.query.dc) if (route.query.pc) paramData.pc = String(route.query.pc) const content = await downloadH5(paramData) const file = new File([content], 'descriptionFile.mobileconfig', { type: 'application/x-apple-aspen-config', }) const objectUrl = URL.createObjectURL(file) const link = document.createElement('a') link.href = objectUrl link.download = 'descriptionFile.mobileconfig' link.click() setTimeout(() => URL.revokeObjectURL(objectUrl), 10000) } // iOS 轻量版:不看 bottonlist 开关,直接调 h5/config 下载描述文件 async function downloadIosLite() { guide.value = 'ios' // 社媒内置浏览器无法装描述文件,只展示引导,由页面「复制到 Safari」处理 if (isSocialInAppBrowser()) return true await downloadMobileConfig() return true } // iOS 商店版:后台开了 iosStore 才有入口,走 bottonclick 拿 link async function downloadIosStore(param: donwloadParam) { if (!buttonList.value.iosStore) return false param.type = 'iosStore' param.packageType = 2 const links = await fetchLinks(param) if (!links.length) return false jumpToLink(links[0]) return true } async function downloadAndroid(param: donwloadParam, index: number) { // 先弹「安卓安装教程」(断网安装、报毒说明),再跳 APK guide.value = 'android' const links = await fetchLinks(param) const target = links[index] || links[0] if (!target) return false jumpToLink(target) return true } /** * 点击下载,返回是否拿到并开始下载(false 时页面可提示稍后重试) * @param index iOS:0 = 轻量版,1 = 商店包;Android:下载地址下标 * @param terminal 默认按 UA;PC 端弹窗里的 iOS / Android 按钮显式传入 */ async function download(index = 0, terminal: Terminal = isIos ? 'ios' : 'android') { const trace_id = renewSessionTraceId() // 写剪贴板供 App 首次启动读取做安装归因:整包 URL 参数 + tid(海角社区 App 按 tid 读) copyToClipboard(JSON.stringify({ ...route.query, tid: trace_id })) if (terminal === 'ios' && index !== 1) { try { return await downloadIosLite() } catch (err) { console.log('下载描述文件失败' + err) return false } } await init() const param: donwloadParam = { appId: APP_ID, ip: ip.value, channel: filterChannel(route.query), domain: window.location.host || '', // bili 默认值,安卓请求同样带 iosH5 type: 'iosH5', terminal, } try { return terminal === 'ios' ? await downloadIosStore(param) : await downloadAndroid(param, index) } catch (err) { console.log('下载失败' + JSON.stringify(err)) return false } } const hasIosStore = computed(() => !!buttonList.value.iosStore) // 社媒引导页「复制到 Safari」用:当前落地页地址(带渠道参数) const iosInstallUrl = computed(() => window.location.href) return { buttonList, ready, isIos, hasIosStore, isSocialInApp: isSocialInAppBrowser(), guide, iosInstallUrl, init, download, } }