Files
hjsq_landpage/src/utils/useAppDownload.ts
T
2026-11-05 12:48:17 +07:00

210 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
}
}