feat(api-public): 新增公共直播室相关功能

- 添加 HTTP 请求工具和 API 接口定义
- 实现公共直播室页面组件和业务逻辑
- 集成阿里云播放器
- 添加指纹识别功能
- 优化错误处理和国际化支持
This commit is contained in:
xingyy 2025-03-11 15:21:02 +08:00
parent 083ef52e5d
commit 4041b45cca
9 changed files with 407 additions and 1 deletions

125
app/api-public/http.js Normal file
View File

@ -0,0 +1,125 @@
import {useRuntimeConfig} from '#app'
import {ofetch} from 'ofetch'
import {message} from '@/components/x-message/useMessage.js'
import { getFingerprint } from '@/utils/fingerprint'
let httpStatusErrorHandler
let http
// HTTP 状态码映射 - 使用i18n国际化
export function setupHttp() {
if (http) return http
const config = useRuntimeConfig()
const baseURL = config.public.NUXT_PUBLIC_API_BASE
const router = useRouter()
const i18n = useNuxtApp().$i18n
// 国际化的HTTP状态码映射
const HTTP_STATUS_MAP = {
400: i18n.t('http.error.badRequest'),
401: i18n.t('http.error.unauthorized'),
403: i18n.t('http.error.forbidden'),
404: i18n.t('http.error.notFound'),
500: i18n.t('http.error.serverError'),
502: i18n.t('http.error.badGateway'),
503: i18n.t('http.error.serviceUnavailable'),
504: i18n.t('http.error.gatewayTimeout')
}
const defaultOptions = {
baseURL,
headers: { 'Content-Type': 'application/json' },
timeout: 15000, // 15秒超时
retry: 3,
retryDelay: 1000,
}
http = ofetch.create({
...defaultOptions,
// 请求拦截
async onRequest({ options, request }) {
const fingerprint = await getFingerprint()
console.log('fingerprint',fingerprint)
// 添加 token
options.headers = {
'Authorization': '12312',
...options.headers,
'fingerprint':fingerprint,
'accept-language': i18n.locale.value
}
// GET 请求添加时间戳防止缓存
if (request.toLowerCase().includes('get')) {
options.params = {
...options.params,
_t: Date.now()
}
}
},
// 响应拦截
async onResponse({ response }) {
const data = response._data
// 处理业务错误
if (data.status === 1) {
message.error(data.msg || i18n.t('http.error.operationFailed'))
}
return response
},
// 响应错误处理
async onResponseError({ response, request }) {
// 网络错误
if (!response) {
message.error(i18n.t('http.error.networkError'))
return Promise.reject(new Error(i18n.t('http.error.networkError')))
}
const status = response.status
const data = response._data
// 处理 HTTP 状态错误
const errorMessage = data.msg || HTTP_STATUS_MAP[status] || i18n.t('http.error.requestFailed')
if (Array.isArray(data.msg)) {
data.msg.forEach(item => {
httpStatusErrorHandler?.(item, status)
})
} else {
httpStatusErrorHandler?.(errorMessage, status)
}
message.error(errorMessage)
return Promise.reject(data)
},
})
return http
}
export function createAbortController() {
return new AbortController()
}
export function injectHttpStatusErrorHandler(handler) {
httpStatusErrorHandler = handler
}
export function getHttp() {
if (!http) {
throw new Error(useNuxtApp().$i18n.t('http.error.httpNotInitialized'))
}
return http
}
// 导出请求工具函数
export async function request({url,...options}) {
const http = getHttp()
try {
return await http(url, {...options,body:options.data})
} catch (error) {
throw error
}
}

View File

@ -0,0 +1,25 @@
import { request } from "../http";
export async function defaultDetail(data) {
return await request( {
url:'/api/v1/m/auction/out/default/detail',
method: 'POST',
data
})
}
export async function getLink(data) {
return await request( {
url:'/api/v1/m/auction/out/log/sendlog/aljdfoqueoirhkjsadhfiu',
method: 'POST',
data
})
}
export async function outBuyList(data) {
return await request( {
url:'/api/v1/m/auction/out/buy/list',
method: 'POST',
data
})
}

28
app/api/public/index.js Normal file
View File

@ -0,0 +1,28 @@
import { request } from "../http";
export async function defaultDetail(data) {
return await request( {
url:'/api/v1/m/auction/out/default/detail',
headers:{
'fingerprint':'12312'
},
method: 'POST',
data
})
}
export async function getLink(data) {
return await request( {
url:'/api/v1/m/auction/out/log/sendlog/aljdfoqueoirhkjsadhfiu',
method: 'POST',
data
})
}
export async function outBuyList(data) {
return await request( {
url:'/api/v1/m/auction/out/buy/list',
method: 'POST',
data
})
}

View File

@ -0,0 +1,76 @@
<script setup>
import {publicStore} from "@/stores/public/index.js";
import {useI18n} from 'vue-i18n'
import {outBuyList} from "@/api-public/public/index.js";
const {auctionData} = publicStore()
function formatThousands(num) {
return Number(num).toLocaleString();
}
const headList=[
{
label:useI18n().t('live_room.head'),
color:'#D03050',
value:'head'
},
{
label:useI18n().t('live_room.out'),
color:'#939393',
value:'out'
},
{
label:useI18n().t('live_room.success'),
color:'#34B633',
value:'success'
}
]
const buyList=ref([])
const headItem=(statusCode)=>{
return headList.find(x=>x.value===statusCode)
}
onMounted(async()=>{
const res=await outBuyList({uuid:auctionData.value.uuid})
buyList.value=res.data.buys
})
</script>
<template>
<div
id="list-container"
class="w-344px h-86px overflow-y-auto bg-#fff rounded-4px text-14px text-#939393 pt-7px pb-7px px-11px flex flex-col justify-between"
>
<transition-group name="list" tag="div">
<template v-if="buyList?.length>0">
<div v-for="(item, index) in buyList" :key="index" class="flex flex-shrink-0">
<!-- 将每列宽度改为相等(约86px)添加文本溢出处理 -->
<div class="text-start shrink-0 w-1/4 truncate" :style="`color: ${headItem(item.statusCode).color}`">
{{ headItem(item.statusCode).label }}
</div>
<div class="text-start shrink-0 w-1/4 truncate">
{{ item.auctionType==='local'? $t('live_room.spot'):$t('live_room.network') }}
</div>
<div class="text-start shrink-0 w-1/4 truncate">
{{ item.createdAt }}
</div>
<div class="text-start shrink-0 w-1/4 truncate">
{{item.baseCurrency}}{{ formatThousands(item.baseMoney) }}
</div>
</div>
</template>
</transition-group>
</div>
</template>
<style scoped>
.list-enter-active, .list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from, .list-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>

View File

@ -0,0 +1,123 @@
<script setup>
import { defaultDetail,getLink } from '@/api-public/public/index.js'
import { liveStore } from '@/stores/live/index.js'
import AliyunPlayer from 'aliyun-aliplayer'
import { publicStore } from '@/stores/public/index.js'
import 'aliyun-aliplayer/build/skins/default/aliplayer-min.css'
import broadcast from './components/broadcast/index.vue'
const {decryptUtils} = liveStore()
const {auctionData} = publicStore()
definePageMeta({
layout: 'publicLiveRoom'
})
const {t}= useI18n()
const pullLink = ref('')
const player = ref(null)
const loading1=ref(false)
const handlePlayerError = (error) => {
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
const initializePlayer = async () => {
try {
if (player.value) {
player.value.dispose()
}
//
const isWechat = /MicroMessenger/i.test(navigator.userAgent)
const playerConfig = {
id: 'J_prismPlayer1',
source: pullLink.value,
isLive: true,
preload: true,
autoplay: true, // true
muted: true, //
diagnosisButtonVisible:false,
// vodRetry:10,
// liveRetry:10,
autoplayPolicy: {
fallbackToMute: true
},
width: '100%', //
height: '100%', //
skinLayout: false,
controlBarVisibility: 'never',
license: {
domain: "szjixun.cn",
key: "OProxmWaOZ2XVHXLtf4030126521c43429403194970aa8af9"
}
}
player.value = new AliyunPlayer(playerConfig, (playerInstance) => {
//
if (isWechat) {
const startPlay = () => {
playerInstance?.play()
document.removeEventListener('WeixinJSBridgeReady', startPlay)
document.removeEventListener('touchstart', startPlay)
}
document.addEventListener('WeixinJSBridgeReady', startPlay)
document.addEventListener('touchstart', startPlay)
}
loading1.value = true
playerInstance?.play()
})
player.value.on('playing', () => {
loading1.value = false
})
player.value.on('loading', () => {
})
player.value.on('error', handlePlayerError)
} catch (error) {
console.log('error',error)
showConfirmDialog({
message: t('live_room.error_mess'),
showCancelButton: true
}).then(() => {
initializePlayer()
}).catch(() => {
})
}
}
onMounted(async () => {
const res = await defaultDetail({})
if(res.status === 0){
auctionData.value = res.data
}
const linkRes = await getLink({uuid:auctionData.value.uuid})
pullLink.value =decryptUtils.decryptData(linkRes.data.code)
initializePlayer()
})
</script>
<template>
<div class="grow-1 relative">
<van-nav-bar :title="auctionData.title" />
<div id="J_prismPlayer1" class="w-100vw" style="height: calc(100vh - var(--van-nav-bar-height));"></div>
<div v-if="loading1" class="absolute left-1/2 transform translate-x--1/2 top-1/2 translate-y--1/2">
<van-loading type="spinner" >直播加载中...</van-loading>
</div>
<div class="absolute left-1/2 transform -translate-x-1/2" style="bottom:calc(var(--safe-area-inset-bottom) + 46px)">
<broadcast></broadcast>
</div>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@ -1,6 +1,9 @@
import { setupHttp } from '@/api/http'
import { setupHttp as setupHttp1} from '@/api-collect-code/http'
import { setupHttp as setupHttp1} from '@/api-collect-code/http'
import { setupHttp as setupHttp2} from '@/api-public/http'
export default defineNuxtPlugin(() => {
setupHttp()
setupHttp1()
setupHttp2()
})

View File

@ -248,6 +248,7 @@ export const liveStore = createGlobalState(() => {
}
}
return{
decryptUtils,
wsClient,
fullLive,
isMinWindow,

View File

@ -0,0 +1,8 @@
import {createGlobalState, useLocalStorage} from '@vueuse/core'
export const publicStore = createGlobalState(() => {
const auctionData=useLocalStorage('auctionData',{})
return {
auctionData
}
})

17
app/utils/fingerprint.js Normal file
View File

@ -0,0 +1,17 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs'
export async function getFingerprint() {
try {
// 初始化 FingerprintJS
const fp = await FingerprintJS.load()
// 获取访问者的指纹
const result = await fp.get()
// 返回指纹哈希值
return result.visitorId
} catch (error) {
console.error('获取浏览器指纹失败:', error)
return null
}
}