[Ai] chore: 初始化 hjsq_landpage 仓库
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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(51短剧 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user