210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
/**
|
||
* 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) // 拉取按钮开关
|
||
* <a @click="download(0)">轻量版 / 主包</a>
|
||
* <a @click="download(1)">商店包</a>
|
||
*/
|
||
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<ButtonList>({})
|
||
const ip = ref('')
|
||
const ready = ref(false)
|
||
let initPromise: Promise<void> | null = null
|
||
|
||
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent)
|
||
const isWeiXin = /MicroMessenger/i.test(navigator.userAgent)
|
||
|
||
// 去掉空值后序列化(bili filterChannel)
|
||
function filterChannel(query: Record<string, unknown>) {
|
||
const result: Record<string, unknown> = {}
|
||
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<DownloadGuide>(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<string[]> {
|
||
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,
|
||
}
|
||
}
|