埋点,im,客服删除
@@ -7,5 +7,3 @@ VITE_PARAMETER_ENCRYPTION = 0 # 是否开启参数加密 0关闭 1开
|
||||
# VITE_BASE_URL= ["https://91djht.remoces.com"] # api选线域名,这里必须为数组
|
||||
VITE_BASE_URL= ["https://d2s4jk18c45pye.cloudfront.net","https://d1ofgr0kx88xsf.cloudfront.net"] # api选线域名,这里必须为数组
|
||||
VITE_DNS_DOMAIN = hgmj.mfcg.it.com # DNS 兜底域名:上面线路全挂时解析它的 TXT 记录拿线路
|
||||
# IM 路由域名(拉取 IM 网关配置,须与 imToken 环境一致)
|
||||
VITE_IM_DYNAMIC_CONFIG_DOMAIN=https://sdk.api.cdflow.cn
|
||||
|
||||
@@ -6,5 +6,3 @@ VITE_BUILD_VCONSOLE = false # 是否开启调试工具 vconsole
|
||||
VITE_PARAMETER_ENCRYPTION = 1 # 是否开启参数加密 0关闭 1开启
|
||||
VITE_BASE_URL= ["https://d2s4jk18c45pye.cloudfront.net","https://d1ofgr0kx88xsf.cloudfront.net"] # api选线域名,这里必须为数组
|
||||
VITE_DNS_DOMAIN = hgmj.mfcg.it.com # DNS 兜底域名:上面线路全挂时解析它的 TXT 记录拿线路
|
||||
# IM 路由域名(拉取 IM 网关配置,须与 imToken 环境一致)
|
||||
VITE_IM_DYNAMIC_CONFIG_DOMAIN=https://sdk.api.cdflow.cn
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/web-sdk-v1.1.8.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"name": "redapricot",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@mimi/im-sdk": "^2.2.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"axios": "^1.3.2",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -641,14 +640,6 @@
|
||||
"@jridgewell/sourcemap-codec": "1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mimi/im-sdk": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://packages.aliyun.com/67c6c2a49f45126533f3b576/npm/npm-registry/@mimi/im-sdk/-/@mimi/im-sdk-2.2.1.tgz",
|
||||
"integrity": "sha512-6jPnV+TxE4doNcKwmTNzjyvqKQtAwoMn4T7AZ7vpdjFrQ2uWQ+Y7STe8RN+18N9YkSl7odLoyAcZwQiEtl9wvA==",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -8244,14 +8235,6 @@
|
||||
"@jridgewell/sourcemap-codec": "1.4.14"
|
||||
}
|
||||
},
|
||||
"@mimi/im-sdk": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://packages.aliyun.com/67c6c2a49f45126533f3b576/npm/npm-registry/@mimi/im-sdk/-/@mimi/im-sdk-2.2.1.tgz",
|
||||
"integrity": "sha512-6jPnV+TxE4doNcKwmTNzjyvqKQtAwoMn4T7AZ7vpdjFrQ2uWQ+Y7STe8RN+18N9YkSl7odLoyAcZwQiEtl9wvA==",
|
||||
"requires": {
|
||||
"axios": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"lint": "eslint --ext .js,.vue,.ts src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mimi/im-sdk": "^2.2.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"axios": "^1.3.2",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import AppApi from '@/service/app';
|
||||
// import ImMessageNotify from '@/components/ImNotify/ImMessageNotify.vue';
|
||||
import { preloadImSdk, warmImIfNeeded } from '@/utils/im/sdk';
|
||||
import { useGlobalStore } from './stores/global';
|
||||
import { useUserStore } from './stores/user';
|
||||
import inobounce from '@/assets/js/inobounce.min.js';
|
||||
import { getDevType } from './utils';
|
||||
import { startAppActiveTracking, stopAppActiveTracking } from '@/utils/appActiveTime';
|
||||
const globalStore = useGlobalStore();
|
||||
const userStore = useUserStore();
|
||||
const route = useRoute();
|
||||
const keepAliveList = computed(() => {
|
||||
return globalStore.$state.keepAliveList;
|
||||
@@ -29,17 +25,6 @@ onMounted(() => {
|
||||
window.addEventListener('pagehide', AppApi.clearImgDecryptCache);
|
||||
});
|
||||
|
||||
/** 离开启动页且已登录时预热 IM(等 token 就绪后再拉凭证) */
|
||||
watch(
|
||||
() => [route.path, userStore.token] as const,
|
||||
([path, token]) => {
|
||||
if (path === '/' || !token) return;
|
||||
preloadImSdk();
|
||||
void warmImIfNeeded();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (getDevType() == 'ios') {
|
||||
inobounce.disable();
|
||||
@@ -145,7 +130,6 @@ const getComponentKey = () => {
|
||||
</script>
|
||||
<template>
|
||||
<div id="appView" @click.prevent>
|
||||
<!-- <ImMessageNotify /> -->
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="keepAliveList">
|
||||
<component :is="Component" :key="getComponentKey()" />
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="37" viewBox="0 0 36 37" fill="none">
|
||||
<path d="M18.1694 29.1902C18.3511 29.1902 18.5361 29.1792 18.7221 29.1571C20.5066 28.9439 22.022 27.7025 22.5813 25.9916C22.6234 25.8635 22.5625 25.7266 22.4395 25.6703C22.3177 25.6139 22.1704 25.6625 22.1017 25.7774C22.0951 25.7884 21.3861 26.9283 18.6258 27.2994C18.2126 27.3546 17.8105 27.3833 17.4295 27.3833C15.4955 27.3822 14.6337 26.6566 14.6259 26.6499C14.5285 26.5638 14.38 26.5616 14.2803 26.6455C14.1796 26.7295 14.1563 26.8752 14.2249 26.9868C15.0668 28.3453 16.5777 29.1902 18.1694 29.1902ZM33.0114 12.1636C32.987 12.1636 32.9648 12.168 32.9416 12.168C31.3243 5.19877 25.1445 0 17.7352 0C10.1221 0 3.80052 5.48596 2.39925 12.7434C1.03681 12.9875 0 14.1793 0 15.6206V21.3893C0 23.0063 1.30266 24.3173 2.91102 24.3173C3.81823 24.3173 4.61909 23.891 5.15188 23.236C6.43896 26.6244 9.05965 29.3568 12.3759 30.7982C12.3839 30.781 12.4467 30.6596 12.5221 30.5628C12.5744 30.4956 12.6328 30.4405 12.6831 30.4405C12.7351 30.4405 12.7828 30.4593 12.8237 30.488C12.0561 29.9192 9.28356 26.9956 8.68319 22.9157C8.41956 21.1198 9.77093 19.3571 11.345 19.0655C13.8716 18.5972 16.385 18.0637 18.9116 17.6043C20.5177 17.3127 21.6155 16.4358 22.2867 14.9757C22.444 14.6343 22.6711 13.9441 22.7753 12.9489C22.8029 12.8009 22.9248 12.6872 23.081 12.6872C23.1851 12.6872 23.2737 12.7402 23.3325 12.8164L23.4022 12.7733C24.398 14.2124 26.3731 17.3989 26.6566 20.7587C26.9823 24.6001 26.8006 27.2309 23.8464 29.9225C23.8431 29.9258 23.8387 29.9302 23.8343 29.9336C23.7933 29.9777 23.7689 30.0352 23.7689 30.0981C23.7689 30.181 23.8132 30.2517 23.8775 30.2947C23.9018 30.3047 23.9262 30.3179 23.9506 30.3279C23.9705 30.3323 23.9893 30.3389 24.0093 30.3389C24.0292 30.3389 24.047 30.3323 24.0647 30.3279C24.1068 30.3058 24.1466 30.2804 24.1876 30.2583C27.1751 28.6203 29.4669 25.8846 30.5258 22.5943C30.9545 23.2283 31.6257 23.6844 32.4056 23.8435C31.155 29.505 26.0818 33.0482 19.7967 33.5861C19.4201 32.6561 18.4929 31.9956 17.4041 31.9956C15.9818 31.9956 14.8287 33.1167 14.8287 34.4984C14.8287 35.8801 15.9818 37 17.4041 37C18.5506 37 19.5109 36.2666 19.8444 35.2594C27.1196 34.6707 32.9516 30.3665 34.169 23.6789C35.2446 23.2316 36 22.1812 36 20.9553V15.1181C35.9999 13.4868 34.6618 12.1636 33.0114 12.1636ZM30.3274 13.8402C28.43 8.72428 23.5129 5.06956 17.7286 5.06956C11.9686 5.06956 7.0693 8.69335 5.1541 13.7761C5.05885 13.6601 4.94696 13.5608 4.83729 13.4603C5.9217 7.26634 11.2774 2.55686 17.7352 2.55686C24.162 2.55686 29.5011 7.21994 30.621 13.3708C30.5102 13.5188 30.4083 13.6735 30.3274 13.8402ZM12.3762 30.7983C12.3761 30.7983 12.376 30.7983 12.3759 30.7982C12.3747 30.8009 12.3746 30.8012 12.3762 30.7983Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 906 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 682 B |
|
Before Width: | Height: | Size: 742 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 152 KiB |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="adv_swiper">
|
||||
<div class="adv-container" v-if="props.list.length > 0" ref="advRef">
|
||||
<div class="adv-container" v-if="props.list.length > 0">
|
||||
<van-swipe
|
||||
:show-indicators="isShowIndicators"
|
||||
:autoplay="autoplay"
|
||||
@@ -21,21 +21,16 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const emit = defineEmits(['load']);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
list: Types.App.AdsInfoListItem[];
|
||||
height?: string;
|
||||
isShowIndicators?: boolean;
|
||||
clickType?: 'ads' | 'recreation';
|
||||
/** ads:广告位广告,点击上报;recreation:娱乐模块;none:非广告内容(如案例图),不上报 */
|
||||
clickType?: 'ads' | 'recreation' | 'none';
|
||||
autoplay?: number;
|
||||
adImpression?: AdImpressionType;
|
||||
/** 为 true 时仅挂载当前/相邻项,减轻首屏 DecryptImg 与解密压力 */
|
||||
lazyRender?: boolean;
|
||||
}>(),
|
||||
@@ -52,32 +47,8 @@ const props = withDefaults(
|
||||
const load = () => {
|
||||
emit('load');
|
||||
};
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => props.list,
|
||||
() => {
|
||||
if (props.list.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, props.list, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'horizontal_cover',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem, clickType?: 'ads' | 'recreation') => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'horizontal_cover',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item, clickType);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem, clickType?: 'ads' | 'recreation' | 'none') => {
|
||||
jumpAdv(item, clickType === 'none' ? undefined : clickType);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -40,8 +40,7 @@
|
||||
</div>
|
||||
<Loading v-else class="coinVideoPayPopup__loading" />
|
||||
<div class="coinVideoPayPopup__tips">
|
||||
*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。如支付未到账,请
|
||||
<span @click.stop="goCustomerService">反馈客服订单号</span>
|
||||
*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。
|
||||
</div>
|
||||
</div>
|
||||
<div class="coinVideoPayPopup__footer">
|
||||
@@ -52,10 +51,6 @@
|
||||
>
|
||||
{{ payBtnText }}
|
||||
</div>
|
||||
<div class="coinVideoPayPopup__service">
|
||||
支付问题反馈,点击联系
|
||||
<span @click.stop="goCustomerService">在线客服</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
@@ -291,11 +286,6 @@ const goTransactionRecord = () => {
|
||||
router.push('/transactionRecord');
|
||||
};
|
||||
|
||||
const goCustomerService = () => {
|
||||
emitClose();
|
||||
router.push('/customerService');
|
||||
};
|
||||
|
||||
const onPopupClosed = () => {
|
||||
state.showRecharge = false;
|
||||
};
|
||||
@@ -508,14 +498,4 @@ watch(
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.coinVideoPayPopup__service {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: #dcdcdc;
|
||||
span {
|
||||
color: #ffd460;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -281,7 +281,6 @@ import VideoApi from '@/service/video';
|
||||
import { watchCount, compatibleAppStoreJump, innerJump } from '@/utils/index';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reportVideoCommentEvent } from '@/utils/eventReporter/videoComment';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
@@ -623,13 +622,6 @@ const sendBtn = async () => {
|
||||
}
|
||||
openLoading();
|
||||
let res: any = await VideoApi.sendComment(req);
|
||||
reportVideoCommentEvent({
|
||||
videoInfo: state.videoInfo,
|
||||
commentInfo: {
|
||||
content: state.content,
|
||||
image: state.img,
|
||||
},
|
||||
});
|
||||
state.content = ''; // 评论输入框内容清空
|
||||
state.lfCount++;
|
||||
if (typeof res === 'object') {
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<p>1.请检测您的网络是否正常,手机是否开了VPN或10分钟后再尝试访问。</p>
|
||||
<p>
|
||||
2.前往官网地址
|
||||
<span @click="download2">91PORN01.CC</span>
|
||||
<span @click="download2">HUANGGUOMJ.NET</span>
|
||||
下载最新版。
|
||||
</p>
|
||||
<p>
|
||||
官方备用:
|
||||
<span @click="download">91PORN01.CC</span>
|
||||
<span @click="download">HUANGGUOMJ.NET</span>
|
||||
</p>
|
||||
<p>3.前往Telegram官方交流群</p>
|
||||
<div class="footer-box">
|
||||
@@ -30,13 +30,13 @@ import { ref } from 'vue';
|
||||
import { compatibleAppStoreJump } from '@/utils/index';
|
||||
const isShow = ref(true);
|
||||
const download = () => {
|
||||
compatibleAppStoreJump('https://91porn01.cc');
|
||||
compatibleAppStoreJump('https://huangguomj.net');
|
||||
};
|
||||
const download1 = () => {
|
||||
compatibleAppStoreJump('https://lolii.fm');
|
||||
};
|
||||
const download2 = () => {
|
||||
compatibleAppStoreJump('https://91porn01.cc');
|
||||
compatibleAppStoreJump('https://huangguomj.net');
|
||||
};
|
||||
const toPotato = () => {
|
||||
compatibleAppStoreJump('https://t.me/hougong9527');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="fiveGridAdv">
|
||||
<div class="fiveGrid" @touchstart.stop @touchmove.stop @touchend.stop v-if="state.advList.length > 0" ref="advRef">
|
||||
<div class="fiveGrid" @touchstart.stop @touchmove.stop @touchend.stop v-if="state.advList.length > 0">
|
||||
<div class="rectAdvBox" @click="handleJumpAdv(item)" v-for="item in state.advList" :key="item.id">
|
||||
<lazyImg class="rectAdvIcon" :src="item.cover" />
|
||||
<div class="rectName">{{ item.title }}</div>
|
||||
@@ -9,18 +9,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { getAdItem } from '@/utils/getConfig';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
advType: number;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
advType: 2,
|
||||
@@ -29,32 +24,8 @@ const props = withDefaults(
|
||||
const state = reactive({
|
||||
advList: <any>(getAdItem(props.advType) || []), // 广告数据
|
||||
});
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => state.advList,
|
||||
() => {
|
||||
if (state.advList.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, state.advList, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="fiveGridAdv">
|
||||
<div class="fiveGrid" v-if="advList.length > 0" ref="advRef">
|
||||
<div class="fiveGrid" v-if="advList.length > 0">
|
||||
<div class="rectAdvBox" @click="handleJumpAdv(item)" v-for="item in advList" :key="item.id">
|
||||
<lazyImg class="rectAdvIcon" :src="item.cover" />
|
||||
<div class="rectName">{{ item.title }}</div>
|
||||
@@ -9,48 +9,18 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
advList: any;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
advList: [],
|
||||
},
|
||||
);
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => props.advList,
|
||||
() => {
|
||||
if (props.advList.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, props.advList, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -119,7 +119,7 @@ function close() {
|
||||
}
|
||||
function jumpUrl() {
|
||||
if (state.isClose) return (state.isClose = false);
|
||||
jumpAdv(state.advObj);
|
||||
jumpAdv(state.advObj, 'ads');
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="headerAdv" v-if="state.advList.length" ref="advRef">
|
||||
<div class="headerAdv" v-if="state.advList.length">
|
||||
<!-- 5宫格广告 -->
|
||||
<div class="fiveGrid" v-if="props.type === 'five'">
|
||||
<div class="rectAdvBox" @click="handleJumpAdv(item)" v-for="item in state.advList" :key="item.id">
|
||||
@@ -19,21 +19,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { reactive } from 'vue';
|
||||
import { getAdItem } from '@/utils/getConfig';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
advType?: number;
|
||||
type?: 'five' | 'swiper' | 'rows';
|
||||
length?: number; //广告获取长度0-不限制
|
||||
random?: boolean; //是否随机获取
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
advType: 28,
|
||||
@@ -58,32 +53,8 @@ const init = () => {
|
||||
}
|
||||
};
|
||||
init();
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => state.advList,
|
||||
() => {
|
||||
if (state.advList.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, state.advList, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'small_grid',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="homeHeader" :data-type="state.advCoverType" v-if="state.advList?.length" ref="advRef">
|
||||
<div class="homeHeader" :data-type="state.advCoverType" v-if="state.advList?.length">
|
||||
<!-- 小方块布局 -->
|
||||
<template v-if="state.advCoverType === 'small-cube'">
|
||||
<div class="firstRow" v-if="firstRowList.length">
|
||||
@@ -85,21 +85,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref, watch, nextTick } from 'vue';
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { getAdItem } from '@/utils/getConfig';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const isStopAnimation = ref(false);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
advType: number;
|
||||
showTitle?: boolean;
|
||||
indicatorColor?: string;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
indicatorColor: '#fff',
|
||||
@@ -137,32 +132,8 @@ const init = () => {
|
||||
}
|
||||
};
|
||||
init();
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => state.advList,
|
||||
() => {
|
||||
if (state.advList.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, state.advList, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || advTypeText.value,
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || advTypeText.value,
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
const onTouchMove = () => {
|
||||
isStopAnimation.value = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="announcementPopup">
|
||||
<van-overlay :show="true" z-index="2001" @click="close()">
|
||||
<div class="imgWrapper" ref="advRef">
|
||||
<div class="imgWrapper">
|
||||
<lazyImg
|
||||
@click.stop="handleJumpAdv(currentAds[0])"
|
||||
class="imgBox"
|
||||
@@ -21,17 +21,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, reactive, ref, watch, computed } from 'vue';
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
advList: Types.App.AdsInfoListItem[];
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
advList: () => [],
|
||||
@@ -45,36 +40,8 @@ const currentIndex = ref(0);
|
||||
const currentAds = computed(() => {
|
||||
return state.advList.slice(currentIndex.value, currentIndex.value + 2);
|
||||
});
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => currentAds.value,
|
||||
() => {
|
||||
if (currentAds.value.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(
|
||||
advRef.value,
|
||||
currentAds.value,
|
||||
{
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'popup',
|
||||
},
|
||||
// 'payload',
|
||||
).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'popup',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
const close = () => {
|
||||
currentIndex.value += 2;
|
||||
|
||||
@@ -64,7 +64,7 @@ const openVideo = () => {
|
||||
return;
|
||||
}
|
||||
if (state.videoInfo.type) {
|
||||
jumpAdv(state.videoInfo);
|
||||
jumpAdv(state.videoInfo, 'ads');
|
||||
return;
|
||||
}
|
||||
if (state.videoInfo.newsType == 'SEED_LINK') {
|
||||
|
||||
@@ -223,7 +223,7 @@ const openVideo = () => {
|
||||
// 必须读 props:列表刷新后组件可能复用,禁止缓存 setup 时的 videoInfo
|
||||
const videoInfo = props.videoInfo;
|
||||
if (videoInfo.type) {
|
||||
jumpAdv(videoInfo);
|
||||
jumpAdv(videoInfo, 'ads');
|
||||
return;
|
||||
}
|
||||
// 点击的暗网视频,需要权限
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<div v-if="!closed && ads.length" class="im-chat-notify-wrap">
|
||||
<van-swipe
|
||||
class="im-chat-notify-swipe"
|
||||
vertical
|
||||
:autoplay="ads.length > 1 ? 3000 : undefined"
|
||||
:show-indicators="false"
|
||||
:touchable="ads.length > 1"
|
||||
>
|
||||
<van-swipe-item v-for="ad in ads" :key="ad.id">
|
||||
<div class="im-chat-notify" @click="onClaim(ad)">
|
||||
<LazyImg class="im-chat-notify__cover" :src="ad.cover" :is-need-decypt="true" round />
|
||||
<div class="im-chat-notify__body">
|
||||
<div class="im-chat-notify__title-row">
|
||||
<span class="im-chat-notify__title">{{ getAdTitle(ad) }}</span>
|
||||
<span class="im-chat-notify__tag">广告</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="im-chat-notify__btn" @click.stop="onClaim(ad)">
|
||||
{{ getActionText(ad) }}
|
||||
</button>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
<button type="button" class="im-chat-notify__close-btn" aria-label="关闭" @click.stop="onClose">
|
||||
<SvgIcon name="im-adv-close" class="im-chat-notify__close" color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { IM_AD_POS, useImAdStore } from '@/stores/imAd';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'visible-change', visible: boolean): void;
|
||||
}>();
|
||||
|
||||
const imAdStore = useImAdStore();
|
||||
const closed = ref(false);
|
||||
|
||||
const ads = computed(() => imAdStore.getByPosition(IM_AD_POS.CHAT_NOTIFY));
|
||||
const isVisible = computed(() => !closed.value && ads.value.length > 0);
|
||||
|
||||
watch(
|
||||
isVisible,
|
||||
(visible) => {
|
||||
emit('visible-change', visible);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function getAdTitle(ad: Types.Im.AdItem) {
|
||||
return ad.title || ad.description || '精选推荐';
|
||||
}
|
||||
|
||||
function getActionText(ad: Types.Im.AdItem) {
|
||||
const desc = ad.description?.trim();
|
||||
if (desc && desc.length <= 4) return desc;
|
||||
return '领取';
|
||||
}
|
||||
|
||||
const onClaim = (ad: Types.Im.AdItem) => {
|
||||
imAdStore.markClicked(ad.id);
|
||||
jumpAdv(ad);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
closed.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im-chat-notify-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #241317;
|
||||
}
|
||||
|
||||
.im-chat-notify-swipe {
|
||||
height: 68px;
|
||||
|
||||
:deep(.van-swipe__track) {
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
:deep(.van-swipe-item) {
|
||||
height: 68px;
|
||||
}
|
||||
}
|
||||
|
||||
.im-chat-notify {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
padding: 10px 36px 10px 16px;
|
||||
border-radius: 0;
|
||||
background: rgba(229, 54, 92, 0.1);
|
||||
box-sizing: border-box;
|
||||
|
||||
&__cover {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
|
||||
:deep(.lazy_img),
|
||||
:deep(.laz_com),
|
||||
:deep(.animationImg),
|
||||
:deep(img) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: #3d2f2f;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
height: 24px;
|
||||
width: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: #ff8800;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__close-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__close {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div v-if="!closed && ads.length" class="session-ad-float">
|
||||
<van-swipe class="ad-swipe" :autoplay="4000" :show-indicators="false" :loop="ads.length > 1">
|
||||
<van-swipe-item v-for="(item, index) in ads" :key="item.id || index">
|
||||
<DecryptImg :src="item.cover" class="ad-cover" round @click="onClick(item)" />
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
<div class="ad-close" @click.stop="closed = true">×</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { IM_AD_POS, useImAdStore } from '@/stores/imAd';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
|
||||
const imAdStore = useImAdStore();
|
||||
const closed = ref(false);
|
||||
const ads = computed(() => imAdStore.getByPosition(IM_AD_POS.FLOAT));
|
||||
|
||||
const onClick = (item: Types.Im.AdItem) => {
|
||||
jumpAdv(item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.session-ad-float {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
@include chat-layout-above-input;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.ad-swipe {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ad-cover {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.img-decypt),
|
||||
:deep(.vanImage),
|
||||
:deep(.van-image),
|
||||
:deep(.van-image__img) {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ad-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
line-height: 13px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<Transition name="im-notify-slide">
|
||||
<div v-if="notify" class="im-message-notify" @click="onOpen">
|
||||
<LazyImg class="im-message-notify__avatar" :src="notify.peerAvatar" round eager />
|
||||
<div class="im-message-notify__body">
|
||||
<p class="im-message-notify__title">{{ notify.peerName }}</p>
|
||||
<p class="im-message-notify__preview">{{ formatSessionPreview(notify.preview) }}</p>
|
||||
</div>
|
||||
<button type="button" class="im-message-notify__close" aria-label="关闭" @click.stop="onClose">×</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { showToast } from 'vant';
|
||||
import ImApi from '@/service/im';
|
||||
import { dismissImMessageNotify, imMessageNotify } from '@/utils/im/notify';
|
||||
import { formatSessionPreview } from '@/utils/im/sessionList';
|
||||
|
||||
const router = useRouter();
|
||||
const notify = computed(() => imMessageNotify.value);
|
||||
|
||||
const onClose = () => {
|
||||
dismissImMessageNotify();
|
||||
};
|
||||
|
||||
const onOpen = async () => {
|
||||
const payload = notify.value;
|
||||
if (!payload) return;
|
||||
dismissImMessageNotify();
|
||||
try {
|
||||
const uid = await ImApi.resolveBusinessUid(payload.peerImUserId);
|
||||
await router.push({
|
||||
path: '/privateLetter',
|
||||
query: { uid: String(uid) },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[IM] 通知跳转失败:', e);
|
||||
showToast('无法打开私信');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im-message-notify {
|
||||
position: fixed;
|
||||
top: calc(12px + env(safe-area-inset-top));
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 300000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
|
||||
&__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
|
||||
:deep(.lazy_img),
|
||||
:deep(.laz_com),
|
||||
:deep(.animationImg),
|
||||
:deep(img) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__close {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.im-notify-slide-enter-active,
|
||||
.im-notify-slide-leave-active {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.im-notify-slide-enter-from,
|
||||
.im-notify-slide-leave-to {
|
||||
transform: translateY(-120%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,6 @@
|
||||
@touchstart.stop
|
||||
@touchmove.stop
|
||||
@touchend.stop
|
||||
ref="advRef"
|
||||
>
|
||||
<div class="adv-container">
|
||||
<van-swipe
|
||||
@@ -31,12 +30,6 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -47,7 +40,6 @@ const props = withDefaults(
|
||||
indicatorColor?: string;
|
||||
showTitle?: boolean;
|
||||
swiperKey?: string;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
list: () => [],
|
||||
@@ -58,31 +50,7 @@ const props = withDefaults(
|
||||
swiperKey: '',
|
||||
},
|
||||
);
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => props.list,
|
||||
() => {
|
||||
if (props.list.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, props.list, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'horizontal_cover',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem, clickType?: 'ads' | 'recreation') => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'horizontal_cover',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item, clickType);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -93,7 +93,7 @@ const jumpPage = (videoInfo: any) => {
|
||||
return;
|
||||
}
|
||||
if (videoInfo.type) {
|
||||
jumpAdv(videoInfo);
|
||||
jumpAdv(videoInfo, 'ads');
|
||||
return;
|
||||
}
|
||||
if (videoInfo.newsType == 'COVER') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="nineGridPopup" v-if="appList.length > 0">
|
||||
<van-overlay :show="isShow" z-index="2001" @click="emit('close')">
|
||||
<div class="wrapper" ref="advRef">
|
||||
<div class="wrapper">
|
||||
<!-- <AdvSwiper
|
||||
v-if="topList.length"
|
||||
@click.stop
|
||||
@@ -23,18 +23,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getAdItem } from '@/utils/getConfig';
|
||||
import { AdType } from '@/enums/app';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
adImpression?: AdImpressionType;
|
||||
}>();
|
||||
const emit = defineEmits(['close']);
|
||||
const isShow = computed(() => {
|
||||
@@ -43,32 +38,8 @@ const isShow = computed(() => {
|
||||
const appList = ref<Types.App.AdsInfoListItem[]>([]);
|
||||
appList.value = getAdItem(AdType.QualityApplication) || [];
|
||||
// const topList = getAdItem(AdType.TopAdvsList);
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
appList,
|
||||
() => {
|
||||
if (appList.value.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, appList.value, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'multi_grid',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem) => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'multi_grid',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -72,10 +72,6 @@
|
||||
<div class="payTip">1.因超时支付无法到账,请重新发起。</div>
|
||||
<div class="payTip">2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。</div>
|
||||
<div class="payBtn" @click="toPay">¥{{ state.discountedPrice }}/{{ payBtnLabel }}</div>
|
||||
<div class="customerService">
|
||||
支付中如有问题,请咨询
|
||||
<router-link class="contactCustomer" to="/customerService">在线客服</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
@@ -84,11 +80,10 @@
|
||||
<div class="kindTipsBox">
|
||||
<div class="title flex-center-center">温馨提示</div>
|
||||
<div class="contents">
|
||||
<p>1.订单支付后,一般会在1-10分钟内到账,如超时未到账,请联系在线客服为您处理。</p>
|
||||
<p>1.订单支付后,一般会在1-10分钟内到账。</p>
|
||||
<p>2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。</p>
|
||||
<p>3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用,如遇报毒提示忽略即可。</p>
|
||||
</div>
|
||||
<div class="messbg"></div>
|
||||
<div class="cBtn" @click="comfinPay" v-if="!state.isPayLoading">确认支付</div>
|
||||
<div class="cBtn" v-else>
|
||||
<van-loading size="20px" type="spinner" color="rgb(255,255,255)">支付中...</van-loading>
|
||||
@@ -100,7 +95,7 @@
|
||||
<van-popup class="payTipPop" v-model:show="state.showPayTip">
|
||||
<div class="payTipBox app-popup-surface">
|
||||
<div class="paySubTitle app-popup-title">支付确认</div>
|
||||
<div class="tipItem">1.订单支付后,一般会在3-5分钟内到账,如超时未到账,请联系在线客服为您处理。</div>
|
||||
<div class="tipItem">1.订单支付后,一般会在3-5分钟内到账。</div>
|
||||
<div class="tipItem">2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。</div>
|
||||
<div class="tipItem">3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用。</div>
|
||||
<div class="question flex-center-center" @click="$router.push('paymentIssues')">
|
||||
@@ -645,14 +640,6 @@ defineExpose({
|
||||
border-radius: 43px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
.customerService {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding-bottom: 30px;
|
||||
.contactCustomer {
|
||||
color: #ffc000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(.van-popup) {
|
||||
@@ -688,13 +675,6 @@ defineExpose({
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
.messbg {
|
||||
width: 271px;
|
||||
height: 101px;
|
||||
margin: 0 auto 22px;
|
||||
background: url('@/assets/images/png/messbg.png') no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.cBtn {
|
||||
width: 263px;
|
||||
height: 44px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="adv_swiper">
|
||||
<div class="adv-container" v-if="props.list.length > 0" ref="advRef">
|
||||
<div class="adv-container" v-if="props.list.length > 0">
|
||||
<van-swipe ref="swipeRef" :show-indicators="false" :autoplay="autoTime" class="custom-swipe" @change="onChange">
|
||||
<van-swipe-item
|
||||
@click="handleJumpAdv(item, props.clickType)"
|
||||
@@ -29,13 +29,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { SwipeInstance } from 'vant';
|
||||
import { AdImpressionType, setAdImpression } from '@/utils/eventReporter/adImpression';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { reportAdClickEvent } from '@/utils/eventReporter/adClick';
|
||||
|
||||
const route = useRoute();
|
||||
const emit = defineEmits(['load']);
|
||||
const swipeRef = ref<SwipeInstance>();
|
||||
|
||||
@@ -46,7 +42,6 @@ const props = withDefaults(
|
||||
isShowIndicators?: boolean;
|
||||
clickType?: 'ads' | 'recreation';
|
||||
autoTime: number;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
list: () => [],
|
||||
@@ -66,31 +61,7 @@ const onChange = (index: number) => {
|
||||
const load = () => {
|
||||
emit('load');
|
||||
};
|
||||
// 广告展示埋点
|
||||
const advRef = ref();
|
||||
watch(
|
||||
() => props.list,
|
||||
() => {
|
||||
if (props.list.length) {
|
||||
nextTick(() => {
|
||||
setAdImpression(advRef.value, props.list, {
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'fullscreen',
|
||||
}).setupObserver();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleJumpAdv = (item: Types.App.AdsInfoListItem, clickType?: 'ads' | 'recreation') => {
|
||||
let params = {
|
||||
...item,
|
||||
page_key: props.adImpression?.page_key || (route.meta.pageKey as string) || '',
|
||||
page_name: props.adImpression?.page_name || (route.meta.pageName as string) || '',
|
||||
ad_type: props.adImpression?.ad_type || 'fullscreen',
|
||||
};
|
||||
reportAdClickEvent(params);
|
||||
jumpAdv(item, clickType);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -102,14 +102,10 @@ export default {
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, watch, reactive, onActivated, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UserApi from '@/service/user';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
import { useGlobalStore } from '@/stores/global';
|
||||
import { useImAdStore } from '@/stores/imAd';
|
||||
import { imTotalUnread } from '@/utils/im/sdk';
|
||||
import { analyticsEventNavigation } from '@/utils/eventReporter/analytics';
|
||||
import setTheme from '@/assets/theme/index';
|
||||
import { unlockShortVideoAudio } from '@/utils/shortVideoAudioUnlock';
|
||||
import AiDramaFloatBubble from './AiDramaFloatBubble.vue';
|
||||
@@ -180,31 +176,15 @@ onActivated(() => {
|
||||
});
|
||||
const onChange = (name: string) => {
|
||||
UserStore.getCheckMessageTip();
|
||||
if (name == 'home') {
|
||||
analyticsEventNavigation('Home', '首页');
|
||||
} else if (name == 'shortVideo') {
|
||||
if (name == 'shortVideo') {
|
||||
// 底部进短视频页:解锁会话有声,抖音/短剧后续竖滑可带声
|
||||
unlockShortVideoAudio();
|
||||
analyticsEventNavigation('ShortVideo', '抖音');
|
||||
} else if (name == 'darkNet') {
|
||||
analyticsEventNavigation('DarkWeb', '暗网');
|
||||
} else if (name == 'aiUndress2') {
|
||||
analyticsEventNavigation('AI', 'AI');
|
||||
} else if (name == 'community') {
|
||||
analyticsEventNavigation('Community', '社区');
|
||||
} else if (name == 'mine') {
|
||||
analyticsEventNavigation('Mine', '我的');
|
||||
}
|
||||
};
|
||||
const { isNewsTip } = toRefs(UserStore);
|
||||
|
||||
const imAdStore = useImAdStore();
|
||||
const { listHasUnclicked } = storeToRefs(imAdStore);
|
||||
|
||||
/** 我的页红点:业务未读 / IM 私信未读 / IM 列表广告未点击 */
|
||||
const showRedDot = computed(() => {
|
||||
return Boolean(isNewsTip.value) || Number(imTotalUnread.value) > 0 || listHasUnclicked.value;
|
||||
});
|
||||
/** 我的页红点:业务未读 */
|
||||
const showRedDot = computed(() => Boolean(isNewsTip.value));
|
||||
|
||||
const backTop = (path: string) => {
|
||||
let dom: any = null;
|
||||
|
||||
@@ -148,9 +148,6 @@ function jumpKeywords(type: any, id: any) {
|
||||
case '9':
|
||||
item.href = 'yinseinner://announcement';
|
||||
break;
|
||||
case '10':
|
||||
item.href = 'yinseinner://kefu';
|
||||
break;
|
||||
case '11':
|
||||
item.href = `yinseinner://horizontalVideo?id=${id}`;
|
||||
break;
|
||||
|
||||
@@ -144,9 +144,6 @@ function jumpKeywords(type: any, id: any) {
|
||||
case '9':
|
||||
item.href = 'yinseinner://announcement';
|
||||
break;
|
||||
case '10':
|
||||
item.href = 'yinseinner://kefu';
|
||||
break;
|
||||
case '11':
|
||||
item.href = `yinseinner://horizontalVideo?id=${id}`;
|
||||
break;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useUserStore } from '@/stores/user';
|
||||
import { onActivated, onBeforeUnmount, onDeactivated, onMounted, Ref, ref, toRefs, reactive, inject } from 'vue';
|
||||
import HlsJSPlayer from 'xgplayer-hls.js';
|
||||
import UserApi from '@/service/user';
|
||||
import { analyticsEventVideo } from '@/utils/eventReporter/analytics';
|
||||
import { judgeTime } from '@/utils/index';
|
||||
import {
|
||||
bindXgTrialProgressGuard,
|
||||
@@ -56,7 +55,6 @@ const emitTrialUnlockPopup = () => {
|
||||
}, 300);
|
||||
};
|
||||
let player: any = null; //播放器实例
|
||||
let isReport = ref(false); // 视频埋点锁
|
||||
let isPreview = ref(false); // 是否预览视频
|
||||
let videoUrl = ref(''); // 播放地址
|
||||
let isPlayingH265 = false;
|
||||
@@ -68,31 +66,8 @@ const state = reactive({
|
||||
playTimer: <any>'', // 统计时使用:时间计时器
|
||||
playTime: 0, // 统计时使用:播放时间
|
||||
media: <any>{}, // 需要传入卖点的媒体数据对象
|
||||
isCompleteReported: false, // 播放完成事件(>=95%)是否已上报,避免重复
|
||||
isSecondPlayReported: false, // 播放超过 1 分钟后二次 play 埋点锁
|
||||
});
|
||||
|
||||
// 进度达到 95% 视为播放完成(自然播放或拖动到 95% 都算)
|
||||
const checkVideoComplete = () => {
|
||||
if (state.isCompleteReported) return;
|
||||
const duration = Number(player?.video?.duration) || 0;
|
||||
const currentTime = Number(player?.video?.currentTime) || 0;
|
||||
if (duration > 0 && currentTime / duration >= 0.9) {
|
||||
state.isCompleteReported = true;
|
||||
analyticsEventVideo(state.media, 'CARTOON', Math.ceil(currentTime || 1), 'video_complete', '播放完成');
|
||||
}
|
||||
};
|
||||
|
||||
const checkSecondVideoPlay = () => {
|
||||
if (state.isSecondPlayReported) return;
|
||||
const duration = Number(player?.video?.duration) || 0;
|
||||
const currentTime = Number(player?.video?.currentTime) || 0;
|
||||
if (currentTime / duration < 0.9) {
|
||||
state.isSecondPlayReported = true;
|
||||
analyticsEventVideo(state.media, 'CARTOON', Math.ceil(currentTime || 1), 'video_play', '播放');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getInfo();
|
||||
//获取免费观看次数
|
||||
@@ -106,7 +81,6 @@ onMounted(() => {
|
||||
videoTypeName: mediaInfo.value.videoTypeName,
|
||||
lsjId: mediaInfo.value.lsjId,
|
||||
};
|
||||
analyticsEventVideo(state.media, 'CARTOON', 0, 'video_view', '展示');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -114,13 +88,9 @@ onMounted(() => {
|
||||
*/
|
||||
function pause() {
|
||||
if (!player) return;
|
||||
const wasPlaying = !player.paused;
|
||||
player.pause();
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
if (wasPlaying) {
|
||||
analyticsEventVideo(state.media, 'CARTOON', Math.ceil(player?.video?.currentTime || 1), 'video_pause', '暂停');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @description 判断是否预览视频
|
||||
@@ -163,7 +133,6 @@ const getInfo = async () => {
|
||||
/**
|
||||
* @description 初始化播放器
|
||||
*/
|
||||
let suppressPlayAnalytics = false;
|
||||
let trialLimitHandled = false;
|
||||
let unbindTrialProgressGuard: (() => void) | null = null;
|
||||
const pageActive = ref(true);
|
||||
@@ -182,7 +151,6 @@ const destroyPlayer = (silent = false) => {
|
||||
unbindH265Fallback?.();
|
||||
unbindH265Fallback = null;
|
||||
if (!player) return;
|
||||
if (!silent) checkSecondVideoPlay();
|
||||
destroySilently = silent;
|
||||
try {
|
||||
player.src = '';
|
||||
@@ -244,9 +212,6 @@ const initVideo = () => {
|
||||
req.tagID = parentInfo.value.tagDetails[0].id;
|
||||
}
|
||||
// VideoApi.getPlayVideo(req);
|
||||
if (state.playTime > 1) {
|
||||
// analyticsEventVideo(state.media, 'CARTOON', state.playTime, 'video_exit', '播放退出');
|
||||
}
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
state.playTime = 0;
|
||||
@@ -306,9 +271,6 @@ const initVideo = () => {
|
||||
// 播放
|
||||
player.on('play', () => {
|
||||
emit('play', true);
|
||||
if (!suppressPlayAnalytics) {
|
||||
analyticsEventVideo(state.media, 'CARTOON', Math.ceil(player?.video?.currentTime || 1), 'video_play', '播放');
|
||||
}
|
||||
});
|
||||
player.on('pause', () => {
|
||||
emit('play', false);
|
||||
@@ -316,7 +278,6 @@ const initVideo = () => {
|
||||
|
||||
player.on('timeupdate', () => {
|
||||
if (!pageActive.value || !player.hasStart) return;
|
||||
checkVideoComplete();
|
||||
if (playCheck()) {
|
||||
if (canResetTrialLimitHandled()) {
|
||||
trialLimitHandled = false;
|
||||
@@ -326,13 +287,9 @@ const initVideo = () => {
|
||||
handleTrialLimitReached();
|
||||
});
|
||||
player.on('seeked', () => {
|
||||
checkVideoComplete();
|
||||
if (!player.hasStart) return;
|
||||
const clamped = clampTrialSeek();
|
||||
clearTrialSeekBlock();
|
||||
window.setTimeout(() => {
|
||||
suppressPlayAnalytics = false;
|
||||
}, 300);
|
||||
if (clamped) return;
|
||||
if (!playCheck()) {
|
||||
handleTrialLimitReached();
|
||||
@@ -340,7 +297,6 @@ const initVideo = () => {
|
||||
});
|
||||
player.on('seeking', () => {
|
||||
if (!player?.hasStart) return;
|
||||
suppressPlayAnalytics = true;
|
||||
if (isTrialPlaybackLimited()) {
|
||||
setTrialFrameBlocked(true);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import HlsJSPlayer from 'xgplayer-hls.js';
|
||||
import UserApi from '@/service/user';
|
||||
import { addVideoHistory } from '@/utils';
|
||||
import { HistoryVideoType } from '@/enums/app';
|
||||
import { analyticsEventVideo } from '@/utils/eventReporter/analytics';
|
||||
import { judgeTime } from '@/utils/index';
|
||||
import {
|
||||
bindXgTrialProgressGuard,
|
||||
@@ -54,7 +53,6 @@ const emitTrialUnlockPopup = () => {
|
||||
}, 300);
|
||||
};
|
||||
let player: any = null; //播放器实例
|
||||
let isReport = ref(false); // 视频埋点锁
|
||||
let isPreview = ref(false); // 是否预览视频
|
||||
let videoUrl = ref(''); // 播放地址
|
||||
/** 当前是否使用 H.265 资源 */
|
||||
@@ -68,36 +66,12 @@ const state = reactive({
|
||||
playTimer: <any>'', // 统计时使用:时间计时器
|
||||
playTime: 0, // 统计时使用:播放时间
|
||||
hideProgress: false,
|
||||
isCompleteReported: false, // 播放完成事件(>=95%)是否已上报,避免重复
|
||||
isSecondPlayReported: false, // 播放超过 1 分钟后二次 play 埋点锁
|
||||
});
|
||||
|
||||
// 进度达到 95% 视为播放完成(自然播放或拖动到 95% 都算)
|
||||
const checkVideoComplete = () => {
|
||||
if (state.isCompleteReported) return;
|
||||
const duration = Number(player?.video?.duration) || 0;
|
||||
const currentTime = Number(player?.video?.currentTime) || 0;
|
||||
if (duration > 0 && currentTime / duration >= 0.9) {
|
||||
state.isCompleteReported = true;
|
||||
analyticsEventVideo(videoInfo.value, 'MOVIE', Math.ceil(currentTime || 1), 'video_complete', '播放完成');
|
||||
}
|
||||
};
|
||||
|
||||
const checkSecondVideoPlay = () => {
|
||||
if (state.isSecondPlayReported) return;
|
||||
const duration = Number(player?.video?.duration) || 0;
|
||||
const currentTime = Number(player?.video?.currentTime) || 0;
|
||||
if (currentTime / duration < 0.9) {
|
||||
state.isSecondPlayReported = true;
|
||||
analyticsEventVideo(videoInfo.value, 'MOVIE', Math.ceil(currentTime || 1), 'video_play', '播放');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getInfo();
|
||||
//获取免费观看次数
|
||||
getFreeWatchCount();
|
||||
analyticsEventVideo(videoInfo.value, 'MOVIE', 0, 'video_view', '展示');
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -105,13 +79,9 @@ onMounted(() => {
|
||||
*/
|
||||
const pause = () => {
|
||||
if (!player) return;
|
||||
const wasPlaying = !player.paused;
|
||||
player.pause();
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
if (wasPlaying) {
|
||||
analyticsEventVideo(videoInfo.value, 'MOVIE', Math.ceil(player?.video?.currentTime || 1), 'video_pause', '暂停');
|
||||
}
|
||||
};
|
||||
|
||||
/** 点击画面切换播放/暂停 */
|
||||
@@ -189,7 +159,6 @@ const getInfo = async () => {
|
||||
* @description 初始化播放器
|
||||
*/
|
||||
const previewStartFlag = ref(false); // 是否进行预览时间设置
|
||||
let suppressPlayAnalytics = false;
|
||||
let trialLimitHandled = false;
|
||||
let unbindTrialProgressGuard: (() => void) | null = null;
|
||||
const pageActive = ref(true);
|
||||
@@ -208,7 +177,6 @@ const destroyPlayer = (silent = false) => {
|
||||
unbindH265Fallback?.();
|
||||
unbindH265Fallback = null;
|
||||
if (!player) return;
|
||||
if (!silent) checkSecondVideoPlay();
|
||||
destroySilently = silent;
|
||||
try {
|
||||
player.src = '';
|
||||
@@ -279,9 +247,6 @@ const initVideo = () => {
|
||||
req.searchAccessTicket = props.searchAccessTicket;
|
||||
}
|
||||
VideoApi.getPlayVideo(req);
|
||||
if (state.playTime > 1) {
|
||||
// analyticsEventVideo(videoInfo.value, 'MOVIE', state.playTime, 'video_exit', '播放退出');
|
||||
}
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
state.playTime = 1;
|
||||
@@ -331,9 +296,6 @@ const initVideo = () => {
|
||||
// 播放
|
||||
player.on('play', () => {
|
||||
emit('play');
|
||||
if (!suppressPlayAnalytics) {
|
||||
analyticsEventVideo(videoInfo.value, 'MOVIE', Math.ceil(player?.video?.currentTime || 1), 'video_play', '播放');
|
||||
}
|
||||
});
|
||||
|
||||
/** 仅当明确回到试看区内才清标记;回弹到 limit-ε 时 playCheck 为 true,不能清,否则半屏关了又弹 */
|
||||
@@ -359,7 +321,6 @@ const initVideo = () => {
|
||||
player.on('timeupdate', () => {
|
||||
if (!pageActive.value || !player.hasStart) return;
|
||||
emit('timeupdate', Number(player?.video?.currentTime) || 0);
|
||||
checkVideoComplete();
|
||||
if (playCheck()) {
|
||||
if (canResetTrialLimitHandled()) {
|
||||
trialLimitHandled = false;
|
||||
@@ -369,7 +330,6 @@ const initVideo = () => {
|
||||
handleTrialLimitReached();
|
||||
});
|
||||
player.on('seeked', () => {
|
||||
checkVideoComplete();
|
||||
if (!player.hasStart) return;
|
||||
const clamped = clampTrialSeek();
|
||||
clearTrialSeekBlock();
|
||||
@@ -377,9 +337,6 @@ const initVideo = () => {
|
||||
if (pageActive.value) {
|
||||
emit('timeupdate', Number(player?.video?.currentTime) || 0);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
suppressPlayAnalytics = false;
|
||||
}, 300);
|
||||
if (clamped) return;
|
||||
if (!playCheck()) {
|
||||
handleTrialLimitReached();
|
||||
@@ -387,7 +344,6 @@ const initVideo = () => {
|
||||
});
|
||||
player.on('seeking', () => {
|
||||
if (!player?.hasStart) return;
|
||||
suppressPlayAnalytics = true;
|
||||
if (needsTrialSeekLimit()) {
|
||||
setTrialFrameBlocked(true);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import { computed, reactive, onBeforeUnmount, onDeactivated, onMounted, Ref, ref
|
||||
import HlsJSPlayer from 'xgplayer-hls.js';
|
||||
import { addVideoHistory } from '@/utils';
|
||||
import { HistoryVideoType } from '@/enums/app';
|
||||
import { analyticsEventVideo } from '@/utils/eventReporter/analytics';
|
||||
import {
|
||||
XG_HLS_FEED_OPTS,
|
||||
XG_HLS_FEED_PRELOAD_OPTS,
|
||||
@@ -130,7 +129,6 @@ const resetTrialLimitHandledIfNeeded = () => {
|
||||
}
|
||||
};
|
||||
let player: any = null; //播放器实例
|
||||
let suppressPlayAnalytics = false;
|
||||
/** 实际播放的资源 key(可能为 H.265) */
|
||||
let playResourceKey = '';
|
||||
let isPlayingH265 = false;
|
||||
@@ -274,19 +272,8 @@ const state = reactive({
|
||||
beginTime: new Date(), // 统计时使用:开始时间
|
||||
playTimer: <any>'', // 统计时使用:时间计时器
|
||||
playTime: 0, // 统计时使用:播放时间
|
||||
isCompleteReported: false, // 播放完成事件(>=95%)是否已上报,避免重复
|
||||
});
|
||||
|
||||
// 进度达到 95% 视为播放完成(自然播放或拖动到 95% 都算)
|
||||
const checkVideoComplete = () => {
|
||||
if (state.isCompleteReported) return;
|
||||
const duration = Number(player?.video?.duration) || 0;
|
||||
const currentTime = Number(player?.video?.currentTime) || 0;
|
||||
if (duration > 0 && currentTime / duration >= 0.95) {
|
||||
state.isCompleteReported = true;
|
||||
analyticsEventVideo(videoInfo.value, 'SP', state.playTime, 'video_complete', '播放完成');
|
||||
}
|
||||
};
|
||||
/** 错峰预热定时器:滑到当前条时取消,避免仍等着才建播 */
|
||||
let warmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -300,7 +287,6 @@ const startPlaybackPipeline = () => {
|
||||
if (!player) initVideo();
|
||||
if (props.playbackActive) {
|
||||
getFreeWatchCount();
|
||||
analyticsEventVideo(videoInfo.value, 'SP', 0, 'video_view', '展示');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -562,7 +548,6 @@ watch(
|
||||
ensurePlay();
|
||||
}
|
||||
getFreeWatchCount();
|
||||
analyticsEventVideo(videoInfo.value, 'SP', 0, 'video_view', '展示');
|
||||
return;
|
||||
}
|
||||
unregisterShortVideoActiveUnmutePlay(kickUnmuteIfPlaying);
|
||||
@@ -592,9 +577,6 @@ const addPlayCount = () => {
|
||||
req.searchAccessTicket = props.searchAccessTicket;
|
||||
}
|
||||
VideoApi.getPlayVideo(req);
|
||||
if (state.playTime > 1) {
|
||||
analyticsEventVideo(videoInfo.value, 'SP', state.playTime, 'video_exit', '播放退出');
|
||||
}
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
state.playTime = 0;
|
||||
@@ -604,7 +586,6 @@ const addPlayCount = () => {
|
||||
*/
|
||||
const pause = () => {
|
||||
if (!player) return;
|
||||
const wasPlaying = !player.paused;
|
||||
try {
|
||||
const video = player.video as HTMLVideoElement | undefined;
|
||||
video?.pause?.();
|
||||
@@ -614,9 +595,6 @@ const pause = () => {
|
||||
player.pause();
|
||||
clearInterval(state.playTimer);
|
||||
state.playTimer = '';
|
||||
if (wasPlaying) {
|
||||
analyticsEventVideo(videoInfo.value, 'SP', state.playTime, 'video_pause', '暂停');
|
||||
}
|
||||
};
|
||||
|
||||
/** 滑走/失活:停声停画,并作废未完成的起播/unmute 回调 */
|
||||
@@ -749,9 +727,6 @@ const initVideo = () => {
|
||||
// 播放
|
||||
player.on('play', () => {
|
||||
emit('play');
|
||||
if (!suppressPlayAnalytics) {
|
||||
analyticsEventVideo(videoInfo.value, 'SP', state.playTime, 'video_play', '播放');
|
||||
}
|
||||
});
|
||||
player.on('timeupdate', () => {
|
||||
if (!player.hasStart) return;
|
||||
@@ -766,7 +741,6 @@ const initVideo = () => {
|
||||
seekGuardTarget = -1;
|
||||
}
|
||||
emit('timeupdate', currentTime, duration);
|
||||
checkVideoComplete();
|
||||
if (playCheck()) {
|
||||
resetTrialLimitHandledIfNeeded();
|
||||
return;
|
||||
@@ -777,11 +751,7 @@ const initVideo = () => {
|
||||
player.on('seeked', () => {
|
||||
seekGuardUntil = 0;
|
||||
seekGuardTarget = -1;
|
||||
checkVideoComplete();
|
||||
if (!player.hasStart) return;
|
||||
window.setTimeout(() => {
|
||||
suppressPlayAnalytics = false;
|
||||
}, 300);
|
||||
if (!playCheck()) {
|
||||
pause();
|
||||
emitTrialUnlockPopup();
|
||||
@@ -789,10 +759,6 @@ const initVideo = () => {
|
||||
resetTrialLimitHandledIfNeeded();
|
||||
}
|
||||
});
|
||||
player.on('seeking', () => {
|
||||
if (!player?.hasStart) return;
|
||||
suppressPlayAnalytics = true;
|
||||
});
|
||||
player.on('playing', () => {
|
||||
if (activeSourceRefreshSeq) {
|
||||
activeSourceRefreshSeq = 0;
|
||||
@@ -1044,7 +1010,6 @@ defineExpose({
|
||||
activeSourceRefreshSeq = ++sourceRefreshSeq;
|
||||
seekGuardTarget = nextResumeAt;
|
||||
seekGuardUntil = Date.now() + 2000;
|
||||
suppressPlayAnalytics = true;
|
||||
try {
|
||||
player.src = nextPath;
|
||||
} catch {
|
||||
@@ -1129,7 +1094,6 @@ defineExpose({
|
||||
}
|
||||
seekGuardTarget = next;
|
||||
seekGuardUntil = Date.now() + 2000;
|
||||
suppressPlayAnalytics = true;
|
||||
// 归零 / 近片头:只改 currentTime,避免 stopLoad/startLoad 造成多次加载后暂停
|
||||
const softSeek = next <= 0.35;
|
||||
try {
|
||||
|
||||
@@ -11,23 +11,10 @@
|
||||
"
|
||||
>
|
||||
<img class="close" src="@/assets/images/png/close1.png" @click.stop="closePopupAds" alt="" />
|
||||
<AdvSwiper
|
||||
:list="state.advList"
|
||||
:isShowIndicators="false"
|
||||
:adImpression="{
|
||||
ad_type: 'small_grid',
|
||||
}"
|
||||
/>
|
||||
<AdvSwiper :list="state.advList" :isShowIndicators="false" />
|
||||
</div>
|
||||
<template v-if="state.videoInfo.newsType == 'SHORT_ADV'">
|
||||
<AdvSwiper
|
||||
:list="[state.videoInfo]"
|
||||
class="advSwiper"
|
||||
height="100%"
|
||||
:adImpression="{
|
||||
ad_type: 'fullscreen',
|
||||
}"
|
||||
/>
|
||||
<AdvSwiper :list="[state.videoInfo]" class="advSwiper" height="100%" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 图文播放器 -->
|
||||
@@ -276,12 +263,6 @@ import { useConfigStore } from '@/stores/config';
|
||||
import { usePaymentGuide } from '@/hooks/usePaymentGuide';
|
||||
import { PaymentGuideScene } from '@/enums/home';
|
||||
import { turnOnPaymentGuideLocalSwitch } from '@/utils/paymentGuideLocalSwitch';
|
||||
import {
|
||||
// getVideoType,
|
||||
// reportCollectCommentEvent,
|
||||
reportCollectFn,
|
||||
// reportLikeCommentEvent,
|
||||
} from '@/utils/eventReporter/videoComment';
|
||||
|
||||
const PaymentStatusPopup = defineAsyncComponent(() => import('@/components/paymentStatusPopup/index.vue'));
|
||||
const PaymentGuidePopup = defineAsyncComponent(() => import('@/components/PaymentGuidePopup/index.vue'));
|
||||
@@ -397,7 +378,6 @@ const clickLike = async () => {
|
||||
state.videoInfo.vidStatus.hasLiked = true;
|
||||
state.videoInfo.likeCount++;
|
||||
showToast('点赞成功');
|
||||
reportCollectFn(2, 1, state.videoInfo);
|
||||
} else {
|
||||
let req = {
|
||||
type: LikeType.SHORT,
|
||||
@@ -407,7 +387,6 @@ const clickLike = async () => {
|
||||
state.videoInfo.vidStatus.hasLiked = false;
|
||||
state.videoInfo.likeCount--;
|
||||
showToast('取消成功');
|
||||
reportCollectFn(2, 2, state.videoInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
@@ -442,11 +421,9 @@ const clickCollect = async () => {
|
||||
if (hasCollected) {
|
||||
showToast('取消成功');
|
||||
state.videoInfo.collectCount--;
|
||||
reportCollectFn(1, 2, state.videoInfo);
|
||||
} else {
|
||||
showToast('收藏成功');
|
||||
state.videoInfo.collectCount++;
|
||||
reportCollectFn(1, 1, state.videoInfo);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -186,10 +186,6 @@
|
||||
<span class="payAmount">¥{{ payButton.price }}</span>
|
||||
<span class="payAction">{{ payButton.action }}</span>
|
||||
</div>
|
||||
<div class="customerService">
|
||||
支付问题反馈,点击联系
|
||||
<span @click.stop="$router.push('customerService')">在线客服</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <Loading v-else :loading="state.loading" /> -->
|
||||
@@ -1143,17 +1139,6 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.customerService {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
color: rgba(220, 220, 220, 1);
|
||||
|
||||
span {
|
||||
color: rgba(255, 212, 96, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,758 +0,0 @@
|
||||
/**
|
||||
* 私聊会话(参考 bili session 页,流程线性化)
|
||||
*
|
||||
* 数据流:
|
||||
*
|
||||
* 打开 openChat:
|
||||
* ① ImApi.resolveUserId 业务 uid → IM userId
|
||||
* ② im.conversation.ensureDirect + 全局 bindActiveDirectChat
|
||||
* ③ subscribeActiveChat SDK 实时消息(含历史,liveQuery 快照)→ messages(顺带 markRead)
|
||||
* ④ 首帧无历史时 ensureFriend + 问候语展示
|
||||
*
|
||||
* 发送 sendText / sendImageUrl:
|
||||
* 本地余额预检 → POST /im/message/send(扣费 + 服务端投递)
|
||||
*
|
||||
* 加载更早 loadOlder:
|
||||
* getActiveMessageList().loadOlder()(SDK 分页,历史全部由 SDK 提供)
|
||||
*/
|
||||
import { ref, nextTick, onActivated, onDeactivated, onUnmounted, type Ref } from 'vue';
|
||||
import ImApi from '@/service/im';
|
||||
import { ensureImReady, getImInstance, getImRuntimeGeneration, subscribeImRuntimeReady } from '@/utils/im/sdk';
|
||||
import {
|
||||
bindActiveDirectChat,
|
||||
canResumeActiveDirectChat,
|
||||
clearActiveDirectChat,
|
||||
clearDirectChatSession,
|
||||
getActiveChatConversationId,
|
||||
getActiveChatSnapshot,
|
||||
getActiveMessageList,
|
||||
getDirectChatSession,
|
||||
rememberDirectChatSession,
|
||||
resumeActiveDirectChat,
|
||||
subscribeActiveChat,
|
||||
} from '@/utils/im/activeChat';
|
||||
import { buildImageMessageExtra, getMessageImageUrls, mergePendingImageMessage } from '@/utils/im/message';
|
||||
import type { ImChatMessage } from '@/utils/im/types';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
|
||||
export type { ImChatMessage };
|
||||
|
||||
/** 服务端加好友问候语文案(与后端模板一致,用于本地占位与去重) */
|
||||
const GREETING_TEXT = '添加好友成功,我们开始聊天吧~~';
|
||||
|
||||
/** 加好友后等 SDK 问候语回流,表示 IM 侧关系已生效(最多等待时长) */
|
||||
const FRIEND_GREETING_WAIT_MS = 5000;
|
||||
|
||||
const resolveUserIdCache = new Map<number, Types.Im.ResolveUserIdResult>();
|
||||
|
||||
const resolveUserIdCached = async (businessUid: number) => {
|
||||
const cached = resolveUserIdCache.get(businessUid);
|
||||
if (cached) return cached;
|
||||
const res = await ImApi.resolveUserId(businessUid);
|
||||
resolveUserIdCache.set(businessUid, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const rememberSession = (businessUid: number, peerImId: number, myImId: string, convId: string) => {
|
||||
rememberDirectChatSession({
|
||||
peerBusinessUid: businessUid,
|
||||
peerImUserId: peerImId,
|
||||
selfId: myImId,
|
||||
conversationId: convId,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param peerBusinessUid 对端业务 uid(路由 query.uid)
|
||||
*/
|
||||
export function useImDirectChat(peerBusinessUid: Ref<number>) {
|
||||
const messages = ref<ImChatMessage[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadingHistory = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const conversationId = ref('');
|
||||
const selfId = ref('');
|
||||
const peerImUserId = ref(0);
|
||||
|
||||
let friendEnsured = false;
|
||||
let lastMessageId = '';
|
||||
let localGreeting: ImChatMessage | null = null;
|
||||
let pendingOutbox: ImChatMessage[] = [];
|
||||
let lastSdkMessages: ImChatMessage[] = [];
|
||||
let autoScrollObserver: ResizeObserver | null = null;
|
||||
let scrollContainer: HTMLElement | null = null;
|
||||
let unbindScrollListener: (() => void) | null = null;
|
||||
let stickToBottom = true;
|
||||
/** 正在分页加载更早消息时,禁止任意“贴底/滚到底”的行为 */
|
||||
let isLoadingOlder = false;
|
||||
let scrollToBottomRetryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let scrollToBottomRetryCount = 0;
|
||||
let greetingSetupDone = false;
|
||||
let unbindActiveChat: (() => void) | null = null;
|
||||
let boundConvId = '';
|
||||
let lastPeerBusinessUid = 0;
|
||||
let chatOpening: Promise<void> | null = null;
|
||||
/** 当前会话绑定的 IM runtime 代数(重连后须重建订阅) */
|
||||
let chatRuntimeGeneration = -1;
|
||||
|
||||
const markChatRuntimeBound = () => {
|
||||
chatRuntimeGeneration = getImRuntimeGeneration();
|
||||
};
|
||||
|
||||
const PRIVATE_LETTER_SCROLL_SELECTOR = '.private-letter-scroll';
|
||||
|
||||
const getPeerBusinessUid = (): number => {
|
||||
const id = Number(peerBusinessUid.value);
|
||||
if (!Number.isFinite(id) || id <= 0) throw new Error('无效的对端用户');
|
||||
return id;
|
||||
};
|
||||
|
||||
const isNearBottom = (el: HTMLElement, threshold = 150) =>
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
|
||||
const pinToBottom = () => {
|
||||
if (!stickToBottom || isLoadingOlder) return;
|
||||
if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
};
|
||||
|
||||
const clearPendingScrollToBottom = () => {
|
||||
if (scrollToBottomRetryTimer) {
|
||||
clearInterval(scrollToBottomRetryTimer);
|
||||
scrollToBottomRetryTimer = null;
|
||||
}
|
||||
scrollToBottomRetryCount = 0;
|
||||
};
|
||||
|
||||
const runScrollToBottom = () => {
|
||||
if (!stickToBottom || isLoadingOlder) return;
|
||||
bindAutoScroll();
|
||||
pinToBottom();
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (isLoadingOlder) return;
|
||||
stickToBottom = true;
|
||||
clearPendingScrollToBottom();
|
||||
|
||||
void nextTick(() => {
|
||||
runScrollToBottom();
|
||||
requestAnimationFrame(() => {
|
||||
runScrollToBottom();
|
||||
requestAnimationFrame(runScrollToBottom);
|
||||
});
|
||||
});
|
||||
|
||||
// 首屏长列表/图片懒加载会分帧撑高,短时轮询补偿滚底
|
||||
scrollToBottomRetryTimer = setInterval(() => {
|
||||
scrollToBottomRetryCount += 1;
|
||||
runScrollToBottom();
|
||||
if (scrollToBottomRetryCount >= 12) {
|
||||
if (scrollToBottomRetryTimer) {
|
||||
clearInterval(scrollToBottomRetryTimer);
|
||||
scrollToBottomRetryTimer = null;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const bindAutoScroll = () => {
|
||||
const container = document.querySelector(PRIVATE_LETTER_SCROLL_SELECTOR) as HTMLElement | null;
|
||||
const content = container?.querySelector('.msg_list') as HTMLElement | null;
|
||||
if (!container || !content) return;
|
||||
|
||||
if (scrollContainer && scrollContainer !== container) {
|
||||
autoScrollObserver?.disconnect();
|
||||
unbindScrollListener?.();
|
||||
autoScrollObserver = null;
|
||||
unbindScrollListener = null;
|
||||
}
|
||||
|
||||
scrollContainer = container;
|
||||
if (autoScrollObserver) return;
|
||||
|
||||
autoScrollObserver = new ResizeObserver(() => {
|
||||
if (stickToBottom && !isLoadingOlder) pinToBottom();
|
||||
});
|
||||
autoScrollObserver.observe(content);
|
||||
|
||||
const onScroll = () => {
|
||||
stickToBottom = isNearBottom(container);
|
||||
// 接近顶部时,自动加载上一页(平顺的连续分页)
|
||||
if (container.scrollTop <= 20 && hasMore.value && !loadingHistory.value && !isLoadingOlder) {
|
||||
void loadOlder();
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
unbindScrollListener = () => container.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
|
||||
const pauseAutoScroll = () => {
|
||||
stickToBottom = false;
|
||||
};
|
||||
|
||||
const sortByTimestamp = (list: ImChatMessage[]) =>
|
||||
[...list].sort((a, b) => {
|
||||
const ts = Number(a.timestamp) - Number(b.timestamp);
|
||||
return ts !== 0 ? ts : String(a.messageId).localeCompare(String(b.messageId));
|
||||
});
|
||||
|
||||
const isLocalPlaceholderId = (messageId: unknown) => {
|
||||
const id = String(messageId || '');
|
||||
return id.startsWith('local_greeting_') || id.startsWith('local_pending_');
|
||||
};
|
||||
|
||||
const isSameOutgoingMessage = (sdkMsg: ImChatMessage, pending: ImChatMessage) => {
|
||||
if (String(sdkMsg.senderId) !== String(selfId.value)) return false;
|
||||
const sdkType = String(sdkMsg.chatMessageType || '').toUpperCase();
|
||||
const pendingType = String(pending.chatMessageType || '').toUpperCase();
|
||||
if (sdkType === 'TEXT' || pendingType === 'TEXT') {
|
||||
return sdkType === 'TEXT' && pendingType === 'TEXT' && sdkMsg.content === pending.content;
|
||||
}
|
||||
const sdkUrl = getMessageImageUrls(sdkMsg)[0] || '';
|
||||
const pendingUrl = getMessageImageUrls(pending)[0] || '';
|
||||
// /im/message/send 回流常只有 [图片] 文案、extra 不完整:内容匹配即可去重
|
||||
return !!pendingUrl && (sdkUrl === pendingUrl || String(sdkMsg.content || '').includes('[图片]'));
|
||||
};
|
||||
|
||||
const composeMessages = (sdkList: ImChatMessage[]): ImChatMessage[] => {
|
||||
if (localGreeting && sdkList.some((m) => m.content === GREETING_TEXT)) {
|
||||
localGreeting = null;
|
||||
}
|
||||
|
||||
const sdkCopy = sdkList.map((m) => ({ ...m }));
|
||||
const stillPending: ImChatMessage[] = [];
|
||||
|
||||
for (const pending of pendingOutbox) {
|
||||
const matchIdx = sdkCopy.findIndex((m) => isSameOutgoingMessage(m, pending));
|
||||
if (matchIdx >= 0) {
|
||||
sdkCopy[matchIdx] = mergePendingImageMessage(sdkCopy[matchIdx], pending);
|
||||
continue;
|
||||
}
|
||||
stillPending.push(pending);
|
||||
}
|
||||
|
||||
pendingOutbox = stillPending;
|
||||
const extras = [...pendingOutbox, ...(localGreeting ? [localGreeting] : [])];
|
||||
return sortByTimestamp(extras.length ? [...sdkCopy, ...extras] : sdkCopy);
|
||||
};
|
||||
|
||||
const refreshComposedMessages = () => {
|
||||
messages.value = composeMessages(lastSdkMessages);
|
||||
};
|
||||
|
||||
const hasGreetingMessage = () => messages.value.some((m) => m.content === GREETING_TEXT);
|
||||
|
||||
const hasRealGreetingFromSdk = () =>
|
||||
messages.value.some((m) => m.content === GREETING_TEXT && !String(m.messageId || '').startsWith('local_greeting_'));
|
||||
|
||||
const waitForFriendGreeting = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (hasRealGreetingFromSdk()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
if (hasRealGreetingFromSdk() || Date.now() - startedAt >= FRIEND_GREETING_WAIT_MS) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const injectGreetingIfMissing = () => {
|
||||
// 仅全新会话补问候语;老会话历史里可能本来就没有这条系统文案
|
||||
if (!isBrandNewChat() || localGreeting || hasGreetingMessage()) return;
|
||||
localGreeting = {
|
||||
messageId: `local_greeting_${Date.now()}`,
|
||||
senderId: String(selfId.value),
|
||||
receiverId: String(peerImUserId.value),
|
||||
chatMessageType: 'TEXT',
|
||||
content: GREETING_TEXT,
|
||||
timestamp: Date.now() - 1,
|
||||
};
|
||||
refreshComposedMessages();
|
||||
};
|
||||
|
||||
const setupFriendGreetingOnOpen = async () => {
|
||||
if (greetingSetupDone || !isBrandNewChat()) {
|
||||
greetingSetupDone = true;
|
||||
return;
|
||||
}
|
||||
const friendReady = await ensureFriendOnce();
|
||||
greetingSetupDone = true;
|
||||
if (!friendReady) return;
|
||||
await waitForFriendGreeting();
|
||||
if (!hasGreetingMessage()) injectGreetingIfMissing();
|
||||
if (stickToBottom && !isLoadingOlder) scrollToBottom();
|
||||
};
|
||||
|
||||
const injectPendingOutgoing = (draft: Omit<ImChatMessage, 'messageId' | 'senderId' | 'receiverId' | 'timestamp'>) => {
|
||||
pendingOutbox.push({
|
||||
messageId: `local_pending_${Date.now()}`,
|
||||
senderId: String(selfId.value),
|
||||
receiverId: String(peerImUserId.value),
|
||||
timestamp: Date.now(),
|
||||
...draft,
|
||||
});
|
||||
refreshComposedMessages();
|
||||
};
|
||||
|
||||
const handleActiveChatSnapshot = (im: Awaited<ReturnType<typeof ensureImReady>>, convId: string) => {
|
||||
return (state: { conversationId: string; messages: ImChatMessage[]; hasMore: boolean; loading: boolean }) => {
|
||||
if (state.conversationId !== convId) return;
|
||||
|
||||
lastSdkMessages = state.messages;
|
||||
messages.value = composeMessages(lastSdkMessages);
|
||||
const wasLoading = loading.value;
|
||||
hasMore.value = state.hasMore;
|
||||
loading.value = state.loading;
|
||||
if (!isLoadingOlder) {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
// 首屏消息加载完成:长列表 DOM 可能晚于快照,需再次滚底
|
||||
if (wasLoading && !state.loading && messages.value.length > 0 && !isLoadingOlder) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
const last = messages.value[messages.value.length - 1];
|
||||
if (last) {
|
||||
im.chat.markRead(last).catch(() => undefined);
|
||||
|
||||
const lastId = String(last.messageId || '');
|
||||
if (lastId && lastId !== lastMessageId) {
|
||||
const isFirstFrame = !lastMessageId;
|
||||
const fromSelf = String(last.senderId) === String(selfId.value);
|
||||
lastMessageId = lastId;
|
||||
if (!isLoadingOlder && !loadingHistory.value && (isFirstFrame || fromSelf || stickToBottom)) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
bindAutoScroll();
|
||||
};
|
||||
};
|
||||
|
||||
const bindPageSnapshot = (im: Awaited<ReturnType<typeof ensureImReady>>, convId: string) => {
|
||||
unbindActiveChat?.();
|
||||
unbindActiveChat = subscribeActiveChat(handleActiveChatSnapshot(im, convId));
|
||||
};
|
||||
|
||||
const restoreSessionToHook = (session: ReturnType<typeof getDirectChatSession>) => {
|
||||
if (!session) return false;
|
||||
peerImUserId.value = session.peerImUserId;
|
||||
selfId.value = session.selfId;
|
||||
conversationId.value = session.conversationId;
|
||||
boundConvId = session.conversationId;
|
||||
lastPeerBusinessUid = session.peerBusinessUid;
|
||||
return true;
|
||||
};
|
||||
|
||||
const prepareCachedChat = (businessUid: number): boolean => {
|
||||
const session = getDirectChatSession(businessUid);
|
||||
if (!session) return false;
|
||||
restoreSessionToHook(session);
|
||||
const cached = getActiveChatSnapshot();
|
||||
if (!cached.messages.length) return false;
|
||||
lastSdkMessages = cached.messages;
|
||||
messages.value = composeMessages(lastSdkMessages);
|
||||
hasMore.value = cached.hasMore;
|
||||
loading.value = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resumeCachedSession = async (businessUid: number) => {
|
||||
const session = getDirectChatSession(businessUid);
|
||||
if (!session) return false;
|
||||
|
||||
prepareCachedChat(businessUid);
|
||||
|
||||
chatOpening = (async () => {
|
||||
const im = await ensureImReady();
|
||||
bindPageSnapshot(im, session.conversationId);
|
||||
await resumeActiveDirectChat(im, session.conversationId);
|
||||
loading.value = false;
|
||||
})();
|
||||
try {
|
||||
await chatOpening;
|
||||
} finally {
|
||||
chatOpening = null;
|
||||
}
|
||||
markChatRuntimeBound();
|
||||
return true;
|
||||
};
|
||||
|
||||
const openChat = async () => {
|
||||
if (chatOpening) return chatOpening;
|
||||
|
||||
chatOpening = (async () => {
|
||||
loading.value = true;
|
||||
const businessUid = getPeerBusinessUid();
|
||||
|
||||
const [im, res] = await Promise.all([ensureImReady(), resolveUserIdCached(businessUid)]);
|
||||
if (!res.enabled) throw new Error('对方未开通私信');
|
||||
const peerImId = Number(res.imUserId);
|
||||
if (!Number.isFinite(peerImId) || peerImId <= 0) throw new Error('无法获取对方 IM 用户 ID');
|
||||
|
||||
const { getCurrentUserId } = await import('@mimi/im-sdk');
|
||||
const myImId = String(useUserStore().userInfo?.imUserId || getCurrentUserId());
|
||||
|
||||
const samePeer = lastPeerBusinessUid === businessUid && !!conversationId.value;
|
||||
if (!samePeer) {
|
||||
await closeChat();
|
||||
}
|
||||
|
||||
peerImUserId.value = peerImId;
|
||||
selfId.value = myImId;
|
||||
lastPeerBusinessUid = businessUid;
|
||||
|
||||
const convId = await im.conversation.ensureDirect(peerImId);
|
||||
if (!convId) {
|
||||
loading.value = false;
|
||||
throw new Error('无法创建私信会话');
|
||||
}
|
||||
conversationId.value = convId;
|
||||
boundConvId = convId;
|
||||
rememberSession(businessUid, peerImId, myImId, convId);
|
||||
|
||||
const canFastResume = samePeer && canResumeActiveDirectChat(convId);
|
||||
if (canFastResume) {
|
||||
bindPageSnapshot(im, convId);
|
||||
await resumeActiveDirectChat(im, convId);
|
||||
loading.value = false;
|
||||
void setupFriendGreetingOnOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
let firstSnapshot = true;
|
||||
let openReadySettled = false;
|
||||
let resolveOpenReady: ((value?: void) => void) | null = null;
|
||||
const openReady = new Promise<void>((resolve) => {
|
||||
resolveOpenReady = resolve;
|
||||
});
|
||||
|
||||
const finishOpenReady = () => {
|
||||
if (openReadySettled) return;
|
||||
openReadySettled = true;
|
||||
const resolve = resolveOpenReady;
|
||||
resolveOpenReady = null;
|
||||
if (!resolve) return;
|
||||
void setupFriendGreetingOnOpen();
|
||||
resolve();
|
||||
};
|
||||
|
||||
unbindActiveChat?.();
|
||||
unbindActiveChat = subscribeActiveChat((state) => {
|
||||
handleActiveChatSnapshot(im, convId)(state);
|
||||
if (firstSnapshot && !state.loading) {
|
||||
firstSnapshot = false;
|
||||
finishOpenReady();
|
||||
}
|
||||
});
|
||||
|
||||
await bindActiveDirectChat(im, convId);
|
||||
|
||||
if (!firstSnapshot) {
|
||||
finishOpenReady();
|
||||
}
|
||||
await openReady;
|
||||
})();
|
||||
|
||||
try {
|
||||
await chatOpening;
|
||||
} finally {
|
||||
chatOpening = null;
|
||||
markChatRuntimeBound();
|
||||
}
|
||||
};
|
||||
|
||||
/** keep-alive 回页 / 同会话再进:复用 messageList,避免每次重建 */
|
||||
const resumeChat = async () => {
|
||||
if (chatOpening) return chatOpening;
|
||||
|
||||
const businessUid = Number(peerBusinessUid.value);
|
||||
if (!Number.isFinite(businessUid) || businessUid <= 0) return;
|
||||
|
||||
const activeConv = getActiveChatConversationId();
|
||||
if (
|
||||
conversationId.value &&
|
||||
lastPeerBusinessUid === businessUid &&
|
||||
chatRuntimeGeneration === getImRuntimeGeneration() &&
|
||||
activeConv === conversationId.value &&
|
||||
canResumeActiveDirectChat(conversationId.value)
|
||||
) {
|
||||
chatOpening = (async () => {
|
||||
const im = await ensureImReady();
|
||||
bindPageSnapshot(im, conversationId.value);
|
||||
await resumeActiveDirectChat(im, conversationId.value);
|
||||
loading.value = false;
|
||||
markChatRuntimeBound();
|
||||
})();
|
||||
try {
|
||||
await chatOpening;
|
||||
} finally {
|
||||
chatOpening = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (await resumeCachedSession(businessUid)) return;
|
||||
|
||||
return openChat();
|
||||
};
|
||||
|
||||
const closeChat = async () => {
|
||||
unbindActiveChat?.();
|
||||
unbindActiveChat = null;
|
||||
boundConvId = '';
|
||||
|
||||
try {
|
||||
await clearActiveDirectChat(imInstanceOrNull());
|
||||
} catch {
|
||||
/* IM 可能已销毁 */
|
||||
}
|
||||
|
||||
conversationId.value = '';
|
||||
messages.value = [];
|
||||
peerImUserId.value = 0;
|
||||
friendEnsured = false;
|
||||
greetingSetupDone = false;
|
||||
localGreeting = null;
|
||||
pendingOutbox = [];
|
||||
lastSdkMessages = [];
|
||||
lastMessageId = '';
|
||||
lastPeerBusinessUid = 0;
|
||||
chatRuntimeGeneration = -1;
|
||||
clearDirectChatSession();
|
||||
stickToBottom = true;
|
||||
autoScrollObserver?.disconnect();
|
||||
autoScrollObserver = null;
|
||||
unbindScrollListener?.();
|
||||
unbindScrollListener = null;
|
||||
scrollContainer = null;
|
||||
};
|
||||
|
||||
function imInstanceOrNull() {
|
||||
try {
|
||||
return getImInstance();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const isBrandNewChat = () => !messages.value.some((m) => !isLocalPlaceholderId(m.messageId));
|
||||
|
||||
const ensureFriendOnce = async (): Promise<boolean> => {
|
||||
if (friendEnsured) return true;
|
||||
try {
|
||||
await ImApi.ensureFriend(getPeerBusinessUid());
|
||||
friendEnsured = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[IM] 建立好友关系失败:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const assertBalanceEnough = () => {
|
||||
const userStore = useUserStore();
|
||||
const price = Number(userStore.userInfo?.sendMsgPrice ?? useConfigStore().appConfig?.sendMsgPrice ?? 0);
|
||||
if (!(price > 0)) return;
|
||||
const wallet = userStore.walletInfo;
|
||||
const balance = Number(wallet?.amount ?? 0) + Number(wallet?.income ?? 0);
|
||||
if (balance < price) throw new Error('8000:余额不足');
|
||||
};
|
||||
|
||||
const deliverMessage = async (payload: { content?: string; linkUrl?: string; imgUrl?: string | string[] }) => {
|
||||
const params: Types.Im.SendMessageParams = { takeUid: getPeerBusinessUid() };
|
||||
const content = payload.content?.trim();
|
||||
if (content) params.content = content;
|
||||
if (payload.linkUrl?.trim()) params.linkUrl = payload.linkUrl.trim();
|
||||
if (payload.imgUrl) {
|
||||
const urls = (Array.isArray(payload.imgUrl) ? payload.imgUrl : [payload.imgUrl])
|
||||
.map((u) => String(u).trim())
|
||||
.filter(Boolean);
|
||||
if (urls.length) params.imgUrl = urls;
|
||||
}
|
||||
if (!params.content && !params.imgUrl?.length && !params.linkUrl) {
|
||||
throw new Error('内容不能为空');
|
||||
}
|
||||
const res = await ImApi.sendMessage(params);
|
||||
const userStore = useUserStore();
|
||||
if (res && typeof res === 'object' && (res.amount != null || res.income != null)) {
|
||||
userStore.walletInfo = {
|
||||
...userStore.walletInfo,
|
||||
amount: Number(res.amount ?? userStore.walletInfo.amount),
|
||||
income: Number(res.income ?? userStore.walletInfo.income),
|
||||
};
|
||||
} else {
|
||||
void userStore.updateWallet();
|
||||
}
|
||||
};
|
||||
|
||||
const sendText = async (content: string) => {
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
|
||||
assertBalanceEnough();
|
||||
await ensureFriendOnce();
|
||||
|
||||
await deliverMessage({ content: text });
|
||||
if (!hasGreetingMessage()) injectGreetingIfMissing();
|
||||
injectPendingOutgoing({ chatMessageType: 'TEXT', content: text });
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
/** 发送文本 + 图片(每张图单独扣费投递,与 hjll priLetterAdd imgUrl 数组一致) */
|
||||
const sendChatMessage = async (options: { content?: string; imgUrls?: string[] }) => {
|
||||
const content = options.content?.trim() ?? '';
|
||||
const imgUrls = (options.imgUrls ?? []).map((u) => u.trim()).filter(Boolean);
|
||||
if (!content && !imgUrls.length) throw new Error('内容不能为空');
|
||||
|
||||
await ensureFriendOnce();
|
||||
|
||||
if (content) {
|
||||
assertBalanceEnough();
|
||||
await deliverMessage({ content });
|
||||
if (!hasGreetingMessage()) injectGreetingIfMissing();
|
||||
injectPendingOutgoing({ chatMessageType: 'TEXT', content });
|
||||
}
|
||||
|
||||
for (const url of imgUrls) {
|
||||
assertBalanceEnough();
|
||||
await deliverMessage({ imgUrl: [url] });
|
||||
if (!hasGreetingMessage()) injectGreetingIfMissing();
|
||||
injectPendingOutgoing({
|
||||
chatMessageType: 'CUSTOMIZED',
|
||||
content: '[图片]',
|
||||
extra: buildImageMessageExtra(url),
|
||||
type: 1,
|
||||
});
|
||||
}
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
/** 单张图片发送(与 hjll sendImageUrl 字段一致) */
|
||||
const sendImageUrl = async (imageUrl: string) => {
|
||||
const url = imageUrl?.trim();
|
||||
if (!url) throw new Error('图片地址无效');
|
||||
await sendChatMessage({ imgUrls: [url] });
|
||||
};
|
||||
|
||||
const restoreScrollAfterPrepend = (
|
||||
container: HTMLElement,
|
||||
anchorId: string,
|
||||
anchorOffset: number,
|
||||
fallbackTop: number,
|
||||
fallbackHeight: number,
|
||||
) => {
|
||||
if (anchorId) {
|
||||
const anchorEl = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
|
||||
if (anchorEl) {
|
||||
container.scrollTop = anchorEl.offsetTop - anchorOffset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
container.scrollTop = fallbackTop + (container.scrollHeight - fallbackHeight);
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
const messageList = getActiveMessageList();
|
||||
if (!messageList || messageList.loading || !messageList.hasMore || boundConvId !== conversationId.value) {
|
||||
hasMore.value = false;
|
||||
return false;
|
||||
}
|
||||
if (isLoadingOlder) return false;
|
||||
|
||||
const container = document.querySelector(PRIVATE_LETTER_SCROLL_SELECTOR) as HTMLElement | null;
|
||||
const anchorId = messages.value[0]?.messageId ? String(messages.value[0].messageId) : '';
|
||||
const prevHeight = container?.scrollHeight ?? 0;
|
||||
const prevTop = container?.scrollTop ?? 0;
|
||||
let anchorOffset = 0;
|
||||
if (container && anchorId) {
|
||||
const anchorEl = container.querySelector(`[data-message-id="${anchorId}"]`) as HTMLElement | null;
|
||||
anchorOffset = anchorEl ? anchorEl.offsetTop - container.scrollTop : 0;
|
||||
}
|
||||
|
||||
clearPendingScrollToBottom();
|
||||
isLoadingOlder = true;
|
||||
stickToBottom = false;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
await messageList.loadOlder();
|
||||
await nextTick();
|
||||
|
||||
if (container) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const applyRestore = () => restoreScrollAfterPrepend(container, anchorId, anchorOffset, prevTop, prevHeight);
|
||||
applyRestore();
|
||||
requestAnimationFrame(() => {
|
||||
applyRestore();
|
||||
requestAnimationFrame(() => {
|
||||
applyRestore();
|
||||
stickToBottom = isNearBottom(container);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[IM] loadOlder failed:', e);
|
||||
return false;
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
isLoadingOlder = false;
|
||||
}
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
bindAutoScroll();
|
||||
});
|
||||
|
||||
const unsubImRuntime = subscribeImRuntimeReady(() => {
|
||||
if (!Number(peerBusinessUid.value) || !conversationId.value) return;
|
||||
if (chatRuntimeGeneration === getImRuntimeGeneration()) return;
|
||||
void resumeChat();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
autoScrollObserver?.disconnect();
|
||||
autoScrollObserver = null;
|
||||
unbindScrollListener?.();
|
||||
unbindScrollListener = null;
|
||||
scrollContainer = null;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubImRuntime();
|
||||
unbindActiveChat?.();
|
||||
unbindActiveChat = null;
|
||||
autoScrollObserver?.disconnect();
|
||||
autoScrollObserver = null;
|
||||
unbindScrollListener?.();
|
||||
unbindScrollListener = null;
|
||||
scrollContainer = null;
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
loadingHistory,
|
||||
hasMore,
|
||||
conversationId,
|
||||
selfId,
|
||||
peerImUserId,
|
||||
openChat,
|
||||
resumeChat,
|
||||
prepareCachedChat,
|
||||
closeChat,
|
||||
loadOlder,
|
||||
sendText,
|
||||
sendChatMessage,
|
||||
sendImageUrl,
|
||||
scrollToBottom,
|
||||
pauseAutoScroll,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* 消息中心私信列表 — 薄 hook,状态与 SDK 订阅在 Pinia imSession store
|
||||
*/
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onActivated, onMounted } from 'vue';
|
||||
import { useImSessionStore } from '@/stores/imSession';
|
||||
|
||||
export function useImPrivateSessionList() {
|
||||
const store = useImSessionStore();
|
||||
const { list, loading, refreshing, finished, isNoData, error, errorMessage } = storeToRefs(store);
|
||||
|
||||
onMounted(() => {
|
||||
void store.bootstrap();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void store.onTabActivated();
|
||||
void store.bootstrap();
|
||||
});
|
||||
|
||||
return {
|
||||
list,
|
||||
loading,
|
||||
refreshing,
|
||||
finished,
|
||||
isNoData,
|
||||
error,
|
||||
errorMessage,
|
||||
refresh: store.refresh,
|
||||
loadMore: store.loadMore,
|
||||
clearUnread: store.clearUnread,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 useImSessionStore().prime() */
|
||||
export function primePrivateSessionList() {
|
||||
void useImSessionStore().prime();
|
||||
}
|
||||
@@ -15,9 +15,6 @@ import { Lazyload } from 'vant';
|
||||
import qrcodeVue from 'qrcode.vue';
|
||||
import * as devtoolsDetector from 'devtools-detector';
|
||||
import '@/assets/theme/index.scss';
|
||||
import { setupAutoPageView } from './utils/eventReporter/autoPageView';
|
||||
import { setupAutoClick } from './utils/eventReporter/autoClick';
|
||||
import trackerPlugin, { initTracker } from '@/plugins/tracker';
|
||||
import AppApi from '@/service/app';
|
||||
import { cryptoDecrypt } from '@/utils/key';
|
||||
|
||||
@@ -53,10 +50,6 @@ if (!devTest && process.env.NODE_ENV == 'production') {
|
||||
const app = createApp(App);
|
||||
app.use(store);
|
||||
app.use(router);
|
||||
app.use(trackerPlugin);
|
||||
initTracker();
|
||||
setupAutoPageView(router);
|
||||
setupAutoClick(router);
|
||||
app.component('SvgIcon', svgIcon);
|
||||
app.component('QrcodeVue', qrcodeVue);
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
keepAliveMap.set('communityTagDeatil', 'communityTagDeatil'); //标签帖子
|
||||
// keepAliveMap.set('messageCenter', 'messageCenter');//消息中心
|
||||
keepAliveMap.set('minepostpage', 'minepostpage'); //我的帖子
|
||||
keepAliveMap.set('privateLetter', 'privateLetter'); //私信
|
||||
keepAliveMap.set('aiUndress', 'aiUndress'); //ai科技
|
||||
keepAliveMap.set('aiRecord', 'aiRecord'); //AI记录
|
||||
keepAliveMap.set('presale', 'presale'); //预售
|
||||
@@ -127,7 +126,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
/**
|
||||
* 短剧播放页缓存(单实例,不按剧 id 区分 key):
|
||||
* - 仅当从播放页内跳出的二级页(客服 / 余额明细 / 评论跳的用户主页、图片预览、影片详情)
|
||||
* - 仅当从播放页内跳出的二级页(余额明细 / 评论跳的用户主页、图片预览、影片详情)
|
||||
* 返回时保留缓存,恢复剧集与播放进度;
|
||||
* - 其余入口(列表选剧 / 广告 / 深链等)一律清缓存重挂载,避免恢复出上一部剧;
|
||||
* - 回到 Tab / 列表页即销毁,避免播放器实例驻留内存。
|
||||
@@ -135,7 +134,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
const dramaPlaySecondary = [
|
||||
'/wallet',
|
||||
'/transactionRecord',
|
||||
'/customerService',
|
||||
'/userInfo',
|
||||
'/picturePreview',
|
||||
'/movieDetails',
|
||||
@@ -177,7 +175,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
'/purchasedVideo',
|
||||
'/minepostpage',
|
||||
'/follow',
|
||||
'/privateLetter',
|
||||
'/myLike',
|
||||
].includes(to.path)
|
||||
) {
|
||||
@@ -224,9 +221,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (from.path == '/minepostpage' && to.path == '/mine') {
|
||||
keepAliveMap.delete('minepostpage');
|
||||
}
|
||||
if (from.path == '/privateLetter' && (to.path == '/userInfo' || to.path == '/messageCenter')) {
|
||||
keepAliveMap.delete('privateLetter');
|
||||
}
|
||||
if (from.path == '/aiUndress' && to.path == '/mine') {
|
||||
keepAliveMap.delete('aiUndress');
|
||||
}
|
||||
@@ -256,7 +250,6 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
if (to.path === '/messageCenter') {
|
||||
keepAliveMap.delete('movieDetails');
|
||||
keepAliveMap.delete('privateLetter');
|
||||
keepAliveMap.delete('postDetail');
|
||||
}
|
||||
if (['/community','/searchResults'].includes(to.path)) {
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import type { App } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import router from '@/router';
|
||||
import { APPID } from '@/utils/eventReporter';
|
||||
import { md5 } from 'js-md5';
|
||||
import moment from 'moment';
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
|
||||
export type TrackerPayload = Record<string, any>;
|
||||
export type TrackerPayloadOrArray = TrackerPayload | TrackerPayload[];
|
||||
|
||||
const registerDate = new Date();
|
||||
|
||||
const EVENT_METHOD_MAP: Record<string, string> = {
|
||||
navigation: 'navigation',
|
||||
app_page_view: 'appPageView',
|
||||
page_click: 'pageClick',
|
||||
recommend_list_view: 'recommendListView',
|
||||
recommend_list_click: 'recommendListClick',
|
||||
advertising: 'advertising',
|
||||
video_event: 'videoEvent',
|
||||
novel_event: 'novelEvent',
|
||||
comic_event: 'comicEvent',
|
||||
keyword_search: 'keywordSearch',
|
||||
keyword_click: 'keywordClick',
|
||||
ad_impression: 'adImpression',
|
||||
ad_click: 'adClick',
|
||||
app_install: 'appInstall',
|
||||
};
|
||||
|
||||
const RECOMMEND_TRACE_ID_EXEMPT_EVENTS = new Set([
|
||||
'navigation',
|
||||
'keyword_search',
|
||||
'keyword_click',
|
||||
'ad_impression',
|
||||
'ad_click',
|
||||
'app_install',
|
||||
'app_page_view',
|
||||
'page_click',
|
||||
]);
|
||||
|
||||
/** 业务侧不需要上报的字段(SDK enrichment 可能补齐,入队前再剥离) */
|
||||
const EVENT_PAYLOAD_STRIP_FIELDS: Record<string, string[]> = {
|
||||
page_click: [
|
||||
'referrer_page_key',
|
||||
'click_viewport_x',
|
||||
'click_viewport_y',
|
||||
'click_document_x',
|
||||
'click_document_y',
|
||||
'viewport_width',
|
||||
'viewport_height',
|
||||
'page_width',
|
||||
'page_height',
|
||||
'scroll_top',
|
||||
'scroll_left',
|
||||
],
|
||||
app_page_view: ['page_id', 'page_enter_ts', 'page_url', 'viewport_width', 'viewport_height', 'recommend_trace_id'],
|
||||
};
|
||||
|
||||
function stripEventPayloadFields(eventName: string, payload: Record<string, any>) {
|
||||
const keys = EVENT_PAYLOAD_STRIP_FIELDS[eventName];
|
||||
if (!keys || !payload) return payload;
|
||||
keys.forEach((key) => {
|
||||
if (key in payload) delete payload[key];
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isSdkQueueItem(item: any): boolean {
|
||||
return (
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
item.record &&
|
||||
typeof item.record.event === 'string' &&
|
||||
item.record.payload &&
|
||||
typeof item.resolve === 'function' &&
|
||||
typeof item.timeoutAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
let _queueStripInstalled = false;
|
||||
|
||||
/**
|
||||
* 拦截 SDK 内部 queue.push:在 enrichment 完成后、真正发送前剥离多余字段。
|
||||
* 只改业务侧包装,不修改 web-sdk 源文件。
|
||||
*/
|
||||
function installSdkQueuePayloadStrip() {
|
||||
if (_queueStripInstalled) return;
|
||||
_queueStripInstalled = true;
|
||||
|
||||
const originalPush = Array.prototype.push;
|
||||
Array.prototype.push = function (...items: any[]) {
|
||||
for (const item of items) {
|
||||
if (!isSdkQueueItem(item)) continue;
|
||||
const eventName = String(item.record.event || '');
|
||||
if (!EVENT_PAYLOAD_STRIP_FIELDS[eventName]) continue;
|
||||
stripEventPayloadFields(eventName, item.record.payload);
|
||||
}
|
||||
return originalPush.apply(this, items as [any, ...any[]]);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SDK 初始化(单例 Promise) ───
|
||||
|
||||
let _sdkReadyResolve!: (sdk: any) => void;
|
||||
const _sdkReady = new Promise<any>((resolve) => {
|
||||
_sdkReadyResolve = resolve;
|
||||
});
|
||||
let _initStarted = false;
|
||||
|
||||
/**
|
||||
* 启动 SDK 初始化:轮询等待 WebSDK 脚本加载 + reportSdkConfig 就绪后执行 init,
|
||||
* 并冲刷 index.html 中 tracker 存根缓存的调用队列。
|
||||
* 在 main.ts 中 app.use(store) 之后调用一次即可。
|
||||
*/
|
||||
export function initTracker() {
|
||||
if (_initStarted) return _sdkReady;
|
||||
_initStarted = true;
|
||||
|
||||
const maxWaitMs = 60000;
|
||||
const startTime = Date.now();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (Date.now() - startTime > maxWaitMs) {
|
||||
clearInterval(timer);
|
||||
console.warn('WebSDK init skipped: not ready within timeout.');
|
||||
_sdkReadyResolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const WebSDK = (window as any).WebSDK;
|
||||
if (!WebSDK) return;
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const reportSdkConfig = configStore.appConfig?.reportSdkConfig;
|
||||
if (!reportSdkConfig) return;
|
||||
|
||||
clearInterval(timer);
|
||||
|
||||
WebSDK.init({
|
||||
appId: APPID,
|
||||
encryptedConfig: reportSdkConfig,
|
||||
channel: '',
|
||||
// 页面展示/点击由业务 autoPageView / autoClick 上报,关闭 SDK 自动采集避免字段不一致
|
||||
disableAutoEvents: ['app_page_view', 'page_click'],
|
||||
});
|
||||
|
||||
installSdkQueuePayloadStrip();
|
||||
patchWebSdkTrackStrip(WebSDK);
|
||||
|
||||
// 冲刷 index.html queue stub 中缓存的早期调用
|
||||
const queued = (window as any).tracker?.q || [];
|
||||
(window as any).tracker = (...args: any[]) => WebSDK.track(...args);
|
||||
queued.forEach((item: any) => {
|
||||
WebSDK.track(...item.args)
|
||||
.then(item.resolve)
|
||||
.catch(item.reject);
|
||||
});
|
||||
|
||||
console.log('WebSDK initialized', WebSDK);
|
||||
_sdkReadyResolve(WebSDK);
|
||||
}, 50);
|
||||
|
||||
return _sdkReady;
|
||||
}
|
||||
|
||||
function getWebSDK(): Promise<any> {
|
||||
return _sdkReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装具名 track 方法:调用前先去掉业务侧不需要的字段。
|
||||
* SDK enrichment 补齐的字段由 installSdkQueuePayloadStrip 在入队时再剥一次。
|
||||
*/
|
||||
function patchWebSdkTrackStrip(WebSDK: any) {
|
||||
const trackApi = WebSDK?.track;
|
||||
if (!trackApi) return;
|
||||
|
||||
(['pageClick', 'appPageView'] as const).forEach((methodName) => {
|
||||
const original = trackApi[methodName];
|
||||
if (typeof original !== 'function') return;
|
||||
|
||||
trackApi[methodName] = (fields: Record<string, any> = {}, endpoint?: string) => {
|
||||
const eventName = methodName === 'pageClick' ? 'page_click' : 'app_page_view';
|
||||
const nextFields = stripEventPayloadFields(eventName, { ...fields });
|
||||
return original.call(trackApi, nextFields, endpoint);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
function registerUUID() {
|
||||
const userStore = useUserStore();
|
||||
// userStore.userInfo?.createdAt ||
|
||||
const createdAt = moment(registerDate).unix();
|
||||
const userId = String(userStore.userInfo?.uid || '');
|
||||
return md5(`${APPID}${userId}${createdAt}`);
|
||||
}
|
||||
|
||||
async function syncSDKContext() {
|
||||
const sdk = await getWebSDK();
|
||||
if (!sdk || typeof sdk.setContext !== 'function') return;
|
||||
|
||||
const userStore = useUserStore();
|
||||
const appStore = useAppStore();
|
||||
const route = router.currentRoute.value;
|
||||
|
||||
const routeDc = String(route.query.dc || route.query.pc || route.query.ch || '');
|
||||
|
||||
if (routeDc !== appStore.dc && routeDc !== '') {
|
||||
appStore.setDc(routeDc);
|
||||
sdk.setContext({
|
||||
uid: String(userStore?.userInfo?.uid || ''),
|
||||
channel: appStore.dc || '',
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const fields = await sdk.getCommonFields(['channel', 'uid']);
|
||||
if (!fields.channel || !fields.uid) {
|
||||
sdk.setContext({
|
||||
uid: String(userStore?.userInfo?.uid || ''),
|
||||
channel: appStore.dc || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('syncSDKContext error', error);
|
||||
}
|
||||
}
|
||||
const uid = await sdk.getCommonField('uid');
|
||||
if (userStore?.userInfo?.uid && !uid) {
|
||||
sdk.setContext({
|
||||
uid: String(userStore?.userInfo?.uid || ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isEmptyTrackInput(payloadOrArray: TrackerPayloadOrArray | null | undefined | string): boolean {
|
||||
if (payloadOrArray == null) return true;
|
||||
if (typeof payloadOrArray === 'string') return payloadOrArray.trim() === '';
|
||||
if (Array.isArray(payloadOrArray)) {
|
||||
return payloadOrArray.length === 0 || payloadOrArray.every((item) => isEmptyTrackInput(item as any));
|
||||
}
|
||||
if (typeof payloadOrArray !== 'object') return !payloadOrArray;
|
||||
return Object.keys(payloadOrArray).length === 0 || !payloadOrArray.event;
|
||||
}
|
||||
|
||||
async function trackSinglePayload(payload: TrackerPayload): Promise<unknown> {
|
||||
if (isEmptyTrackInput(payload)) return Promise.resolve();
|
||||
|
||||
const eventName = payload.event;
|
||||
const sdk = await getWebSDK();
|
||||
if (!sdk) return Promise.resolve();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { event: _e, userId: _uid, ...businessFields } = payload;
|
||||
|
||||
if (eventName === 'user_register') {
|
||||
sdk.setContext({ uid: String(useUserStore().userInfo?.uid || '') });
|
||||
businessFields.event_id = registerUUID();
|
||||
businessFields.create_time = moment(useUserStore().userInfo?.createdAt || registerDate).unix();
|
||||
}
|
||||
|
||||
if (eventName && !RECOMMEND_TRACE_ID_EXEMPT_EVENTS.has(eventName) && !('recommend_trace_id' in businessFields)) {
|
||||
businessFields.recommend_trace_id = '';
|
||||
}
|
||||
|
||||
if (eventName) {
|
||||
stripEventPayloadFields(eventName, businessFields);
|
||||
}
|
||||
|
||||
if (sdk?.track) {
|
||||
const methodName = EVENT_METHOD_MAP[eventName];
|
||||
console.log(businessFields, 'businessFields', methodName);
|
||||
if (methodName && typeof sdk.track[methodName] === 'function') {
|
||||
console.log(businessFields, 'businessFields', methodName);
|
||||
return sdk.track[methodName](businessFields);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// ─── public API ───
|
||||
|
||||
export const track = (payloadOrArray: TrackerPayloadOrArray) => {
|
||||
try {
|
||||
if (isEmptyTrackInput(payloadOrArray)) return Promise.resolve();
|
||||
|
||||
syncSDKContext();
|
||||
|
||||
const payloads = (Array.isArray(payloadOrArray) ? payloadOrArray : [payloadOrArray]).filter(
|
||||
(p) => !isEmptyTrackInput(p),
|
||||
);
|
||||
if (!payloads.length) return Promise.resolve();
|
||||
|
||||
const results = payloads.map((p) => trackSinglePayload(p));
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const label = '埋点';
|
||||
try {
|
||||
Promise.all(results)
|
||||
.then((e: unknown[]) => {
|
||||
const succeeded: unknown[] = [];
|
||||
const failed: unknown[] = [];
|
||||
console.log(e, 'e');
|
||||
try {
|
||||
if (e[0] == undefined) return;
|
||||
} catch (error) {
|
||||
console.log(error, 'error');
|
||||
}
|
||||
e.forEach((item: unknown) => {
|
||||
if ((item as { ok: boolean }).ok) {
|
||||
succeeded.push(item);
|
||||
} else {
|
||||
const it = item as any;
|
||||
if (it == undefined) return;
|
||||
failed.push(it?.reason || it?.message || it);
|
||||
}
|
||||
});
|
||||
if (succeeded.length) {
|
||||
console.log(`🟢 ${label}上报成功 (${succeeded.length}/${e.length})`, succeeded);
|
||||
}
|
||||
if (failed.length) {
|
||||
console.error(`🔴 ${label}上报失败 (${failed.length}/${e.length})`, failed);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`🔴 ${label}上报失败`, e);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(payloadOrArray) ? Promise.all(results) : results[0];
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('tracker 调用失败', err);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
export const flushBatch = async (reason?: string) => {
|
||||
try {
|
||||
const sdk = await getWebSDK();
|
||||
if (sdk && typeof sdk.flush === 'function') {
|
||||
return sdk.flush(reason);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('flushBatch 调用失败', err);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const trackerPlugin = {
|
||||
install(app: App) {
|
||||
app.config.globalProperties.$track = track;
|
||||
app.config.globalProperties.$flushBatch = flushBatch;
|
||||
app.provide('track', track);
|
||||
app.provide('flushBatch', flushBatch);
|
||||
},
|
||||
};
|
||||
|
||||
export default trackerPlugin;
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { App } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import router from '@/router';
|
||||
import { APPID } from '@/utils/eventReporter';
|
||||
import { md5 } from 'js-md5';
|
||||
import moment from 'moment';
|
||||
import { resolveDeviceMetadata } from '@/plugins/device-metadata';
|
||||
|
||||
export type TrackerPayload = Record<string, any>;
|
||||
export type TrackerPayloadOrArray = TrackerPayload | TrackerPayload[];
|
||||
|
||||
const SID_STORAGE_KEY = 'analytics_session_id';
|
||||
const LAST_ACTION_AT_KEY = 'analytics_last_action_at';
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const SID_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
const registerDate = new Date();
|
||||
|
||||
function getCookieValue(key: string): string {
|
||||
try {
|
||||
const cookie = document.cookie || '';
|
||||
const entries = cookie.split(';').map((item) => item.trim());
|
||||
const hit = entries.find((item) => item.startsWith(`${key}=`));
|
||||
if (!hit) return '';
|
||||
return decodeURIComponent(hit.slice(key.length + 1));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setCookieValue(key: string, value: string, maxAgeSeconds = SID_COOKIE_MAX_AGE_SECONDS): void {
|
||||
try {
|
||||
document.cookie = `${key}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax`;
|
||||
} catch {
|
||||
// cookie 写入失败时降级,仅依赖 localStorage
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalStorageValue(key: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalStorageValue(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// localStorage 写入失败时忽略,避免影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
return md5(`${APPID}_${Date.now()}_${Math.random()}`);
|
||||
}
|
||||
|
||||
function resolveSessionId(payloadOrArray: TrackerPayloadOrArray): string {
|
||||
const payloadList = Array.isArray(payloadOrArray) ? payloadOrArray : [payloadOrArray];
|
||||
const hasPageLoadEvent = payloadList.some((payload) => payload?.event === 'app_install');
|
||||
|
||||
const localSid = getLocalStorageValue(SID_STORAGE_KEY);
|
||||
const cookieSid = getCookieValue(SID_STORAGE_KEY);
|
||||
const lastActionAtRaw = getLocalStorageValue(LAST_ACTION_AT_KEY) || getCookieValue(LAST_ACTION_AT_KEY);
|
||||
const lastActionAt = Number(lastActionAtRaw) || 0;
|
||||
const now = Date.now();
|
||||
|
||||
const sessionIdMissing = !localSid || !cookieSid;
|
||||
const isSessionExpired = Boolean(lastActionAt) && now - lastActionAt > SESSION_TIMEOUT_MS;
|
||||
const shouldRegenerateSid = hasPageLoadEvent || sessionIdMissing || isSessionExpired;
|
||||
|
||||
const sid = shouldRegenerateSid ? generateSessionId() : localSid || cookieSid || generateSessionId();
|
||||
|
||||
setLocalStorageValue(SID_STORAGE_KEY, sid);
|
||||
setCookieValue(SID_STORAGE_KEY, sid);
|
||||
setLocalStorageValue(LAST_ACTION_AT_KEY, String(now));
|
||||
setCookieValue(LAST_ACTION_AT_KEY, String(now));
|
||||
|
||||
return sid;
|
||||
}
|
||||
|
||||
// 注册 事件ID
|
||||
function registerUUID() {
|
||||
const userStore = useUserStore();
|
||||
const createdAt = moment(userStore.userInfo?.createdAt || registerDate).unix();
|
||||
const userId = String(userStore.userInfo?.uid || '');
|
||||
const appId = APPID;
|
||||
const registerId = md5(`${appId}${userId}${createdAt}`);
|
||||
return registerId;
|
||||
}
|
||||
|
||||
// 直接调用全局 SDK,仅做容错
|
||||
// 支持单条上报(传对象)和批量上报(传数组)
|
||||
export const track = (payloadOrArray: TrackerPayloadOrArray) => {
|
||||
try {
|
||||
const globalAny = window as any;
|
||||
const tracker = globalAny.tracker as ((p: TrackerPayloadOrArray) => Promise<unknown>) | undefined;
|
||||
const appStore = useAppStore();
|
||||
const route = router.currentRoute.value;
|
||||
if (route.query.dc && route.query.dc !== appStore.dc) {
|
||||
appStore.setDc(route.query.dc as string);
|
||||
}
|
||||
if (typeof tracker === 'function') {
|
||||
const userStore: any = useUserStore();
|
||||
const sid = resolveSessionId(payloadOrArray);
|
||||
const deviceMeta = resolveDeviceMetadata();
|
||||
|
||||
// 判断是数组(批量上报)还是对象(单条上报)
|
||||
const isArray = Array.isArray(payloadOrArray);
|
||||
|
||||
if (isArray) {
|
||||
// 批量上报
|
||||
const batchPayload = payloadOrArray.map((payload) => {
|
||||
const payloadWithoutEventAndUserId = { ...(payload || {}) };
|
||||
delete payloadWithoutEventAndUserId.event;
|
||||
delete payloadWithoutEventAndUserId.userId;
|
||||
return {
|
||||
...payload,
|
||||
recommend_trace_id: '',
|
||||
channel: appStore.dc || '',
|
||||
uid: userStore?.userInfo?.uid || '',
|
||||
sid,
|
||||
device_id: appStore.uid || '',
|
||||
...deviceMeta,
|
||||
payload: payloadWithoutEventAndUserId,
|
||||
};
|
||||
});
|
||||
const result = tracker(batchPayload);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
result
|
||||
.then((e) => console.log('🟢 批量埋点上报成功', e))
|
||||
.catch((e) => console.error('批量埋点上报失败', e));
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// 单条上报
|
||||
const result = tracker({
|
||||
...payloadOrArray,
|
||||
channel: appStore.dc || '',
|
||||
uid: userStore?.userInfo?.uid || '',
|
||||
sid,
|
||||
device_id: appStore.uid || '',
|
||||
...deviceMeta,
|
||||
event_id: payloadOrArray.event == 'user_register' ? registerUUID() : undefined,
|
||||
create_time:
|
||||
payloadOrArray.event == 'user_register'
|
||||
? moment(userStore.userInfo?.createdAt || registerDate).unix()
|
||||
: undefined,
|
||||
recommend_trace_id: '',
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
result.then((e) => console.log('🟢 埋点上报成功', e)).catch((e) => console.error('埋点上报失败', e));
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('tracker 调用失败', err);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
// 手动触发批量上报 flush
|
||||
export const flushBatch = (reason?: string) => {
|
||||
try {
|
||||
const globalAny = window as any;
|
||||
const tracker = globalAny.tracker as { flushBatch?: (reason?: string) => Promise<unknown> } | undefined;
|
||||
if (tracker && typeof tracker.flushBatch === 'function') {
|
||||
return tracker.flushBatch(reason);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('flushBatch 调用失败', err);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const trackerPlugin = {
|
||||
install(app: App) {
|
||||
app.config.globalProperties.$track = track;
|
||||
app.config.globalProperties.$flushBatch = flushBatch;
|
||||
app.provide('track', track);
|
||||
app.provide('flushBatch', flushBatch);
|
||||
},
|
||||
};
|
||||
|
||||
export default trackerPlugin;
|
||||
@@ -216,15 +216,6 @@ const communityCollection: RouteRecordRaw[] = [
|
||||
pageName: '图片详情',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/privateLetter', //私信
|
||||
name: 'privateLetter',
|
||||
component: () => import('@/views/community/privateLetter/index.vue'),
|
||||
meta: {
|
||||
pageKey: 'private_letter',
|
||||
pageName: '私信',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/coverPage', //套图站
|
||||
name: 'coverPage',
|
||||
@@ -497,15 +488,6 @@ const mineCollection: RouteRecordRaw[] = [
|
||||
pageName: '精品应用',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/customerService', //我的客服
|
||||
name: 'customerService',
|
||||
component: () => import('@/views/lldmine/customerService/index.vue'),
|
||||
meta: {
|
||||
pageKey: 'customer_service',
|
||||
pageName: '我的客服',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/transactionRecord', //交易记录
|
||||
name: 'transactionRecord',
|
||||
@@ -682,24 +664,6 @@ const mineCollection: RouteRecordRaw[] = [
|
||||
pageName: '我的关注',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/originalenter', // 原创入驻
|
||||
name: 'originalenter',
|
||||
component: () => import('@/views/lldmine/originalenter/index.vue'),
|
||||
meta: {
|
||||
pageKey: 'originalenter',
|
||||
pageName: '原创入驻',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/originalenter2', // 官方招募
|
||||
name: 'originalenter2',
|
||||
component: () => import('@/views/lldmine/originalenter/index2.vue'),
|
||||
meta: {
|
||||
pageKey: 'originalenter_2',
|
||||
pageName: '官方招募',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/revenueDetails', // 收益明细
|
||||
name: 'revenueDetails',
|
||||
|
||||
@@ -163,11 +163,11 @@ export default class AppApi {
|
||||
return request.get('/modules/announce');
|
||||
}
|
||||
/**
|
||||
* @description 广告统计
|
||||
* @param data 参数
|
||||
* @returns
|
||||
* @description 广告点击日志
|
||||
* @param id 广告 id(后端必填)
|
||||
* @param type 固定传 0(普通广告)
|
||||
*/
|
||||
static clickAdv(data: any) {
|
||||
static clickAdv(data: { id: string; type: number }) {
|
||||
return request.post('/ads/click', data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// IM userId → 业务 uid 反查缓存(一次会话内不重复请求)
|
||||
const businessUidCache = new Map<number, number>();
|
||||
|
||||
function clearBusinessUidCache() {
|
||||
businessUidCache.clear();
|
||||
}
|
||||
|
||||
function pickImInitConfig(data: Record<string, unknown>): Types.Im.InitConfig {
|
||||
return {
|
||||
imToken: (data.imToken as string) || (data.token as string) || '',
|
||||
imUserId: data.imUserId != null ? Number(data.imUserId) : undefined,
|
||||
dynamicConfigDomain: (data.dynamicConfigDomain as string) || undefined,
|
||||
socketURL: (data.socketURL as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default class ImApi {
|
||||
/**
|
||||
* @description 刷新 IM 凭证与配置
|
||||
*/
|
||||
static async refreshToken(): Promise<Types.Im.InitConfig> {
|
||||
const data = (await request.post('/im/token', {})) as Record<string, unknown>;
|
||||
return pickImInitConfig(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 发起私聊前确保双方已注册 IM 并建立好友关系
|
||||
*/
|
||||
static ensureFriend(peerId: number): Promise<unknown> {
|
||||
return request.post('/im/friend/ensure', { peerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 业务 uid 解析为 IM userId
|
||||
*/
|
||||
static resolveUserId(userId: number): Promise<Types.Im.ResolveUserIdResult> {
|
||||
return request.post('/im/user-id', { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description IM userId 反查业务 uid(同一端点,入参传 imUserId),带缓存。
|
||||
* 私信列表纯 SDK 驱动后行内只有 IM userId,进私聊页前需反查。
|
||||
*/
|
||||
static async resolveBusinessUid(imUserId: number): Promise<number> {
|
||||
const cached = businessUidCache.get(imUserId);
|
||||
if (cached) return cached;
|
||||
|
||||
const res = (await request.post('/im/user-id', { imUserId })) as Types.Im.ResolveUserIdResult;
|
||||
const uid = Number(res?.userId ?? 0);
|
||||
if (!(uid > 0)) throw new Error('无法获取对方账号');
|
||||
|
||||
businessUidCache.set(imUserId, uid);
|
||||
return uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 按广告位拉取 IM 广告(透传 AD_NOTIFY 携带 positions 后调用)
|
||||
*/
|
||||
static ad(params: Types.Im.AdParams): Promise<Types.Im.AdResult> {
|
||||
return request.post('/im/ad', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description IM 发私信(扣费 + 服务端投递,不走 SDK 发送)
|
||||
*/
|
||||
static sendMessage(params: Types.Im.SendMessageParams): Promise<Types.Im.SendMessageResult> {
|
||||
return request.post('/im/message/send', params);
|
||||
}
|
||||
|
||||
/** 换号登录后清空 IM userId 反查缓存 */
|
||||
static clearBusinessUidCache(): void {
|
||||
clearBusinessUidCache();
|
||||
}
|
||||
}
|
||||
@@ -85,13 +85,6 @@ export default class UserApi {
|
||||
return request.get('/code/userRecord', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 获取客服链接接口
|
||||
*/
|
||||
static queryCustomerServiceUrl() {
|
||||
return request.get('/customer/url');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 意见反馈
|
||||
*/
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import ImApi from '@/service/im';
|
||||
import { useImSessionStore } from '@/stores/imSession';
|
||||
import { getVisibleListFeedAdSlotIndices, listFeedAdSlotKey } from '@/utils/im/listFeedAds';
|
||||
|
||||
/** IM 广告位编码(与 /im/ad、AD_NOTIFY 透传 positions 一致) */
|
||||
export const IM_AD_POS = {
|
||||
FLOAT: 'IM_CHAT_FLOATING_GIF_BALL',
|
||||
BANNER: 'IM_LIST_BANNER',
|
||||
LIST: 'IM_LIST_FEED_NATIVE',
|
||||
CHAT_NOTIFY: 'IM_CHAT_NOTIFICATION_BAR',
|
||||
} as const;
|
||||
|
||||
export type ImAdPosition = (typeof IM_AD_POS)[keyof typeof IM_AD_POS];
|
||||
|
||||
type ListFeedDotState = {
|
||||
/** AD_NOTIFY 推送批次:新推送后列表广告全部展示红点(含历史已点) */
|
||||
notifySeq: number;
|
||||
/** 某批次内已读:slotKey -> 已读时的 notifySeq */
|
||||
readAtSeq: Record<string, number>;
|
||||
/** 无推送批次时(notifySeq=0)已读槽位,持久化不过期 */
|
||||
readSlots: string[];
|
||||
};
|
||||
|
||||
function emptyListFeedDot(): ListFeedDotState {
|
||||
return { notifySeq: 0, readAtSeq: {}, readSlots: [] };
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* IM 广告:透传 AD_NOTIFY → POST /im/ad
|
||||
* - 广告内容:sessionStorage(关页失效)
|
||||
* - 列表信息流红点:localStorage 按槽位独立存储(重进 App 仍保留)
|
||||
* - 其他广告位红点:localStorage clicked(按日重置)
|
||||
*/
|
||||
export const useImAdStore = defineStore(
|
||||
'imAd',
|
||||
() => {
|
||||
const groupsByPosition = ref<Record<string, Types.Im.AdItem[]>>({});
|
||||
const clicked = ref<{ date: string; ids: string[] }>({ date: '', ids: [] });
|
||||
const listFeedDot = ref<ListFeedDotState>(emptyListFeedDot());
|
||||
|
||||
const setGroups = (groups: Types.Im.AdGroup[] = [], requestedPositions: string[] = []) => {
|
||||
const next = { ...groupsByPosition.value };
|
||||
const requested = new Set(requestedPositions.map(String));
|
||||
|
||||
// 本次请求的广告位先清空,避免后端下线后仍展示旧缓存
|
||||
for (const pos of requested) {
|
||||
next[pos] = [];
|
||||
}
|
||||
|
||||
for (const g of groups) {
|
||||
if (g?.position == null) continue;
|
||||
const pos = String(g.position);
|
||||
if (requested.size > 0 && !requested.has(pos)) continue;
|
||||
const list = Array.isArray(g.list) ? [...g.list].sort((a, b) => (a?.sortCode ?? 0) - (b?.sortCode ?? 0)) : [];
|
||||
next[pos] = list;
|
||||
}
|
||||
groupsByPosition.value = next;
|
||||
};
|
||||
|
||||
/** 同参请求合并 + TTL,避免 setup/onMounted/onActivated 重叠调用打多次 /im/ad */
|
||||
const adsInFlight = new Map<string, Promise<void>>();
|
||||
const adsFetchedAt = new Map<string, number>();
|
||||
const AD_FETCH_TTL_MS = 30 * 1000;
|
||||
|
||||
/**
|
||||
* @param force AD_NOTIFY 推送驱动时跳过 TTL(服务端明确有新广告)
|
||||
*/
|
||||
const fetchAds = async (positions: ImAdPosition[] | ImAdPosition = [], force = false): Promise<void> => {
|
||||
const pos = (Array.isArray(positions) ? positions : [positions]).map((p) => String(p).trim()).filter(Boolean);
|
||||
if (!pos.length) return;
|
||||
|
||||
const key = [...pos].sort().join(',');
|
||||
const inflight = adsInFlight.get(key);
|
||||
if (inflight) return inflight;
|
||||
if (!force && Date.now() - (adsFetchedAt.get(key) || 0) < AD_FETCH_TTL_MS) return;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const res = await ImApi.ad({ positions: pos });
|
||||
setGroups(res?.groups ?? [], pos);
|
||||
adsFetchedAt.set(key, Date.now());
|
||||
} catch (e) {
|
||||
console.warn('[imAd] 拉取广告失败:', e);
|
||||
} finally {
|
||||
adsInFlight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
adsInFlight.set(key, task);
|
||||
return task;
|
||||
};
|
||||
|
||||
const getByPosition = (position: ImAdPosition): Types.Im.AdItem[] => {
|
||||
return groupsByPosition.value[position] || [];
|
||||
};
|
||||
|
||||
const refreshDay = () => {
|
||||
const today = todayKey();
|
||||
// date 为空表示 persist 尚未从 localStorage 恢复,跳过以免覆盖已点记录
|
||||
if (!clicked.value.date) return;
|
||||
if (clicked.value.date !== today) {
|
||||
clicked.value = { date: today, ids: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const isClicked = (id: string | number) => clicked.value.ids.includes(String(id));
|
||||
|
||||
const notifyListAds = () => {
|
||||
listFeedDot.value = {
|
||||
...listFeedDot.value,
|
||||
notifySeq: listFeedDot.value.notifySeq + 1,
|
||||
};
|
||||
};
|
||||
|
||||
const shouldShowListFeedAdDot = (slotIndex: number) => {
|
||||
const key = listFeedAdSlotKey(slotIndex);
|
||||
const { notifySeq, readAtSeq, readSlots } = listFeedDot.value;
|
||||
if (notifySeq > 0) {
|
||||
return (readAtSeq[key] ?? 0) < notifySeq;
|
||||
}
|
||||
return !readSlots.includes(key);
|
||||
};
|
||||
|
||||
/** @deprecated 列表信息流请用 shouldShowListFeedAdDot(slotIndex) */
|
||||
const shouldShowListAdDot = (id: string | number) => {
|
||||
const ads = groupsByPosition.value[IM_AD_POS.LIST] || [];
|
||||
const slotIndex = ads.findIndex((a) => String(a.id) === String(id));
|
||||
if (slotIndex < 0) return !isClicked(id);
|
||||
return shouldShowListFeedAdDot(slotIndex);
|
||||
};
|
||||
|
||||
const markListFeedAdClicked = (slotIndex: number) => {
|
||||
const key = listFeedAdSlotKey(slotIndex);
|
||||
const { notifySeq, readAtSeq, readSlots } = listFeedDot.value;
|
||||
if (notifySeq > 0) {
|
||||
listFeedDot.value = {
|
||||
...listFeedDot.value,
|
||||
readAtSeq: { ...readAtSeq, [key]: notifySeq },
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (readSlots.includes(key)) return;
|
||||
listFeedDot.value = {
|
||||
...listFeedDot.value,
|
||||
readSlots: [...readSlots, key],
|
||||
};
|
||||
};
|
||||
|
||||
/** 非列表信息流广告(聊天通知栏等) */
|
||||
const markClicked = (id: string | number) => {
|
||||
refreshDay();
|
||||
const sid = String(id);
|
||||
const today = todayKey();
|
||||
const ids = clicked.value.ids.includes(sid) ? clicked.value.ids : [...clicked.value.ids, sid];
|
||||
clicked.value = { date: clicked.value.date || today, ids };
|
||||
};
|
||||
|
||||
const listHasUnclicked = computed(() => {
|
||||
const ads = groupsByPosition.value[IM_AD_POS.LIST] || [];
|
||||
if (!ads.length) return false;
|
||||
const visibleSlots = getVisibleListFeedAdSlotIndices(useImSessionStore().list.length, ads.length);
|
||||
return visibleSlots.some((slotIndex) => shouldShowListFeedAdDot(slotIndex));
|
||||
});
|
||||
|
||||
return {
|
||||
groupsByPosition,
|
||||
clicked,
|
||||
listFeedDot,
|
||||
setGroups,
|
||||
fetchAds,
|
||||
getByPosition,
|
||||
refreshDay,
|
||||
isClicked,
|
||||
notifyListAds,
|
||||
shouldShowListFeedAdDot,
|
||||
shouldShowListAdDot,
|
||||
markListFeedAdClicked,
|
||||
markClicked,
|
||||
listHasUnclicked,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: [
|
||||
{
|
||||
key: 'imAd',
|
||||
storage: window.sessionStorage,
|
||||
pick: ['groupsByPosition'],
|
||||
},
|
||||
{
|
||||
key: 'imAd-clicked',
|
||||
storage: window.localStorage,
|
||||
pick: ['clicked'],
|
||||
},
|
||||
{
|
||||
key: 'imAd-listFeedDot',
|
||||
storage: window.localStorage,
|
||||
pick: ['listFeedDot'],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -1,320 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { clearImActiveConversation, destroyIm, ensureImAccess, ensureImReady, getImErrorMessage } from '@/utils/im/sdk';
|
||||
import { mapSdkConversations, type PrivateSessionItem, type SdkConversation } from '@/utils/im/sessionList';
|
||||
|
||||
/**
|
||||
* 私信会话列表(Pinia 单例 + SDK liveQuery 常驻订阅)
|
||||
* list + ownerUid 持久化到 sessionStorage;换账号登录会清缓存。
|
||||
*/
|
||||
export const useImSessionStore = defineStore(
|
||||
'imSession',
|
||||
() => {
|
||||
const list = ref<PrivateSessionItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const finished = ref(false);
|
||||
const isNoData = ref(false);
|
||||
const error = ref(false);
|
||||
const errorMessage = ref('');
|
||||
/** 是否已收到过至少一次 SDK 快照(含空列表) */
|
||||
const synced = ref(false);
|
||||
/** 缓存所属业务 uid,与当前用户不一致时丢弃 */
|
||||
const ownerUid = ref('');
|
||||
|
||||
let listUnsub: (() => void) | null = null;
|
||||
|
||||
function currentUid(): string {
|
||||
const uid = useUserStore().userInfo?.uid;
|
||||
if (uid == null) return '';
|
||||
return String(uid);
|
||||
}
|
||||
|
||||
/** 换账号时清空列表缓存(IM 销毁由 resetImForAccountSwitch 统一处理) */
|
||||
function ensureOwnerMatch() {
|
||||
const uid = currentUid();
|
||||
if (!uid) return;
|
||||
if (ownerUid.value && ownerUid.value !== uid) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
function isListSyncedForCurrentUser() {
|
||||
const uid = currentUid();
|
||||
return !!uid && ownerUid.value === uid && synced.value;
|
||||
}
|
||||
|
||||
function invalidateListSubscription() {
|
||||
unsubscribeOnly();
|
||||
synced.value = false;
|
||||
}
|
||||
|
||||
function markOwner() {
|
||||
const uid = currentUid();
|
||||
if (uid) ownerUid.value = uid;
|
||||
}
|
||||
|
||||
function hasCache() {
|
||||
return list.value.length > 0;
|
||||
}
|
||||
|
||||
function applyCacheUi() {
|
||||
if (!hasCache()) return;
|
||||
loading.value = false;
|
||||
finished.value = true;
|
||||
isNoData.value = false;
|
||||
}
|
||||
|
||||
function applySnapshot(convs: SdkConversation[]) {
|
||||
list.value = mapSdkConversations(convs || []);
|
||||
loading.value = false;
|
||||
isNoData.value = list.value.length === 0;
|
||||
finished.value = true;
|
||||
synced.value = true;
|
||||
error.value = false;
|
||||
errorMessage.value = '';
|
||||
markOwner();
|
||||
}
|
||||
|
||||
function subscribeConversationList(im: {
|
||||
conversation: { subscribeList: (cb: (c: SdkConversation[]) => void) => { unsubscribe?: () => void } };
|
||||
}) {
|
||||
if (listUnsub) return;
|
||||
const sub = im.conversation.subscribeList((convs: SdkConversation[]) => {
|
||||
applySnapshot(convs);
|
||||
});
|
||||
listUnsub = () => sub?.unsubscribe?.();
|
||||
}
|
||||
|
||||
function subscribeConversationListFresh(im: {
|
||||
conversation: { subscribeList: (cb: (c: SdkConversation[]) => void) => { unsubscribe?: () => void } };
|
||||
}) {
|
||||
unsubscribeOnly();
|
||||
subscribeConversationList(im);
|
||||
}
|
||||
|
||||
/** IM 建连成功后尽早订阅(由 sdk 在 start 完成时调用) */
|
||||
function attachFromImInstance(im: unknown) {
|
||||
ensureOwnerMatch();
|
||||
if (listUnsub) {
|
||||
reattachFromImInstance(im);
|
||||
return;
|
||||
}
|
||||
const keepCache = hasCache() && isListSyncedForCurrentUser();
|
||||
if (!keepCache && !synced.value) {
|
||||
loading.value = true;
|
||||
finished.value = false;
|
||||
isNoData.value = false;
|
||||
}
|
||||
subscribeConversationList(im as Parameters<typeof subscribeConversationList>[0]);
|
||||
}
|
||||
|
||||
/** IM 重建或 WS 恢复后强制重绑列表订阅(避免 listUnsub 指向旧实例) */
|
||||
function reattachFromImInstance(im: unknown) {
|
||||
ensureOwnerMatch();
|
||||
unsubscribeOnly();
|
||||
synced.value = false;
|
||||
subscribeConversationList(im as Parameters<typeof subscribeConversationList>[0]);
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
ensureOwnerMatch();
|
||||
if (listUnsub && isListSyncedForCurrentUser()) {
|
||||
loading.value = false;
|
||||
isNoData.value = list.value.length === 0;
|
||||
finished.value = true;
|
||||
return;
|
||||
}
|
||||
if (listUnsub) {
|
||||
invalidateListSubscription();
|
||||
}
|
||||
|
||||
await clearImActiveConversation();
|
||||
const im = await ensureImReady();
|
||||
subscribeConversationListFresh(im);
|
||||
}
|
||||
|
||||
function unsubscribeOnly() {
|
||||
listUnsub?.();
|
||||
listUnsub = null;
|
||||
}
|
||||
|
||||
/** IM 实例销毁:取消订阅但保留列表缓存,重连后可秒开 */
|
||||
function onImDestroyed() {
|
||||
unsubscribeOnly();
|
||||
synced.value = false;
|
||||
}
|
||||
|
||||
/** 登出 / 换账号时清空缓存与订阅 */
|
||||
function reset() {
|
||||
unsubscribeOnly();
|
||||
list.value = [];
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
finished.value = false;
|
||||
isNoData.value = false;
|
||||
error.value = false;
|
||||
errorMessage.value = '';
|
||||
synced.value = false;
|
||||
ownerUid.value = '';
|
||||
}
|
||||
|
||||
async function resetImConnection() {
|
||||
unsubscribeOnly();
|
||||
synced.value = false;
|
||||
await destroyIm();
|
||||
}
|
||||
|
||||
function setSyncError(e: unknown, keepCache: boolean) {
|
||||
console.warn('[IM] 列表订阅失败:', e);
|
||||
loading.value = false;
|
||||
if (!keepCache) {
|
||||
error.value = true;
|
||||
errorMessage.value = `${getImErrorMessage(e, '私信连接失败', {
|
||||
hadImSession: synced.value || !!ownerUid.value,
|
||||
})},点击重新加载`;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
ensureOwnerMatch();
|
||||
|
||||
if (listUnsub && isListSyncedForCurrentUser()) {
|
||||
loading.value = false;
|
||||
isNoData.value = list.value.length === 0;
|
||||
finished.value = true;
|
||||
return;
|
||||
}
|
||||
if (listUnsub) {
|
||||
invalidateListSubscription();
|
||||
}
|
||||
|
||||
const keepCache = hasCache() && isListSyncedForCurrentUser();
|
||||
if (keepCache) {
|
||||
applyCacheUi();
|
||||
} else {
|
||||
loading.value = true;
|
||||
finished.value = false;
|
||||
}
|
||||
isNoData.value = false;
|
||||
|
||||
if (!keepCache) {
|
||||
const userStore = useUserStore();
|
||||
const hasImHint = !!(userStore.userInfo?.imEnabled || userStore.userInfo?.imToken?.trim());
|
||||
if (!hasImHint) {
|
||||
const hasAccess = await ensureImAccess();
|
||||
if (!hasAccess) {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
errorMessage.value = '暂不支持私信,点击重新加载';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await startSync();
|
||||
error.value = false;
|
||||
errorMessage.value = '';
|
||||
if (synced.value) {
|
||||
markOwner();
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncError(e, keepCache);
|
||||
}
|
||||
}
|
||||
|
||||
/** 账号找回 / 换号后:仅重置 UI,IM 由进入消息中心时 bootstrap */
|
||||
function restartAfterAccountSwitch() {
|
||||
invalidateListSubscription();
|
||||
list.value = [];
|
||||
ownerUid.value = '';
|
||||
loading.value = false;
|
||||
finished.value = false;
|
||||
isNoData.value = false;
|
||||
error.value = false;
|
||||
errorMessage.value = '';
|
||||
synced.value = false;
|
||||
}
|
||||
|
||||
/** App 预热:确保已订阅(建连时通常已完成) */
|
||||
async function prime() {
|
||||
ensureOwnerMatch();
|
||||
if (listUnsub) return;
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true;
|
||||
const keepCache = hasCache();
|
||||
try {
|
||||
if (error.value) {
|
||||
await resetImConnection();
|
||||
}
|
||||
await startSync();
|
||||
error.value = false;
|
||||
errorMessage.value = '';
|
||||
} catch (e) {
|
||||
setSyncError(e, keepCache);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
if (error.value) {
|
||||
loading.value = false;
|
||||
} else if (finished.value) {
|
||||
loading.value = false;
|
||||
isNoData.value = list.value.length === 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (error.value) {
|
||||
await resetImConnection();
|
||||
await bootstrap();
|
||||
return;
|
||||
}
|
||||
if (loading.value && list.value.length === 0) return;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function clearUnread(item: PrivateSessionItem) {
|
||||
const imId = Number(item.friendImUserId);
|
||||
if (!(imId > 0)) return;
|
||||
list.value = list.value.map((row) => (row.friendImUserId === imId ? { ...row, noReadNum: 0 } : row));
|
||||
}
|
||||
|
||||
async function onTabActivated() {
|
||||
await clearImActiveConversation();
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
loading,
|
||||
refreshing,
|
||||
finished,
|
||||
isNoData,
|
||||
error,
|
||||
errorMessage,
|
||||
synced,
|
||||
prime,
|
||||
bootstrap,
|
||||
restartAfterAccountSwitch,
|
||||
attachFromImInstance,
|
||||
reattachFromImInstance,
|
||||
refresh,
|
||||
loadMore,
|
||||
clearUnread,
|
||||
onTabActivated,
|
||||
onImDestroyed,
|
||||
reset,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'imSession',
|
||||
storage: window.sessionStorage,
|
||||
pick: ['list', 'ownerUid'],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,5 @@
|
||||
import UserApi from '@/service/user';
|
||||
import MineApi from '@/service/mine';
|
||||
import { resetImForAccountSwitch, warmImIfNeeded } from '@/utils/im/sdk';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/** 兼容扁平 user、以及 { userInfo, token } 嵌套回包 */
|
||||
@@ -34,82 +33,19 @@ export const useUserStore = defineStore({
|
||||
userInfo: <Types.User.UserInfoType>{}, //用户信息
|
||||
token: '',
|
||||
walletInfo: <Types.User.WalletInfo>{},
|
||||
reportVid: <any>{}, //已经埋过点的视频id
|
||||
};
|
||||
},
|
||||
actions: {
|
||||
/** 业务 uid 变化时重建 IM(凭证/扫码/手机号找回等) */
|
||||
async onBusinessUidChanged(prevUid: unknown, nextUid: unknown) {
|
||||
const prev = prevUid == null || prevUid === '' ? '' : String(prevUid);
|
||||
const next = nextUid == null || nextUid === '' ? '' : String(nextUid);
|
||||
if (!next || prev === next) return;
|
||||
// 冷启动 / 首次登录时 prev 为空,不属于换号,勿销毁 IM 或清空 imToken
|
||||
if (!prev) return;
|
||||
await resetImForAccountSwitch();
|
||||
if (this.token) {
|
||||
try {
|
||||
await warmImIfNeeded();
|
||||
} catch (e) {
|
||||
console.warn('[IM] 换号后建连失败:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
/** 登录/找回账号:必须先写业务 token,再写 userInfo(换号时 IM 依赖新 token) */
|
||||
/** 登录/找回账号:必须先写业务 token,再写 userInfo */
|
||||
async saveLoginResult(payload: { userInfo: Types.User.UserInfoType; token: string }) {
|
||||
const { userInfo: loginUser, token } = normalizeLoginPayload(payload);
|
||||
const prevToken = this.token;
|
||||
const switching = !!(prevToken && token && prevToken !== token);
|
||||
|
||||
const { userInfo, token } = normalizeLoginPayload(payload);
|
||||
this.saveToken(token);
|
||||
|
||||
// 换号/重登:丢掉登录回包里的 IM 凭证,强制走 /im/token,避免短窗口误用旧 imToken
|
||||
const userInfo = switching
|
||||
? {
|
||||
...loginUser,
|
||||
imToken: '',
|
||||
imEnabled: false,
|
||||
}
|
||||
: loginUser;
|
||||
|
||||
if (switching) {
|
||||
// 覆盖:换 uid、同 uid 换 token、回包缺 uid 导致 onBusinessUidChanged 未触发
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo));
|
||||
this.userInfo = userInfo;
|
||||
await resetImForAccountSwitch();
|
||||
if (this.token) {
|
||||
try {
|
||||
await warmImIfNeeded();
|
||||
} catch (e) {
|
||||
console.warn('[IM] 重新登录后建连失败:', e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveUserInfo(userInfo);
|
||||
},
|
||||
//保存用户信息
|
||||
async saveUserInfo(userInfo: Types.User.UserInfoType) {
|
||||
const prevUid = this.userInfo?.uid;
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo));
|
||||
this.userInfo = userInfo;
|
||||
await this.onBusinessUidChanged(prevUid, userInfo?.uid);
|
||||
},
|
||||
updateImToken(imToken: string) {
|
||||
this.updateImConfig({ imToken });
|
||||
},
|
||||
/** 合并 IM 初始化字段(登录 / mine/info / /im/token) */
|
||||
updateImConfig(config: Types.Im.InitConfig) {
|
||||
const next = { ...this.userInfo };
|
||||
if (config.imToken != null) next.imToken = config.imToken;
|
||||
if (config.imUserId != null && Number.isFinite(Number(config.imUserId))) {
|
||||
next.imUserId = Number(config.imUserId);
|
||||
}
|
||||
if (config.dynamicConfigDomain) next.dynamicConfigDomain = config.dynamicConfigDomain;
|
||||
if (config.socketURL) next.socketURL = config.socketURL;
|
||||
if (config.imToken) next.imEnabled = true;
|
||||
this.userInfo = next;
|
||||
localStorage.setItem('userInfo', JSON.stringify(this.userInfo));
|
||||
},
|
||||
//保存token
|
||||
saveToken(token: string) {
|
||||
@@ -123,9 +59,6 @@ export const useUserStore = defineStore({
|
||||
return data;
|
||||
});
|
||||
},
|
||||
addReportVid(vid: string) {
|
||||
this.reportVid[vid] = true;
|
||||
},
|
||||
getCheckMessageTip() {
|
||||
MineApi.getCheckMessageTip().then((data) => {
|
||||
const { newsTip } = data;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
export namespace Im {
|
||||
/** 登录 / 用户信息 / /im/token 返回的 IM 初始化字段 */
|
||||
interface InitConfig {
|
||||
imToken?: string;
|
||||
imUserId?: number;
|
||||
dynamicConfigDomain?: string;
|
||||
socketURL?: string;
|
||||
}
|
||||
|
||||
/** /im/user-id 正查(传 userId)/ 反查(传 imUserId)共用返回 */
|
||||
interface ResolveUserIdResult {
|
||||
enabled: boolean;
|
||||
userId: number;
|
||||
imUserId: number;
|
||||
}
|
||||
|
||||
/** /im/ad 单条广告 */
|
||||
interface AdItem {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
cover?: string;
|
||||
href?: string;
|
||||
link?: string;
|
||||
url?: string;
|
||||
linkUrl?: string;
|
||||
sortCode?: number;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
interface AdGroup {
|
||||
position: string;
|
||||
positionName?: string;
|
||||
list?: AdItem[];
|
||||
}
|
||||
|
||||
interface AdParams {
|
||||
positions: string[];
|
||||
}
|
||||
|
||||
interface AdResult {
|
||||
groups?: AdGroup[];
|
||||
}
|
||||
|
||||
/** /im/message/send 发私信(扣费 + 服务端投递) */
|
||||
interface SendMessageParams {
|
||||
takeUid: number;
|
||||
content?: string;
|
||||
linkUrl?: string;
|
||||
imgUrl?: string | string[];
|
||||
}
|
||||
|
||||
interface SendMessageResult {
|
||||
price?: number;
|
||||
deductAmount?: number;
|
||||
deductIncome?: number;
|
||||
amount?: number;
|
||||
income?: number;
|
||||
balance?: number;
|
||||
}
|
||||
}
|
||||
@@ -122,16 +122,6 @@ export namespace User {
|
||||
sendMsgPrice: number;
|
||||
urrPortraitStatus: number;
|
||||
isUpgrade: boolean;
|
||||
/** IM 是否开通 */
|
||||
imEnabled?: boolean;
|
||||
/** IM 登录 token,由业务后端签发 */
|
||||
imToken?: string;
|
||||
/** 当前用户在 IM 侧的 userId */
|
||||
imUserId?: number;
|
||||
/** IM 路由域名(拉取网关配置) */
|
||||
dynamicConfigDomain?: string;
|
||||
/** IM WebSocket 地址(后端下发,供排查;SDK 仍通过 dynamicConfigDomain 选线) */
|
||||
socketURL?: string;
|
||||
}
|
||||
/**
|
||||
* @description 钱包信息
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
|
||||
// APP 广告行为对内事件参数类型定义
|
||||
export interface AdInnerJumpEventParams {
|
||||
event_type: string; // 事件类型:click(点击), close(关闭), show(展示)
|
||||
advertising_key: number; // 广告标识:home_popup(首页弹窗), home_banner(首页Banner), video_reward(激励视频)等
|
||||
advertising_name: string; // 广告标识名称:首页弹窗, 首页Banner, 激励视频
|
||||
advertising_id: string; // 广告ID
|
||||
}
|
||||
/**
|
||||
* APP 广告行为对内
|
||||
* */
|
||||
export function reportAdInnerJumpEvent(params: AdInnerJumpEventParams) {
|
||||
track({
|
||||
event: EventName.Advertising,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
// APP 广告行为对外事件参数类型定义
|
||||
export interface AdOuterJumpEventParams {
|
||||
page_key: string; // 页面标识:如 home(首页), detail(详情页), video_play(播放页)等
|
||||
page_name: string; // 页面名称:如 首页, 详情页, 播放页
|
||||
ad_slot_key: string; //广告位标识:与展示事件一致,如 home_banner_1
|
||||
ad_slot_name: string; // 广告位名称:与展示事件一致
|
||||
ad_id: string; // 被点击的广告ID
|
||||
creative_id: string; //素材ID(可选)
|
||||
ad_type: string; //广告类型:banner, feed, interstitial, reward_video 等
|
||||
}
|
||||
/**
|
||||
* APP 广告行为对内
|
||||
* */
|
||||
export function reportAdOuterJumpEvent(params: AdOuterJumpEventParams) {
|
||||
track({
|
||||
event: EventName.AdClick,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告统一处理点击上报
|
||||
*/
|
||||
export function reportAdClickEvent(params: any) {
|
||||
try {
|
||||
const index = params.href.indexOf('yinseinner://');
|
||||
// 内链
|
||||
if (index != -1) {
|
||||
reportAdInnerJumpEvent({
|
||||
event_type: 'click',
|
||||
advertising_key: params.position,
|
||||
advertising_name: params.positionName,
|
||||
advertising_id: params.id,
|
||||
});
|
||||
} else {
|
||||
reportAdOuterJumpEvent({
|
||||
page_key: params.page_key || '',
|
||||
page_name: params.page_name || '',
|
||||
ad_type: params.ad_type || '',
|
||||
ad_slot_key: String(params.position),
|
||||
ad_slot_name: params.positionName,
|
||||
ad_id: params.id,
|
||||
creative_id: '',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`广告点击埋点上报失败`, err);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { EventName } from './eventDefs';
|
||||
import { track } from '@/plugins/tracker';
|
||||
export interface AdImpressionType {
|
||||
page_key?: string;
|
||||
page_name?: string;
|
||||
ad_type?: string;
|
||||
}
|
||||
export function setAdImpression(
|
||||
El: HTMLElement,
|
||||
advList: Types.App.AdsInfoListItem[],
|
||||
impression: AdImpressionType,
|
||||
batchType?: string,
|
||||
) {
|
||||
// 已曝光去重(避免同广告组件重复上报)
|
||||
let hasReported = false;
|
||||
// 监听广告是否在可视区域在进行上报
|
||||
const setupObserver = () => {
|
||||
if (!El) return;
|
||||
const newObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting && !hasReported) {
|
||||
triggerReport();
|
||||
newObserver.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
// 首次加载已在可视区域上报一次
|
||||
requestAnimationFrame(() => {
|
||||
// vat-tabs类型验证是否进行首屏上报,其余检测均首屏上报
|
||||
if (!isInActiveTab()) return;
|
||||
const rect = El.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight * 0.7 && rect.bottom > window.innerHeight * 0.3;
|
||||
|
||||
if (inView && !hasReported) {
|
||||
triggerReport();
|
||||
newObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
newObserver.observe(El);
|
||||
};
|
||||
|
||||
const isInActiveTab = () => {
|
||||
let currentPane = El.closest('.van-tabs__content');
|
||||
if (!currentPane) return true; // 不在 tabs 中,允许直接上报
|
||||
|
||||
// 从广告所在的这一层开始,一层一层往上判断
|
||||
while (currentPane) {
|
||||
const tabs = currentPane.closest('.van-tabs') as Element;
|
||||
if (!tabs) break; // 到顶层 tabs 了
|
||||
|
||||
// 找本层 tabs 的 nav
|
||||
const nav = tabs.querySelector(':scope > .van-tabs__wrap > .van-tabs__nav');
|
||||
if (!nav) return true; // 没有 nav,说明这层不是使用的可切换 tab
|
||||
|
||||
// 获取本层所有 tab
|
||||
const tabList = Array.from(nav.querySelectorAll('.van-tab'));
|
||||
|
||||
// 找本层选中 tab
|
||||
const activeTab = tabList.find((tab) => tab.getAttribute('aria-selected') === 'true');
|
||||
if (!activeTab) return false;
|
||||
|
||||
const activeId = activeTab.getAttribute('aria-controls');
|
||||
if (!activeId) return false;
|
||||
|
||||
// 在当前 tabs-content 下找对应的 swipe-item
|
||||
const swipeItems = Array.from(
|
||||
tabs.querySelectorAll(':scope > .van-tabs__content > .van-swipe > .van-swipe__track > .van-swipe-item'),
|
||||
);
|
||||
|
||||
const matchedItem = swipeItems.find((item) => item.id === activeId);
|
||||
if (!matchedItem) return false;
|
||||
|
||||
// 判断 active swipe-item 是否是当前可见项
|
||||
if (matchedItem.getAttribute('aria-hidden') === 'true') {
|
||||
return false; // 未显示,不首屏上报
|
||||
}
|
||||
|
||||
// 判断广告是否属于这一层选中的 tab 范围内
|
||||
// 当前广告所在 pane 必须在 matchedItem 里面
|
||||
if (!matchedItem.contains(El)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下一层:往上继续递归检测
|
||||
currentPane = tabs.closest('.van-tabs__content');
|
||||
}
|
||||
|
||||
return true; // 所有层都 valid,允许上报
|
||||
};
|
||||
//广告展示页面埋点
|
||||
const triggerReport = () => {
|
||||
hasReported = true;
|
||||
if (advList.length) {
|
||||
const adIds = advList.map((i) => i.id).join(',');
|
||||
if (batchType == 'payload') {
|
||||
track([
|
||||
{
|
||||
event: EventName.AdImpression,
|
||||
ad_slot_key: String(advList[0].position),
|
||||
ad_slot_name: advList[0].positionName,
|
||||
ad_id: adIds,
|
||||
creative_id: '',
|
||||
page_key: impression.page_key,
|
||||
page_name: impression.page_name,
|
||||
ad_type: impression.ad_type,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
track({
|
||||
event: EventName.AdImpression,
|
||||
ad_slot_key: String(advList[0].position),
|
||||
ad_slot_name: advList[0].positionName,
|
||||
ad_id: adIds,
|
||||
creative_id: '',
|
||||
page_key: impression.page_key,
|
||||
page_name: impression.page_name,
|
||||
ad_type: impression.ad_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
setupObserver,
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
/**
|
||||
* **************************数据埋点**************************
|
||||
*/
|
||||
|
||||
//安装日志
|
||||
export function analyticsEventAppInstall(trace_id: any) {
|
||||
try {
|
||||
track({
|
||||
event: EventName.AppInstall,
|
||||
trace_id: trace_id,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
//注册日志
|
||||
export function analyticsEventUserRegister(trace_id: any) {
|
||||
try {
|
||||
track({
|
||||
event: EventName.UserRegister,
|
||||
type: 'deviceid',
|
||||
trace_id: trace_id,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
//登录日志
|
||||
export function analyticsEventUserLogin() {
|
||||
try {
|
||||
track({
|
||||
event: EventName.UserLogin,
|
||||
type: 'deviceid',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
//导航路径
|
||||
export function analyticsEventNavigation(navigation_key: any, navigation_name: any) {
|
||||
try {
|
||||
track({
|
||||
event: EventName.Navigation,
|
||||
navigation_key: navigation_key,
|
||||
navigation_name: navigation_name,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
//视频埋点
|
||||
export function analyticsEventVideo(
|
||||
mediaInfo: Types.Video.VInfosItem,
|
||||
videoType: string,
|
||||
playedTimes: number,
|
||||
video_behavior_key: string,
|
||||
video_behavior_name: string,
|
||||
) {
|
||||
let typeName = '长视频'; // 视频类型
|
||||
let tag_keys = '' as any; // 标签ids
|
||||
let tag_names = '' as any; // 标签名称数组
|
||||
if (videoType == 'SP') {
|
||||
typeName = '短视频';
|
||||
} else if (videoType == 'CARTOON') {
|
||||
typeName = '动漫';
|
||||
}
|
||||
if (mediaInfo.tags && mediaInfo.tags.length) {
|
||||
tag_keys = mediaInfo.tags.map((item) => item.id).join(',');
|
||||
tag_names = mediaInfo.tags.map((item) => item.name).join(',');
|
||||
}
|
||||
try {
|
||||
track({
|
||||
event: EventName.VideoEvent,
|
||||
video_id: mediaInfo.id,
|
||||
video_title: mediaInfo.title,
|
||||
video_type_id: mediaInfo.videoTypeId || 'default',
|
||||
video_type_name: mediaInfo.videoTypeName || '默认分类',
|
||||
video_tag_key: tag_keys || 'default',
|
||||
video_tag_name: tag_names || '默认标签',
|
||||
video_duration: mediaInfo.playTime,
|
||||
play_duration: playedTimes,
|
||||
play_progress: Math.ceil((playedTimes / mediaInfo.playTime) * 100),
|
||||
video_behavior_key: video_behavior_key,
|
||||
video_behavior_name: video_behavior_name,
|
||||
media_id: mediaInfo.lsjId,
|
||||
video_content_type: typeName == '短视频' ? 'short_video' : 'video',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// import { reportEvent } from '.';
|
||||
import { Router } from 'vue-router';
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
// import { useUserStore } from '@/stores/user';
|
||||
|
||||
export function setupAutoClick(router: Router) {
|
||||
let throttleTimer: number | null = null;
|
||||
let isThrottling = false;
|
||||
const THROTTLE_DELAY = 300; // 节流延迟,单位ms
|
||||
|
||||
document.body.addEventListener(
|
||||
'click',
|
||||
(e: Event) => {
|
||||
if (isThrottling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const pageKey = (currentRoute.meta.pageKey as string) || (currentRoute.name as string) || currentRoute.path;
|
||||
const pageName = (currentRoute.meta.pageName as string) || (currentRoute.name as string) || currentRoute.path;
|
||||
|
||||
// 设置节流标志,立即执行处理函数
|
||||
isThrottling = true;
|
||||
|
||||
// 清除之前的定时器(如果有)
|
||||
if (throttleTimer) {
|
||||
clearTimeout(throttleTimer);
|
||||
}
|
||||
|
||||
// 立即执行埋点逻辑
|
||||
const event = e as MouseEvent;
|
||||
const target = event.target as HTMLElement;
|
||||
// 检查点击的元素或其祖先有 data-ignore-track 属性,有则忽略埋点
|
||||
if (target.closest('[data-ignore-track]')) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('🟡 埋点忽略:data-ignore-track');
|
||||
}
|
||||
// 即使忽略埋点,也要清除节流标志
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
isThrottling = false;
|
||||
}, THROTTLE_DELAY);
|
||||
return;
|
||||
}
|
||||
// 检查点击的元素是否在 Vant Popup 内部,如果是则忽略埋点
|
||||
if (target.closest('.van-popup')) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('🟡 埋点忽略:点击发生在 Vant Popup 内部');
|
||||
}
|
||||
// 即使忽略埋点,也要清除节流标志
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
isThrottling = false;
|
||||
}, THROTTLE_DELAY);
|
||||
return;
|
||||
}
|
||||
// 检查点击的元素是否是关闭弹窗的动作,如果是则忽略埋点
|
||||
if (target.closest('[data-track-action="close-popup"]')) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('🟡 埋点忽略:点击了弹窗关闭按钮');
|
||||
}
|
||||
// 即使忽略埋点,也要清除节流标志
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
isThrottling = false;
|
||||
}, THROTTLE_DELAY);
|
||||
return;
|
||||
}
|
||||
// 获取带有 data-track-click 属性的元素,用于自定义事件名和额外数据
|
||||
const markEl = target.closest('[data-track-click]');
|
||||
|
||||
let eventName: string;
|
||||
let extraData: Record<string, any> = {};
|
||||
// let elementInfo: string; // 之前被移除,如果需要可以再次启用
|
||||
|
||||
if (markEl) {
|
||||
eventName = markEl.getAttribute('data-track-click') || 'page_click';
|
||||
const extra = markEl.getAttribute('data-track-extra');
|
||||
try {
|
||||
extraData = extra ? JSON.parse(extra) : {};
|
||||
} catch (err) {
|
||||
console.warn('Error parsing data-track-extra', err);
|
||||
}
|
||||
// elementInfo = markEl.tagName + (markEl.id ? `#${markEl.id}` : '') + (markEl.className ? `.${markEl.className}` : ''); // 如果需要元素信息,启用此行
|
||||
} else {
|
||||
// 如果没有指定 data-track-click,则默认上报 page_click 事件
|
||||
eventName = EventName.PageClick;
|
||||
// 使用实际点击的元素信息
|
||||
// elementInfo = target.tagName + (target.id ? `#${target.id}` : '') + (target.className ? `.${target.className}` : ''); // 如果需要元素信息,启用此行
|
||||
}
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight;
|
||||
const clickX = event.clientX;
|
||||
const clickY = event.clientY;
|
||||
const clickXPercent = Math.round((clickX / screenWidth) * 100);
|
||||
const clickYPercent = Math.round((clickY / screenHeight) * 100);
|
||||
|
||||
const payload = {
|
||||
...extraData,
|
||||
page_key: pageKey,
|
||||
page_name: pageName,
|
||||
click_page_x: clickX,
|
||||
click_page_y: clickY,
|
||||
click_x_percent: clickXPercent,
|
||||
click_y_percent: clickYPercent,
|
||||
screen_width: screenWidth,
|
||||
screen_height: screenHeight,
|
||||
// element: elementInfo, // 如果需要元素信息,启用此行
|
||||
// userId: useUserStore().userInfo.id || '', // userId 需从全局状态获取,可从 userStore 引入
|
||||
};
|
||||
|
||||
// 上报
|
||||
track([{ event: eventName, ...payload }]);
|
||||
|
||||
// 设置定时器,在延迟后清除节流标志,允许下一次点击
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
isThrottling = false;
|
||||
}, THROTTLE_DELAY);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Router } from 'vue-router';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { nextTick } from 'vue';
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
|
||||
let prevRoute = { pageKey: '', pageName: '' };
|
||||
let prevPageLeaveTime = Date.now();
|
||||
|
||||
export function setupAutoPageView(router: Router) {
|
||||
let debounceTimer: number | null = null;
|
||||
const DEBOUNCE_DELAY = 300; // 防抖延迟,单位ms
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
prevRoute = {
|
||||
pageKey: (from.meta.pageKey as string) || (from.name as string) || from.path,
|
||||
pageName: (from.meta.pageName as string) || (from.name as string) || from.path,
|
||||
};
|
||||
prevPageLeaveTime = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
// 清除之前的防抖定时器
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
nextTick(() => {
|
||||
const userStore = useUserStore();
|
||||
const userType = userStore.isVip ? 'vip' : 'normal';
|
||||
|
||||
const currentPageKey = (to.meta.pageKey as string) || (to.name as string) || to.path;
|
||||
const currentPageName = (to.meta.pageName as string) || (to.name as string) || to.path;
|
||||
const pageLoadTime = Date.now() - prevPageLeaveTime;
|
||||
track([
|
||||
{
|
||||
event: EventName.AppPageView,
|
||||
user_type: userType,
|
||||
page_key: currentPageKey,
|
||||
page_name: currentPageName,
|
||||
referrer_page_key: prevRoute.pageKey,
|
||||
referrer_page_name: prevRoute.pageName,
|
||||
current_page_key: currentPageKey,
|
||||
current_page_name: currentPageName,
|
||||
page_load_time: pageLoadTime,
|
||||
userId: userStore.userInfo.id || '', // 获取真实 userId
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, DEBOUNCE_DELAY);
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
|
||||
export type ComicBehaviorKey = 'view' | 'page_next' | 'page_prev' | 'complete' | string;
|
||||
|
||||
export interface ComicEventParams {
|
||||
media_id: string; // 老司机媒体资源ID(老司机接口返回的id字段)
|
||||
comic_id: string; // 漫画 id
|
||||
comic_title: string; // 漫画标题
|
||||
comic_type_id: string; // 分类ID
|
||||
comic_type_name: string; // 分类名称
|
||||
recommend_trace_id?: string; // 推荐引擎trace_id,没有则上报空字符串
|
||||
comic_tag_key: string | string[]; // 标签KEY,多个标签使用英文逗号分隔
|
||||
comic_tag_name: string | string[]; // 标签名称,多个标签使用英文逗号分隔
|
||||
read_progress: number; // 阅读进度百分比(0-100)
|
||||
page_no: number; // 当前阅读第几页(从1开始)
|
||||
chapter_id?: string | number; // 章节ID
|
||||
chapter_name?: string; // 章节名称
|
||||
comic_behavior_key: ComicBehaviorKey; // 漫画行为标识
|
||||
comic_behavior_name: string; // 漫画行为名称
|
||||
}
|
||||
|
||||
function normalizeTagField(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join(',') : value;
|
||||
}
|
||||
|
||||
export function reportComicEvent(params: ComicEventParams) {
|
||||
const payload = {
|
||||
...params,
|
||||
recommend_trace_id: params.recommend_trace_id || '',
|
||||
comic_tag_key: normalizeTagField(params.comic_tag_key) || 'default',
|
||||
comic_tag_name: normalizeTagField(params.comic_tag_name) || '默认标签',
|
||||
chapter_id: String(params.chapter_id ?? '') || '1',
|
||||
chapter_name: params.chapter_name || '第一章',
|
||||
};
|
||||
|
||||
track({
|
||||
event: EventName.ComicEvent,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
export enum EventName {
|
||||
AppInstall = 'app_install',
|
||||
UserRegister = 'user_register',
|
||||
UserLogin = 'user_login',
|
||||
Navigation = 'navigation',
|
||||
AppPageView = 'app_page_view',
|
||||
PageClick = 'page_click',
|
||||
Advertising = 'advertising',
|
||||
PageLifecycle = 'page_lifecycle',
|
||||
VideoEvent = 'video_event',
|
||||
VideoLike = 'video_like',
|
||||
VideoComment = 'video_comment',
|
||||
VideoCollect = 'video_collect',
|
||||
VideoPurchase = 'video_purchase',
|
||||
NovelEvent = 'novel_event',
|
||||
ComicEvent = 'comic_event',
|
||||
KeywordSearch = 'keyword_search',
|
||||
KeywordClick = 'keyword_click',
|
||||
AdImpression = 'ad_impression',
|
||||
AdClick = 'ad_click',
|
||||
}
|
||||
|
||||
export const eventMeta: Partial<Record<EventName | string, { desc: string; level: string }>> = {
|
||||
[EventName.UserRegister]: { desc: '新用户注册(包括推广注册与自然注册)', level: 'P0' },
|
||||
[EventName.UserLogin]: { desc: '用户登录(注册完成后自动登录也算)', level: 'P0' },
|
||||
[EventName.Navigation]: { desc: '导航路径行为', level: 'P1' },
|
||||
[EventName.AppPageView]: { desc: '应用页面展示', level: 'P0' },
|
||||
[EventName.PageClick]: { desc: '应用页面点击', level: 'P0' },
|
||||
[EventName.Advertising]: { desc: 'APP广告行为', level: 'P1' },
|
||||
[EventName.PageLifecycle]: { desc: '页面存活', level: 'P1' },
|
||||
[EventName.VideoEvent]: { desc: '视频事件', level: 'P0' },
|
||||
[EventName.VideoLike]: { desc: '视频点赞', level: 'P1' },
|
||||
[EventName.VideoComment]: { desc: '视频评论', level: 'P1' },
|
||||
[EventName.VideoCollect]: { desc: '视频收藏', level: 'P1' },
|
||||
[EventName.VideoPurchase]: { desc: '视频购买', level: 'P1' },
|
||||
[EventName.NovelEvent]: { desc: '小说事件', level: 'P1' },
|
||||
[EventName.ComicEvent]: { desc: '漫画事件', level: 'P1' },
|
||||
[EventName.KeywordSearch]: { desc: '关键词搜索', level: 'P1' },
|
||||
[EventName.KeywordClick]: { desc: '关键词搜索点击', level: 'P1' },
|
||||
[EventName.AdImpression]: { desc: '广告展示', level: 'P0' },
|
||||
[EventName.AdClick]: { desc: '广告点击', level: 'P0' },
|
||||
} as const;
|
||||
@@ -1,256 +0,0 @@
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import axios from 'axios';
|
||||
// import CryptoJS from 'crypto-js';
|
||||
import { EventName, eventMeta } from './eventDefs';
|
||||
|
||||
export const APPID = 'JHA-214';
|
||||
|
||||
// 生成 UUID
|
||||
function generateUUID() {
|
||||
const userStore = useUserStore();
|
||||
const timestamp = Date.now().toString(16);
|
||||
const userId = String(userStore.userInfo?.id || '');
|
||||
const appId = APPID;
|
||||
const randomString = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
|
||||
let combined = `${appId}${timestamp}${userId}${randomString}`;
|
||||
combined = combined.padEnd(32, '0');
|
||||
return combined.substring(0, 32);
|
||||
}
|
||||
|
||||
// 获取设备类型
|
||||
function getDeviceType(): string {
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/Android/i.test(ua)) return 'Android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS';
|
||||
return 'PC';
|
||||
}
|
||||
|
||||
// 获取 / 生成 持久化的设备 ID(标识唯一客户端) 暂时性,后面再根据实际要求修改
|
||||
function getDeviceId(): string {
|
||||
try {
|
||||
const KEY = 'analytics_device_id';
|
||||
let id = localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
// 本地存储异常时退化为一次性的 UUID
|
||||
return generateUUID();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 / 生成 会话 ID(当前会话的 UUID)(临时性)
|
||||
function getSessionId(): string {
|
||||
try {
|
||||
const KEY = 'analytics_session_id';
|
||||
let id = sessionStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
sessionStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return generateUUID();
|
||||
}
|
||||
}
|
||||
|
||||
// indexedDB
|
||||
const DB_NAME = 'event_reporter';
|
||||
const STORE_NAME = 'fail_event_queue';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function openFailEventDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function addFailEventToDB(record: any) {
|
||||
const db = await openFailEventDB();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.put(record);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function delFailEventFromDB(id: string) {
|
||||
const db = await openFailEventDB();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getAllFailEventsFromDB() {
|
||||
const db = await openFailEventDB();
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* replayFailedEvents 是一个用于自动重传本地 P0 级别埋点事件的函数。
|
||||
* 当网络离线导致部分关键(P0级别)埋点事件上报失败时,这些事件会被存储到本地 indexedDB 数据库中。
|
||||
* 调用该方法会尝试从本地数据库读取所有未成功上报的事件(getAllFailEventsFromDB),
|
||||
* 然后依次用 reportEvent 重新上报,
|
||||
* 如果上报成功,会在 reportEvent/postWithRetryAndP0Backup 过程中自动从本地数据库删除,
|
||||
* 如果还是上报失败则继续保留在本地,下次再重试。
|
||||
*
|
||||
* 该机制保证重要的埋点事件不会因为临时网络异常丢失。
|
||||
*/
|
||||
export async function replayFailedEvents(concurrency = 5) {
|
||||
let all = [];
|
||||
try {
|
||||
all = await getAllFailEventsFromDB();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// 并发池批量处理
|
||||
const queue = all.slice();
|
||||
async function worker() {
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
try {
|
||||
await reportEvent(item.event, item.payload, { forceRetry: true, __dbId: item.id });
|
||||
} catch {
|
||||
// 上报失败继续保留,下次重试
|
||||
}
|
||||
}
|
||||
}
|
||||
const workers = [];
|
||||
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
// 重试发包 + P0 落库逻辑
|
||||
async function postWithRetryAndP0Backup(
|
||||
url: string,
|
||||
formData: URLSearchParams,
|
||||
config: any,
|
||||
eventName: string,
|
||||
payload: Record<string, any>,
|
||||
encryptedPayload: Record<string, any>,
|
||||
eventLevel: string,
|
||||
backupId: string,
|
||||
) {
|
||||
let attempt = 0;
|
||||
while (attempt < 3) {
|
||||
try {
|
||||
await axios.post(url, formData, config);
|
||||
if (backupId) {
|
||||
await delFailEventFromDB(backupId); // 成功后移除归档
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
attempt++;
|
||||
if (attempt >= 3) {
|
||||
if (eventLevel === 'P0' && !backupId) {
|
||||
// 只归档一次,防止无限递归
|
||||
const failDbId = `${eventName}_${payload.event_id}`;
|
||||
await addFailEventToDB({
|
||||
id: failDbId,
|
||||
event: eventName,
|
||||
payload,
|
||||
encryptedPayload,
|
||||
time: Date.now(),
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportEvent(
|
||||
event: EventName | string,
|
||||
data: Record<string, any> = {},
|
||||
options?: { forceRetry?: boolean; __dbId?: string },
|
||||
) {
|
||||
try {
|
||||
const configStore: any = useConfigStore();
|
||||
const userStore: any = useUserStore();
|
||||
const appStore: any = useAppStore();
|
||||
const hook_api: any = configStore.analyticsUrl;
|
||||
if (!hook_api) return;
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
const commonPayload: Record<string, any> = {
|
||||
event: event || '',
|
||||
channel: appStore.dc || '',
|
||||
event_id: generateUUID(),
|
||||
app_id: APPID,
|
||||
uid: userStore?.userInfo?.id || '',
|
||||
sid: getSessionId(),
|
||||
client_ts: nowTs,
|
||||
device: getDeviceType(),
|
||||
device_id: getDeviceId(),
|
||||
user_agent: navigator.userAgent || '',
|
||||
};
|
||||
const payload: Record<string, any> = {
|
||||
...commonPayload,
|
||||
...data,
|
||||
};
|
||||
const encryptedPayload: Record<string, any> = Object.keys(payload).reduce((acc, key) => {
|
||||
const value = payload[key];
|
||||
const normalized = value === undefined || value === null ? '' : String(value);
|
||||
// acc[key] = key === 'event_id' ? normalized : CryptoJS.MD5(normalized).toString();
|
||||
acc[key] = normalized;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const formData = new URLSearchParams();
|
||||
Object.keys(encryptedPayload).forEach((key) => {
|
||||
const value = encryptedPayload[key];
|
||||
formData.append(key, value);
|
||||
});
|
||||
const eventMetaItem = eventMeta[event as EventName] as { desc?: string; level?: string } | undefined;
|
||||
const level = eventMetaItem?.level || '';
|
||||
// 支持:replay 时带 __dbId,用于删除老数据
|
||||
await postWithRetryAndP0Backup(
|
||||
hook_api,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
String(event),
|
||||
payload,
|
||||
encryptedPayload,
|
||||
level,
|
||||
options?.__dbId || '',
|
||||
);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const eventDesc = eventMeta[event as EventName]?.desc || '';
|
||||
console.log('🟢 埋点上报', { event, eventDesc, payload, encryptedPayload });
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('埋点上报异常:', err, event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
|
||||
export type NovelBehaviorKey = 'view' | 'page_next' | 'page_prev' | 'complete' | string;
|
||||
|
||||
export interface NovelEventParams {
|
||||
media_id: string; // 老司机媒体资源ID(老司机接口返回的id字段)
|
||||
novel_id: string; // 小说 id
|
||||
novel_title: string; // 小说标题
|
||||
novel_type_id: string; // 分类ID
|
||||
novel_type_name: string; // 分类名称
|
||||
recommend_trace_id?: string; // 推荐引擎trace_id,没有则上报空字符串
|
||||
novel_tag_key: string | string[]; // 标签KEY,多个标签使用英文逗号分隔
|
||||
novel_tag_name: string | string[]; // 标签名称,多个标签使用英文逗号分隔
|
||||
read_progress: number; // 阅读进度百分比(0-100)
|
||||
page_no: number; // 当前阅读第几页(从1开始)
|
||||
chapter_id?: string | number; // 章节ID
|
||||
chapter_name?: string; // 章节名称
|
||||
novel_behavior_key: NovelBehaviorKey; // 小说行为标识
|
||||
novel_behavior_name: string; // 小说行为名称
|
||||
}
|
||||
|
||||
function normalizeTagField(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join(',') : value;
|
||||
}
|
||||
|
||||
export function reportNovelEvent(params: NovelEventParams) {
|
||||
const payload = {
|
||||
...params,
|
||||
recommend_trace_id: params.recommend_trace_id || '',
|
||||
novel_tag_key: normalizeTagField(params.novel_tag_key) || 'default',
|
||||
novel_tag_name: normalizeTagField(params.novel_tag_name) || '默认标签',
|
||||
chapter_id: String(params.chapter_id ?? '') || '1',
|
||||
chapter_name: params.chapter_name || '第一章',
|
||||
};
|
||||
|
||||
track({
|
||||
event: EventName.NovelEvent,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
|
||||
/** 搜索 Tab / 内容类型 → 埋点 search_content_type / click_item_type_* */
|
||||
export const SEARCH_CONTENT_TYPE_MAP: Record<string, { key: string; name: string }> = {
|
||||
SP: { key: 'video', name: '影片' },
|
||||
SHORT: { key: 'short_video', name: '抖音' },
|
||||
DRAMA: { key: 'drama', name: '短剧' },
|
||||
'1': { key: 'cartoon', name: '动漫' },
|
||||
'2': { key: 'comic', name: '漫画' },
|
||||
COVER: { key: 'post', name: '帖子' },
|
||||
PIC: { key: 'pic', name: '图集' },
|
||||
};
|
||||
|
||||
export function resolveSearchContentType(tab?: string | number | null) {
|
||||
// 无 Tab 时默认影片(与搜索结果页默认 Tab 一致)
|
||||
if (tab === undefined || tab === null || tab === '') {
|
||||
return SEARCH_CONTENT_TYPE_MAP.SP;
|
||||
}
|
||||
const mapped = SEARCH_CONTENT_TYPE_MAP[String(tab)];
|
||||
return mapped || { key: String(tab), name: String(tab) };
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
interface KeywordSearch {
|
||||
keyword: string; //搜索关键词
|
||||
search_result_count: number; //搜索结果数量
|
||||
search_content_type: string; //搜索内容类型
|
||||
search_trace_id?: string; //搜索trace_id
|
||||
search_id?: string; //搜索id
|
||||
}
|
||||
export function setKeywordSearch(params: KeywordSearch) {
|
||||
track({
|
||||
event: EventName.KeywordSearch,
|
||||
keyword: String(params.keyword || ''),
|
||||
search_result_count: Number(params.search_result_count) || 0,
|
||||
search_content_type: String(params.search_content_type || ''),
|
||||
search_trace_id: String(params.search_trace_id || ''),
|
||||
search_id: String(params.search_id || ''),
|
||||
});
|
||||
}
|
||||
// 关键词搜索结果点击事件
|
||||
interface KeywordClick {
|
||||
keyword: string; //搜索关键词
|
||||
click_item_id: string | number; //点击项目ID
|
||||
click_item_type_key: string; //点击项目类型
|
||||
click_item_type_name: string; //点击项目类型名称
|
||||
click_position: number; //点击位置(搜索结果中的排序位置)
|
||||
search_trace_id?: string; //搜索trace_id
|
||||
}
|
||||
export function setKeywordClick(params: KeywordClick) {
|
||||
const clickPosition = Number(params.click_position);
|
||||
track({
|
||||
event: EventName.KeywordClick,
|
||||
keyword: String(params.keyword || ''),
|
||||
click_item_id: String(params.click_item_id ?? ''),
|
||||
click_item_type_key: String(params.click_item_type_key || ''),
|
||||
click_item_type_name: String(params.click_item_type_name || ''),
|
||||
// SDK 要求 int;位置从 1 起,0 视为第 1 位
|
||||
click_position: Number.isFinite(clickPosition) && clickPosition > 0 ? Math.floor(clickPosition) : 1,
|
||||
search_trace_id: String(params.search_trace_id || ''),
|
||||
});
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { track } from '@/plugins/tracker';
|
||||
import { EventName } from './eventDefs';
|
||||
import { isLongFormVideo } from '@/utils/index';
|
||||
|
||||
// 视频评论事件参数类型定义
|
||||
export interface VideoCommentEventParams {
|
||||
videoInfo: Types.Video.VInfosItem & {
|
||||
videoTypeId?: string;
|
||||
videoTypeName?: string;
|
||||
}; // 视频信息
|
||||
commentInfo: {
|
||||
content: string; // 评论内容
|
||||
image: string; // 评论图片
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频类型信息
|
||||
* @param videoInfo 视频信息
|
||||
* @returns 返回视频类型ID和名称
|
||||
*/
|
||||
function getVideoTypeInfo(videoInfo: Types.Video.VInfosItem): {
|
||||
video_type_id: string;
|
||||
video_type_name: string;
|
||||
} {
|
||||
// 如果有mediaType字段且值为'video',返回动漫类型
|
||||
if ('mediaType' in videoInfo && (videoInfo as any).mediaType === 'video') {
|
||||
return {
|
||||
video_type_id: 'CARTOON',
|
||||
video_type_name: '动漫',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLongFormVideo(videoInfo)) {
|
||||
return {
|
||||
video_type_id: 'MOVIE',
|
||||
video_type_name: '长视频',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
video_type_id: 'SP',
|
||||
video_type_name: '短视频',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报视频评论事件
|
||||
* @param params VideoCommentEventParams
|
||||
*/
|
||||
export function reportVideoCommentEvent(params: VideoCommentEventParams) {
|
||||
const { videoInfo, commentInfo } = params;
|
||||
const hasMediaType = 'mediaType' in videoInfo;
|
||||
if (hasMediaType && (videoInfo as any).mediaType !== 'video') return;
|
||||
if (!hasMediaType && (videoInfo.playTime == null || videoInfo.playTime === 0)) return;
|
||||
const { video_type_id, video_type_name } = getVideoTypeInfo(videoInfo);
|
||||
try {
|
||||
track({
|
||||
event: EventName.VideoComment,
|
||||
video_id: videoInfo.id,
|
||||
video_title: videoInfo.title,
|
||||
video_type_id: videoInfo.videoTypeId || video_type_id,
|
||||
video_type_name: videoInfo.videoTypeName || video_type_name,
|
||||
comment_content: commentInfo.content + commentInfo.image,
|
||||
media_id: videoInfo.lsjId,
|
||||
video_content_type: video_type_name == '短视频' ? 'short_video' : 'video',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('视频评论埋点上报失败', err);
|
||||
}
|
||||
}
|
||||
// 视频点赞&收藏
|
||||
export interface VideoCollectEventParams {
|
||||
video_id: string; // 视频 id
|
||||
video_title: string; // 视频标题
|
||||
video_type_id: string; // 视频分类ID
|
||||
video_type_name: string; // 视频分类名称
|
||||
flag: 1 | 2; // 点赞状态:1(点赞), 2(取消点赞);收藏状态:1(收藏), 2(取消收藏)
|
||||
}
|
||||
/**
|
||||
* 上报视频点赞
|
||||
* @param params VideoCommentEventParams
|
||||
*/
|
||||
export function reportLikeCommentEvent(params: VideoCollectEventParams) {
|
||||
track({ event: EventName.VideoLike, ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报视频收藏
|
||||
* @param params VideoCommentEventParams
|
||||
*/
|
||||
export function reportCollectCommentEvent(params: VideoCollectEventParams) {
|
||||
track({ event: EventName.VideoCollect, ...params });
|
||||
}
|
||||
export const reportCollectFn = (
|
||||
operateType: 1 | 2,
|
||||
statusType: 1 | 2,
|
||||
videoInfo: Types.Video.VInfosItem & {
|
||||
videoTypeId?: string;
|
||||
videoTypeName?: string;
|
||||
},
|
||||
) => {
|
||||
const { video_type_id, video_type_name } = getVideoTypeInfo(videoInfo);
|
||||
const reportParams = {
|
||||
video_id: videoInfo.id, // 视频 id
|
||||
video_title: videoInfo.title, // 视频标题
|
||||
video_type_id: videoInfo.videoTypeId || video_type_id,
|
||||
video_type_name: videoInfo.videoTypeName || video_type_name,
|
||||
flag: statusType,
|
||||
media_id: videoInfo.lsjId,
|
||||
video_content_type: video_type_name == '短视频' ? 'short_video' : 'video',
|
||||
};
|
||||
// console.log('reportParams', reportParams);
|
||||
try {
|
||||
if (operateType === 1) {
|
||||
reportCollectCommentEvent(reportParams);
|
||||
} else {
|
||||
reportLikeCommentEvent(reportParams);
|
||||
}
|
||||
} catch (err) {
|
||||
// 本地环境容错
|
||||
console.warn(`视频${operateType === 1 ? '收集' : '点赞'}埋点${statusType === 2 ? '取消' : ''}上报失败`, err);
|
||||
}
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* 全局活跃私聊 messageList 订阅(单例)
|
||||
* IM 重建 / WS 恢复时由 sdk.reattachGlobalSubscriptions 统一重绑,页面 hook 只消费快照。
|
||||
*/
|
||||
import { normalizeChatMessageList } from '@/utils/im/message';
|
||||
import { getImRuntimeGeneration } from '@/utils/im/sdk';
|
||||
import type { ImChatMessage } from '@/utils/im/types';
|
||||
|
||||
export type ActiveChatSnapshot = {
|
||||
conversationId: string;
|
||||
messages: ImChatMessage[];
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type SnapshotListener = (snapshot: ActiveChatSnapshot) => void;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ImInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type MessageListHandle = any;
|
||||
|
||||
const EMPTY_SNAPSHOT: ActiveChatSnapshot = {
|
||||
conversationId: '',
|
||||
messages: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
let activeConversationId = '';
|
||||
let messageList: MessageListHandle | null = null;
|
||||
let sdkUnsub: (() => void) | null = null;
|
||||
/** messageList 绑定时的 IM runtime 代数,重连后须重建订阅以拉离线消息 */
|
||||
let boundRuntimeGeneration = -1;
|
||||
const listeners = new Set<SnapshotListener>();
|
||||
let snapshot: ActiveChatSnapshot = { ...EMPTY_SNAPSHOT };
|
||||
|
||||
/** 跨页面实例复用的会话元数据(组件 unmount 后仍可 resume) */
|
||||
export type DirectChatSession = {
|
||||
peerBusinessUid: number;
|
||||
peerImUserId: number;
|
||||
selfId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
let directChatSession: DirectChatSession | null = null;
|
||||
|
||||
export function rememberDirectChatSession(session: DirectChatSession) {
|
||||
directChatSession = session;
|
||||
}
|
||||
|
||||
export function getDirectChatSession(peerBusinessUid: number): DirectChatSession | null {
|
||||
if (!directChatSession || directChatSession.peerBusinessUid !== peerBusinessUid) return null;
|
||||
if (!canResumeActiveDirectChat(directChatSession.conversationId)) return null;
|
||||
return directChatSession;
|
||||
}
|
||||
|
||||
export function getActiveChatSnapshot(): ActiveChatSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function hasDirectChatCache(peerBusinessUid: number): boolean {
|
||||
return !!getDirectChatSession(peerBusinessUid) && snapshot.messages.length > 0;
|
||||
}
|
||||
|
||||
export function clearDirectChatSession() {
|
||||
directChatSession = null;
|
||||
}
|
||||
|
||||
function emitSnapshot() {
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSnapshot(partial: Partial<ActiveChatSnapshot>) {
|
||||
snapshot = { ...snapshot, ...partial };
|
||||
emitSnapshot();
|
||||
}
|
||||
|
||||
/** 订阅活跃会话消息快照(订阅时立即回调当前状态) */
|
||||
export function subscribeActiveChat(listener: SnapshotListener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(snapshot);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getActiveChatConversationId(): string {
|
||||
return activeConversationId;
|
||||
}
|
||||
|
||||
export function getActiveMessageList(): MessageListHandle | null {
|
||||
return messageList;
|
||||
}
|
||||
|
||||
/** 当前会话 messageList 是否仍可用(用于快速 resume) */
|
||||
export function canResumeActiveDirectChat(conversationId: string): boolean {
|
||||
return (
|
||||
!!conversationId &&
|
||||
activeConversationId === conversationId &&
|
||||
!!messageList &&
|
||||
!!sdkUnsub &&
|
||||
boundRuntimeGeneration === getImRuntimeGeneration()
|
||||
);
|
||||
}
|
||||
|
||||
function disposeMessageListOnly() {
|
||||
sdkUnsub?.();
|
||||
sdkUnsub = null;
|
||||
messageList?.dispose?.();
|
||||
messageList = null;
|
||||
boundRuntimeGeneration = -1;
|
||||
}
|
||||
|
||||
function bindMessageList(im: ImInstance, conversationId: string) {
|
||||
disposeMessageListOnly();
|
||||
activeConversationId = conversationId;
|
||||
messageList = im.chat.createMessageList(conversationId);
|
||||
boundRuntimeGeneration = getImRuntimeGeneration();
|
||||
sdkUnsub = messageList.subscribe((state: { messages: ImChatMessage[]; hasMore: boolean }) => {
|
||||
updateSnapshot({
|
||||
conversationId: activeConversationId,
|
||||
messages: normalizeChatMessageList(state.messages || []),
|
||||
hasMore: !!state.hasMore,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 进入私聊页:绑定全局活跃会话与 messageList */
|
||||
export async function bindActiveDirectChat(im: ImInstance, conversationId: string): Promise<void> {
|
||||
if (!conversationId) return;
|
||||
|
||||
if (canResumeActiveDirectChat(conversationId)) {
|
||||
await im.conversation.setActive?.(conversationId);
|
||||
emitSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
disposeActiveDirectChat();
|
||||
activeConversationId = conversationId;
|
||||
updateSnapshot({ conversationId, messages: [], hasMore: true, loading: true });
|
||||
|
||||
await im.conversation.setActive?.(conversationId);
|
||||
bindMessageList(im, conversationId);
|
||||
}
|
||||
|
||||
/** 回到已打开的会话:仅 setActive + 回放缓存快照,不重建 messageList */
|
||||
export async function resumeActiveDirectChat(im: ImInstance, conversationId: string): Promise<void> {
|
||||
if (!conversationId) return;
|
||||
if (canResumeActiveDirectChat(conversationId)) {
|
||||
await im.conversation.setActive?.(conversationId);
|
||||
emitSnapshot();
|
||||
return;
|
||||
}
|
||||
await bindActiveDirectChat(im, conversationId);
|
||||
}
|
||||
|
||||
/** 离开私聊页:释放 messageList 订阅 */
|
||||
export function disposeActiveDirectChat() {
|
||||
disposeMessageListOnly();
|
||||
activeConversationId = '';
|
||||
directChatSession = null;
|
||||
snapshot = { ...EMPTY_SNAPSHOT };
|
||||
emitSnapshot();
|
||||
}
|
||||
|
||||
/** IM 重建或 WS 恢复后,按记录的会话 id 重绑 messageList */
|
||||
export async function reattachActiveDirectChat(im: ImInstance): Promise<void> {
|
||||
const convId = activeConversationId;
|
||||
if (!convId) return;
|
||||
|
||||
if (canResumeActiveDirectChat(convId)) {
|
||||
await im.conversation.setActive?.(convId);
|
||||
emitSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
updateSnapshot({ conversationId: convId, loading: true });
|
||||
await im.conversation.setActive?.(convId);
|
||||
bindMessageList(im, convId);
|
||||
}
|
||||
|
||||
/** 离开私聊:释放订阅并取消 SDK 活跃会话 */
|
||||
export async function clearActiveDirectChat(im?: ImInstance): Promise<void> {
|
||||
disposeActiveDirectChat();
|
||||
if (!im) return;
|
||||
try {
|
||||
await im.conversation.setActive?.(null);
|
||||
} catch {
|
||||
/* IM 可能正在重连或已销毁 */
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { PrivateSessionItem } from '@/utils/im/sessionList';
|
||||
|
||||
export type ImListFeedRow =
|
||||
| { _type: 'conv'; data: PrivateSessionItem }
|
||||
| { _type: 'ad'; data: Types.Im.AdItem; slotIndex: number };
|
||||
|
||||
/** 信息流广告槽位 key(按列表位置,不依赖后端 id,避免 id 重复/缺失导致已读串扰) */
|
||||
export function listFeedAdSlotKey(slotIndex: number): string {
|
||||
return `slot-${slotIndex}`;
|
||||
}
|
||||
|
||||
/** 第 i 条广告(0-based)落在最终列表的第几条(1-based):1、5、9、13… */
|
||||
function adSlotIndex(adIndex: number): number {
|
||||
return 1 + 4 * adIndex;
|
||||
}
|
||||
|
||||
/** 第 i 条信息流广告要展示,至少需要 i×3 条私信(第 1 条广告前 0 条) */
|
||||
export function minConversationsForListFeedAd(adIndex: number): number {
|
||||
return 3 * adIndex;
|
||||
}
|
||||
|
||||
/** 当前私信条数下,实际会出现在列表里的信息流广告槽位 index(0-based) */
|
||||
export function getVisibleListFeedAdSlotIndices(conversationCount: number, adCount: number): number[] {
|
||||
return Array.from({ length: adCount }, (_, ai) => ai).filter(
|
||||
(ai) => conversationCount >= minConversationsForListFeedAd(ai),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 私信列表混入信息流广告
|
||||
* 第 1 条为广告,之后每 3 条私信插 1 条广告;广告用完即停
|
||||
*/
|
||||
export function mixImListFeedAds(
|
||||
conversations: PrivateSessionItem[],
|
||||
ads: Types.Im.AdItem[],
|
||||
): ImListFeedRow[] {
|
||||
if (!ads.length) {
|
||||
return conversations.map((data) => ({ _type: 'conv' as const, data }));
|
||||
}
|
||||
|
||||
const out: ImListFeedRow[] = [];
|
||||
let ci = 0;
|
||||
let ai = 0;
|
||||
|
||||
while (ci < conversations.length || ai < ads.length) {
|
||||
const pos = out.length + 1;
|
||||
if (ai < ads.length && pos === adSlotIndex(ai)) {
|
||||
out.push({ _type: 'ad', data: ads[ai], slotIndex: ai });
|
||||
ai++;
|
||||
} else if (ci < conversations.length) {
|
||||
out.push({ _type: 'conv', data: conversations[ci++] });
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useConfigStore } from '@/stores/config';
|
||||
import type { ImChatMessage } from '@/utils/im/types';
|
||||
|
||||
/** 与 @mimi/im-sdk protobuf ChatMessageType 枚举一致 */
|
||||
const CHAT_MESSAGE_TYPE_BY_NUMBER: Record<number, string> = {
|
||||
0: 'TEXT',
|
||||
1: 'AUDIO',
|
||||
2: 'IMAGE',
|
||||
3: 'VIDEO',
|
||||
4: 'FILE',
|
||||
5: 'EMOJI',
|
||||
100: 'CUSTOMIZED',
|
||||
200: 'GROUP_EVENT',
|
||||
300: 'SHARE_CARD',
|
||||
400: 'PASSTHROUGH',
|
||||
};
|
||||
|
||||
export type ImageExtraPayload = {
|
||||
url?: string;
|
||||
thumbUrl?: string;
|
||||
imageUrl?: string;
|
||||
coverImg?: string;
|
||||
};
|
||||
|
||||
/** CUSTOMIZED 图片消息子类型(与 IM SDK / 跨端约定一致) */
|
||||
const IMAGE_CUSTOM_TYPE = 1;
|
||||
|
||||
function parseExtraObject(extra: unknown): Record<string, unknown> | null {
|
||||
if (extra == null || extra === '') return null;
|
||||
if (typeof extra === 'object') return extra as Record<string, unknown>;
|
||||
if (typeof extra !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(extra) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getImageCdnBase(): string {
|
||||
try {
|
||||
return useConfigStore().sourceInfo.IMAGE?.replace(/\/$/, '') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务图床路径归一(DecryptImg/LazyImg:IMAGE + '/' + path)
|
||||
* - blob/data:本地地址,原样返回
|
||||
* - 指向本站 CDN 的 http(s):剥离域名转相对路径,后续走解密
|
||||
* - 其余 http(s):真正的外部公网图,原样返回(不解密)
|
||||
* - 相对路径:去掉前导斜杠
|
||||
*/
|
||||
export function toBusinessImagePath(raw: unknown): string {
|
||||
const path = String(raw || '').trim();
|
||||
if (!path) return '';
|
||||
if (/^(blob:|data:)/i.test(path)) return path;
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
const cdn = getImageCdnBase();
|
||||
if (!cdn) return path;
|
||||
const stripProto = (u: string) => u.replace(/^https?:\/\//i, '');
|
||||
const cdnKey = stripProto(cdn);
|
||||
const urlKey = stripProto(path);
|
||||
if (urlKey === cdnKey || urlKey.startsWith(`${cdnKey}/`)) {
|
||||
const relative = urlKey.slice(cdnKey.length).replace(/^\/+/, '');
|
||||
return relative || path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
return path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要走 imgDecypt 解密
|
||||
* - blob/data:本地地址,免解密
|
||||
* - http(s):归一后仍是 http 说明是外部公网图,免解密
|
||||
* - 其余(相对路径,含被剥离域名的本站 CDN 图):需要解密
|
||||
*/
|
||||
export function needsImageDecrypt(url?: string): boolean {
|
||||
const path = String(url || '').trim();
|
||||
return !!path && !/^(blob:|data:|https?:\/\/)/i.test(path);
|
||||
}
|
||||
|
||||
/** IM 附件多为完整 https,不走业务图床解密 */
|
||||
export function isImImageNeedDecrypt(url?: string): boolean {
|
||||
return needsImageDecrypt(url);
|
||||
}
|
||||
|
||||
function resolveImageUrl(raw: unknown): string {
|
||||
const url = String(raw || '').trim();
|
||||
if (!url || url === '[图片]' || /^图片$/i.test(url)) return '';
|
||||
return url;
|
||||
}
|
||||
|
||||
function resolveNestedImageUrl(raw: unknown): string {
|
||||
if (raw == null || raw === '') return '';
|
||||
const inner = parseExtraObject(raw);
|
||||
if (inner) {
|
||||
return resolveImageUrl(inner.url || inner.imageUrl || inner.coverImg);
|
||||
}
|
||||
return resolveImageUrl(raw);
|
||||
}
|
||||
|
||||
/** 业务私信图片列表(老接口 /im/message/send 主字段) */
|
||||
export function normalizeImgUrlList(imgUrl: unknown): string[] {
|
||||
if (imgUrl == null || imgUrl === '') return [];
|
||||
if (Array.isArray(imgUrl)) {
|
||||
return imgUrl.map((u) => resolveImageUrl(u)).filter(Boolean);
|
||||
}
|
||||
const single = resolveImageUrl(imgUrl);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
/** 业务图床上传后的地址,通过 IM extra 透传(与 hjll / 跨端一致) */
|
||||
export function buildImageMessageExtra(imageUrl: string): string {
|
||||
const url = imageUrl.trim();
|
||||
return JSON.stringify({
|
||||
height: 120,
|
||||
type: IMAGE_CUSTOM_TYPE,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseImageExtra(extra: unknown): ImageExtraPayload | null {
|
||||
const parsed = parseExtraObject(extra);
|
||||
if (!parsed) return null;
|
||||
|
||||
// CUSTOMIZED 图片:{ type: 1, extra|url: "{\"url\":\"...\"}" } 或直接 url 字符串
|
||||
if (Number(parsed.type) === IMAGE_CUSTOM_TYPE) {
|
||||
const nested = parsed.extra ?? parsed.url;
|
||||
const innerUrl = resolveNestedImageUrl(nested);
|
||||
if (innerUrl) {
|
||||
return {
|
||||
url: innerUrl,
|
||||
thumbUrl: resolveImageUrl(parsed.thumbUrl) || innerUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const url = resolveNestedImageUrl(parsed.url || parsed.imageUrl || parsed.coverImg);
|
||||
if (!url) return null;
|
||||
return {
|
||||
...(parsed as ImageExtraPayload),
|
||||
url,
|
||||
thumbUrl: resolveImageUrl(parsed.thumbUrl) || url,
|
||||
};
|
||||
}
|
||||
|
||||
function applyImageExtra(msg: ImChatMessage): ImChatMessage {
|
||||
const fromExtra = parseImageExtra(msg.extra);
|
||||
if (!fromExtra?.url) return msg;
|
||||
|
||||
const att = parseAttachment(msg.attachment);
|
||||
const url = fromExtra.url;
|
||||
return {
|
||||
...msg,
|
||||
chatMessageType: 'IMAGE',
|
||||
attachment: {
|
||||
...att,
|
||||
url,
|
||||
thumbUrl: fromExtra.thumbUrl || url,
|
||||
rawUrl: resolveImageUrl(att?.rawUrl) || url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttachment(att: unknown): ImChatMessage['attachment'] | undefined {
|
||||
if (!att) return undefined;
|
||||
if (typeof att === 'string') {
|
||||
try {
|
||||
return JSON.parse(att) as ImChatMessage['attachment'];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (typeof att === 'object') return att as ImChatMessage['attachment'];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeMessageType(type: unknown): string {
|
||||
if (type == null || type === '') return 'TEXT';
|
||||
if (typeof type === 'number' && CHAT_MESSAGE_TYPE_BY_NUMBER[type]) {
|
||||
return CHAT_MESSAGE_TYPE_BY_NUMBER[type];
|
||||
}
|
||||
const t = String(type).toUpperCase();
|
||||
if (t === 'PICTURE' || t === '2') return 'IMAGE';
|
||||
if (CHAT_MESSAGE_TYPE_BY_NUMBER[Number(t)]) return CHAT_MESSAGE_TYPE_BY_NUMBER[Number(t)];
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 统一 SDK / 历史接口消息结构,供私信气泡渲染 */
|
||||
export function normalizeChatMessage(item: ImChatMessage): ImChatMessage | null {
|
||||
if (!item?.messageId) return null;
|
||||
|
||||
const chatMessageType = normalizeMessageType(item.chatMessageType);
|
||||
const msg: ImChatMessage = {
|
||||
...item,
|
||||
messageId: String(item.messageId),
|
||||
senderId: String(item.senderId ?? ''),
|
||||
receiverId: String(item.receiverId ?? ''),
|
||||
chatMessageType,
|
||||
content: item.content ?? '',
|
||||
timestamp: Number(item.timestamp) || (item.createdAt ? new Date(item.createdAt).getTime() : Date.now()),
|
||||
extra: item.extra,
|
||||
conversationId: item.conversationId,
|
||||
status: item.status,
|
||||
sequence: item.sequence,
|
||||
atUserList: item.atUserList ?? [],
|
||||
imgUrl: item.imgUrl,
|
||||
};
|
||||
|
||||
if (chatMessageType === 'IMAGE') {
|
||||
const att = parseAttachment(item.attachment);
|
||||
const legacyUrl = normalizeImgUrlList(item.imgUrl)[0] || '';
|
||||
const url = resolveImageUrl(att?.rawUrl) || resolveImageUrl(att?.url || att?.thumbUrl) || legacyUrl;
|
||||
const thumbUrl = resolveImageUrl(att?.rawThumbUrl) || resolveImageUrl(att?.thumbUrl) || url;
|
||||
if (url || thumbUrl) {
|
||||
msg.attachment = {
|
||||
...att,
|
||||
url,
|
||||
thumbUrl: thumbUrl || url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return applyImageExtra(msg);
|
||||
}
|
||||
|
||||
export function normalizeChatMessageList(list: ImChatMessage[] | undefined): ImChatMessage[] {
|
||||
if (!list?.length) return [];
|
||||
return list
|
||||
.map((item) => normalizeChatMessage(item))
|
||||
.filter((m): m is ImChatMessage => !!m && m.chatMessageType !== 'GROUP_EVENT');
|
||||
}
|
||||
|
||||
/** 气泡展示用:优先 attachment,保留原始地址(http 直显,相对路径解密) */
|
||||
export function getImageDisplayUrl(item: ImChatMessage): string {
|
||||
return getMessageImageUrls(item)[0] || '';
|
||||
}
|
||||
|
||||
function getAttachmentImageCandidates(att: ImChatMessage['attachment']): unknown[] {
|
||||
if (!att) return [];
|
||||
return [att.rawThumbUrl, att.rawUrl, att.thumbUrl, att.url];
|
||||
}
|
||||
|
||||
/** 多图列表(与老私信 item.imgUrl 数组一致) */
|
||||
export function getMessageImageUrls(item: ImChatMessage): string[] {
|
||||
const result: string[] = [];
|
||||
const add = (raw: unknown) => {
|
||||
const url = resolveImageUrl(raw);
|
||||
if (url && !result.includes(url)) result.push(url);
|
||||
};
|
||||
|
||||
for (const raw of getAttachmentImageCandidates(item.attachment)) add(raw);
|
||||
|
||||
const fromExtra = parseImageExtra(item.extra);
|
||||
add(fromExtra?.thumbUrl);
|
||||
add(fromExtra?.url);
|
||||
|
||||
if (Array.isArray(item.imgUrl)) {
|
||||
item.imgUrl.forEach(add);
|
||||
} else {
|
||||
add(item.imgUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getImagePreviewUrl(item: ImChatMessage): string {
|
||||
return (
|
||||
resolveImageUrl(item.attachment?.url) || resolveImageUrl(item.attachment?.thumbUrl) || getImageDisplayUrl(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function isImageMessage(item: ImChatMessage): boolean {
|
||||
if (normalizeMessageType(item.chatMessageType) === 'IMAGE') return true;
|
||||
if (parseImageExtra(item.extra)?.url) return true;
|
||||
if (normalizeImgUrlList(item.imgUrl).length > 0) return true;
|
||||
const content = String(item.content || '').trim();
|
||||
return /^\[图片\]|^图片$/i.test(content);
|
||||
}
|
||||
|
||||
/** SDK 回流缺图时,用本地 pending 的 extra / imgUrl 补齐 */
|
||||
export function mergePendingImageMessage(sdkMsg: ImChatMessage, pending: ImChatMessage): ImChatMessage {
|
||||
if (getMessageImageUrls(sdkMsg).length) return sdkMsg;
|
||||
const pendingUrls = getMessageImageUrls(pending);
|
||||
if (!pendingUrls.length) return sdkMsg;
|
||||
|
||||
const url = pendingUrls[0];
|
||||
const fromExtra = parseImageExtra(pending.extra);
|
||||
const att = parseAttachment(sdkMsg.attachment);
|
||||
|
||||
return {
|
||||
...sdkMsg,
|
||||
chatMessageType: 'IMAGE',
|
||||
extra: sdkMsg.extra || pending.extra,
|
||||
imgUrl: pending.imgUrl ?? [url],
|
||||
attachment: {
|
||||
...att,
|
||||
url,
|
||||
thumbUrl: fromExtra?.thumbUrl || url,
|
||||
rawUrl: url,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import { buildMessagePreview } from '@/utils/im/sessionList';
|
||||
import { toBusinessImagePath } from '@/utils/im/message';
|
||||
import { normalizeMessageType } from '@/utils/im/message';
|
||||
import type { ImMessageNotifyPayload } from '@/utils/im/types';
|
||||
|
||||
/** 当前待展示的私信通知(全局单条,新消息覆盖旧条) */
|
||||
export const imMessageNotify = ref<ImMessageNotifyPayload | null>(null);
|
||||
|
||||
const RECENT_MESSAGE_MS = 15_000;
|
||||
const BOOTSTRAP_GRACE_MS = 2500;
|
||||
const AUTO_DISMISS_MS = 5000;
|
||||
const MAX_SEEN_IDS = 200;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ImInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ImSdkMessage = Record<string, any>;
|
||||
type ImSdkHelpers = {
|
||||
getActiveConversationId: () => string | number | null | undefined;
|
||||
getCurrentUserId: () => string | number | null | undefined;
|
||||
};
|
||||
|
||||
let notifyUnsub: (() => void) | null = null;
|
||||
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let bootstrapUntil = 0;
|
||||
const seenMessageIds = new Set<string>();
|
||||
let sdkHelpers: ImSdkHelpers | null = null;
|
||||
|
||||
function rememberMessageId(id: string) {
|
||||
seenMessageIds.add(id);
|
||||
if (seenMessageIds.size <= MAX_SEEN_IDS) return;
|
||||
const first = seenMessageIds.values().next().value;
|
||||
if (first) seenMessageIds.delete(first);
|
||||
}
|
||||
|
||||
function clearDismissTimer() {
|
||||
if (!dismissTimer) return;
|
||||
clearTimeout(dismissTimer);
|
||||
dismissTimer = null;
|
||||
}
|
||||
|
||||
export function dismissImMessageNotify() {
|
||||
clearDismissTimer();
|
||||
imMessageNotify.value = null;
|
||||
}
|
||||
|
||||
function pushImMessageNotify(payload: ImMessageNotifyPayload) {
|
||||
imMessageNotify.value = payload;
|
||||
clearDismissTimer();
|
||||
dismissTimer = setTimeout(() => {
|
||||
if (imMessageNotify.value?.messageId === payload.messageId) {
|
||||
imMessageNotify.value = null;
|
||||
}
|
||||
}, AUTO_DISMISS_MS);
|
||||
}
|
||||
|
||||
function isSkippableMessageType(type: string): boolean {
|
||||
return type === 'GROUP_EVENT' || type === 'PASSTHROUGH';
|
||||
}
|
||||
|
||||
function getSelfImUserId(): string {
|
||||
return String(sdkHelpers?.getCurrentUserId() || '');
|
||||
}
|
||||
|
||||
function shouldSkipMessage(msg: ImSdkMessage, selfImId: string): boolean {
|
||||
if (!msg?.messageId) return true;
|
||||
const messageId = String(msg.messageId);
|
||||
if (seenMessageIds.has(messageId)) return true;
|
||||
|
||||
const ts = Number(msg.timestamp) || 0;
|
||||
if (ts > 0 && Date.now() - ts > RECENT_MESSAGE_MS) return true;
|
||||
if (Date.now() < bootstrapUntil) return true;
|
||||
|
||||
if (Number(msg.isGroup) !== 0) return true;
|
||||
|
||||
const senderId = String(msg.senderId ?? '');
|
||||
if (!senderId || (selfImId && senderId === selfImId)) return true;
|
||||
|
||||
const activeConvId = sdkHelpers?.getActiveConversationId();
|
||||
if (activeConvId && msg.conversationId === activeConvId) return true;
|
||||
|
||||
const chatType = normalizeMessageType(msg.chatMessageType);
|
||||
if (isSkippableMessageType(chatType)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolvePeerProfile(
|
||||
im: ImInstance,
|
||||
peerImUserId: number,
|
||||
): Promise<{ peerName: string; peerAvatar: string }> {
|
||||
try {
|
||||
const users = await im.contact.getUsersProfileFromLocal([peerImUserId]);
|
||||
const user = users?.[0];
|
||||
return {
|
||||
peerName: user?.nickName || user?.name || '新消息',
|
||||
peerAvatar: toBusinessImagePath(user?.avatar || user?.portrait || user?.imgUrl || ''),
|
||||
};
|
||||
} catch {
|
||||
return { peerName: '新消息', peerAvatar: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** IM 连接成功后订阅全局新消息,驱动 App 级通知条 */
|
||||
export async function bindImMessageNotify(im: ImInstance) {
|
||||
unbindImMessageNotify();
|
||||
// 动态导入,避免 IM SDK 被打进入口同步包
|
||||
const sdk = await import('@mimi/im-sdk');
|
||||
sdkHelpers = {
|
||||
getActiveConversationId: sdk.getActiveConversationId,
|
||||
getCurrentUserId: sdk.getCurrentUserId,
|
||||
};
|
||||
bootstrapUntil = Date.now() + BOOTSTRAP_GRACE_MS;
|
||||
|
||||
const selfImId = getSelfImUserId();
|
||||
const sub = im.chat.subscribeAllMessages((msg: ImSdkMessage) => {
|
||||
if (shouldSkipMessage(msg, selfImId)) return;
|
||||
|
||||
const messageId = String(msg.messageId);
|
||||
rememberMessageId(messageId);
|
||||
|
||||
const peerImUserId = Number(msg.senderId);
|
||||
if (!(peerImUserId > 0)) return;
|
||||
|
||||
const preview = buildMessagePreview({
|
||||
content: msg.content,
|
||||
chatMessageType: msg.chatMessageType,
|
||||
extra: msg.extra,
|
||||
});
|
||||
|
||||
void resolvePeerProfile(im, peerImUserId).then(({ peerName, peerAvatar }) => {
|
||||
pushImMessageNotify({
|
||||
messageId,
|
||||
conversationId: String(msg.conversationId || ''),
|
||||
peerImUserId,
|
||||
peerName,
|
||||
peerAvatar,
|
||||
preview,
|
||||
timestamp: Number(msg.timestamp) || Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
notifyUnsub = () => sub?.unsubscribe?.();
|
||||
}
|
||||
|
||||
export function unbindImMessageNotify() {
|
||||
notifyUnsub?.();
|
||||
notifyUnsub = null;
|
||||
sdkHelpers = null;
|
||||
dismissImMessageNotify();
|
||||
seenMessageIds.clear();
|
||||
bootstrapUntil = 0;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { needsImageDecrypt, parseImageExtra, toBusinessImagePath } from '@/utils/im/message';
|
||||
|
||||
/** 消息中心私信列表的一行数据(纯 SDK 会话映射) */
|
||||
export type PrivateSessionItem = {
|
||||
id: string;
|
||||
/** 业务 uid;列表由 SDK 驱动时为 0,进会话前用 /im/user-id 反查 */
|
||||
userId: number;
|
||||
/** 对端 IM userId(= conv.receiverId) */
|
||||
friendImUserId: number;
|
||||
userName: string;
|
||||
userAvatar: string;
|
||||
preContent: string;
|
||||
createdAt: string | number;
|
||||
noReadNum: number;
|
||||
};
|
||||
|
||||
/** SDK conversation.subscribeList 推送的会话结构 */
|
||||
export type SdkConversation = {
|
||||
type?: string;
|
||||
receiverId?: number | string;
|
||||
unreadCount?: number;
|
||||
extra?: string;
|
||||
lastMessageTime?: number;
|
||||
updatedAt?: number;
|
||||
createdAt?: number;
|
||||
content?: string;
|
||||
chatMessageType?: string | number;
|
||||
user?: {
|
||||
id?: number;
|
||||
nickName?: string;
|
||||
nickname?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
portrait?: string;
|
||||
imgUrl?: string;
|
||||
};
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
// —— 展示文案 ——
|
||||
|
||||
/** blob/data 本地地址、http(s) 公网图无需再走 imgDecypt */
|
||||
export function isPublicImageUrl(url?: string): boolean {
|
||||
return !needsImageDecrypt(url);
|
||||
}
|
||||
|
||||
export function formatSessionPreview(content?: string): string {
|
||||
const text = (content || '').trim();
|
||||
if (!text) return '暂无消息';
|
||||
if (/^\[图片\]|^图片$|image/i.test(text)) return '[图片]';
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatSessionListTime(date: string | number): string {
|
||||
if (date == null || date === '') return '';
|
||||
const d = typeof date === 'number' ? new Date(date) : new Date(date);
|
||||
if (Number.isNaN(d.getTime()) || d.getFullYear() === 1) return '';
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
}
|
||||
|
||||
export function buildMessagePreview(msg: {
|
||||
content?: string;
|
||||
chatMessageType?: string | number;
|
||||
extra?: unknown;
|
||||
}): string {
|
||||
const type = msg.chatMessageType;
|
||||
if (type === 'IMAGE' || type === 2 || parseImageExtra(msg.extra)) return '[图片]';
|
||||
return (msg.content || '').trim() || '暂无消息';
|
||||
}
|
||||
|
||||
// —— SDK 会话 → 列表行 ——
|
||||
|
||||
function toTimestamp(value: string | number | undefined): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const ts = typeof value === 'number' ? value : new Date(value).getTime();
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
}
|
||||
|
||||
function isDirectConversation(conv: SdkConversation): boolean {
|
||||
if (conv.type && conv.type !== 'direct') return false;
|
||||
const imId = Number(conv.receiverId);
|
||||
return Number.isFinite(imId) && imId > 0;
|
||||
}
|
||||
|
||||
/** IM 快照偶发幽灵会话(user 资料整体缺失),无法渲染也无法跳转,不展示 */
|
||||
function hasUserProfile(conv: SdkConversation): boolean {
|
||||
const user = conv.user;
|
||||
return !!(user?.nickName || user?.nickname || user?.name || user?.avatar || user?.portrait || user?.imgUrl);
|
||||
}
|
||||
|
||||
function toSessionItem(conv: SdkConversation): PrivateSessionItem {
|
||||
const imId = Number(conv.receiverId);
|
||||
const user = conv.user;
|
||||
|
||||
return {
|
||||
id: String(imId),
|
||||
userId: 0,
|
||||
friendImUserId: imId,
|
||||
userName: user?.nickName || user?.nickname || user?.name || conv.remark || '',
|
||||
userAvatar: toBusinessImagePath(user?.avatar || user?.portrait || user?.imgUrl || ''),
|
||||
preContent: parseImageExtra(conv.extra)
|
||||
? '[图片]'
|
||||
: buildMessagePreview({
|
||||
content: conv.content,
|
||||
chatMessageType: conv.chatMessageType,
|
||||
extra: conv.extra,
|
||||
}),
|
||||
createdAt: conv.lastMessageTime ?? conv.updatedAt ?? conv.createdAt ?? '',
|
||||
noReadNum: Number(conv.unreadCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** SDK 会话快照 → 私信列表(仅单聊;按最后消息时间倒序) */
|
||||
export function mapSdkConversations(conversations: SdkConversation[]): PrivateSessionItem[] {
|
||||
const seen = new Set<number>();
|
||||
const list: PrivateSessionItem[] = [];
|
||||
|
||||
for (const conv of conversations) {
|
||||
if (!isDirectConversation(conv) || !hasUserProfile(conv)) continue;
|
||||
const imId = Number(conv.receiverId);
|
||||
if (seen.has(imId)) continue;
|
||||
seen.add(imId);
|
||||
list.push(toSessionItem(conv));
|
||||
}
|
||||
|
||||
return list.sort((a, b) => toTimestamp(b.createdAt) - toTimestamp(a.createdAt));
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ImChatMessage = any;
|
||||
|
||||
/** App 级私信新消息通知 payload */
|
||||
export type ImMessageNotifyPayload = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
peerImUserId: number;
|
||||
peerName: string;
|
||||
peerAvatar: string;
|
||||
preview: string;
|
||||
timestamp: number;
|
||||
};
|
||||
@@ -343,7 +343,6 @@ export function innerJump(url: string, options: JumpAdvOptions = {}) {
|
||||
science: '/aiUndress', //AI科技
|
||||
live_streaming: '/shortVideo?type=0', // 直播
|
||||
|
||||
kefu: '/customerService', //在线客服
|
||||
community_page: '/community', //跳转社区
|
||||
publish_page: '/publishGraphics', //跳转发布
|
||||
binding_page: '/bindingPhone', //跳转绑定手机号
|
||||
@@ -479,9 +478,19 @@ function hasInnerQueryParam(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告点击日志:id 为空不报;上报失败不影响跳转
|
||||
*/
|
||||
export function reportAdClick(id?: string | number) {
|
||||
if (id === undefined || id === null || id === '') return;
|
||||
AppApi.clickAdv({ id: String(id), type: 0 }).catch((e) => console.log(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转广告
|
||||
* @param {*} item 广告信息
|
||||
* @param clickTypeOrOptions 'ads':广告位广告,点击上报 /ads/click;'recreation':娱乐模块广告;
|
||||
* 公告、跑马灯、文字内链等非广告跳转不传
|
||||
*/
|
||||
export async function jumpAdv(
|
||||
item: any,
|
||||
@@ -493,17 +502,15 @@ export async function jumpAdv(
|
||||
typeof clickTypeOrOptions === 'object' && clickTypeOrOptions
|
||||
? clickTypeOrOptions
|
||||
: maybeOptions || {};
|
||||
if (clickType === 'ads') {
|
||||
reportAdClick(item?.id);
|
||||
}
|
||||
const url = item.href || item.link || item.url || item.link_url || item.linkUrl;
|
||||
if (!url) return;
|
||||
try {
|
||||
if (clickType === 'recreation') {
|
||||
// 娱乐模块广告点击
|
||||
HomeApi.upperRecreatio({ id: item.id, type: 'adv', sysType: getDevType() });
|
||||
} else {
|
||||
if (item.id) {
|
||||
// 点击广告埋点
|
||||
// AppApi.clickAdv({ id: item.id });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
@@ -129,7 +129,7 @@ class RequestHttp {
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 优先从 WebSDK 获取设备/会话字段,回退到本地 UA 解析
|
||||
* @description 基于本地 UA 解析设备字段
|
||||
*/
|
||||
private getUserAgent = async (): Promise<string> => {
|
||||
const appStore = useAppStore();
|
||||
@@ -140,45 +140,13 @@ class RequestHttp {
|
||||
const Terminal = PARAMETER_ENCRYPTION;
|
||||
const IsH5 = 1;
|
||||
|
||||
let DeviceBrand = '';
|
||||
let DeviceModel = '';
|
||||
let SystemName = '';
|
||||
let SystemVersion = '';
|
||||
let Sid = '';
|
||||
|
||||
const WebSDK = (window as any).WebSDK;
|
||||
if (WebSDK && typeof WebSDK.getCommonFields === 'function') {
|
||||
try {
|
||||
const fields = await WebSDK.getCommonFields([
|
||||
'device_id',
|
||||
'device_fingerprint',
|
||||
'fp_version',
|
||||
'sid',
|
||||
'device_brand',
|
||||
'device_model',
|
||||
]);
|
||||
if (fields) {
|
||||
DeviceBrand = fields.device_brand || '';
|
||||
DeviceModel = fields.device_model || '';
|
||||
Sid = fields.sid || '';
|
||||
}
|
||||
} catch {
|
||||
// SDK 未就绪,走下方本地回退
|
||||
}
|
||||
}
|
||||
|
||||
if (!DeviceBrand || !DeviceModel) {
|
||||
const meta = resolveDeviceMetadata();
|
||||
DeviceBrand = DeviceBrand || meta.device_brand;
|
||||
DeviceModel = DeviceModel || meta.device_model;
|
||||
SystemName = meta.system_name;
|
||||
SystemVersion = meta.system_version;
|
||||
} else {
|
||||
const meta = resolveDeviceMetadata();
|
||||
SystemName = meta.system_name;
|
||||
SystemVersion = meta.system_version;
|
||||
}
|
||||
|
||||
const {
|
||||
device_brand: DeviceBrand,
|
||||
device_model: DeviceModel,
|
||||
system_name: SystemName,
|
||||
system_version: SystemVersion,
|
||||
} = resolveDeviceMetadata();
|
||||
const Sid = '';
|
||||
return `BuildID=com.abc.Butterfly;SysType=${SysType};DevID=${DevID};Ver=${Ver};DevType=${DevType};DeviceBrand=${DeviceBrand};DeviceModel=${DeviceModel};SystemName=${SystemName};SystemVersion=${SystemVersion};Terminal=${Terminal};IsH5=${IsH5};Sid=${Sid}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,13 +34,7 @@ import { reactive } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { jumpAdv } from '@/utils/index';
|
||||
import { useGlobalStore } from '@/stores/global';
|
||||
import {
|
||||
analyticsEventAppInstall,
|
||||
// analyticsEventUserRegister,
|
||||
analyticsEventUserLogin,
|
||||
} from '@/utils/eventReporter/analytics';
|
||||
import setTheme from '@/assets/theme/index';
|
||||
import { preloadImSdk, warmImIfNeeded } from '@/utils/im/sdk';
|
||||
import { markHomeOldUserGuidePendingFromSplash } from '@/utils/paymentGuide';
|
||||
import { detectLine } from '@/utils/detectLine';
|
||||
|
||||
@@ -176,7 +170,7 @@ const getFirstLoginTime = () => {
|
||||
};
|
||||
|
||||
const toAdv = (item: any) => {
|
||||
jumpAdv(item);
|
||||
jumpAdv(item, 'ads');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -221,7 +215,6 @@ const login = async (): Promise<boolean> => {
|
||||
}
|
||||
let userInfo = await AppApi.login(uid, sysType, cutInfos, isAppStore);
|
||||
await userStore.saveLoginResult({ userInfo, token: userInfo.token });
|
||||
// analyticsEventUserRegister(route.query.tid);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -258,9 +251,6 @@ const ensureAuthAndConfig = async (): Promise<boolean> => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// token 已就绪(含本地恢复):IM 预热与拉配置并行,不阻塞开屏主流程
|
||||
preloadImSdk();
|
||||
void warmImIfNeeded();
|
||||
const configOk = await getConfig();
|
||||
if (!configOk) {
|
||||
showDialog({
|
||||
@@ -365,7 +355,6 @@ const getConfig = async (): Promise<boolean> => {
|
||||
globalStore.setAdvanceStatus(configList.advanceStatus);
|
||||
initSourceInfo(configList.sourceList);
|
||||
setTheme(configList.festivalUi);
|
||||
analyticsEventUserLogin();
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.isResLock = false;
|
||||
@@ -398,7 +387,6 @@ const onLineCheckFail = () => {
|
||||
*/
|
||||
const init = async () => {
|
||||
generateUid();
|
||||
analyticsEventAppInstall(route.query.tid);
|
||||
route.query.isAppStore
|
||||
? localStorage.setItem('isAppStore', route.query.isAppStore.toString())
|
||||
: localStorage.setItem('isAppStore', ''); // 保存是否来自商店包,用于判断是否跳转外链
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="props.list" class="fiveGrid">
|
||||
<div class="rectAdvBox" @click="jumpAdv(item)" v-for="item in props.list" :key="item.id">
|
||||
<div class="rectAdvBox" @click="jumpAdv(item, 'ads')" v-for="item in props.list" :key="item.id">
|
||||
<lazyImg class="rectAdvIcon" :src="item.cover" />
|
||||
<div class="rectName ellipsis">{{ item.title }}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
<div class="acg-tab-item overflow-y-auto" :id="'home' + tabIndex" ref="rootEl">
|
||||
<div class="acg-theme">
|
||||
<ModuleBanners :banners="banners" :moduleType="props.type" />
|
||||
<HomeHeader
|
||||
:advType="AdType.COMMUNITYCAROUSEL"
|
||||
class="advBox"
|
||||
:adImpression="props.adImpression"
|
||||
v-if="!props.pureVersion"
|
||||
/>
|
||||
<HomeHeader :advType="AdType.COMMUNITYCAROUSEL" class="advBox" v-if="!props.pureVersion" />
|
||||
<!-- 标签区 -->
|
||||
<div class="labelBox" v-if="state.allSection && state.allSection.length">
|
||||
<div
|
||||
@@ -55,7 +50,6 @@ import HomeApi from '@/service/home';
|
||||
import { AdType } from '@/enums/app';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getMediaTypeKey } from '../../utils/common';
|
||||
import { AdImpressionType } from '@/utils/eventReporter/adImpression';
|
||||
|
||||
const router = useRouter();
|
||||
const dvSort = ref();
|
||||
@@ -66,7 +60,6 @@ const props = defineProps<{
|
||||
tabIndex?: number;
|
||||
type: number;
|
||||
advType?: number;
|
||||
adImpression?: AdImpressionType;
|
||||
pureVersion?: boolean;
|
||||
banners?: Types.Home.ModuleBanner[] | null;
|
||||
}>();
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
<div class="wrap">
|
||||
<ModuleBanners :banners="banners" :moduleType="props.type" />
|
||||
<!-- 广告轮播 -->
|
||||
<HomeHeader
|
||||
:advType="AdType.COMMUNITYCAROUSEL"
|
||||
class="advBox"
|
||||
:adImpression="props.adImpression"
|
||||
v-if="!props.pureVersion"
|
||||
/>
|
||||
<HomeHeader :advType="AdType.COMMUNITYCAROUSEL" class="advBox" v-if="!props.pureVersion" />
|
||||
<!-- <CategoryTag v-if="['video', 'image'].includes(mediaType)" :list="tagList" :mediaType="mediaType"></CategoryTag> -->
|
||||
<template v-for="(item, index) in state.list" :key="item.sectionID">
|
||||
<!--
|
||||
@@ -87,16 +82,7 @@
|
||||
:contentType="mediaType"
|
||||
></CategoryContent>
|
||||
<template v-if="(index + 1) % 2 === 0 && !props.pureVersion">
|
||||
<HeaderAdv
|
||||
class="advBox"
|
||||
:advType="AdType.TopicGridAdv"
|
||||
random
|
||||
:length="0"
|
||||
:adImpression="{
|
||||
page_key: 'comm_novel',
|
||||
page_name: '社区小说',
|
||||
}"
|
||||
/>
|
||||
<HeaderAdv class="advBox" :advType="AdType.TopicGridAdv" random :length="0" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@@ -116,7 +102,6 @@ import HomeApi from '@/service/home';
|
||||
import { useAcgStore } from '@/stores/acg';
|
||||
import { AdType } from '@/enums/app';
|
||||
import { getAdItem } from '@/utils/getConfig';
|
||||
import { AdImpressionType } from '@/utils/eventReporter/adImpression';
|
||||
|
||||
const acgStore = useAcgStore();
|
||||
const props = defineProps<{
|
||||
@@ -125,7 +110,6 @@ const props = defineProps<{
|
||||
showType: number;
|
||||
type: number;
|
||||
advType?: number;
|
||||
adImpression?: AdImpressionType;
|
||||
pureVersion?: boolean;
|
||||
banners?: Types.Home.ModuleBanner[] | null;
|
||||
}>();
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
:showType="item.showType"
|
||||
:advType="props.advType"
|
||||
:type="props.type"
|
||||
:adImpression="props.adImpression"
|
||||
:pureVersion="item.pureVersion"
|
||||
:banners="item.banners"
|
||||
/>
|
||||
@@ -44,7 +43,6 @@ import TabItem from './tabItem.vue';
|
||||
import TabHome from './tabHome.vue';
|
||||
import { showToast } from 'vant';
|
||||
import { AdType } from '@/enums/app';
|
||||
import { AdImpressionType } from '@/utils/eventReporter/adImpression';
|
||||
// const componentMap: any = {
|
||||
// 2: TabHome,
|
||||
// 3: TabItem,
|
||||
@@ -56,7 +54,6 @@ const props = withDefaults(
|
||||
mediaType: MediaTypeKey;
|
||||
moduleName: string;
|
||||
advType?: number;
|
||||
adImpression?: AdImpressionType;
|
||||
}>(),
|
||||
{
|
||||
advType: AdType.HOME,
|
||||
|
||||
@@ -224,7 +224,6 @@ const {
|
||||
switchEpisodeNumberCatalogue,
|
||||
historyData,
|
||||
addHistoryReadData,
|
||||
reportCompleteEvent,
|
||||
sort,
|
||||
} = mediaContentInfo({ isGetHistoryInit: true, dbType: HistoryVideoType.AcgImage });
|
||||
provide('historyData', historyData);
|
||||
@@ -278,7 +277,6 @@ const onContentScroll = (e: Event) => {
|
||||
const isEnd = contentScrollTop.value + 10 >= contentMaxScrollTop.value;
|
||||
if (isEnd) {
|
||||
console.log('阅读结束')
|
||||
reportCompleteEvent(state.episodeNumber);
|
||||
running.value = false;
|
||||
visibleSides.value = true;
|
||||
bottomRef.value?.showTool();
|
||||
|
||||
@@ -123,7 +123,6 @@ const {
|
||||
switchEpisodeNumber,
|
||||
currentItem,
|
||||
addHistoryReadData,
|
||||
reportCompleteEvent,
|
||||
} = mediaContentInfo({ dbType: HistoryVideoType.AcgText });
|
||||
provide('historyData', historyData);
|
||||
const autoText = computed(() => {
|
||||
@@ -153,9 +152,6 @@ const onScroll = (e: Event) => {
|
||||
progress,
|
||||
scrollTop: currentTop,
|
||||
});
|
||||
if (progress >= 100) {
|
||||
reportCompleteEvent(state.episodeNumber);
|
||||
}
|
||||
|
||||
if (currentTop - scrollTop > 20) {
|
||||
visibleAction.value = false;
|
||||
|
||||
@@ -148,7 +148,6 @@ const {
|
||||
historyData,
|
||||
addHistory,
|
||||
addHistoryReadData,
|
||||
reportCompleteEvent,
|
||||
sort,
|
||||
} = mediaContentInfo({ dbType: HistoryVideoType.AcgText, isGetHistoryInit: true });
|
||||
provide('historyData', historyData);
|
||||
@@ -297,9 +296,6 @@ const onTimeUpdate = () => {
|
||||
progress: Math.round(progress),
|
||||
currentTime,
|
||||
});
|
||||
if (progress >= 100) {
|
||||
reportCompleteEvent(state.episodeNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = async () => {
|
||||
@@ -307,7 +303,6 @@ const onEnded = async () => {
|
||||
progress: 100,
|
||||
currentTime: audio.value?.duration || audioCurrentTime.value,
|
||||
});
|
||||
reportCompleteEvent(state.episodeNumber);
|
||||
if (playMode.value === PlayMode.singleCycle) {
|
||||
currentProgress.value = 0;
|
||||
audio.value.play();
|
||||
|
||||
@@ -149,12 +149,6 @@ import mediaContentInfo from '../../utils/mediaContentInfo';
|
||||
import CommunityApi from '@/service/community';
|
||||
import { useGlobalStore } from '@/stores/global';
|
||||
import Episode from '../../components/episode.vue';
|
||||
import {
|
||||
getVideoType,
|
||||
reportCollectCommentEvent,
|
||||
reportCollectFn,
|
||||
reportLikeCommentEvent,
|
||||
} from '@/utils/eventReporter/videoComment';
|
||||
const emits = defineEmits(['updateLiked', 'updateCollected', 'updateItem', 'openRoadLine']);
|
||||
const globalStore = useGlobalStore();
|
||||
|
||||
@@ -232,7 +226,6 @@ const onToogleCollected = () => {
|
||||
mediaStatus: { hasCollected },
|
||||
} = props.info;
|
||||
const tempIsCollect = !hasCollected;
|
||||
tempIsCollect ? reportCollectFn(1, 1, props.info) : reportCollectFn(1, 2, props.info);
|
||||
handleCollected(id, tempIsCollect).then(() => {
|
||||
emits('updateCollected', tempIsCollect);
|
||||
Collected_emit(id, tempIsCollect);
|
||||
@@ -244,7 +237,6 @@ const onToogleCollected = () => {
|
||||
|
||||
const onToogleLiked = () => {
|
||||
const zan = !props.info.mediaStatus.hasLiked;
|
||||
zan ? reportCollectFn(2, 1, props.info) : reportCollectFn(2, 2, props.info);
|
||||
(zan ? CommunityApi.zan : CommunityApi.cancleZan)(
|
||||
zan
|
||||
? {
|
||||
|
||||
@@ -7,8 +7,6 @@ import AcgApi from '@/service/acg';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { freeSet } from './common';
|
||||
import { reportNovelEvent } from '@/utils/eventReporter/novel';
|
||||
import { reportComicEvent } from '@/utils/eventReporter/comic';
|
||||
interface ReadData {
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -50,120 +48,6 @@ export default ({
|
||||
|
||||
const historyReadData = [] as Types.Acg.ReadData[];
|
||||
|
||||
const isReadScene = () => {
|
||||
const routeName = String(route.name || '');
|
||||
return routeName.includes('AcgTextDetailWatch') || routeName.includes('AcgImageDetailWatch');
|
||||
};
|
||||
|
||||
const getRecommendTraceId = () => {
|
||||
return String(route.query.recommend_trace_id || route.query.trace_id || '');
|
||||
};
|
||||
|
||||
const getTagData = () => {
|
||||
const tagList = (historyData.value.tagDetails || []) as Array<{ id: string; name: string }>;
|
||||
return {
|
||||
tagKey: tagList.map((item) => item.id).filter(Boolean).join(','),
|
||||
tagName: tagList.map((item) => item.name).filter(Boolean).join(','),
|
||||
};
|
||||
};
|
||||
|
||||
const getReadProgress = (episodeNo = state.episodeNumber) => {
|
||||
const currentEpisode = Number(episodeNo || 0);
|
||||
if (currentEpisode <= 0) return 0;
|
||||
|
||||
const readDataList = (historyReadData.length ? historyReadData : historyData.value.historyReadData || []) as Types.Acg.ReadData[];
|
||||
const currentReadData = readDataList.find((item) => Number(item?.episodeNumber) === currentEpisode);
|
||||
const progress = Number(currentReadData?.progress || 0);
|
||||
if (!Number.isFinite(progress)) return 0;
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
};
|
||||
|
||||
const getBehaviorName = (behaviorKey: string) => {
|
||||
const map: Record<string, string> = {
|
||||
view: '展示',
|
||||
page_next: '下一页',
|
||||
page_prev: '上一页',
|
||||
complete: '读完',
|
||||
};
|
||||
return map[behaviorKey] || behaviorKey;
|
||||
};
|
||||
|
||||
const reportReadEvent = (
|
||||
behaviorKey: 'view' | 'page_next' | 'page_prev' | 'complete',
|
||||
episodeNo = state.episodeNumber,
|
||||
) => {
|
||||
if (![HistoryVideoType.AcgText, HistoryVideoType.AcgImage].includes(dbType)) return;
|
||||
if (!isReadScene()) return;
|
||||
|
||||
const mediaInfo = historyData.value || ({} as Types.Media.MediaInfo);
|
||||
const media_id = String(mediaInfo.lsjId || '');
|
||||
// if (!media_id) return;
|
||||
|
||||
// const resourceId = String(state.info?.id || mediaInfo.id || state.id || '');
|
||||
const title = String(mediaInfo.title || state.title || '');
|
||||
const type_id = String(mediaInfo.mId.includes('0000') ? 'default' : mediaInfo.mId || 'default');
|
||||
const type_name = String(mediaInfo.moduleName || '默认分类');
|
||||
const { tagKey, tagName } = getTagData();
|
||||
const read_progress = behaviorKey === 'complete' ? 100 : getReadProgress(episodeNo);
|
||||
|
||||
const currentEpisodeNo = Number(episodeNo) || 1;
|
||||
const chapterItem =
|
||||
state.list.find((item) => item.episodeNumber === currentEpisodeNo) ||
|
||||
(currentItem.value as Types.Acg.MediaContentListItem) ||
|
||||
({} as Types.Acg.MediaContentListItem);
|
||||
const chapter_id = String(chapterItem?.id || '1');
|
||||
const chapter_name = String(chapterItem?.name || '第一章');
|
||||
|
||||
const commonPayload = {
|
||||
media_id,
|
||||
recommend_trace_id: getRecommendTraceId(),
|
||||
read_progress,
|
||||
page_no: currentEpisodeNo,
|
||||
chapter_id,
|
||||
chapter_name,
|
||||
};
|
||||
|
||||
try {
|
||||
if (dbType === HistoryVideoType.AcgText) {
|
||||
console.log(state, 'mediaInfo', mediaInfo)
|
||||
reportNovelEvent({
|
||||
...commonPayload,
|
||||
novel_id: state.validateItem?.mediaId,
|
||||
novel_title: title,
|
||||
novel_type_id: type_id,
|
||||
novel_type_name: type_name,
|
||||
novel_tag_key: tagKey,
|
||||
novel_tag_name: tagName,
|
||||
novel_behavior_key: behaviorKey,
|
||||
novel_behavior_name: getBehaviorName(behaviorKey),
|
||||
});
|
||||
} else if (dbType === HistoryVideoType.AcgImage) {
|
||||
reportComicEvent({
|
||||
...commonPayload,
|
||||
comic_id: state.validateItem?.mediaId,
|
||||
comic_title: title,
|
||||
comic_type_id: type_id,
|
||||
comic_type_name: type_name,
|
||||
comic_tag_key: tagKey,
|
||||
comic_tag_name: tagName,
|
||||
comic_behavior_key: behaviorKey,
|
||||
comic_behavior_name: getBehaviorName(behaviorKey),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('acg 阅读埋点上报失败', err);
|
||||
}
|
||||
};
|
||||
const completeReportedEpisodes = new Set<number>();
|
||||
const reportCompleteEvent = (episodeNo = state.episodeNumber) => {
|
||||
const currentEpisode = Number(episodeNo || 0);
|
||||
if (currentEpisode <= 0) return;
|
||||
if (completeReportedEpisodes.has(currentEpisode)) return;
|
||||
completeReportedEpisodes.add(currentEpisode);
|
||||
reportReadEvent('complete', currentEpisode);
|
||||
};
|
||||
|
||||
const addHistoryReadData = (num: number, obj: ReadData, imgObj?: { url: string; width: number; height: number }) => {
|
||||
const listItem = state.list.find((item) => item.episodeNumber === num);
|
||||
if (!listItem) return;
|
||||
@@ -382,7 +266,6 @@ export default ({
|
||||
(newId, oldId) => {
|
||||
if (!newId || newId === oldId) return;
|
||||
getContent();
|
||||
reportReadEvent('view', state.episodeNumber);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -545,8 +428,6 @@ export default ({
|
||||
};
|
||||
|
||||
const switchEpisodeNumber = async (num: number, isValidate = true) => {
|
||||
const fromEpisode = state.episodeNumber;
|
||||
|
||||
if (num === -1) {
|
||||
if (state.episodeNumber !== 1) {
|
||||
const prevNum = state.episodeNumber - 1;
|
||||
@@ -562,7 +443,6 @@ export default ({
|
||||
addHistory(prevNum);
|
||||
}
|
||||
replaceRouter(prevNum);
|
||||
reportReadEvent('page_prev', fromEpisode);
|
||||
return true;
|
||||
} else {
|
||||
showToast('这是第一章了喔~');
|
||||
@@ -583,7 +463,6 @@ export default ({
|
||||
addHistory(nextNum);
|
||||
}
|
||||
replaceRouter(nextNum);
|
||||
reportReadEvent('page_next', fromEpisode);
|
||||
return true;
|
||||
} else {
|
||||
showToast('已经到最后一章了喔~');
|
||||
@@ -654,7 +533,6 @@ export default ({
|
||||
historyEpisodeNumber,
|
||||
addHistory,
|
||||
addHistoryReadData,
|
||||
reportCompleteEvent,
|
||||
switchEpisodeNumberCatalogue,
|
||||
validatePermission,
|
||||
goSectionScrollTop,
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<template>
|
||||
<!-- 广告轮播 -->
|
||||
<div class="coverSeekType">
|
||||
<HomeHeader
|
||||
:advType="AdType.COMMUNITYCAROUSEL"
|
||||
class="advBox"
|
||||
:adImpression="{
|
||||
page_key: 'comm_img',
|
||||
page_name: '社区图集',
|
||||
}"
|
||||
v-if="!props.pureVersion"
|
||||
/>
|
||||
<HomeHeader :advType="AdType.COMMUNITYCAROUSEL" class="advBox" v-if="!props.pureVersion" />
|
||||
<van-tabs class="seek_tabs" v-model:active="state.tabActive" background="transparent" animated @change="tabCange">
|
||||
<van-tab v-for="item in state.tabList" :key="item.type" :title="item.title" :name="item.type">
|
||||
<template v-if="!state.loading">
|
||||
|
||||