[Human] chore(project): 初始化导入 91dj H5 客户端项目代码

从本地工程首次入库;.npmrc(含私有 npm 源 token)与 tsbuildinfo 已加入 .gitignore,不随仓库提交。
This commit is contained in:
谢宇宁
2026-10-08 14:22:35 +07:00
commit eada71e211
1421 changed files with 144498 additions and 0 deletions
@@ -0,0 +1,75 @@
<template>
<van-popup
v-model:show="state.popupShow"
class="accountCredentialsPopup"
:style="{ background: 'transparent', overflow: 'visible' }"
>
<div class="popupBody">
<Promote />
<div class="promoteBtnBox">
<div class="promoteBtn saved" @click="close">我已保存</div>
<div class="promoteBtn primary" @click="saveImg">保存图片</div>
</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import { useAppStore } from '@/stores/app';
import { showToast } from 'vant';
const appStore = useAppStore();
const state = reactive({
popupShow: appStore.isCertificate,
});
const close = () => {
state.popupShow = false;
appStore.setCertificate();
};
const saveImg = () => {
showToast('请截图保存图片');
};
</script>
<style lang="scss" scoped>
.popupBody {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 0 8px;
}
.promoteBtnBox {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
.promoteBtn {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
box-sizing: border-box;
&.saved {
width: 94px;
border-radius: 90px;
background: #dcdcdc;
color: #292929;
font-size: 14px;
font-weight: 400;
}
&.primary {
width: 130px;
border-radius: 999px;
background: #ffc000;
color: #1f2029;
font-size: 16px;
}
}
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="adv_swiper">
<div class="adv-container" v-if="props.list.length > 0" ref="advRef">
<van-swipe
:show-indicators="isShowIndicators"
:autoplay="autoplay"
:lazy-render="lazyRender"
>
<van-swipe-item
@click="handleJumpAdv(item, props.clickType)"
v-for="item in props.list"
:key="item.id"
:style="{ height: height }"
class="adv-swiper-slide"
>
<DecryptImg @load="load" :src="item.cover" />
</van-swipe-item>
</van-swipe>
</div>
</div>
</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';
autoplay?: number;
adImpression?: AdImpressionType;
/** 为 true 时仅挂载当前/相邻项,减轻首屏 DecryptImg 与解密压力 */
lazyRender?: boolean;
}>(),
{
list: () => [],
height: '156px',
isShowIndicators: true,
clickType: 'ads',
autoplay: 5000,
lazyRender: true,
},
);
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);
};
</script>
<style lang="scss" scoped>
:deep(.van-swipe__indicators) {
display: flex;
width: 100%;
justify-content: flex-end;
padding-right: 12px;
}
.adv_swiper {
overflow: hidden;
border-radius: 6px;
.adv-container {
width: 100%;
overflow: hidden;
.adv-swiper-slide {
width: 100%;
border-radius: 6px;
overflow: hidden;
}
}
}
:deep(.van-swipe__indicator) {
width: 7px;
height: 7px;
border: none;
background: rgba(225, 53, 31, 1);
opacity: 0.5;
}
:deep(.van-swipe__indicator--active) {
width: 12px;
height: 6px;
border-radius: 4px;
opacity: 1;
background: rgba(225, 53, 31, 1);
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div class="aiIcon-new">
<img class="bg" src="@/assets/gif/cartoon-gif.gif" alt="" @click.stop="$router.push('aiUndress')" />
<!-- <div v-if="visibleText" class="text-box">
<p class="text" v-html="aiText"></p>
</div> -->
<div class="ai-text" v-if="configStore.appConfig.aiCharacterImg">
<div class="text ellipsis2">
{{ configStore.appConfig.aiCharacterImg }}
</div>
</div>
<div v-if="props.isShowClose" class="aiClose" @click.stop="emit('close')"></div>
</div>
</template>
<script lang="ts">
export default {
name: 'AiFloat',
};
</script>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore();
const emit = defineEmits(['close']);
const route = useRoute();
const props = defineProps({
isShowClose: {
type: Boolean,
default: true,
},
routeName: {
type: String,
default: 'home',
},
});
watch(
() => route.name,
(toName) => {
if (toName === props.routeName) {
setText();
}
},
);
const visibleText = ref(true);
const aiText = ref('');
let timer = null as any;
const setText = () => {
const arr = configStore.appConfig.aiBubble || [];
const len = arr.length;
if (len === 0) {
aiText.value = '';
} else {
aiText.value = arr[Math.floor(Math.random() * len)].replace('\n', '<br>');
}
visibleText.value = true;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
visibleText.value = false;
}, 3000);
};
setText();
</script>
<style lang="scss" scoped>
.aiIcon-new {
width: 61.6px;
height: 56px;
position: fixed;
bottom: 110px;
right: 20px;
z-index: 10;
.bg {
position: absolute;
bottom: 0;
right: -10px;
z-index: 1;
width: 61.6px;
height: 56px;
object-fit: contain;
}
.ai-text {
width: 60px;
height: 36px;
position: absolute;
left: -14px;
top: -10px;
background: url('@/assets/images/png/ai-text.png') no-repeat;
background-size: contain;
z-index: 10;
.text {
font-size: 7px;
color: #000;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 52px;
}
}
.text-box {
position: absolute;
top: -20px;
left: -10px;
z-index: 2;
width: 76px;
height: 54px;
background: url('@/assets/images/png/AiGirlFriend-float-txt.png') 0 0 / contain no-repeat;
padding: 10px 6.8px 20px;
display: flex;
align-items: center;
justify-self: center;
.text {
color: #000;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-align: center;
overflow: hidden;
width: 100%;
max-height: 26px;
}
}
.aiClose {
position: absolute;
top: -14px;
right: -10px;
z-index: 1;
height: 14px;
width: 14px;
background: url('@/assets/images/png/aiClose.png') no-repeat;
background-size: 100% 100%;
}
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<template>
<!-- <van-overlay v-model:show="isShow" z-index="2001"> -->
<van-popup
v-model:show="isShow"
close-on-click-overlay
z-index="2001"
position="bottom"
:style="{ height: '350px' }"
@close="close"
>
<div class="wrapper_gold">
<div class="popup-title">
<div class="title">使用抵扣券</div>
</div>
<div class="wrap">
<div class="couponMain overflow-y-auto">
<div class="couponItem" v-for="item in state.couponList" :key="item.id">
<!-- <div class="aiLogo"></div> -->
<div class="coupon-name">{{ item.goodsName }}</div>
<div class="couponInfo">
<div class="coupon-value">{{ item.goodsDesc }}</div>
<!-- <div class="faceValue">{{ item.goodsValue }}金币</div>
<div class="time">
有效期:<span>{{ dateFormatYyyyMmDd(item.expiredTime) }}</span>
</div> -->
</div>
<div class="right">
<div class="use-btn" @click="handleUseCoupon(item)">立即使用</div>
<!-- <div class="selectedCoupon" v-if="item.id == state.couponId"></div>
<div class="selectCoupon" v-else></div> -->
</div>
</div>
<Empty v-if="state.couponList.length === 0" />
</div>
</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { nextTick, reactive, watch, ref } from 'vue';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
import MineApi from '@/service/mine';
import { dateFormatYyyyMmDd } from '@/utils/index.ts';
const props = defineProps({
gold: {
type: Number,
required: true,
},
isOpen: {
type: Boolean,
required: true,
},
isAv: {
type: Boolean,
required: false,
default() {
return false;
},
},
isCoupon: {
type: Boolean,
required: false,
default() {
return true;
},
},
});
let state = reactive({
couponSelect: -1,
couponIndex: -1,
couponList: [],
couponId: '',
});
const userStore = useUserStore();
const userInfo = useUserStore().userInfo;
const router = useRouter();
const { openLoading, closeLoading } = useLoading();
const emit = defineEmits(['close', 'buySuccess', 'update:isOpen', 'useCoupon']);
const isShow = ref(false);
watch(
() => props.isOpen,
(newVal) => {
isShow.value = newVal;
},
);
nextTick(() => {
getAiCoupon();
});
/**
* 获取抵用卷
*/
const getAiCoupon = async () => {
let res: any = await MineApi.getAibackpack({ page: 1, limit: 50, status: 2, type: 3 });
console.log(res);
state.couponList = res || [];
};
const select = (item: any) => {
if (item.id == state.couponId) {
// 说明已经选了此优惠券,想要取消
state.couponId = '';
} else {
state.couponId = item.id;
}
setTimeout(() => {
// state.showCouponPopup = false;
}, 500);
};
const selectCoupon = (index: any) => {
if (state.couponSelect == state.couponIndex) {
state.couponSelect = -1;
state.couponIndex = -1;
} else {
state.couponIndex = index;
}
};
const unlockNowCoupon = (type: any) => {
if (state.couponIndex > -1 && type) {
emit('buySuccess', state.couponList[state.couponIndex]['coupon']['id']);
} else {
emit('buySuccess', null);
}
};
const close = () => {
emit('close');
emit('update:isOpen', false);
};
const handleUseCoupon = (item: any) => {
console.log(item);
emit('useCoupon', item);
close();
};
// const selectCoupon = (item: any) => {
// let money = userInfo.isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
// if (item.gold < money) {
// showToast('观影券面值过小,请选择其他支付方式');
// } else {
// unlockNow(item.gold);
// }
// };
</script>
<style lang="scss" scoped>
.wrapper_gold {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(3, 15, 24, 1);
padding: 16px;
overflow: hidden;
.wrap {
flex: 1;
overflow: hidden;
// border-radius: 5px;
// padding: 20px 0;
}
.popup-title {
text-align: center;
position: relative;
font-weight: 600;
font-size: 18px;
color: #fff;
margin-bottom: 16px;
}
.buyAiTitleBg {
.title {
font-size: 16px;
}
.close {
position: absolute;
font-size: 18px;
right: 15px;
top: -5px;
z-index: 5;
}
}
.couponMain {
padding-top: 16px;
height: 388px;
.couponItem {
height: 68px;
width: 343px;
margin: 0 auto 14px;
background: url('@/assets/images/png/aiCouponBg.png') no-repeat;
background-size: 100% 100%;
position: relative;
display: flex;
align-items: center;
.coupon-name {
font-size: 18px;
font-weight: 600;
color: rgba(126, 68, 68, 1);
margin-left: 12px;
}
.coupon-value {
font-size: 14px;
color: rgba(126, 68, 68, 1);
opacity: 0.6;
margin-left: 12px;
}
// .aiLogo {
// height: 48px;
// width: 43.2px;
// background: url("@/assets/images/png/ai.png") no-repeat;
// background-size: 100% 100%;
// position: absolute;
// left: 30px;
// top: 20px;
// }
.couponInfo {
// position: absolute;
// left: 124px;
// top: 14px;
.faceValue {
font-size: 23px;
font-weight: 500;
color: rgb(242, 106, 80);
}
.time {
font-size: 14px;
color: rgb(100, 94, 91);
margin-top: 7px;
span {
font-size: 12px;
}
}
}
.right {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
.use-btn {
width: 72px;
height: 26px;
background: rgba(255, 220, 179, 1);
border-radius: 50px;
font-size: 14px;
color: rgba(127, 0, 0, 1);
text-align: center;
line-height: 26px;
}
// .selectCoupon {
// height: 16px;
// width: 16px;
// background: url("@/assets/images/png/selectCoupon.png") no-repeat;
// background-size: 100% 100%;
// }
// .selectedCoupon {
// height: 16px;
// width: 16px;
// background: url("@/assets/images/png/selectedCoupon.png")
// no-repeat;
// background-size: 100% 100%;
// }
}
}
}
}
</style>
@@ -0,0 +1,221 @@
<template>
<div class="horizontalVideoBox">
<AnimationHorizontalVideo
:videoInfo="props.videoInfo"
:mediaInfo="props.mediaInfo"
@getFreeCount="getFreeCount"
@payShow="visiblePayForWatch = true"
@showPreviewTime="showPreviewTime"
ref="verticalVideoRef"
class="videoBox"
@play="play"
/>
<div
class="tipBox"
v-if="!isVip && userFreeCount.watchCount > 0 && parentInfo.price == 0 && parentInfo.permission != 2"
>
免费观影数: {{ userFreeCount.watchCount }}次
</div>
<div class="tipBox" v-else-if="isVip && parentInfo.permission == 0 && isVipFreeShow">
<span>已享VIP免费特权</span>
</div>
<div class="tipBox" v-else-if="parentInfo.permission == 1 && isVipFreeShow && vipLevel >= 2">
<span>已享金币视频免费特权</span>
</div>
<PayForWatch
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="props.videoInfo"
@close="visiblePayForWatch = false"
@buySuccess="buySuccess"
></PayForWatch>
</div>
</template>
<script lang="ts" setup>
import { Ref, ref, inject } from 'vue';
import { useUserStore } from '@/stores/user';
// import PayForWatch from '@/views/acgModule/components/pay/PayForWatch.vue';
import PayForWatch from './pay/PayForWatch.vue';
import type { PayInfo } from '@/views/acgModule/utils/pay-mitt';
const parentInfo = inject('historyData') as Ref<Types.Media.MediaInfo>;
const verticalVideoRef = ref();
let previewTime = ref(0); // 预览视频时间
const isVipFreeShow = ref(true);
const props = defineProps<{
videoInfo: Types.Acg.MediaContentListItem;
mediaInfo: Types.Acg.ListItem;
}>();
const visiblePayForWatch = ref(false);
const emit = defineEmits(['play', 'buySuccess']);
const { isVip, vipLevel } = useUserStore();
let userFreeCount: Ref<Types.Video.UserFreeCount> = ref({
isCan: false,
watchCount: 0,
});
//免费观看视频次数
const getFreeCount = (userCount: Types.Video.UserFreeCount) => {
userFreeCount.value = userCount;
};
const showPreviewTime = (time: number) => {
previewTime.value = parseInt(time.toString());
};
let isFirst = false;
const play = (bool: boolean) => {
if (!isFirst) {
isFirst = true;
let timer = setTimeout(() => {
isVipFreeShow.value = false;
clearTimeout(timer);
}, 3000);
}
emit('play', bool);
};
/**
* @description 视频购买成功
*/
const buySuccess = (obj: PayInfo) => {
visiblePayForWatch.value = false;
verticalVideoRef.value.success();
emit('buySuccess', obj);
};
</script>
<style lang="scss" scoped>
.horizontalVideoBox {
height: 100%;
width: 100%;
position: relative;
.videoBox {
height: 212px;
}
.tipBox {
position: absolute;
z-index: 999;
top: 12px;
right: 16px;
background-color: rgba(0, 0, 0, 0.6);
font-size: 12px;
color: rgb(242, 242, 242);
padding: 6px 12px;
border-radius: 6px;
span {
color: #fff;
}
}
.memberPopup {
height: 100%;
width: 100%;
background-color: rgba(12, 12, 15, 0.61);
position: absolute;
left: 0;
top: 0;
z-index: 999;
text-align: center;
.close {
height: 13px;
width: 13px;
background: url('./../../assets/images/png/movieClose.png') no-repeat;
background-size: 100% 100%;
position: absolute;
top: 14px;
right: 19px;
}
.countUsed {
font-size: 15px;
font-weight: 600;
color: #eeeeee;
margin-top: 60px;
}
.unlimited {
font-size: 12px;
color: #eeeeee;
//margin: 6px 0 25px;
margin-top: 15px;
}
.btnBox {
margin-top: 25px;
.btn {
width: 106px;
height: 39px;
line-height: 38px;
text-align: center;
font-size: 16px;
color: rgb(244, 244, 244);
border: 1px solid white;
border-radius: 3px;
box-sizing: border-box;
}
.openVip {
background-color: #fd2d55;
border: 1px solid #fd2d55;
margin-left: 19px;
}
}
}
.goldPopup {
height: 100%;
width: 100%;
background-color: rgba(12, 12, 15, 0.61);
position: absolute;
left: 0;
top: 0;
z-index: 999;
text-align: center;
.close {
height: 13px;
width: 13px;
background: url('./../../assets/images/png/movieClose.png') no-repeat;
background-size: 100% 100%;
position: absolute;
top: 14px;
right: 19px;
}
.countUsed {
font-size: 15px;
//font-weight: 600;
color: #eeeeee;
margin-top: 60px;
.amountBox {
display: flex;
align-items: center;
justify-content: center;
svg {
margin-left: 2px;
font-size: 18px;
}
.amount {
font-size: 18px;
}
}
}
.unlimited {
font-size: 12px;
color: #eeeeee;
//margin: 6px 0 25px;
margin-top: 15px;
}
.btnBox {
margin-top: 25px;
.btn {
width: 106px;
height: 39px;
line-height: 38px;
text-align: center;
font-size: 16px;
color: rgb(244, 244, 244);
border: 1px solid white;
border-radius: 3px;
box-sizing: border-box;
}
.openVip {
background-color: #fd2d55;
border: 1px solid #fd2d55;
margin-left: 19px;
}
}
}
}
</style>
@@ -0,0 +1,249 @@
<template>
<div v-if="visiblePermission1" class="goldPopup">
<div class="title">试看结束</div>
<div class="content">开通VIP 全站视频免费看</div>
<div class="btnBox flex-center-center">
<div class="btn" @click="showGoldPayPopup = true">{{ historyData.price }}金币解锁</div>
<div class="btn primary" @click="showPaymentStatusPopup = true">开通VIP免费看</div>
</div>
<!-- 观影卷 -->
<div class="couponBox" v-if="useCoupon?.gold" @click.stop="nowBuy('all', true)">
使用观影券
<span>x{{ userStore.userInfo.goldVideoCoupon?.length }}</span>
</div>
</div>
<div v-else-if="visiblePermission0" class="goldPopup">
<div class="title">试看结束</div>
<div class="content">开通VIP 全站视频免费看</div>
<div class="btnBox flex-center-center">
<div class="btn" @click="router.push('/share')">邀请得3日VIP</div>
<div class="btn primary" @click="showPaymentStatusPopup = true">开通VIP免费看</div>
</div>
<!-- 观影卷 -->
<div class="couponBox" v-if="useCoupon?.gold" @click.stop="nowBuy('all', true)">
使用观影券
<span>x{{ useCouponNum }}</span>
</div>
</div>
<!-- 开通会员 -->
<PaymentStatusPopup
:show="showPaymentStatusPopup"
:video-id="String(historyData?.id || '')"
@close="closePaymentStatusPopup"
/>
<GoldPopup
:is-open="showGoldPayPopup"
:video-info="props.info"
confirm-only
@close="showGoldPayPopup = false"
@confirm="handleGoldConfirm"
/>
</template>
<script lang="ts" setup>
import { computed, ref, watch, onActivated, inject, Ref, nextTick, defineAsyncComponent } from 'vue';
import { useUserStore } from '@/stores/user';
import UserApi from '@/service/user';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import VideoApi from '@/service/video';
import { ProductType } from '@/enums/app';
import useLoading from '@/hooks/loading';
import GoldPopup from '@/views/acgModule/components/pay/GoldPopup.vue';
const { openLoading, closeLoading } = useLoading();
const PaymentStatusPopup = defineAsyncComponent(() => import('@/components/paymentStatusPopup/index.vue'));
const router = useRouter();
const showPaymentStatusPopup = ref(false);
const showGoldPayPopup = ref(false);
const closePaymentStatusPopup = () => {
showPaymentStatusPopup.value = false;
};
const props = withDefaults(
defineProps<{
info: any; // Types.Media.MediaInfo | Types.Acg.MediaContentListItem;
isOpen: boolean;
}>(),
{
info: () => <any>{},
isOpen: () => false,
},
);
const historyData = inject('historyData') as Ref<Types.Media.MediaInfo>;
const userStore = useUserStore();
const isTypeVip = computed(() => {
return userStore.isVip;
});
const emit = defineEmits(['close', 'buySuccess']);
const visiblePermission0 = ref(false);
const visiblePermission1 = ref(false);
// const visibleTodayFree = ref(false);
// permission 收听权限 0:会员 1:金币购买 2:免费
const permission = computed(() => {
return historyData.value.permission;
});
const recharge = computed(() => {
return (userStore.walletInfo.amount * 100 + userStore.walletInfo.income * 100) / 100;
});
const gold = computed(() => {
return historyData.value.price;
});
const useCoupon: any = computed(() => {
if (userStore.userInfo.goldVideoCoupon?.length) {
return userStore.userInfo.goldVideoCoupon.find((item: any) => item.gold >= gold.value) || {};
}
return {};
});
const useCouponNum = computed(() => {
if (userStore.userInfo.goldVideoCoupon?.length) {
return userStore.userInfo.goldVideoCoupon.reduce((sum: number, item: any) => sum + item.count, 0);
}
return 0;
});
const handleGoldConfirm = () => {
showGoldPayPopup.value = false;
nowBuy('all');
};
/**
* @description 视频购买
*/
const nowBuy = (type: 'all', coupon?: boolean) => {
try {
if (recharge.value < gold.value) {
emit('close');
router.push('/wallet');
return;
}
let productID = historyData.value.id;
if (!productID) {
showToast('购买出错~');
return;
}
openLoading();
VideoApi.videoBuying({
productType: ProductType.AcgAll,
productID,
goldVideoCouponNum: coupon ? useCoupon.value.gold : 0,
})
.then(async () => {
showToast('购买成功');
userStore.updateWallet();
emit('buySuccess', { type, productID });
if (coupon) {
userStore.updateUserInfo();
}
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
emit('close');
nextTick(() => {
router.push({
path: '/wallet',
query: {
activeTab: 1,
id: historyData.value.id,
title: historyData.value.title || '无标题',
},
});
});
}
});
} finally {
closeLoading();
}
};
watch(
() => props.isOpen,
(bool) => {
if (!bool) {
// visibleTodayFree.value = false;
visiblePermission0.value = false;
visiblePermission1.value = false;
showGoldPayPopup.value = false;
return;
} else {
if (permission.value === undefined) {
emit('close');
return;
}
if (permission.value === 1) {
visiblePermission1.value = true;
} else if (permission.value === 0) {
visiblePermission0.value = true;
}
}
},
{
immediate: true,
},
);
onActivated(() => {
// 会员类型要从接口取用户最新信息
if (permission.value === 0 && !isTypeVip.value) {
UserApi.queryUserInfo(null).then((res) => {
userStore.saveUserInfo(res);
if (res.isVip) {
emit('close');
}
});
}
});
</script>
<style lang="scss" scoped>
.goldPopup {
position: absolute;
left: 0;
top: 0;
z-index: 999;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.7);
font-size: 14px;
font-weight: 400;
color: #fff;
padding-top: 42px;
text-align: center;
.content {
margin: 26px 0;
span {
color: rgb(62, 100, 234);
}
}
.btnBox {
.btn {
padding: 6px 18px;
line-height: 20px;
border-radius: 30px;
background: linear-gradient(90deg, #ffe8be 1.65%, #e6b764 99.23%);
text-align: center;
font-size: 14px;
color: #694923;
font-weight: 400;
&.primary {
margin-left: 15px;
background: linear-gradient(320deg, #ff4d4d 0%, #ff6e6e 100%);
color: #fff;
}
}
}
.couponBox {
font-family: 'PingFang SC';
font-size: 12px;
color: #fff;
margin-top: 13px;
display: flex;
align-items: center;
justify-content: center;
span {
color: #f52c56;
margin-left: 4px;
}
}
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<template>
<div class="animationImg">
<transition name="bounceFade">
<img
v-show="isLoad"
:src="decyImg"
@load="load"
alt="加载失败"
:class="{ round: round }"
:style="{ borderRadius: borderRadius + 'px' }"
/>
</transition>
<transition name="fade">
<div
class="loading"
v-if="!isLoad"
:class="{ round: round }"
:style="{ borderRadius: borderRadius + 'px' }"
></div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useConfigStore } from '@/stores/config';
import AppApi from '@/service/app';
const props = defineProps({
//图片地址
src: {
type: String,
required: true,
default() {
return '';
},
},
isNeedDecypt: {
//是否需要解密
type: Boolean,
required: false,
default() {
return true;
},
},
round: {
//图片是否显示为圆形
type: Boolean,
required: false,
default() {
return false;
},
},
borderRadius: {
//图片圆角
type: Number,
required: false,
default() {
return 0;
},
},
});
const emit = defineEmits(['load']);
let isLoad = ref(false);
let decyImg = ref('');
const configStore = useConfigStore();
const load = () => {
isLoad.value = true;
emit('load');
};
const init = () => {
isLoad.value = false;
decyImg.value = '';
if (!props.src) {
return;
}
if (!props.isNeedDecypt) {
decyImg.value = props.src;
return;
}
if (/^(blob:|data:)/.test(props.src)) {
decyImg.value = props.src;
return;
}
try {
const isHttp = /^https?:\/\//.test(props.src);
const imgSrc = isHttp ? props.src : configStore.sourceInfo.IMAGE + '/' + props.src;
AppApi.imgDecypt(imgSrc)
.then((url: any) => {
decyImg.value = url;
})
.catch((err) => {
console.error('imgDecypt error', err);
});
} catch (error) {
console.error('imgDecypt catch', error);
}
};
watch(
() => [props.src, props.isNeedDecypt],
() => {
init();
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.bounceFade-enter-active {
animation: bounceFade-in 0.4s;
}
.bounceFade-leave-active {
animation: bounceFade-in 0.4s;
}
@keyframes bounceFade-in {
0% {
opacity: 0;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.round {
border-radius: 50% !important;
}
.loading {
width: 100%;
height: 100%;
background: url('@/assets/images/png/placeholder.png') no-repeat;
background-size: 50% auto;
background-position: 50% 50%;
background-color: #141414;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
img {
object-fit: cover !important;
}
}
.animationImg {
width: 100%;
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover !important;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="announcementPopup">
<van-overlay
v-if="currentAnnouncement && currentAnnouncement.type == 0"
:show="true"
z-index="2000"
:lock-scroll="false"
@click="close"
>
<div class="wrapper" @click.stop>
<div class="content app-popup-surface">
<div class="title app-popup-title">{{ currentAnnouncement.title }}</div>
<div class="horizontalLine app-popup-divider"></div>
<div class="text app-popup-body">
{{ currentAnnouncement.content }}
</div>
<div class="btnList app-popup-primary" @click.stop="close">我知道了</div>
</div>
</div>
</van-overlay>
<van-overlay v-if="currentAnnouncement && currentAnnouncement.type == 1" :show="true" z-index="1999" @click="close">
<div class="imgWrapper" @click.stop>
<lazyImg @click.stop="jumpAdv(currentAnnouncement)" class="imgBox" :src="currentAnnouncement.cover" />
<div class="close" @click.stop="close"></div>
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import { jumpAdv } from '@/utils/index';
const props = withDefaults(
defineProps<{
announList: Types.App.AnnounListItem[];
}>(),
{
announList: () => [],
},
);
const emit = defineEmits(['close']);
const state = reactive({
announList: <Array<Types.App.AnnounListItem>>props.announList,
});
const currentAnnouncement = computed(() => state.announList[state.announList.length - 1]);
const close = () => {
state.announList.pop();
if (!state.announList.length) {
emit('close');
}
};
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.announcementPopup {
height: 100%;
width: 100%;
position: relative;
.wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 311px;
box-sizing: border-box;
.content {
padding: 30px 24px 20px 24px;
border-radius: 12px;
background: #030f18;
width: 311px;
margin: 0 auto;
color: #656565;
font-family: 'PingFang SC';
font-size: 14px;
.title {
color: rgba(255, 255, 255, 0.9);
text-align: center;
font-family: PingFang SC;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: normal;
height: 40px;
}
// .horizontalLine {
// height: 1px;
// background-color: rgba(255, 255, 255, 0.1);
// margin: 18px 0;
// }
.text {
font-size: 14px;
line-height: 22px;
white-space: pre-line;
height: 260px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
color: rgba(255, 255, 255, 0.55);
}
.btnList {
margin: 6px auto 0;
height: 44px;
width: 263px;
text-align: center;
line-height: 44px;
font-size: 16px;
font-weight: 500;
border-radius: 3px;
background: #e1351f;
color: white;
}
}
}
.imgWrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
.imgBox {
width: 325px;
height: 325px;
:deep(.animationImg) {
img {
object-fit: contain !important;
}
}
}
.close {
height: 36px;
width: 36px;
background: url('./../../assets/images/png/closeDialog.png') no-repeat;
background-size: 100% 100%;
margin: 10px auto;
flex-shrink: 0;
cursor: pointer;
}
}
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<template>
<Teleport to="body">
<van-overlay class="share-overlay" z-index="2000" :show="visible" @click="emit('close')">
<div class="wrapper flex-center-center" @click.stop="emit('close')">
<div class="share-box" :class="[props.theme]" @click.stop>
<div class="content">
<div class="app">
<img class="logo" src="@/assets/images/ui/share-logo@2x.png" alt="" />
<div class="info">
<span class="name">91短剧</span>
<span class="desc">百万大神短剧原创</span>
</div>
</div>
<div class="cover-box" :class="[props.coverType]">
<lazyImg
:src="cover || globalStore.shareInfo.cover"
:key="cover || globalStore.shareInfo.cover"
class="cover"
/>
</div>
<div class="qrCodeBg">
<div class="qrCodeBox flex-center-center">
<qrcode-vue id="qrCode" class="qrCodeImg" v-if="state.qrUrl" :value="state.qrUrl" level="M" />
<van-loading size="50px" type="spinner" color="#c0c9ea" v-else />
</div>
<div class="share-info">
<div class="invite-code">
<span>邀请码</span>
<span>{{ state.userInfo.promotionCode }}</span>
</div>
<div class="tips-link">
<p class="tips">
每邀请
<span>1人</span>
送
<span>1天</span>
会员
</p>
<p class="link">{{ displayLink }}</p>
</div>
</div>
</div>
</div>
<div class="promoteBtnBox">
<div class="btn" @click="copyLink">复制链接</div>
<div class="btn primary" @click="saveImg">保存图片</div>
</div>
</div>
<!-- <img
class="close-icon"
src="@/assets/images/png/community/rule-popup-close.svg"
alt=""
@click.stop="emit('close')"
/> -->
</div>
</van-overlay>
</Teleport>
</template>
<script lang="ts" setup>
import { reactive, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { showToast } from 'vant';
import toClipboard from '@/utils/clipboard';
import { useConfigStore } from '@/stores/config';
import { useGlobalStore } from '@/stores/global';
const props = withDefaults(
defineProps<{
isOpen: boolean;
isNormalLink?: boolean;
theme?: string;
cover?: string;
coverType?: string;
}>(),
{
isOpen: false,
isNormalLink: false,
theme: 'black',
},
);
const userStore = useUserStore();
const emit = defineEmits(['close', 'getLink']);
const configStore = useConfigStore();
const globalStore = useGlobalStore();
const state = reactive({
visible: true,
userInfo: <Types.User.UserInfoType>userStore.userInfo,
qrUrl: <string>'',
});
const init = () => {
if (props.isNormalLink) {
state.qrUrl = configStore.sourceInfo.LAND;
} else {
state.qrUrl = state.userInfo.promoteURL;
emit('getLink', state.qrUrl);
}
};
init();
const visible = computed(() => {
return props.isOpen;
});
const displayLink = computed(() => {
return configStore.sourceInfo.GUIDE || '';
});
const copyLink = async () => {
try {
if (!state.qrUrl) {
showToast('复制失败');
return;
}
await toClipboard(state.qrUrl);
showToast('复制成功');
} catch (e: any) {
console.log(e);
}
};
const saveImg = () => {
showToast('请截图保存图片');
};
</script>
<style lang="scss" scoped>
.share-overlay {
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.wrapper {
width: 100%;
height: 100%;
flex-direction: column;
gap: 40px;
}
.share-box {
width: 311px;
border-radius: 12px;
background: #0f0f0f;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
.content {
width: 100%;
padding: 32px 24px 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.app {
width: 265px;
display: flex;
align-items: center;
gap: 10px;
.logo {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
object-fit: cover;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.name {
font-size: 14px;
font-weight: 500;
line-height: normal;
color: rgba(255, 255, 255, 0.9);
}
.desc {
color: rgba(255, 255, 255, 0.55);
font-size: 10px;
font-weight: 400;
line-height: normal;
}
}
}
.cover-box {
width: 261px;
height: 123px;
flex-shrink: 0;
overflow: hidden;
border-radius: 9px;
.cover {
width: 100%;
height: 100%;
}
&.portrait {
width: 211px;
height: 281px;
}
}
.qrCodeBg {
width: 265px;
display: flex;
align-items: center;
gap: 16px;
.qrCodeBox {
flex-shrink: 0;
width: 100px;
height: 100px;
background-color: #fff;
.qrCodeImg {
width: 94px !important;
height: 94px !important;
flex-shrink: 0;
}
}
.share-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.invite-code {
display: flex;
gap: 8px;
font-size: 16px;
font-weight: 500;
line-height: 22px;
color: rgba(255, 255, 255, 0.9);
span {
opacity: 0.9;
}
}
}
.tips-link {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: rgba(255, 255, 255, 0.55);
}
.tips {
margin: 0;
opacity: 0.9;
span {
color: #ffc000;
}
}
.link {
margin: 0;
word-break: break-all;
opacity: 0.9;
}
.promoteBtnBox {
width: 100%;
padding: 24px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
.btn {
height: 44px;
padding: 0 24px;
box-sizing: border-box;
border-radius: 88px;
background: rgba(255, 255, 255, 0.1);
color: #989898;
font-size: 16px;
font-weight: 500;
line-height: 22px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
&.primary {
flex: 1;
min-width: 0;
color: #1f2029;
background: #ffc000;
}
}
}
}
.close-icon {
width: 36px;
height: 36px;
display: block;
flex-shrink: 0;
}
</style>
+521
View File
@@ -0,0 +1,521 @@
<template>
<van-popup
class="coin-video-pay-popup"
:show="isOpen"
position="bottom"
z-index="2002"
teleport="body"
:overlay="true"
:style="{ maxHeight: '85dvh' }"
@click-overlay="emitClose"
@closed="onPopupClosed"
>
<div class="coinVideoPayPopup">
<div class="coinVideoPayPopup__scroll">
<div class="coinVideoPayPopup__handle" />
<div class="coinVideoPayPopup__title">金币支付</div>
<div class="coinVideoPayPopup__balance">
<div class="coinVideoPayPopup__balanceLabel">我的金币余额</div>
<div class="coinVideoPayPopup__balanceRow">
<div class="coinVideoPayPopup__balanceValue">
<img class="coinVideoPayPopup__coinIcon" src="@/assets/images/png/mine/coin.png" alt="" />
<span>{{ balanceText }}</span>
</div>
<div class="coinVideoPayPopup__balanceDetail" @click.stop="goTransactionRecord">余额明细</div>
</div>
</div>
<div v-if="state.amountList.length" class="coinVideoPayPopup__grid">
<div
v-for="(item, index) in state.amountList"
:key="item.id"
class="coinVideoPayPopup__package"
:class="{ active: state.selectIndex === index }"
@click="selectAmount(index)"
>
<div v-if="item.couponDesc" class="coinVideoPayPopup__packageBadge">{{ item.couponDesc }}</div>
<img class="coinVideoPayPopup__packageIcon" src="@/assets/images/png/mine/coin.png" alt="" />
<div class="coinVideoPayPopup__packageCoin">{{ item.money / 10 }}金币</div>
<div class="coinVideoPayPopup__packagePrice">¥{{ item.money / 100 }}</div>
</div>
</div>
<Loading v-else class="coinVideoPayPopup__loading" />
<div class="coinVideoPayPopup__tips">
*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。如支付未到账,请
<span @click.stop="goCustomerService">反馈客服订单号</span>
</div>
</div>
<div class="coinVideoPayPopup__footer">
<div
class="coinVideoPayPopup__payBtn"
:class="{ 'coinVideoPayPopup__payBtn--disabled': !canClickPay }"
@click="handlePay"
>
{{ payBtnText }}
</div>
<div class="coinVideoPayPopup__service">
支付问题反馈,点击联系
<span @click.stop="goCustomerService">在线客服</span>
</div>
</div>
</div>
</van-popup>
<!-- 与 LimitedTimeOfferPopup 一致:独立于金币弹层挂载,避免 lazy-render 导致 ref 未就绪 / 被父层遮挡 -->
<RechargeBox
ref="rechargeBoxRef"
:is-open="state.showRecharge"
:z-index="2003"
@closePay="state.showRecharge = false"
@paySuccess="onRechargeSuccess"
/>
</template>
<script lang="ts" setup>
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import MineApi from '@/service/mine';
import VideoApi from '@/service/video';
import { ProductType } from '@/enums/app';
import { useUserStore } from '@/stores/user';
import useLoading from '@/hooks/loading';
const props = defineProps<{
isOpen: boolean;
videoInfo: Types.Video.VInfosItem;
/**
* 为 true 时余额足够不调 videoBuying,仅通知父组件解锁
*(短剧等非长视频商品购买场景)
*/
deferUnlock?: boolean;
/** 下单来源;短剧传 DRAMA_PAYWALL */
sourcePage?: Types.Recharge.PaySourcePage;
/** 短剧整部 ID */
mediaId?: string;
/** 短剧当前子集 ID */
contentId?: string;
}>();
/** 勿对 defineEmits 结果做 `as` 断言,否则 Vue 3.2 编译器无法剥离宏,运行时报错 */
const emit = defineEmits(['close', 'buySuccess']);
const emitClose = () => {
(emit as (e: string) => void)('close');
};
const emitBuySuccess = () => {
(emit as (e: string) => void)('buySuccess');
};
const router = useRouter();
const userStore = useUserStore();
const { openLoading, closeLoading } = useLoading();
const rechargeBoxRef = ref();
const state = reactive({
amountList: [] as Types.Recharge.ListItem[],
dcInfo: {} as Types.Recharge.Daichong,
selectIndex: 0,
showRecharge: false,
packagesLoading: false,
});
const videoPrice = computed(() => {
return userStore.isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
});
const balance = computed(() => {
return ((userStore.walletInfo.amount || 0) * 100 + (userStore.walletInfo.income || 0) * 100) / 100;
});
const balanceText = computed(() => (balance.value === 0 ? '0' : balance.value.toFixed(1)));
const selectedPackage = computed(() => state.amountList[state.selectIndex]);
const canPayWithCoin = computed(() => videoPrice.value > 0 && balance.value >= videoPrice.value);
const canClickPay = computed(() => !state.packagesLoading && (canPayWithCoin.value || !!selectedPackage.value));
const payBtnText = computed(() => {
if (canPayWithCoin.value) {
return `${videoPrice.value}金币/立即支付`;
}
if (!selectedPackage.value) return '立即支付';
return `¥${selectedPackage.value.money / 100}/立即支付`;
});
const buildRechargePayData = () => {
const item = selectedPackage.value;
if (!item) return null;
return {
rechargeList: [...(item.rchgType || [])],
dcInfo: state.dcInfo,
popType: 'gold',
productType: undefined,
productID: item.id,
dcType: 0,
buyType: 1,
discountedPrice: item.money / 100,
videoId: props.mediaId || props.videoInfo.id,
mediaId: props.mediaId,
contentId: props.contentId,
sourcePage: props.sourcePage || 'VIDEO_BOTTOM_SHEET',
sourceRef: props.mediaId || props.videoInfo.id,
};
};
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** 异步组件 + 弹层首次打开时 ref 可能晚于 nextTick,短重试确保 changeItem 能执行 */
const syncRechargePayData = async () => {
const payData = buildRechargePayData();
if (!payData) return false;
for (let i = 0; i < 10; i += 1) {
await nextTick();
if (rechargeBoxRef.value?.changeItem) {
rechargeBoxRef.value.changeItem(payData);
return true;
}
await wait(32);
}
return false;
};
const openRechargeBox = async () => {
if (!selectedPackage.value) {
showToast('暂无可选充值档位');
return;
}
const synced = await syncRechargePayData();
if (!synced) {
showToast('支付方式加载中,请稍后再试');
return;
}
state.showRecharge = true;
};
const loadCoinPackages = async () => {
state.packagesLoading = true;
try {
const res: any = await MineApi.getGoldCoinList({ type: '1' });
state.amountList = res.list || [];
state.dcInfo = JSON.parse(JSON.stringify(res.daichong || {}));
if (state.amountList.length) {
state.selectIndex = 0;
await syncRechargePayData();
}
} catch (e) {
console.log(e);
} finally {
state.packagesLoading = false;
}
};
const selectAmount = (index: number) => {
state.selectIndex = index;
void syncRechargePayData();
};
const notifyParentUnlock = () => {
// 父组件 buySuccess 内会关弹窗,避免连续 emit 触发重复更新
emitBuySuccess();
};
const unlockWithCoin = async () => {
if (balance.value < videoPrice.value) {
if (!selectedPackage.value) {
showToast('金币余额不足,请先充值');
return;
}
await openRechargeBox();
return;
}
if (props.deferUnlock) {
notifyParentUnlock();
return;
}
const productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
try {
openLoading();
await VideoApi.videoBuying({
productType: ProductType.VIDEO,
productID,
goldVideoCouponNum: 0,
});
await userStore.updateWallet();
notifyParentUnlock();
} catch (e: any) {
if (e?.toString().indexOf('8000') !== -1) {
if (selectedPackage.value) {
await openRechargeBox();
} else {
showToast('金币余额不足,请先充值');
}
}
} finally {
closeLoading();
}
};
const handlePay = async () => {
if (!canClickPay.value) {
if (state.packagesLoading) {
showToast('充值档位加载中,请稍候');
} else {
showToast('暂无可选充值档位');
}
return;
}
if (canPayWithCoin.value) {
await unlockWithCoin();
return;
}
await openRechargeBox();
};
const onRechargeSuccess = async () => {
state.showRecharge = false;
await userStore.updateWallet();
if (balance.value >= videoPrice.value) {
await unlockWithCoin();
}
};
const goTransactionRecord = () => {
emitClose();
router.push('/transactionRecord');
};
const goCustomerService = () => {
emitClose();
router.push('/customerService');
};
const onPopupClosed = () => {
state.showRecharge = false;
};
watch(
() => props.isOpen,
(open) => {
if (!open) {
state.showRecharge = false;
return;
}
userStore.updateWallet();
loadCoinPackages();
},
);
</script>
<style lang="scss">
.coin-video-pay-popup {
background: transparent !important;
overflow: hidden;
}
</style>
<style lang="scss" scoped>
.coinVideoPayPopup {
position: relative;
display: flex;
flex-direction: column;
max-height: 85dvh;
width: 100%;
overflow: hidden;
border-top: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px 24px 0 0;
/* Figma 21659:85708 弹层底色 */
background: linear-gradient(180deg, #040018 0%, #060606 49.04%, #060606 100%);
/* Figma 21659:85709 Bg-Pattern:顶部金色径向光晕 */
&::before {
content: '';
position: absolute;
left: 0;
top: -1px;
z-index: 0;
width: 100%;
height: 429px;
pointer-events: none;
background: radial-gradient(
262.5px 300.3px at 50% 21px,
rgba(250, 204, 21, 0.16) 0%,
rgba(250, 204, 21, 0) 70%
);
}
> * {
position: relative;
z-index: 1;
}
}
.coinVideoPayPopup__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 16px 16px;
-webkit-overflow-scrolling: touch;
}
.coinVideoPayPopup__handle {
width: 36px;
height: 4px;
margin: 0 auto 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.3);
}
.coinVideoPayPopup__title {
padding: 4px 0 16px;
font-size: 17px;
font-weight: 600;
line-height: 24px;
color: #fff;
text-align: center;
}
.coinVideoPayPopup__balance {
height: 101px;
padding: 18px 17px;
border-radius: 8px;
/* 透明/黑色/010 → #ffffff1a */
background: #ffffff1a;
}
.coinVideoPayPopup__balanceLabel {
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
line-height: 18px;
color: #fff;
}
.coinVideoPayPopup__balanceRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.coinVideoPayPopup__balanceValue {
display: flex;
align-items: center;
gap: 4px;
font-size: 32px;
font-weight: 600;
line-height: 1;
color: #ffd460;
}
.coinVideoPayPopup__coinIcon {
width: 36px;
height: 36px;
}
.coinVideoPayPopup__balanceDetail {
min-width: 90px;
height: 30px;
padding: 0 24px;
border-radius: 999px;
background: #ffd460;
font-size: 14px;
line-height: 30px;
text-align: center;
color: #3d3d3d;
}
.coinVideoPayPopup__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 5px;
margin: 16px 0;
}
.coinVideoPayPopup__package {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 130px;
margin-top: 8px;
box-sizing: border-box;
border: 1px solid transparent;
border-radius: 12px;
/* 透明/黑色/005 → #ffffff0d */
background: #ffffff0d;
}
.coinVideoPayPopup__package.active {
border-color: #ffe580;
background: #ffffff0d;
}
.coinVideoPayPopup__packageBadge {
position: absolute;
top: -8px;
left: -1px;
max-width: 100%;
padding: 2px 8px;
border-radius: 12px 0 12px 0;
background: #f9c142;
font-size: 12px;
line-height: 16px;
color: #141414;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coinVideoPayPopup__packageIcon {
width: 36px;
height: 36px;
margin-bottom: 4px;
}
.coinVideoPayPopup__packageCoin {
font-size: 14px;
line-height: 20px;
color: #989898;
}
.coinVideoPayPopup__packagePrice {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: #f9c142;
}
.coinVideoPayPopup__loading {
margin: 24px auto;
}
.coinVideoPayPopup__tips {
font-size: 12px;
line-height: 18px;
color: #989898;
text-align: justify;
span {
color: #ffd460;
}
}
.coinVideoPayPopup__footer {
flex-shrink: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
/* 透明/白色/000 → #00000000;透明/白色/100 → #000000 */
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, #000 100%);
box-shadow: 0 -4px 4px rgba(0, 0, 0, 0.05);
}
.coinVideoPayPopup__payBtn {
height: 44px;
border-radius: 3px;
background: var(--app-primary);
font-size: 16px;
font-weight: 500;
line-height: 44px;
text-align: center;
color: var(--app-primary-text);
}
.coinVideoPayPopup__payBtn--disabled {
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>
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
<template>
<!-- 新用户倒计时 -->
<div
class="newUserBg"
v-if="displayType === 'newUser' && newUserTime.countdownSec > 0"
@click="$emit('openMemberPay')"
>
<lazyImg
class="newUserBannerImg"
:src="configStore.appConfig?.newUserBannerImg"
:key="configStore.appConfig?.newUserBannerImg"
v-if="configStore.appConfig?.newUserBannerImg"
/>
<img src="@/assets/images/png/home/newUserBg.png" class="newUserBannerImg" v-else />
<div class="hour">{{ newUserTime.hr }}</div> <span class="hourColon">:</span>
<div class="minute">{{ newUserTime.min }}</div> <span class="minuteColon">:</span>
<div class="second">{{ newUserTime.sec }}</div>
<!-- <div class="nowOpenBtn" @click="$emit('openMemberPay')"></div> -->
</div>
<!-- 限时特惠倒计时 -->
<div
class="newUserBg specialOfferBg"
v-else-if="displayType === 'specialOffer' && specialOfferTime.countdownSec > 0"
@click="handleSpecialOfferClick"
>
<lazyImg
class="newUserBannerImg"
:src="
specialOfferType === 'newbie' ? configStore.appConfig?.specialBannerImg : configStore.appConfig?.backBannerImg
"
:key="
specialOfferType === 'newbie' ? configStore.appConfig?.specialBannerImg : configStore.appConfig?.backBannerImg
"
v-if="
(specialOfferType === 'newbie' && configStore.appConfig?.specialBannerImg) ||
(specialOfferType === 'old' && configStore.appConfig?.backBannerImg)
"
/>
<img src="@/assets/images/png/home/newUserBg.png" class="newUserBannerImg" v-else />
<div class="hour">{{ specialOfferTime.hr }}</div> <span class="hourColon">:</span>
<div class="minute">{{ specialOfferTime.min }}</div> <span class="minuteColon">:</span>
<div class="second">{{ specialOfferTime.sec }}</div>
<div class="nowOpenBtn"></div>
</div>
<!-- 活动banner倒计时 -->
<div
class="bannerBox"
v-else-if="displayType === 'activity' && activeTime.countdownSec > 0"
@click="jumpAdv(configStore.appConfig.bannerJump && configStore.appConfig.bannerJump[1])"
>
<lazyImg
class="bannerImg"
:src="configStore.appConfig.bannerJump && configStore.appConfig.bannerJump[1]?.banner"
:key="configStore.appConfig.bannerJump && configStore.appConfig.bannerJump[1]?.banner"
/>
<div class="timeBox">{{ activeTime.timeStr }}</div>
</div>
<!-- 付费分层 playPage banner -->
<div
class="newUserBg paymentPlayPageBg"
v-else-if="showPaymentPlayPage"
@click="openDirectPay(paymentVipCardId)"
>
<lazyImg class="newUserBannerImg" :src="paymentPlayPageImg" :key="paymentPlayPageImg" />
<template v-if="showPaymentCountdown">
<div class="hour">{{ paymentPlayPageTime.hr }}</div>
<span class="hourColon">:</span>
<div class="minute">{{ paymentPlayPageTime.min }}</div>
<span class="minuteColon">:</span>
<div class="second">{{ paymentPlayPageTime.sec }}</div>
</template>
</div>
<!-- <div
class="newUserBg"
v-else-if="configStore.appConfig.dailyBannerImg && !isVip"
@click="handleToMember(configStore.appConfig?.dailyBannerVipCardId)"
>
<lazyImg
class="newUserBannerImg"
:src="configStore.appConfig.dailyBannerImg"
:key="configStore.appConfig.dailyBannerImg"
/>
</div>
<div class="vipBox" v-else-if="displayType === 'vipBox'" @click="handleToMember('')">
<img src="@/assets/images/png/up_vip_icon.png" alt="" class="vipIcon">
<div class="vipContent">
<span class="vipName">{{ userInfo.vipName || '普通会员' }}</span>
<span class="vipTime">到期时间:{{ moment(userInfo.vipExpireDate).format('YYYY.MM.DD') }}</span>
</div>
<img src="@/assets/images/png/up_vip_btn.png" alt="" class="vipBtn">
</div> -->
<RechargeBox
ref="rechargeBoxRef"
:is-open="isRecharge"
@closePay="handleClosePay"
@paySuccess="handlePaySuccess"
/>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch, defineAsyncComponent } from 'vue';
import { useConfigStore } from '@/stores/config';
import { useUserStore } from '@/stores/user';
import { jumpAdv } from '@/utils/index';
import { useRouter } from 'vue-router';
import moment from 'moment';
import { usePaymentStatusDirectPay } from '@/hooks/usePaymentStatusDirectPay';
import {
hasAnyPaymentConfigField,
isPaymentPlayPageStatus,
parseLastDiscountTime,
resolvePaymentConfigImg,
} from '@/utils/paymentStatusPopup';
const RechargeBox = defineAsyncComponent(() => import('@/components/RechargeBox/index.vue'));
const configStore = useConfigStore();
const { isNewUser, isVip, userInfo } = useUserStore();
const router = useRouter();
const props = defineProps({
// 新用户倒计时秒数
newUserCountdownSec: {
type: Number,
default: 0,
},
// 活动倒计时秒数
activeCountdownSec: {
type: Number,
default: 0,
},
// 限时特惠倒计时毫秒数
specialOfferCountdownMs: {
type: Number,
default: 0,
},
// 限时特惠类型:newbie-新人特惠, old-返场特惠
specialOfferType: {
type: String,
default: 'newbie',
},
});
const emit = defineEmits(['openMemberPay', 'openSpecialOffer']);
// 新用户倒计时
const newUserTime = ref({
countdownSec: props.newUserCountdownSec,
hr: '',
min: '',
sec: '',
});
// 活动倒计时
const activeTime = ref({
countdownSec: props.activeCountdownSec,
timeStr: '',
});
// 限时特惠倒计时
const specialOfferTime = ref({
countdownSec: Math.floor(props.specialOfferCountdownMs / 1000),
hr: '',
min: '',
sec: '',
});
// 付费分层 playPage 倒计时
const paymentPlayPageTime = ref({
countdownSec: 0,
hr: '',
min: '',
sec: '',
});
const paymentCountdownExpired = ref(false);
const { rechargeBoxRef, isRecharge, openDirectPay, handlePaySuccess, handleClosePay } = usePaymentStatusDirectPay({
sourcePage: 'VIDEO_BOTTOM_BANNER',
});
let newUserTimer: any = null;
let activeTimer: any = null;
let oldReturnTimer: any = null;
let specialOfferTimer: any = null;
let paymentPlayPageTimer: any = null;
// 格式化时分秒
const formatTime = (timer: number) => {
if (timer <= 0) return { hr: '00', min: '00', sec: '00' };
const hr = Math.floor(timer / 60 / 60);
const min = Math.floor((timer / 60) % 60);
const sec = Math.floor(timer % 60);
return {
hr: hr > 9 ? hr.toString() : '0' + hr,
min: min > 9 ? min.toString() : '0' + min,
sec: sec > 9 ? sec.toString() : '0' + sec,
};
};
// 格式化活动倒计时(天时分秒)
const formatActiveTime = (timer: number) => {
if (timer <= 0) return '';
const day = Math.floor(timer / 60 / 60 / 24);
const hr = Math.floor((timer - day * 24 * 60 * 60) / 60 / 60);
const min = Math.floor((timer / 60) % 60);
const sec = Math.floor(timer % 60);
return `${day}天${String(hr).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
// 新用户倒计时
const startNewUserCountdown = () => {
if (newUserTime.value.countdownSec <= 0) return;
const updateTime = () => {
const formatted = formatTime(newUserTime.value.countdownSec);
newUserTime.value.hr = formatted.hr;
newUserTime.value.min = formatted.min;
newUserTime.value.sec = formatted.sec;
if (newUserTime.value.countdownSec > 0) {
newUserTimer = setTimeout(() => {
newUserTime.value.countdownSec--;
updateTime();
}, 1000);
}
};
updateTime();
};
// 活动倒计时
const startActiveCountdown = () => {
if (activeTime.value.countdownSec <= 0) return;
const updateTime = () => {
activeTime.value.timeStr = formatActiveTime(activeTime.value.countdownSec);
if (activeTime.value.countdownSec > 0) {
activeTimer = setTimeout(() => {
activeTime.value.countdownSec--;
updateTime();
}, 1000);
}
};
updateTime();
};
// 限时特惠倒计时
const startSpecialOfferCountdown = () => {
if (specialOfferTime.value.countdownSec <= 0) return;
const updateTime = () => {
const formatted = formatTime(specialOfferTime.value.countdownSec);
specialOfferTime.value.hr = formatted.hr;
specialOfferTime.value.min = formatted.min;
specialOfferTime.value.sec = formatted.sec;
if (specialOfferTime.value.countdownSec > 0) {
specialOfferTimer = setTimeout(() => {
specialOfferTime.value.countdownSec--;
updateTime();
}, 1000);
}
};
updateTime();
};
// 付费分层 playPage 倒计时
const startPaymentPlayPageCountdown = () => {
clearTimeout(paymentPlayPageTimer);
if (paymentPlayPageTime.value.countdownSec <= 0) {
paymentPlayPageTime.value.hr = '';
paymentPlayPageTime.value.min = '';
paymentPlayPageTime.value.sec = '';
return;
}
const updateTime = () => {
const formatted = formatTime(paymentPlayPageTime.value.countdownSec);
paymentPlayPageTime.value.hr = formatted.hr;
paymentPlayPageTime.value.min = formatted.min;
paymentPlayPageTime.value.sec = formatted.sec;
if (paymentPlayPageTime.value.countdownSec > 0) {
paymentPlayPageTimer = setTimeout(() => {
paymentPlayPageTime.value.countdownSec--;
updateTime();
}, 1000);
} else {
paymentCountdownExpired.value = true;
}
};
updateTime();
};
// 新用户 / 限时特惠 / 活动倒计时是否有任一可展示
const hasLegacyCountdown = computed(() => {
// if (isVip) return false;
// const hasNewUser = isNewUser && newUserTime.value.countdownSec > 0;
// const hasSpecialOffer = specialOfferTime.value.countdownSec > 0;
const hasActivity =
!!configStore.appConfig.bannerJump?.[1]?.banner && activeTime.value.countdownSec > 0;
return hasActivity;
});
const paymentPlayPageImg = computed(() =>
resolvePaymentConfigImg(configStore.appConfig?.paymentStatusPopupConfig?.playPage),
);
const paymentDiscountDeadline = computed(() =>
parseLastDiscountTime(configStore.appConfig?.paymentStatusPopupConfig?.lastDiscountTime),
);
const paymentCountdownSec = computed(() => {
if (!paymentDiscountDeadline.value || paymentCountdownExpired.value) return 0;
const remaining = Math.floor((paymentDiscountDeadline.value - Date.now()) / 1000);
return remaining > 0 ? remaining : 0;
});
const showPaymentCountdown = computed(() => paymentCountdownSec.value > 0);
const showPaymentPlayPage = computed(() => {
// if (hasLegacyCountdown.value) return false;
if (!isPaymentPlayPageStatus(configStore.appConfig?.paymentStatusPopup)) return false;
if (!hasAnyPaymentConfigField(configStore.appConfig?.paymentStatusPopupConfig, ['playPage', 'vipCard'])) {
return false;
}
// if (paymentDiscountDeadline.value && paymentCountdownSec.value <= 0) return false;
return !!paymentPlayPageImg.value;
});
const paymentVipCardId = computed(() => configStore.appConfig?.paymentStatusPopupConfig?.vipCard || '');
// 当前锁定的显示类型
const lockedDisplayType = ref<string | null>(null);
// 计算显示哪个倒计时(选择剩余时间最短的,但一旦选择就锁定直到结束)
const displayType = computed(() => {
const times = [];
//检查新用户倒计时
// if (isNewUser && newUserTime.value.countdownSec > 0 && !isVip) {
// times.push({ type: 'newUser', sec: newUserTime.value.countdownSec });
// }
// // 检查限时特惠倒计时
// if (specialOfferTime.value.countdownSec > 0 && !isVip) {
// times.push({ type: 'specialOffer', sec: specialOfferTime.value.countdownSec });
// }
// 检查活动倒计时
if (
configStore.appConfig.bannerJump &&
configStore.appConfig.bannerJump[1]?.banner &&
activeTime.value.countdownSec > 0
) {
times.push({ type: 'activity', sec: activeTime.value.countdownSec });
}
// if (isVip && userInfo.vipLevel == 1) {
// times.push({ type: 'vipBox', sec: 0 });
// }
// 如果没有有效的倒计时,返回 null
if (times.length === 0) {
lockedDisplayType.value = null;
return null;
}
// 如果当前锁定的类型仍然有效,继续使用它
if (lockedDisplayType.value) {
const lockedType = times.find((t) => t.type === lockedDisplayType.value);
if (lockedType && lockedType.sec > 0) {
return lockedDisplayType.value;
}
// 锁定的类型已失效,清除锁定
lockedDisplayType.value = null;
}
// 返回剩余时间最短的倒计时类型,并锁定它
times.sort((a, b) => a.sec - b.sec);
const selectedType = times[0].type;
lockedDisplayType.value = selectedType;
return selectedType;
});
const handleToMember = (vipCardId?: string) => {
router.push(`/member${vipCardId ? `?id=${vipCardId}` : ''}`);
};
// 处理特惠 banner 点击事件
const handleSpecialOfferClick = () => {
// 根据特惠类型获取对应的 VIP 卡 ID
const vipCardId =
props.specialOfferType === 'newbie'
? configStore.appConfig?.specialBannerVipCardId
: configStore.appConfig?.backBannerVipCardId;
emit('openSpecialOffer', vipCardId);
};
// 监听 props 变化,重新初始化倒计时
watch(
() => props.newUserCountdownSec,
(newVal) => {
if (newVal !== newUserTime.value.countdownSec) {
clearTimeout(newUserTimer);
newUserTime.value.countdownSec = newVal;
startNewUserCountdown();
}
},
);
watch(
() => props.activeCountdownSec,
(newVal) => {
if (newVal !== activeTime.value.countdownSec) {
clearTimeout(activeTimer);
activeTime.value.countdownSec = newVal;
startActiveCountdown();
}
},
);
watch(
() => props.specialOfferCountdownMs,
(newVal) => {
const newSec = Math.floor(newVal / 1000);
if (newSec !== specialOfferTime.value.countdownSec) {
clearTimeout(specialOfferTimer);
specialOfferTime.value.countdownSec = newSec;
startSpecialOfferCountdown();
}
},
);
watch(
paymentCountdownSec,
(newSec) => {
if (newSec !== paymentPlayPageTime.value.countdownSec) {
clearTimeout(paymentPlayPageTimer);
paymentPlayPageTime.value.countdownSec = newSec;
startPaymentPlayPageCountdown();
}
},
{ immediate: true },
);
watch(
() => configStore.appConfig?.paymentStatusPopupConfig?.lastDiscountTime,
() => {
paymentCountdownExpired.value = false;
},
);
// 组件挂载时启动倒计时
onMounted(() => {
startNewUserCountdown();
startActiveCountdown();
startSpecialOfferCountdown();
});
// 组件卸载时清理定时器
onUnmounted(() => {
clearTimeout(newUserTimer);
clearTimeout(activeTimer);
clearTimeout(oldReturnTimer);
clearTimeout(specialOfferTimer);
clearTimeout(paymentPlayPageTimer);
});
</script>
<style lang="scss" scoped>
.bannerBox {
height: 46px;
width: 100%;
position: relative;
.bannerImg {
height: 100%;
width: 100%;
}
.timeBox {
height: 22px;
width: 110px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.5);
position: absolute;
top: 50%;
right: 16px;
transform: translate(0, -50%);
z-index: 1;
color: rgba(255, 255, 255, 0.9);
text-align: center;
font-size: 14px;
line-height: 22px;
}
}
.newUserBg {
height: 46px;
font-size: 12px;
font-weight: 500;
padding-left: 82px;
padding-right: 12px;
color: #e24359;
position: relative;
.newUserBannerImg {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
}
.hour,
.minute,
.second {
font-size: 12px;
color: white;
position: absolute;
z-index: 10;
left: 0;
top: 0;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
background: rgba(27, 27, 27, 1);
border-radius: 3px;
border: 0.5px solid rgba(255, 227, 129, 1);
}
.hour {
left: 162px;
top: 50%;
transform: translate(0, -50%);
}
.minute {
left: 194.5px;
top: 50%;
transform: translate(0, -50%);
}
.second {
left: 227px;
top: 50%;
transform: translate(0, -50%);
}
.nowOpenBtn {
height: 21px;
width: 64px;
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
}
.hourColon{
position: absolute;
left: 187px;
top: 50%;
transform: translate(0, -50%);
color: #fff;
}
.minuteColon{
position: absolute;
left: 219px;
top: 50%;
transform: translate(0, -50%);
color: #fff;
}
}
.specialOfferBg {
.hour,
.minute,
.second {
// color: rgba(123, 0, 0, 1);
color: #fff;
}
}
.paymentPlayPageBg {
.hour,
.minute,
.second {
color: #fff;
}
}
.vipBox {
height: 46px;
font-size: 14px;
font-weight: 500;
padding-right: 12px;
color: rgba(255, 190, 93, 1);
position: relative;
background: url('@/assets/images/png/up_vip_bg.png') no-repeat;
background-size: 100% 100%;
display: flex;
align-items: center;
position: relative;
.vipIcon {
width: 15px;
height: 14px;
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
}
.vipContent {
padding-left: 30px;
.vipTime {
font-size: 10px;
font-weight: 400;
margin-left: 10px;
}
}
.vipName {
font-size: 14px;
font-weight: 500;
}
.vipBtn {
width: 72px;
height: 24px;
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
}
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="img-decypt">
<van-image :src="decyImg" class="vanImage" :fit="fit" :round="round" @load="emit('load')" @error="emit('error')">
<template v-slot:loading>
<div class="loading" v-if="!isSplash" />
<div class="splash-loading-img" v-else></div>
</template>
<template v-slot:error>
<div class="loading" v-if="!isSplash" />
<div class="splash-error-img" v-else></div>
</template>
<slot></slot>
</van-image>
</div>
</template>
<script setup lang="ts">
import { Image as VanImage } from 'vant';
import { ref, watch, type PropType } from 'vue';
import { useConfigStore } from '@/stores/config';
import AppApi from '@/service/app';
const props = defineProps({
fit: {
type: String as PropType<'cover' | 'contain'>,
default: 'cover',
},
//图片地址
src: {
type: String,
required: true,
default() {
return '';
},
},
isNeedDecypt: {
//是否需要解密
type: Boolean,
required: false,
default() {
return true;
},
},
isSplash: {
//是否来自启动页
type: Boolean,
required: false,
default() {
return false;
},
},
round: {
//图片是否显示为圆形
type: Boolean,
required: false,
default() {
return false;
},
},
});
let decyImg = ref('');
const configStore = useConfigStore();
const emit = defineEmits(['load', 'error']);
const init = () => {
decyImg.value = '';
if (!props.src) {
return;
}
if (!props.isNeedDecypt) {
decyImg.value = props.src;
return;
}
if (/^(blob:|data:)/.test(props.src)) {
decyImg.value = props.src;
return;
}
try {
const isHttp = /^https?:\/\//.test(props.src);
const imgSrc = isHttp ? props.src : configStore.sourceInfo.IMAGE + '/' + props.src;
AppApi.imgDecypt(imgSrc)
.then((url: any) => {
decyImg.value = url;
})
.catch((err) => {
console.error('imgDecypt error', err);
});
} catch (error) {
console.error('imgDecypt catch', error);
}
};
watch(
() => [props.src, props.isNeedDecypt],
() => {
init();
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
:deep(.van-image__loading) {
// background-color: #2d2d2d !important;
overflow: hidden;
width: 100%;
height: 100%;
}
.splash-loading-img,
.splash-error-img {
width: 100%;
height: 100%;
background: url('../../assets/images/webp/splash.webp') no-repeat;
background-size: cover;
position: relative;
}
.img-decypt {
width: 100%;
height: 100%;
.vanImage {
width: 100%;
height: 100%;
// background-color: #2d2d2d !important;
.loading {
width: 100%;
height: 100%;
background: url('./../../assets/images/png/placeholder.png') no-repeat;
background-size: 50% auto;
background-position: 50% 50%;
background-color: #343434;
}
:deep(.van-image__error) {
// background-color: #2d2d2d !important;
overflow: hidden;
width: 100%;
}
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<van-popup v-model:show="showDrawer" position="right" z-index="99999" @close="close">
<div class="drawerBox">
<div class="title-box flex-center-between">
导航列表
<span class="reset" @click.stop="resetList">恢复默认</span>
</div>
<div class="tips">长按拖动排序</div>
<draggable
class="menuBox overflow-y-auto"
v-model="list"
@start="drag = true"
@end="drag = false"
@change="change"
item-key="id"
>
<template #item="{ element, index }">
<div
class="menu"
:class="activeIndex == index ? 'menued' : undefined"
@click.stop="goTab(index, element.moduleName)"
>
{{ element.moduleName }}
</div>
</template>
</draggable>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { useGlobalStore } from '@/stores/global';
import draggable from 'vuedraggable-es';
const props = defineProps({
elType: String,
});
const globalStore = useGlobalStore();
const list = ref<Array<Types.Home.HomeTabItem>>([]);
switch (props.elType) {
case 'home':
list.value = [...globalStore.homePageList];
break;
case 'darkNet':
list.value = [...globalStore.darkNetPageList];
break;
}
const showDrawer = computed(() => {
let show = false;
switch (props.elType) {
case 'home':
show = globalStore.showHomeDrawer;
break;
case 'darkNet':
show = globalStore.showDarkNetDrawer;
break;
}
return show;
});
const activeIndex = computed(() => {
let index;
switch (props.elType) {
case 'home':
index = globalStore.activeIndex;
break;
case 'darkNet':
index = globalStore.darkNetActiveIndex;
break;
}
return index;
});
const goTab = (index: number, title: any) => {
switch (props.elType) {
case 'home':
globalStore.setPreIndex(globalStore.activeIndex, props.elType as string);
break;
case 'darkNet':
globalStore.setPreIndex(globalStore.preDarkNetIndex, props.elType as string);
break;
}
globalStore.setActiveIndex(index, props.elType as string);
nextTick(() => {
close();
});
};
const drag = ref(false);
let isChange = false;
const change = (move: any) => {
isChange = true;
};
const close = () => {
globalStore.setShowDrawer(false, props.elType as string);
if (isChange) {
switch (props.elType) {
case 'home':
localStorage.setItem('homePageIds', list.value.map((item: any) => item.id).join(','));
break;
case 'darkNet':
localStorage.setItem('darkNetPageIds', list.value.map((item: any) => item.id).join(','));
break;
}
globalStore.updateHomePageList(list.value, props.elType as string);
}
};
const resetList = () => {
const { homePage, deepWeb } = globalStore.tabObj;
switch (props.elType) {
case 'home':
if (homePage) {
list.value = homePage || [];
}
break;
case 'darkNet':
if (homePage) {
list.value = deepWeb || [];
}
break;
}
nextTick(() => {
close();
});
};
</script>
<style lang="scss" scoped>
:deep(.van-popup) {
height: 100%;
overflow: hidden;
}
:deep(.van-overlay) {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.drawerBox {
width: 218px;
height: 100vh;
background-color: var(--app-surface);
backdrop-filter: blur(6.5px);
padding: 56px 10px 0px;
.title-box {
color: white;
font-size: 16px;
font-weight: 500;
.reset {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
.tips {
margin-top: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.menuBox {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 6px;
grid-row-gap: 10px;
max-height: 100%;
padding-top: 30px;
padding-bottom: 80px;
.menu {
height: 40px;
width: 84px;
border-radius: 6px;
text-align: center;
line-height: 40px;
font-size: 14px;
color: white;
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 12px rgba(15, 15, 15, 0.05);
}
.menued {
color: var(--app-primary-text);
background: var(--app-primary);
}
}
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<template>
<div class="richText" @click.stop="delegateClick">
<template v-for="(node, index) in parsedContent" :key="index">
<!-- 动态渲染组件 -->
<component v-if="node.type === 'component'" :is="node.name" v-bind="node.props" @lz-image-click="lzImageClick" />
<!-- 渲染普通 HTML -->
<div class="text_box" v-else v-html="node.html" />
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import LazyImg from '../lazyImg/index.vue';
import { innerJump, compatibleAppStoreJump } from '@/utils/index';
// Props
const props = defineProps({
html: {
type: String,
required: true,
},
});
// 解析 HTML 内容
const parseHtml = (html: string) => {
const regex = /<img\s+[^>]*src="([^"]+)"[^>]*>/g;
const segments: Array<{ type: 'div' | 'component'; html?: string; name?: any; props?: any }> = [];
let lastIndex = 0;
let match;
// 遍历匹配 <img> 标签
while ((match = regex.exec(html))) {
// 保存普通 HTML
if (match.index > lastIndex) {
segments.push({
type: 'div',
html: html.slice(lastIndex, match.index),
});
}
// 替换 img 为 LazyImg 组件
segments.push({
type: 'component',
name: LazyImg,
props: { src: match[1], borderRadius: 8 },
});
lastIndex = regex.lastIndex;
}
// 添加剩余的普通 HTML
if (lastIndex < html.length) {
segments.push({
type: 'div',
html: html.slice(lastIndex),
});
}
return segments;
};
const updatedText = ref(props.html);
// a标题标签替换为span标签
updatedText.value = updatedText.value.replace(
/<a href="(.*?)">(.*?)<\/a>/g,
`<span style="color: #ffc000;" data-href="$1">$2</span>`,
);
// 正则替换 font 为 span
updatedText.value = updatedText.value.replace(/<font([^>]*)>(.*?)<\/font>/gi, (match, attributes, content) => {
// 提取 font 标签的各属性并构造 style
const inlineStyles: string[] = [];
// 提取颜色
const colorMatch = attributes.match(/color=["'](.*?)["']/i);
if (colorMatch) {
inlineStyles.push(`color:${colorMatch[1]}`);
}
// 提取字体大小
const sizeMatch = attributes.match(/size=["'](.*?)["']/i);
if (sizeMatch) {
const fontSizeMap: any = {
'1': '14',
'2': '17',
'3': '20',
'4': '23',
'5': '26',
'6': '29',
'7': '32',
};
inlineStyles.push(`font-size:${fontSizeMap[sizeMatch[1]] || sizeMatch[1]}px`);
}
// 提取字体样式(font-family)
const faceMatch = attributes.match(/face=["'](.*?)["']/i);
if (faceMatch) {
inlineStyles.push(`font-family:${faceMatch[1]}`);
}
// 提取现有 style 属性
const styleMatch = attributes.match(/style=["'](.*?)["']/i);
if (styleMatch) {
inlineStyles.push(styleMatch[1]);
}
// 合并生成最终的 style 属性
const finalStyle = inlineStyles.join('; ');
// 返回新的 span 标签
return `<span style="${finalStyle}">${content}</span>`;
});
// 解析后的内容
const parsedContent = computed(() => parseHtml(updatedText.value));
// 处理点击事件
const emit = defineEmits(['lz-image-click']);
const lzImageClick = (src: string) => {
emit('lz-image-click', src);
};
//span 标签跳转
const delegateClick = (event: any) => {
const target = event.target.closest('span[data-href]');
if (event.target.tagName === 'SPAN' && target) {
const href = target.getAttribute('data-href');
console.log(href);
const index = href.indexOf('yinseinner://');
if (index != -1) {
innerJump(href.substring(13));
return;
}
compatibleAppStoreJump(href);
}
};
</script>
<style lang="scss" scoped>
.richText {
margin-bottom: 20px;
font-size: 14px;
color: rgb(206, 206, 206);
line-height: 1.5;
font-weight: 500;
word-wrap: break-word;
word-break: break-all;
:deep(a) {
color: #3e64ea;
}
:deep(b) {
color: #fff;
font-weight: bold;
}
:deep(.lazy_img) {
margin-bottom: 8px;
}
.text_box {
margin-bottom: 8px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
all: revert;
color: #fff;
}
:deep(i) {
all: revert;
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div class="empty_warp">
<div class="empty_box">
<div class="noDataIcon"></div>
<div class="noData">暂无数据</div>
<p>请稍后再试~</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.empty_warp {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding-top: 60px;
padding-bottom: 120px;
.noDataIcon {
width: 170px;
height: 120px;
background: url('./../../assets/images/png/nodata3.png') no-repeat;
background-size: 100% 100%;
}
.noData {
font-size: 18px;
text-align: center;
color: rgb(102, 102, 102);
}
p {
padding-top: 8px;
text-align: center;
color: rgb(153, 153, 153);
font-size: 11px;
}
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<van-overlay :show="isShow" z-index="999">
<div class="warp">
<div class="content_warp app-popup-surface">
<div class="title"></div>
<div class="content app-popup-body">
<h2>APP线路检测失败</h2>
<h3>解决方案:</h3>
<p>1.请检测您的网络是否正常,手机是否开了VPN或10分钟后再尝试访问。</p>
<p>
2.前往官网地址
<span @click="download2">91PORN01.CC</span>
下载最新版。
</p>
<p>
官方备用:
<span @click="download">91PORN01.CC</span>
</p>
<p>3.前往Telegram官方交流群</p>
<div class="footer-box">
<div class="btn app-popup-primary" @click="toPotato">前往群聊</div>
</div>
</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { compatibleAppStoreJump } from '@/utils/index';
const isShow = ref(true);
const download = () => {
compatibleAppStoreJump('https://91porn01.cc');
};
const download1 = () => {
compatibleAppStoreJump('https://lolii.fm');
};
const download2 = () => {
compatibleAppStoreJump('https://91porn01.cc');
};
const toPotato = () => {
compatibleAppStoreJump('https://t.me/hougong9527');
};
</script>
<style lang="scss" scoped>
.warp {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.content_warp {
width: 311px;
background: url('@/assets/images/png/systemPromptBg.png') no-repeat;
background-size: 100% 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
border-radius: 12px;
padding: 30px 0 10px;
.title {
height: 24px;
width: 96.6px;
background: url('@/assets/images/png/systemPromptTitle.png') no-repeat;
background-size: 100% 100%;
margin: 0 auto;
}
.content {
margin: 0 auto;
font-size: 14px;
border-radius: 20px;
padding: 18px 22px 27px;
color: #fff;
line-height: 24px;
font-weight: 400;
font-family: 'PingFang SC';
h2 {
color: #f52c56;
line-height: 20px;
}
h3 {
margin: 12px 0;
line-height: 20px;
}
p {
span {
color: #3476ff;
}
}
.footer-box {
display: flex;
align-items: center;
.btn {
font-family: 'PingFang SC';
height: 42px;
width: 230px;
background: #f68804;
border-radius: 3px;
text-align: center;
line-height: 42px;
font-size: 14px;
font-weight: 400;
margin: 19px auto 0;
color: #fff;
}
}
}
}
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div class="fiveGridAdv">
<div class="fiveGrid" @touchstart.stop @touchmove.stop @touchend.stop v-if="state.advList.length > 0" ref="advRef">
<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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } 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,
},
);
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);
};
</script>
<style scoped lang="scss">
.fiveGridAdv {
.fiveGrid {
display: flex;
overflow-x: auto;
margin: 0 0 18px;
padding-left: 10px;
.rectAdvBox {
width: 60px;
margin-right: 6px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 12px;
overflow: hidden;
}
.rectName {
font-size: 12px;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
.rectAdvBox:last-child {
margin-right: 9px;
}
}
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div class="fiveGridAdv">
<div class="fiveGrid" v-if="advList.length > 0" ref="advRef">
<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>
</div>
</div>
</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(
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);
};
</script>
<style scoped lang="scss">
.fiveGridAdv {
.fiveGrid {
display: flex;
overflow-x: auto;
margin: 18px 0;
.rectAdvBox {
width: 60px;
margin-right: 12px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 12px;
overflow: hidden;
}
.rectName {
font-size: 12px;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
.rectAdvBox:last-child {
margin-right: 9px;
}
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<!-- 浮窗 -->
<div
class="square"
id="moveDiv"
:class="{ closeSquare: state.isClose, squareHome: state.isHome }"
@mousedown="down()"
@touchstart="down()"
@mousemove.prevent.stop="move()"
@touchmove.prevent.stop="move()"
@mouseup="end()"
@touchend="end()"
@click="jumpUrl()"
v-if="state.advObj"
>
<LazyImg class="squareImg" :src="state.advObj?.cover" />
<div class="close" @click.stop="close" v-if="!state.isClose && state.showClose"></div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { AdType } from '@/enums/app';
import { getAdItem } from '@/utils/getConfig';
import { jumpAdv } from '@/utils/index';
const props = withDefaults(
defineProps<{
showClose?: boolean;
isHome?: boolean;
}>(),
{
showClose: () => true,
isHome: () => false,
},
);
const state = reactive({
showClose: props.showClose,
isHome: props.isHome,
flags: false,
position: {
x: 0,
y: 0,
},
nx: 0,
ny: 0,
dx: 0,
dy: 0,
xPum: 0,
yPum: 0,
advObj: <any>{},
isClose: false,
});
// 初始化广告
init();
function init() {
state.advObj = getAdItem(AdType.FLOAT)[0];
}
// 实现移动端拖拽
function down() {
state.flags = true;
let touch;
if ((event as any).touches) {
touch = (event as any).touches[0];
} else {
touch = event;
}
state.position.x = touch.clientX;
state.position.y = touch.clientY;
state.dx = (document.getElementById('moveDiv') as HTMLElement).offsetLeft;
state.dy = (document.getElementById('moveDiv') as HTMLElement).offsetTop;
}
function move() {
if (state.flags) {
let touch;
if ((event as any).touches) {
touch = (event as any).touches[0];
} else {
touch = event;
}
state.nx = touch.clientX - state.position.x;
state.ny = touch.clientY - state.position.y;
state.xPum = state.dx + state.nx;
state.yPum = state.dy + state.ny;
//添加限制:只允许在屏幕内拖动
const maxWidth = document.body.clientWidth - 90; //屏幕宽度减去悬浮框宽高
const maxHeight = document.body.clientHeight - 90;
if (state.xPum < 0) {
//屏幕x限制
state.xPum = 0;
} else if (state.xPum > maxWidth) {
state.xPum = maxWidth;
}
if (state.yPum < 0) {
//屏幕y限制
state.yPum = 0;
} else if (state.yPum > maxHeight - 100) {
state.yPum = maxHeight - 100;
}
(document.getElementById('moveDiv') as HTMLElement).style.left = state.xPum + 'px';
(document.getElementById('moveDiv') as HTMLElement).style.top = state.yPum + 'px';
//阻止页面的滑动默认事件
// document.addEventListener(
// "touchmove",
// function () {
// // 1.2 如果碰到滑动问题,请注意是否获取到 touchmove
// (event as any).preventDefault(); //jq 阻止冒泡事件
// // event.stopPropagation(); // 如果没有引入jq 就用 stopPropagation()
// },
// { passive: true }
// );
}
}
//鼠标释放时候的函数
function end() {
state.flags = false;
}
function close() {
state.isClose = true;
}
function jumpUrl() {
if (state.isClose) return (state.isClose = false);
jumpAdv(state.advObj);
}
</script>
<style lang="scss" scoped>
.square {
height: 90px;
width: 90px;
z-index: 1000;
position: fixed;
top: 50px;
left: 5px;
border-radius: 10px;
overflow: hidden;
:deep(img) {
border-radius: 10px;
}
.squareImg {
height: 90px;
width: 90px;
}
.close {
position: absolute;
right: 5px;
top: 5px;
height: 20px;
width: 20px;
border-radius: 50%;
background: url('../../assets/images/png/appsClose.png') no-repeat;
background-size: 100% 100%;
}
}
.squareHome {
top: unset;
left: unset;
bottom: 100px;
right: 14px;
}
.closeSquare {
width: 20px;
border-radius: 20px;
.squareImg {
width: 20px;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="fullScreenGallery">
<van-swipe
ref="swipeRef"
class="my-swipe"
indicator-color="white"
:loop="isLoop"
:autoplay="autoTime"
:lazy-render="true"
@change="change"
>
<van-swipe-item class="my-swier-item" v-for="url in videoInfo.seriesCover" :key="url" @click="jumpAdv(videoInfo)">
<div class="fullScreen_item" @touchstart="touchstart" @touchmove="touchmove">
<AnimationImg :src="url" />
</div>
</van-swipe-item>
</van-swipe>
</div>
</template>
<script lang="ts" setup>
import type { SwipeInstance } from 'vant';
import { ref } from 'vue';
import { jumpAdv } from '@/utils/index';
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
}>();
const autoTime = ref(2000);
const isLoop = ref(true);
const swipeRef = ref<SwipeInstance>();
function change(index: number) {
console.log(index);
}
const startX = ref();
const startY = ref();
function touchstart(e: any) {
// 如果你要阻止点击事件,请反注释下一行代码
// e.preventDefault()
startX.value = e.touches[0].clientX;
startY.value = e.touches[0].clientY;
}
function touchmove(e: any) {
// e.preventDefault()
let moveX = e.touches[0].clientX;
if (startX.value - moveX != 0) {
// 右滑触发
// do something
isLoop.value = false;
}
}
</script>
<style lang="scss" scoped>
:deep(.van-swipe__indicator) {
width: 28px;
height: 4px;
border-radius: 2px;
}
:deep(.van-swipe__indicators) {
bottom: 1%;
}
.fullScreenGallery {
height: 100%;
width: 100%;
display: flex;
align-items: center;
.my-swipe,
.van-swipe-item,
.my-swier-item {
height: 100%;
width: 100%;
.fullScreen_item {
width: 100%;
height: 100%;
:deep(.van-image) {
display: flex;
align-items: center;
background: #000000 !important;
}
:deep(img) {
height: auto;
}
}
}
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<Teleport to="body">
<van-popup :show="isShow" z-index="2001" position="bottom" @click-overlay="emit('close')">
<div class="wrapper flex-center-center">
<div class="content" :class="[props.theme]" @click.stop="void 0">
<div class="line_top" />
<div class="title">{{ props.title }}</div>
<div class="priceList">
<div class="leader">作品解锁</div>
<div class="price gold flex-vcenter">
<!-- <SvgIcon name="gold" /> -->
{{ gold }} 金币
</div>
</div>
<template v-if="props.isSale">
<div class="priceList">
<div class="leader">作品原价</div>
<div class="price through flex-vcenter">{{ props.videoInfo.originCoins }}金币</div>
</div>
<div v-if="userInfo.isVip" class="priceList vip">
<div class="leader">您当前享受{{ sale }}折扣优惠</div>
<div class="price underline flex-vcenter" @click.stop="$router.push('member'), emit('close')">
升级会员
</div>
</div>
<div v-else class="priceList vip">
<div class="leader">您当前不享受免费观看</div>
<div class="price underline flex-vcenter" @click.stop="$router.push('member'), emit('close')">
购买VIP享免费观看
</div>
</div>
</template>
<div class="priceList split">
<div class="leader">我的金币</div>
<div class="price flex-vcenter">
{{ recharge }}
<!-- 100 -->
<span class="go" @click.stop="$router.push('wallet'), emit('close')">充值</span>
</div>
</div>
<!-- <div class="couponBox" v-if="userGoldVideoCoupon.length">
<div
class="couponItem flex-center-between"
:class="index === changeGoldVideoCouponIndex ? 'active' : ''"
v-for="(item, index) in userGoldVideoCoupon"
:key="index"
@click.stop="selectCoupon(item, index)"
>
<div>观影券({{ item.num }}金币)</div>
<div class="count">剩余{{ item.newcount }}张</div>
<div class="checkbox"></div>
</div>
</div> -->
<div class="btnBox flex-center-between">
<div class="btn flex-center-center" :class="!showVipBtn ? 'themeColor' : ''" @click="unlockNow">
{{ !showVipBtn ? `${gold}金币/立即支付` : `${gold}金币支付` }}
</div>
<div v-if="showVipBtn" class="btn vip-btn flex-center-center" @click.stop="clickVipHandle">
开通会员免费看
</div>
</div>
</div>
</div>
</van-popup>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, nextTick } from 'vue';
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
const props = withDefaults(
defineProps<{
videoInfo: Types.Video.VInfosItem;
isOpen: boolean;
productType?: ProductType;
theme?: string; //black 为黑色主题,默认为白色主题
title?: string; //弹窗标题
isSale?: boolean; //是否显示折扣区
showVipBtn?: boolean; // 是否显示购买vip按钮
membershipActivation?: boolean;
}>(),
{
title: '购买作品',
theme: 'black',
productType: ProductType.VIDEO,
isSale: true,
showVipBtn: false,
membershipActivation: false,
},
);
const userStore = useUserStore();
const userInfo = userStore.userInfo;
const router = useRouter();
const { openLoading, closeLoading } = useLoading();
const emit = defineEmits(['close', 'buySuccess', 'membershipActivationShow']);
const isShow = computed(() => {
return props.isOpen;
});
// const changeGoldVideoCouponIndex = ref(-1);
// const userGoldVideoCoupon = computed(() => {
// const mergedData: any = [];
// (userInfo.userGoldVideoCoupon || [])
// .filter((item: any) => !item.used)
// .forEach((item: any) => {
// const existingItem = mergedData.find((element: any) => element.num === item.num);
// if (existingItem) {
// existingItem.newcount += 1;
// } else {
// item.newcount = 1;
// mergedData.push(item);
// }
// });
// return mergedData;
// });
const gold = computed(() => {
// return userInfo.isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
return props.isSale ? props.videoInfo.coins : props.videoInfo.originCoins;
});
const sale = computed(() => {
return userStore.userInfo.payVidDiscount;
});
const recharge = computed(() => {
const balance = ((userStore.walletInfo.amount || 0) * 100 + (userStore.walletInfo.income || 0) * 100) / 100;
return balance === 0 ? '0' : balance.toFixed(1);
});
// const selectCoupon = (item: any, index: number) => {
// if (changeGoldVideoCouponIndex.value === index) {
// changeGoldVideoCouponIndex.value = -1;
// return;
// }
// let money = gold.value;
// if (item.num < money) return showToast('观影券面值过小,请选择其他支付方式');
// changeGoldVideoCouponIndex.value = index;
// let productID = props.videoInfo.id;
// if (!productID) {
// showToast('购买出错~');
// return;
// }
// };
const clickVipHandle = () => {
emit('close');
if (props.membershipActivation) {
emit('membershipActivationShow');
return;
}
router.push('/member');
};
const unlockNow = () => {
try {
openLoading();
// const money =
// changeGoldVideoCouponIndex.value > -1 ? userGoldVideoCoupon.value[changeGoldVideoCouponIndex.value].num : 0;
let productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
VideoApi.videoBuying({
productType: props.productType, //props.productType ||
productID: productID,
goldVideoCouponNum: 0, //money || 0,
})
.then(async () => {
showToast('购买成功');
userStore.updateWallet();
emit('buySuccess');
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
emit('close');
nextTick(() => {
router.push({
path: '/wallet',
query: {
activeTab: 1,
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
});
}
});
} finally {
closeLoading();
}
};
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
.content {
color: #000;
width: 100%;
padding: 18px 10px 0;
background-color: #fff;
position: relative;
border-radius: 10px 10px 0 0;
padding-bottom: 24px;
.line_top {
width: 36px;
height: 5px;
background: #f5f5f5;
margin: 0 auto;
border-radius: 99px;
}
.title {
padding: 18px 0;
font-size: 20px;
font-weight: 500;
text-align: center;
}
.priceList {
display: flex;
align-items: center;
justify-content: space-between;
margin: 18px 0;
&.vip {
.leader,
.price {
color: #ac7e6e;
}
}
&.split {
border-top: $borderSplit;
margin-top: 18px;
padding-top: 18px;
}
.leader {
color: #757575;
font-size: 14px;
}
.price {
font-size: 14px;
font-weight: 500;
> svg {
height: 18px;
width: 18px;
margin-right: 10px;
}
.go {
text-decoration-line: underline;
color: #ac7e6e;
margin-left: 12px;
}
}
.gold {
color: #ff9000;
}
.through {
text-decoration: line-through;
color: rgba(255, 255, 255, 0.55);
}
.underline {
text-decoration: underline;
color: rgba(0, 0, 0, 0.5);
}
}
&.black {
background-color: #0f0f0f;
color: rgba(255, 255, 255, 0.9);
.line_top {
background: rgba(255, 255, 255, 0.2);
}
.leader {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
}
.text {
font-size: 20px;
text-align: center;
margin: 30px 0 18px;
span {
font-size: 28px;
color: $themeColor;
}
&.line {
display: flex;
justify-content: center;
.text-center {
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
bottom: 30%;
height: 1px;
width: 100%;
background: rgba(255, 255, 255, 0.1);
}
}
}
}
p {
font-size: 14px;
text-align: center;
opacity: 0.9;
}
.btnBox {
margin-top: 26px;
.btn {
height: 44px;
width: 100%;
background: linear-gradient(165deg, #f8f3ae 0%, #ddac44 100%);
text-align: center;
line-height: 44px;
font-size: 16px;
font-weight: 500;
color: #7b0000;
border-radius: 3px;
}
.vip-btn {
background: var(--app-primary);
color: var(--app-primary-text);
margin-left: 18px;
}
.themeColor {
background: var(--app-primary);
color: var(--app-primary-text);
}
}
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<van-overlay :show="isShow" z-index="2001" @click="emit('close')">
<div class="wrapper flex-center-center">
<div class="content">
<p>金币购买本视频解锁精彩完整版!</p>
<p style="color: #3f57b9">{{ videoInfo.originCoins }}金币</p>
<p>可用金币{{ (userWallet.amount * 100 + userWallet.income * 100) / 100 || 0 }}金币</p>
<div class="btnBox flex-center-between">
<div class="btn" @click="unlockNow(0)">立即购买</div>
<div class="btn" @click="$router.push('freeBenefits')">做任务得VIP</div>
</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { computed } from 'vue';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
isOpen: boolean;
}>();
const emit = defineEmits(['close', 'buySuccess']);
const userInfo = useUserStore().userInfo;
const userWallet = useUserStore().walletInfo;
const { openLoading, closeLoading } = useLoading();
const isShow = computed(() => {
return props.isOpen;
});
/**
* @description 视频购买
*/
const unlockNow = (money: any) => {
try {
openLoading();
let productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
VideoApi.videoBuying({ productType: ProductType.VIDEO, productID: productID, goldVideoCouponNum: money || 0 })
.then(async () => {
showToast('购买成功');
if (money) {
for (let i = 0; i < userInfo.goldVideoCoupon.length; i++) {
if (money == userInfo.goldVideoCoupon[i].gold) {
if (userInfo.goldVideoCoupon[i].count > 1) {
userInfo.goldVideoCoupon[i].count--;
} else {
userInfo.goldVideoCoupon.splice(i, 1);
}
}
}
}
useUserStore().updateWallet();
emit('buySuccess');
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
router.push({
path: '/wallet',
query: {
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
}
});
} finally {
closeLoading();
}
};
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
flex-direction: column;
.content {
width: 313px;
height: 293px;
background: url('@/assets/images/png/popVip.png') no-repeat;
background-size: 100% 100%;
padding: 135px 34px 0;
position: relative;
p {
font-size: 12px;
line-height: 26px;
text-align: center;
}
}
.btnBox {
width: 235px;
margin-top: 21px;
.btn {
height: 31px;
width: 92px;
background: #3476ff;
text-align: center;
line-height: 31px;
font-size: 11px;
border-radius: 4px;
}
}
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="header flex-center-between" :style="{ background: headerBg }">
<slot name="left">
<SvgIcon v-if="leftIcon" :name="leftIcon" class="back" :color="leftIconColor" @click="router.go(-1)" />
</slot>
<div v-if="title" class="title">{{ title }}</div>
<div v-else class="custom">
<slot></slot>
</div>
<div class="right-btn" v-if="rightIcon || rightTitle" @click="rightBtn">
<SvgIcon class="rightIcon" :name="rightIcon" v-if="rightIcon" />
<div v-if="rightTitle">{{ rightTitle }}</div>
</div>
<slot name="right"></slot>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const props: any = defineProps({
title: {
type: String,
required: true,
default() {
return '标题';
},
},
leftIcon: {
type: String,
default() {
return 'back_white';
},
},
leftIconColor: {
type: String,
default() {
return '';
},
},
rightTitle: {
type: String,
required: false,
},
rightIcon: {
type: String,
required: false,
},
path: {
type: String,
required: false,
},
isIcon: {
type: Boolean,
required: false,
default() {
return false;
},
},
headerBg: {
type: String,
default: '#000',
},
});
const emit = defineEmits(['rightFuc']);
const rightBtn = () => {
if (props.path) {
router.push(props.path);
} else {
emit('rightFuc');
}
};
</script>
<style lang="scss" scoped>
.header {
height: 44px;
padding: 0 14px;
// background: #000;
.back {
width: 24px;
height: 24px;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-text-primary);
font-size: 16px;
max-width: 220px;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
font-weight: 500;
}
.custom {
flex: 1;
height: 100%;
}
.right-btn {
color: #ffffff73;
font-size: 12px;
}
.rightIcon {
width: 24px;
height: 24px;
}
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<div class="headerAdv" v-if="state.advList.length" ref="advRef">
<!-- 5宫格广告 -->
<div class="fiveGrid" v-if="props.type === 'five'">
<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>
</div>
</div>
<AdvSwiper class="swiper" v-else-if="props.type === 'swiper'" :list="state.advList" height="18.933vw"></AdvSwiper>
<div class="rows-adv" v-else-if="props.type === 'rows'">
<div class="box" @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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } 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,
type: 'five',
length: 5,
},
);
const state = reactive({
advList: <any>[], // 广告数据
});
const init = () => {
const arr = getAdItem(props.advType) || [];
if (props.length > 0) {
state.advList = arr.slice(0, props.length);
} else {
if (props.random) {
const shuffled = arr.slice().sort(() => 0.5 - Math.random());
state.advList = shuffled.slice(0, 5);
} else {
state.advList = arr;
}
}
};
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);
};
</script>
<style scoped lang="scss">
.headerAdv {
width: 100%;
.fiveGrid {
margin: 8px 0 12px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-row-gap: 12px;
grid-column-gap: 10px;
box-sizing: border-box;
.rectAdvBox {
width: 60px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 12px;
overflow: hidden;
}
.rectName {
font-size: 12px;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
}
.swiper {
padding: 0 10px;
}
.rows-adv {
.box {
display: flex;
overflow-x: auto;
padding: 18px 0;
.rectAdvBox {
width: 60px;
margin-right: 6px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 12px;
overflow: hidden;
}
.rectName {
font-size: 12px;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
.rectAdvBox:last-child {
margin-right: 9px;
}
}
}
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<!--
@description 顶部搜索栏:首页按 Figma 展示搜索图标与轮播文字,其他页面保留原有入口。
@figma https://www.figma.com/design/ju3V3C1DiVqw9qK6gIpXVd?node-id=42199-184055
-->
<template>
<div class="header_search" :class="{ 'header_search--home': variant === 'home' }">
<div class="header_logo" />
<div class="search-input" @click.stop="toSearchPage">
<img
v-if="variant === 'home'"
class="search-icon"
src="@/assets/images/ui/header-search.svg"
alt=""
aria-hidden="true"
/>
<van-notice-bar :scrollable="false" v-if="searchHintWord.length" class="notice-bar">
<van-swipe
vertical
class="notice-swipe"
:autoplay="3000"
:touchable="false"
@change="noticeChange"
:show-indicators="false"
>
<van-swipe-item v-for="(item, index) in searchHintWord" :key="index">
{{ item }}
</van-swipe-item>
</van-swipe>
</van-notice-bar>
<span v-else class="search-placeholder">热搜短剧轮播</span>
</div>
<div class="search_box">
<template v-if="getTheme.holidays !== 'default'">
<div class="holidays_sign_icon" @click="router.push('/task_sign_day')"></div>
</template>
<template v-else>
<lazyImg
v-if="configStore.appConfig.signIcon"
:src="configStore.appConfig.signIcon"
class="sign_icon"
@click="router.push('/task_sign_day')"
/>
</template>
<!-- <div v-if="variant !== 'home'" class="menu" @click="emit('menuChange')" />-->
</div>
</div>
</template>
<script setup lang="ts">
import setTheme from '@/assets/theme/index';
import { useConfigStore } from '@/stores/config';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
withDefaults(defineProps<{ variant?: 'default' | 'home' }>(), { variant: 'default' });
const currentKeywordIndex = ref(0);
const configStore = useConfigStore();
const getTheme = setTheme(configStore.appConfig.festivalUi);
const router = useRouter();
const emit = defineEmits(['menuChange']);
const searchHintWord = computed(() => configStore.appConfig.searchHintWord || []);
const noticeChange = (index: number) => {
currentKeywordIndex.value = index;
};
const toSearchPage = () => {
if (searchHintWord.value.length) {
router.push(`/searchPage?text=${searchHintWord.value[currentKeywordIndex.value]}`);
} else {
router.push('/searchPage');
}
};
</script>
<style scoped lang="scss">
.header_search {
padding: 4px 16px;
height: 44px;
display: flex;
justify-content: space-between;
align-items: center;
.header_logo {
height: 36px;
width: 109px;
background: url('@/assets/images/ui/header-logo@2x.png') no-repeat;
background-size: 100% 100%;
}
.search-input {
flex: 1;
margin: 0 12px;
height: 32px;
line-height: 32px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
text-indent: 12px;
/* Body/Medium */
font-family: 'PingFang SC';
font-size: 14px;
.notice-bar {
height: 32px;
background: transparent;
padding: 0 6px;
.notice-swipe {
height: 32px;
line-height: 32px;
}
:deep(.van-swipe-item) {
color: rgba(255, 255, 255, 0.7);
}
}
}
.search_box {
display: flex;
align-items: center;
.menu {
height: 24px;
width: 24px;
background: url('@/assets/images/ui/menu.svg') no-repeat;
background-size: 100% 100%;
}
.sign_icon {
width: 20px;
height: 35px;
margin-right: 12px;
}
}
}
.header_search--home {
.header_logo,
.search_box {
flex-shrink: 0;
}
.search-input {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin: 0 0 0 12px;
padding: 0 12px;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.12);
font-size: 13px;
font-weight: 400;
line-height: 18px;
text-indent: 0;
.search-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.search-placeholder {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-bar {
flex: 1;
min-width: 0;
height: 18px;
padding: 0;
font-size: inherit;
line-height: inherit;
:deep(.van-notice-bar__wrap) {
min-width: 0;
}
:deep(.van-notice-bar__content) {
position: static;
width: 100%;
min-width: 0;
}
.notice-swipe {
width: 100%;
height: 18px;
line-height: 18px;
}
:deep(.van-swipe-item) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.search_box {
.sign_icon,
.holidays_sign_icon {
margin: 0 0 0 12px;
}
}
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="highlighted" v-html="highlightedText" @click="handleKeywordClick"></div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { innerJump, compatibleAppStoreJump } from '@/utils/index';
const props = defineProps({
text: {
type: String,
required: true,
default() {
return '';
},
},
keyword: {
type: String,
required: false,
default() {
return '';
},
},
linkType: {
type: Number,
required: false,
default() {
return 1;
},
},
linkStr: {
type: String,
required: false,
default() {
return '';
},
},
});
const handleKeywordClick = (e: Event) => {
const { keyword, linkType, linkStr } = props;
if (!keyword || !linkStr) return;
const target = e.target as HTMLElement;
if (target.tagName === 'SPAN') {
const index = linkStr.indexOf('yinseinner://');
if (linkType === 2 || index !== -1) {
innerJump(linkStr.substring(13));
} else {
compatibleAppStoreJump(linkStr);
}
}
};
const highlightedText = computed(() => {
const { keyword } = props;
if (!keyword) return props.text;
let result = '';
let currentIndex = 0;
// for (const keyword of props.keywords) {
const index = props.text.indexOf(keyword, currentIndex);
if (index !== -1) {
result += props.text.slice(currentIndex, index);
result += `<span>${keyword}</span>`;
currentIndex = index + keyword.length;
}
// }
result += props.text.slice(currentIndex);
return result;
});
</script>
<style lang="scss" scoped>
.highlighted {
font-size: 14px;
:deep(span) {
font-size: 14px;
color: var(--app-primary);
}
}
</style>
@@ -0,0 +1,236 @@
import { computed, ref, watch, type ComputedRef } from 'vue';
import AppApi from '@/service/app';
import { useConfigStore } from '@/stores/config';
/** 浮窗倒计时显示阶段 */
export type HongbaoRainPhase = 'countdown' | 'active';
export interface HongbaoRainStatus {
/** 浮窗上展示的文字(未开始:HH:mm:ss;进行中:立即参与) */
text: string;
phase: HongbaoRainPhase;
}
/** 服务端单查空数据错误码:-1010 时浮窗已下线或暂无下一场,需移除 */
const CODE_EMPTY_DATA = '-1010';
/**
* 把 banner.url 上的 query 解析出来,兼容只在 # 后带参数的写法
*/
const getBannerUrlSearchParams = (url: string) => {
const queryIndex = url.indexOf('?');
if (queryIndex !== -1) {
const hashIndex = url.indexOf('#', queryIndex + 1);
const query = hashIndex === -1 ? url.slice(queryIndex + 1) : url.slice(queryIndex + 1, hashIndex);
return new URLSearchParams(query);
}
const hashIndex = url.indexOf('#');
if (hashIndex !== -1 && url.slice(hashIndex + 1).includes('=')) {
return new URLSearchParams(url.slice(hashIndex + 1));
}
return new URLSearchParams();
};
/**
* 红包雨外链:url 上带 type=hongbaoRain(及活动 id)即视为红包雨入口;
* 不要求 url 的 id 与 banner.id 字符串完全一致
*/
export const isHongbaoRainBanner = (banner: Types.App.BannerJump | undefined | null) => {
if (!banner?.id || !banner?.url) return false;
try {
const type = getBannerUrlSearchParams(banner.url).get('type');
if (type?.toLowerCase() === 'hongbaorain') return true;
return /(?:^|[?&#])type=hongbaoRain(?:&|$|[#])/i.test(banner.url);
} catch {
return /(?:^|[?&#])type=hongbaoRain(?:&|$|[#])/i.test(banner.url);
}
};
/** HH:mm:ss 格式化剩余时间 */
const formatCountdown = (remainMs: number) => {
const totalSec = Math.max(0, Math.floor(remainMs / 1000));
const hour = Math.floor(totalSec / 3600);
const minute = Math.floor((totalSec % 3600) / 60);
const second = totalSec % 60;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
};
const isAbortError = (error: unknown) => {
if (!error || typeof error !== 'object') return false;
const err = error as { code?: string; name?: string };
return err.code === 'ERR_CANCELED' || err.name === 'AbortError' || err.name === 'CanceledError';
};
/**
* 浮窗「红包雨场次倒计时」状态管理:
* - 维护各浮窗的倒计时文案 / 进行中文案
* - 每秒刷新一次;场次结束时按 banner.id 单查刷新,处理下一场或下线
*
* 抽离原因:包含定时器、AbortController、跨场次副作用,且与展示组件其它业务无耦合,
* 需要独立的启动/停止时机,符合"多个副作用块需抽离"的规范。
*/
export const useHongbaoRainCountdown = (bannerList: ComputedRef<Types.App.BannerJump[]>) => {
const configStore = useConfigStore();
const statusMap = ref<Record<string, HongbaoRainStatus>>({});
/** 已处理过"场次结束"事件的 banner,避免每秒重复单查 */
const endedHandledIds = ref<Set<string>>(new Set());
const refreshingBannerId = ref('');
let countdownTimer: number | null = null;
let isActive = false;
let refreshAbort: AbortController | null = null;
const hongbaoRainBannerList = computed(() => bannerList.value.filter(isHongbaoRainBanner));
const getBannerStatus = (bannerId: string) => statusMap.value[bannerId];
const setStatus = (bannerId: string, status: HongbaoRainStatus) => {
statusMap.value = { ...statusMap.value, [bannerId]: status };
};
const cancelRefresh = () => {
refreshAbort?.abort();
refreshAbort = null;
};
/**
* 用单查结果替换列表中的 banner;
* 传 null 表示该 banner 已下线,从列表中移除
*/
const replaceBannerById = (bannerId: string, banner: Types.App.BannerJump | null) => {
const oldList = configStore.appConfig?.bannerJumpList || [];
const nextList = oldList.filter((item) => item?.id !== bannerId);
if (banner) {
nextList.push(banner);
}
configStore.appConfig.bannerJumpList = nextList;
};
/**
* 场次结束 → 单查最新场次;
* - 成功:用新的 startAt/endAt 接续倒计时
* - -1010:移除该 banner
*/
const refreshBanner = async (bannerId: string) => {
if (!isActive || !bannerId || refreshingBannerId.value === bannerId) return;
cancelRefresh();
const abortController = new AbortController();
refreshAbort = abortController;
refreshingBannerId.value = bannerId;
try {
const latestBanner = await AppApi.queryBannerById(bannerId, abortController.signal);
if (!isActive) return;
if (latestBanner?.id) {
replaceBannerById(bannerId, latestBanner);
endedHandledIds.value.delete(bannerId);
updateStatus();
}
} catch (error: unknown) {
if (isAbortError(error) || !isActive) return;
const errorCode = String((error as Error)?.message || '');
if (errorCode === CODE_EMPTY_DATA) {
replaceBannerById(bannerId, null);
}
} finally {
if (refreshAbort === abortController) {
refreshAbort = null;
}
refreshingBannerId.value = '';
}
};
/** 单条 banner 状态更新:未开始 / 进行中 / 已结束 */
const updateStatusForBanner = (banner: Types.App.BannerJump) => {
const bannerId = banner.id;
if (!isHongbaoRainBanner(banner)) return;
// 非红包雨倒计时类型(兜底兼容):直接展示"立即参与"
if (Number(banner.countdownType) !== 1) {
setStatus(bannerId, { text: '立即参与', phase: 'active' });
return;
}
const startAt = new Date(banner.startAt).getTime();
const endAt = new Date(banner.endAt).getTime();
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
setStatus(bannerId, { text: '', phase: 'countdown' });
return;
}
const now = Date.now();
if (now < startAt) {
setStatus(bannerId, {
text: formatCountdown(startAt - now),
phase: 'countdown',
});
endedHandledIds.value.delete(bannerId);
return;
}
if (now < endAt) {
setStatus(bannerId, { text: '立即参与', phase: 'active' });
endedHandledIds.value.delete(bannerId);
return;
}
// 场次已结束:先清空文案;同一 id 只在边界触发一次单查
setStatus(bannerId, { text: '', phase: 'countdown' });
if (!endedHandledIds.value.has(bannerId)) {
endedHandledIds.value = new Set(endedHandledIds.value).add(bannerId);
refreshBanner(bannerId);
}
};
const updateStatus = () => {
if (!isActive) return;
const list = hongbaoRainBannerList.value;
if (!list.length) {
statusMap.value = {};
endedHandledIds.value = new Set();
return;
}
// 清理已不在列表中的状态/已处理标记,避免内存堆积
const activeIds = new Set(list.map((item) => item.id));
statusMap.value = Object.fromEntries(
Object.entries(statusMap.value).filter(([id]) => activeIds.has(id)),
);
endedHandledIds.value = new Set([...endedHandledIds.value].filter((id) => activeIds.has(id)));
list.forEach((banner) => updateStatusForBanner(banner));
};
const startCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
}
isActive = true;
updateStatus();
countdownTimer = window.setInterval(() => {
updateStatus();
}, 1000);
};
const stopCountdown = () => {
isActive = false;
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
cancelRefresh();
};
/** 配置侧的 bannerJumpList 变更时(如其它入口刷新配置)同步更新状态 */
watch(
() => [hongbaoRainBannerList.value, configStore.appConfig?.bannerJumpList] as const,
() => {
if (!isActive) return;
updateStatus();
},
{ deep: true },
);
return {
statusMap,
getBannerStatus,
isHongbaoRainBanner,
startCountdown,
stopCountdown,
};
};
+322
View File
@@ -0,0 +1,322 @@
<template>
<!-- 商店包下载按钮 -->
<div class="iosAppStoreBtn" @click="download" v-if="activeLayerKey === 'shopIosLink'">
<SvgIcon name="closeIosAppStore" @click.stop="closeShopIosLink" />
</div>
<!-- 弹窗广告 -->
<HomePopupAdv v-if="activeLayerKey === 'homeAdv'" :advList="state.advList" @close="closeHomePopupAdv" />
<!-- APP公告 -->
<AnnouncementPopup
v-if="activeLayerKey === 'announcement'"
:announList="state.announList"
@close="closeAnnouncementPopup"
/>
<!-- 系统公告 -->
<!-- <SystemAnnouncement /> -->
<!-- 九宫格广告 -->
<NineGridPopup v-if="activeLayerKey === 'nineGrid'" :is-open="true" @close="closeNineGridPopup" />
<!-- 浮窗广告 -->
<!-- <FloatingBlock :show-close="false" :is-home="true" /> -->
<!-- 预售浮窗 -->
<PersalePopup v-if="activeLayerKey === 'persale'" :is-open="true" @close="closePersale" />
<!-- 支付预售尾款弹框 -->
<PayPresalePopup
v-if="activeLayerKey === 'payPresale'"
:is-open="true"
@close="closePayPersale"
ref="PayPresalePopupRef"
/>
<!-- 游戏活动弹框 -->
<JointGameing v-if="activeLayerKey === 'jointGame'" :is-open="true" @close="closeJointGameingPop" />
<!-- 首页付费分层弹窗 -->
<HomepagePaymentPopup v-if="activeLayerKey === 'paymentHome'" @close="closePaymentHomePopup" />
<!-- 轮播浮窗(不参与弹窗顺序队列;短剧页可关) -->
<SwipeFloat v-if="showSwipeFloat && !activeLayerKey" />
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onDeactivated, reactive, ref, watch } from 'vue';
import { useGlobalStore } from '@/stores/global';
import { useConfigStore } from '@/stores/config';
import { AdType } from '@/enums/app';
import { getAdItem } from '@/utils/getConfig';
import { useRouter } from 'vue-router';
import { judgeTime } from '@/utils/index';
import { isPaymentPlayPageStatus, resolvePaymentConfigImg } from '@/utils/paymentStatusPopup';
import SwipeFloat from './swipeFloat.vue';
const HomepagePaymentPopup = defineAsyncComponent(() => import('@/components/HomepagePaymentPopup/index.vue'));
const props = withDefaults(
defineProps<{
/** 商店下载条;短剧进页关闭 */
showShopDownload?: boolean;
/** 队列结束后的轮播浮窗;短剧进页关闭 */
showSwipeFloat?: boolean;
}>(),
{
showShopDownload: true,
showSwipeFloat: true,
},
);
const PayPresalePopupRef = ref();
const router = useRouter();
const globalStore = useGlobalStore();
const configStore = useConfigStore();
const state = reactive({
tabList: <Array<Types.Home.HomeTabItem>>[],
tabActive: 0,
isShowAdvPopup: !globalStore.homeAdvFirst, // 首页弹窗广告
isShowPaymentHomePopup: !globalStore.homePaymentPopupFirst, // 首页付费分层弹窗
advList: <Array<Types.App.AdsInfoListItem>>[], // 弹窗广告数据
isShowAnnouncementPopup: !globalStore.announcementFirst, // app公告
announList: <Array<Types.App.AnnounListItem>>[], //公告数据
isShowNineGridPopup: !globalStore.nineGridFirst, // 九宫格广告
nineGridList: <Array<Types.App.AdsInfoListItem>>[], // 九宫格广告数据
isShowPersalePopup: !globalStore.presaleFirst, //预售弹框
isHasPresale: false, //是否开启预售活动
isShowPayPersalePopup: !globalStore.payPresaleFirst, //支付预售尾款弹框
isPayPresale: false, //是否支付预售尾款
scrollDistance: <any>0, // 滚动距离
isShowGamePopup: !globalStore.joinGameingFirst, //游戏活动弹框
isHaveGameActive: false, //是否包含游戏活动
showLuckIcon: true, //是否显示抽奖图标
showAiIcon: false, //是否显示Ai图标
closeAiAcount: 0, // 关闭ai脱衣次数
showShopIosLink: false,
isChangeList: false,
homeTabActive: 1,
});
type ModalLayerKey = 'nineGrid' | 'homeAdv' | 'announcement' | 'jointGame' | 'payPresale' | 'persale' | 'shopIosLink' | 'paymentHome';
const shouldShowPaymentHomePopup = computed(() => {
if (!isPaymentPlayPageStatus(configStore.appConfig?.paymentStatusPopup)) return false;
return !!resolvePaymentConfigImg(configStore.appConfig?.paymentStatusPopupConfig?.homepage);
});
const isModalLayerReady = (key: ModalLayerKey): boolean => {
const advancePage = configStore.appConfig.advancePage;
switch (key) {
case 'nineGrid':
return state.isShowNineGridPopup && state.nineGridList.length > 0;
case 'homeAdv':
return state.isShowAdvPopup && state.advList.length > 0;
case 'announcement':
return state.isShowAnnouncementPopup && state.announList.length > 0;
case 'jointGame':
return state.isShowGamePopup && state.isHaveGameActive;
case 'payPresale':
return state.isShowPayPersalePopup && state.isPayPresale && !!advancePage?.popBgProc;
case 'persale':
return state.isShowPersalePopup && state.isHasPresale && !!advancePage?.popBgWait;
case 'shopIosLink':
return props.showShopDownload && state.showShopIosLink && !!configStore.appConfig.shopIosLink;
case 'paymentHome':
return state.isShowPaymentHomePopup && shouldShowPaymentHomePopup.value;
default:
return false;
}
};
const modalLayerQueue: { key: ModalLayerKey }[] = [
{ key: 'nineGrid' },
{ key: 'homeAdv' },
{ key: 'announcement' },
{ key: 'jointGame' },
{ key: 'payPresale' },
{ key: 'persale' },
{ key: 'paymentHome' },
{ key: 'shopIosLink' },
];
const activeLayerKey = computed(() => {
return modalLayerQueue.find((item) => isModalLayerReady(item.key))?.key || '';
});
/** 须用运行时数组声明:Vue 3.2 的 type-only defineEmits 不会生成 emits,多根节点会触发 fallthrough warning */
const emit = defineEmits(['queue-busy']);
watch(
activeLayerKey,
(key) => {
emit('queue-busy', !!key);
},
{ immediate: true },
);
/** 无配置时清掉展示标记,避免占队列位置 */
const skipUnconfiguredLayers = () => {
if (state.nineGridList.length === 0) {
state.isShowNineGridPopup = false;
}
if (state.advList.length === 0) {
state.isShowAdvPopup = false;
}
if (state.announList.length === 0) {
state.isShowAnnouncementPopup = false;
}
if (!state.isHaveGameActive) {
state.isShowGamePopup = false;
}
const advancePage = configStore.appConfig.advancePage;
if (!state.isHasPresale || !advancePage?.popBgWait) {
state.isShowPersalePopup = false;
state.isHasPresale = false;
}
if (!state.isPayPresale || !advancePage?.popBgProc) {
state.isShowPayPersalePopup = false;
state.isPayPresale = false;
}
if (!configStore.appConfig.shopIosLink) {
state.showShopIosLink = false;
}
};
const init = () => {
state.showShopIosLink = props.showShopDownload && !localStorage.getItem('isAppStore');
// 预售弹框
const advancePage = configStore.appConfig.advancePage;
let data = configStore.appConfig.advanceStatus;
if (data && data.activityPopUp) {
if (data.status == 0 && advancePage?.popBgWait) {
state.isHasPresale = true;
globalStore.setPresaleFirst();
} else if (
(data.status == 2 || data.status == 3) &&
judgeTime(configStore.appConfig.advanceStatus.startTime) &&
advancePage?.popBgProc
) {
state.isPayPresale = true;
globalStore.setPayPresaleFirst();
}
}
state.nineGridList = getAdItem(AdType.QualityApplication) || [];
if (state.nineGridList.length > 0) {
globalStore.setNineGridFirst();
}
// 游戏活动弹框
if (data && data.gameActivityPopUp) {
state.isHaveGameActive = true;
globalStore.setJoinGameingFirst();
}
state.advList = getAdItem(AdType.HOMEPOPUPADV);
if (state.advList && state.advList.length > 0) {
globalStore.setHomeAdvFirst();
}
if (configStore.appConfig.announList && configStore.appConfig.announList.length > 0) {
state.announList = JSON.parse(JSON.stringify(configStore.appConfig.announList));
globalStore.setAnnouncementFirst();
}
if (shouldShowPaymentHomePopup.value) {
globalStore.setHomePaymentPopupFirst();
}
skipUnconfiguredLayers();
};
init();
const download = () => {
window.location.href = configStore.appConfig.shopIosLink;
};
const closeShopIosLink = () => {
state.showShopIosLink = false;
};
const closePersale = (type: any) => {
if (!type) {
router.push('/presale');
}
state.isShowPersalePopup = false;
state.isHasPresale = false;
};
const closePayPersale = (type: any) => {
if (!type) {
router.push('/presale');
}
state.isShowPayPersalePopup = false;
state.isPayPresale = false;
};
const closeJointGameingPop = (type: any) => {
if (!type) {
router.push('/jointGamingActivitie');
}
state.isShowGamePopup = false;
state.isHaveGameActive = false;
};
const closeHomePopupAdv = () => {
state.isShowAdvPopup = false;
};
const closeAnnouncementPopup = () => {
state.isShowAnnouncementPopup = false;
};
const closeNineGridPopup = () => {
state.isShowNineGridPopup = false;
};
const closePaymentHomePopup = () => {
state.isShowPaymentHomePopup = false;
};
onDeactivated(() => {
if (PayPresalePopupRef.value) {
clearTimeout(PayPresalePopupRef.value.timeoutTimerPay);
PayPresalePopupRef.value.timeoutTimerPay = <any>null;
}
});
</script>
<style lang="scss" scoped>
.iosAppStoreBtn {
height: 36px;
width: 284px;
background: url('@/assets/gif/iosAppStoreBtn.gif') no-repeat;
background-size: 100% 100%;
position: fixed;
left: 0px;
top: 443px;
z-index: 99;
> svg {
height: 16px;
width: 16px;
position: absolute;
right: -8px;
top: -8px;
}
}
.persalebox {
position: fixed;
bottom: 30%;
right: 10px;
.persaleImg {
width: 56px;
animation: shake 1.5s infinite;
}
}
@keyframes shake {
0%,
65% {
transform: rotate(0deg) scale(1);
}
70% {
transform: rotate(0deg) scale(1.1);
}
75% {
transform: rotate(6deg) scale(1.1);
}
80% {
transform: rotate(-6deg) scale(1.1);
}
85% {
transform: rotate(6deg) scale(1.1);
}
90% {
transform: rotate(-6deg) scale(1.1);
}
95% {
transform: rotate(0deg) scale(1);
}
100% {
transform: rotate(0deg) scale(1);
}
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<template>
<!-- 轮播浮框 -->
<div class="rightBottomBox" :style="{ display: display }">
<!-- 轮播浮框1 -->
<div class="floatingBox1" v-if="$route.name != 'mine' && hasFloatingBox1Items">
<van-swipe class="my-swipe1" :autoplay="3000" indicator-color="white" :show-indicators="false">
<van-swipe-item class="swipe-item" v-if="state.showLimitedTime && !userStore.isVip">
<!-- 限时优惠 -->
<limitedTime @exShowEl="state.showLimitedTime = false" />
</van-swipe-item>
<van-swipe-item
class="swipe-item"
v-if="state.activityDetail && (state.isShowPersaleFC == 'ys' || state.isShowPersaleFC == 'pay')"
>
<!-- 预售 +支付尾款 浮窗 -->
<div class="persalebox">
<lazyImg
class="persaleImg"
v-if="state.isShowPersaleFC == 'ys'"
@click="$router.push('/presale')"
:src="configStore?.appConfig?.advancePage?.enterBgWait"
:key="configStore?.appConfig?.advancePage?.enterBgWait"
/>
<lazyImg
class="persaleImg"
v-if="state.isShowPersaleFC == 'pay'"
@click="$router.push('/presale')"
:src="configStore?.appConfig?.advancePage?.enterBgProc"
:key="configStore?.appConfig?.advancePage?.enterBgProc"
/>
</div>
</van-swipe-item>
<van-swipe-item class="swipe-item" v-if="paymentFloatShouldShow">
<HomepagePaymentFloat embedded :legacy-float-visible="state.showLimitedTime && !userStore.isVip" />
</van-swipe-item>
</van-swipe>
</div>
<!-- 轮播浮框2 -->
<div
class="floatingBox2"
v-if="floatingBox2List.length || (configStore.appConfig.luckyDrawH5 && state.showLuckIcon)"
>
<van-swipe class="my-swipe2" :autoplay="3000" indicator-color="white" :show-indicators="false">
<van-swipe-item
class="swipe-item"
@click="openFloatAdv(item)"
v-for="item in floatingBox2List"
:key="item.id || item.banner"
>
<div class="countdownBanner">
<lazyImg class="cover" :src="item.banner" :key="item.banner" />
<div
v-if="isHongbaoRainBanner(item) && getBannerStatus(item.id)?.text"
class="countdownText"
:class="getBannerStatus(item.id)?.phase === 'countdown' ? 'countdownText--timer' : 'countdownText--join'"
>
{{ getBannerStatus(item.id)?.text }}
</div>
</div>
</van-swipe-item>
<van-swipe-item v-if="configStore.appConfig.luckyDrawH5 && state.showLuckIcon">
<div class="luckyDraw" @click.stop="$router.push('/lotteryPage')">
<lazyImg
class="lotteryIcon"
v-if="configStore?.appConfig?.luckyDrawIcon"
:src="configStore?.appConfig?.luckyDrawIcon"
:key="configStore?.appConfig?.luckyDrawIcon"
/>
<div class="lotteryImg" v-else></div>
</div>
</van-swipe-item>
</van-swipe>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import HomeApi from '@/service/home';
import { useConfigStore } from '@/stores/config';
import { judgeTime, jumpAdv } from '@/utils/index';
import { useUserStore } from '@/stores/user';
import { hasAnyPaymentConfigField, isActivePaymentStatus } from '@/utils/paymentStatusPopup';
import { useHongbaoRainCountdown } from './hooks/useHongbaoRainCountdown';
const HomepagePaymentFloat = defineAsyncComponent(() => import('@/components/HomepagePaymentFloat/index.vue'));
/** 浮窗2 固定显示在首页右下角的位置 */
const FLOAT_POSITION_HOME = 3;
const configStore = useConfigStore();
const userStore = useUserStore();
let display = ref('block');
const state = reactive({
showLuckIcon: true, //是否显示抽奖图标
activityDetail: <any>'', //预售详情
isShowPersaleFC: '', //浮窗展示
showLimitedTime: true,
});
/**
* 浮窗2 列表:直接从配置派生,便于 hook 内单查刷新后自动反映到 UI;
* 避免 init 时一次性快照,错过场次切换/下线
*/
const floatingBox2List = computed<Types.App.BannerJump[]>(() => {
const list = configStore.appConfig?.bannerJumpList || [];
return list.filter((item) => item?.position === FLOAT_POSITION_HOME && item?.banner);
});
const paymentFloatShouldShow = computed(() => {
const paymentConfig = configStore.appConfig?.paymentStatusPopupConfig;
if (!isActivePaymentStatus(configStore.appConfig?.paymentStatusPopup)) return false;
return hasAnyPaymentConfigField(paymentConfig, ['homepageFlot', 'vipCard']);
});
const presaleFloatVisible = computed(
() => !!(state.activityDetail && (state.isShowPersaleFC == 'ys' || state.isShowPersaleFC == 'pay')),
);
const hasFloatingBox1Items = computed(
() =>
(state.showLimitedTime && !userStore.isVip) ||
presaleFloatVisible.value ||
paymentFloatShouldShow.value,
);
const { getBannerStatus, isHongbaoRainBanner, startCountdown, stopCountdown } =
useHongbaoRainCountdown(floatingBox2List);
/** 浮窗2 内链;跳会员中心时带 HOME_FLOAT_WINDOW */
const openFloatAdv = (item: Types.App.BannerJump) => {
void jumpAdv(item, { sourcePage: 'HOME_FLOAT_WINDOW' });
};
const isNoWatchVideoFirst = computed(() => !!localStorage.getItem(`secondViewing${userStore.userInfo.uid}`));
const isNoWatchVideoOld = computed(() => !!localStorage.getItem(`secondOldViewing${userStore.userInfo.uid}`));
watch(
[isNoWatchVideoFirst, isNoWatchVideoOld],
([newFirst, newOld]) => {
console.log(newFirst, 'isNoWatchVideoFirst');
console.log(newOld, 'isNoWatchVideoOld');
if (newFirst || newOld) {
state.showLimitedTime = true;
} else {
state.showLimitedTime = false;
}
},
{ immediate: true },
);
const getPresaleData = async () => {
try {
let req = {
pageNumber: 1,
pageSize: 1,
};
let res: any = await HomeApi.getPresaleList(req);
nextTick(() => {
state.activityDetail = res.activityDetail;
// 预售浮窗
if (state.activityDetail.status == 0 || state.activityDetail.status == 1) {
state.isShowPersaleFC = 'ys';
} else if (
(state.activityDetail.status == 2 || state.activityDetail.status == 3) &&
judgeTime(state.activityDetail.startTime)
) {
//支付尾款浮窗
state.isShowPersaleFC = 'pay';
} else if (
(state.activityDetail.status == 2 || state.activityDetail.status == 3) &&
!judgeTime(state.activityDetail.startTime)
) {
//预售
state.isShowPersaleFC = 'ys';
} else {
state.isShowPersaleFC = '';
}
// 判断剩余支付时间是否过期
const endTime = new Date(state.activityDetail.endTime).getTime();
const newTime = new Date().getTime();
const diffTime = (endTime - newTime) / 1000;
if (diffTime < 0) {
console.log('过期了');
}
});
} catch (e) {
console.log(e);
}
};
// SwipeFloat 在 HomeAdv 内通过 v-if 在弹窗关闭后才挂载,
// 此时 keep-alive 父级已激活,onActivated 不会再触发,
// 必须用 onMounted 兜底首次启动倒计时
const startupCountdown = () => {
if (configStore.appConfig.advanceStatus && configStore.appConfig.advanceStatus.activityStatus) {
getPresaleData();
}
startCountdown();
};
onMounted(() => {
startupCountdown();
});
onActivated(() => {
startupCountdown();
});
onDeactivated(() => {
stopCountdown();
});
onUnmounted(() => {
stopCountdown();
});
</script>
<style scoped lang="scss">
.rightBottomBox {
position: fixed;
bottom: 140px;
right: 16px;
z-index: 10;
.floatingBox1 {
width: 60px;
margin-bottom: 20px;
.my-swipe1 {
height: 100%;
width: 100%;
:deep(.van-swipe__track) {
align-items: center;
}
.swipe-item {
height: 100%;
width: 100%;
}
}
.persalebox {
display: flex;
justify-content: center;
.persaleImg {
width: 56px;
animation: shake 1.5s infinite;
}
@keyframes shake {
0%,
65% {
transform: rotate(0deg) scale(1);
}
70% {
transform: rotate(0deg) scale(1.1);
}
75% {
transform: rotate(6deg) scale(1.1);
}
80% {
transform: rotate(-6deg) scale(1.1);
}
85% {
transform: rotate(6deg) scale(1.1);
}
90% {
transform: rotate(-6deg) scale(1.1);
}
95% {
transform: rotate(0deg) scale(1);
}
100% {
transform: rotate(0deg) scale(1);
}
}
}
}
.floatingBox2 {
width: 60px;
.my-swipe2 {
height: 100%;
width: 100%;
:deep(.van-swipe__track) {
align-items: center;
}
.swipe-item {
height: 100%;
width: 100%;
.cover {
height: 100%;
width: 100%;
}
}
}
.countdownBanner {
height: 60px;
width: 60px;
position: relative;
.cover {
height: 100%;
width: 100%;
}
.countdownText {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 14px;
padding: 0 4px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
letter-spacing: -0.2px;
font-family: 'Alimama ShuHeiTi';
}
.countdownText--timer {
color: #fff;
background: linear-gradient(90deg, #d000ff 0%, #40c0ff 100%);
}
.countdownText--join {
min-width: 44px;
color: #fff;
font-size: 9px;
}
}
}
}
.luckyDraw {
height: 60px;
width: 60px;
.lotteryIcon {
height: 100%;
width: 100%;
}
.lotteryImg {
height: 100%;
width: 100%;
background: url('@/assets/gif/lottery.gif') no-repeat;
background-size: 100% 100%;
}
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="homeHeader" :data-type="state.advCoverType" v-if="state.advList?.length" ref="advRef">
<!-- 小方块布局 -->
<template v-if="state.advCoverType === 'small-cube'">
<div class="firstRow" v-if="firstRowList.length">
<div class="rectAdvBox" @click="handleJumpAdv(item)" v-for="item in firstRowList" :key="item.id">
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
<template v-if="secondRowList.length >= 6">
<div
:class="['adv-line-2', 'flex', isStopAnimation ? 'stopAnima' : '']"
@touchmove.stop="onTouchMove"
@touchend="onTouchEnd"
ref="scrollableRef"
:style="{ '--animation-duration': `${secondRowList.length * 2}s` }"
>
<div class="fiveGrid secondLine shrink animation">
<div
class="rectAdvBox"
v-for="item in secondRowList"
:key="'first-' + item.id"
@click="handleJumpAdv(item)"
>
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
<div class="fiveGrid secondLine shrink append animation">
<div
class="rectAdvBox"
v-for="item in secondRowList"
:key="'second-' + item.id"
@click="handleJumpAdv(item)"
>
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="firstRow" v-if="secondRowList?.length">
<div class="rectAdvBox" v-for="item in secondRowList" :key="item.id" @click="handleJumpAdv(item)">
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
</template>
</template>
<template v-else-if="state.advCoverType === 'vertical-cube'">
<div
:class="['adv-line-2 flex verticalCube', isStopAnimation ? 'stopAnima' : '']"
@touchmove.stop="onTouchMove"
@touchend="onTouchEnd"
ref="scrollableRef"
:style="{ '--animation-duration': `${state.advList.length * 3}s` }"
>
<div class="fiveGrid secondLine shrink animation">
<div class="rectAdvBox" v-for="item in state.advList" :key="'first-' + item.id" @click="handleJumpAdv(item)">
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
<div class="fiveGrid secondLine shrink append animation">
<div class="rectAdvBox" v-for="item in state.advList" :key="'second-' + item.id" @click="handleJumpAdv(item)">
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName">{{ item.title }}</div>
</div>
</div>
</div>
</template>
<!-- 大图轮播 -->
<div class="banner_box" v-else-if="state.advCoverType.indexOf('banner-') > -1">
<van-swipe :autoplay="2000" :indicator-color="indicatorColor" class="swipe_box">
<van-swipe-item v-for="(item, index) in state.advList" @click="handleJumpAdv(item)" :key="index">
<DecryptImg :src="item.cover" :key="item.cover" />
<div class="title" v-if="showTitle">{{ item.title }}</div>
</van-swipe-item>
</van-swipe>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, ref, watch, nextTick } 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',
showTitle: false,
},
);
const state = reactive({
advList: <Array<Types.App.AdsInfoListItem>>[], // 所有广告
advCoverType: 'small-cube', // 广告类型
});
// 第一行广告
const firstRowList = computed(() => {
return state.advList.slice(0, 5);
});
// 第二行广告
const secondRowList = computed(() => {
return state.advList.slice(5, state.advList.length);
});
const advTypeText = ref('');
const init = () => {
let list: any = [];
if (props.advType) {
list = getAdItem(props.advType) || [];
}
state.advList = list.slice(0, list.length);
state.advCoverType = list?.[0]?.coverType || list?.[0]?.coverImgSize || 'small-cube';
if (state.advCoverType === 'small-cube') {
advTypeText.value = 'small_grid';
}
if (state.advCoverType === 'vertical-cube') {
advTypeText.value = 'vertical_grid';
}
if (state.advCoverType.indexOf('banner-') > -1) {
advTypeText.value = 'large_carousel';
}
};
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);
};
const onTouchMove = () => {
isStopAnimation.value = true;
};
let timer: any = null;
const scrollableRef = ref<HTMLElement>();
const onTouchEnd = async () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
scrollableRef.value!.scrollLeft = 0;
isStopAnimation.value = false;
}, 3000);
};
</script>
<style scoped lang="scss">
.homeHeader {
--size-width: 60px;
--size-height: 60px;
.rectAdvBox {
width: var(--size-width);
.rectAdvIcon {
height: var(--size-height);
width: var(--size-width);
border-radius: 10px;
overflow: hidden;
}
.rectName {
color: #fff;
font-size: 12px;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
height: 18px;
line-height: 18px;
text-overflow: ellipsis;
}
}
.firstRow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 9px;
margin: 12px 0;
}
.adv-line-2 {
overflow-y: hidden;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.fiveGrid {
display: flex;
gap: 10px;
&.secondLine {
&.animation {
animation: scroll var(--animation-duration) linear 0s infinite;
}
}
&.append {
margin-left: 10px;
}
}
}
.stopAnima {
.animation {
animation: none !important;
animation-play-state: paused !important;
}
}
// 竖方块
.verticalCube {
--size-width: 100px;
--size-height: 150px;
.fiveGrid {
gap: 5px;
}
.rectAdvBox {
.rectName {
font-size: 14px;
text-align: left;
}
}
}
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - 10px));
}
}
.banner_box {
.swipe_box {
width: 100%;
height: 143px;
margin-bottom: 6px;
border-radius: 6px;
}
}
:deep(.van-swipe__indicators) {
transform: translateX(0);
left: unset;
right: 9px;
.van-swipe__indicator {
width: 6px;
height: 6px;
border-radius: 3px;
}
.van-swipe__indicator--active {
width: 12px;
height: 6px;
}
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="announcementPopup">
<van-overlay :show="true" z-index="2001" @click="close()">
<div class="imgWrapper" ref="advRef">
<lazyImg
@click.stop="handleJumpAdv(currentAds[0])"
class="imgBox"
:src="currentAds[0]?.cover"
:key="currentAds[0]?.id"
/>
<lazyImg
v-if="currentAds[1]"
@click.stop="handleJumpAdv(currentAds[1])"
class="imgBox"
:src="currentAds[1]?.cover"
:key="currentAds[1]?.id"
/>
<div class="close"></div>
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref, watch, 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: () => [],
},
);
const emit = defineEmits(['close']);
const state = reactive({
advList: <Array<Types.App.AdsInfoListItem>>props.advList,
});
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);
};
const close = () => {
currentIndex.value += 2;
// 如果没有更多广告(即当前索引超出或等于广告总数),关闭弹窗
if (currentIndex.value >= state.advList.length) {
emit('close');
}
};
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 1);
}
.announcementPopup {
height: 100%;
width: 100%;
position: relative;
.imgWrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
.imgBox {
height: 169px;
width: 300px;
margin-bottom: 6px;
}
.close {
height: 36px;
width: 36px;
background: url('./../../assets/images/png/closeDialog.png') no-repeat;
background-size: 100% 100%;
margin: 30px auto;
}
}
}
</style>
@@ -0,0 +1,160 @@
<template>
<div
v-if="visible"
class="homepagePaymentFloat"
:class="[{ embedded }, isStopScroll ? '' : 'scrollHomepagePaymentFloat']"
:style="{ display: displayStyle }"
@click.stop="handleToMember"
>
<lazyImg v-if="floatIcon" class="floatImg" :src="floatIcon" :key="floatIcon" />
<img v-else class="floatImg" src="@/assets/images/png/limitedTime.png" alt="" />
<div v-if="showCountdown" class="floatTime">
<van-count-down :time="countdownMs" @finish="onCountdownFinish" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config';
import {
hasAnyPaymentConfigField,
isActivePaymentStatus,
parseLastDiscountTime,
resolvePaymentConfigImg,
} from '@/utils/paymentStatusPopup';
const props = defineProps({
/** 新老用户限时浮窗是否正在展示,展示时本组件不显示 */
legacyFloatVisible: {
type: Boolean,
default: false,
},
isStopScroll: {
type: Boolean,
default: true,
},
displayStyle: {
type: String,
default: 'block',
},
/** 嵌入首页浮窗轮播时使用,取消 fixed 定位 */
embedded: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const router = useRouter();
const configStore = useConfigStore();
const countdownExpired = ref(false);
const paymentConfig = computed(() => configStore.appConfig?.paymentStatusPopupConfig);
const discountDeadline = computed(() => parseLastDiscountTime(paymentConfig.value?.lastDiscountTime));
const countdownMs = computed(() => {
if (!discountDeadline.value || countdownExpired.value) return 0;
const remaining = discountDeadline.value - Date.now();
return remaining > 0 ? remaining : 0;
});
const floatIcon = computed(() => resolvePaymentConfigImg(paymentConfig.value?.homepageFlot));
const vipCardId = computed(() => paymentConfig.value?.vipCard || '');
const showCountdown = computed(() => countdownMs.value > 0);
const visible = computed(() => {
if (props.legacyFloatVisible) return false;
if (!isActivePaymentStatus(configStore.appConfig?.paymentStatusPopup)) return false;
if (!hasAnyPaymentConfigField(paymentConfig.value, ['homepageFlot', 'vipCard'])) {
return false;
}
if (discountDeadline.value && countdownMs.value <= 0) return false;
return true;
});
watch(
visible,
(value) => {
emit('update:visible', value);
},
{ immediate: true },
);
const handleToMember = () => {
const id = vipCardId.value;
router.push({
path: '/member',
query: {
...(id ? { id } : {}),
sourcePage: 'HOME_FLOAT_WINDOW',
},
});
};
const onCountdownFinish = () => {
countdownExpired.value = true;
};
watch(
() => paymentConfig.value?.lastDiscountTime,
() => {
countdownExpired.value = false;
},
);
</script>
<style lang="scss" scoped>
.homepagePaymentFloat {
// position: fixed;
// bottom: 24%;
// right: 0;
// z-index: 10;
&.embedded {
position: relative;
bottom: auto;
right: auto;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.floatImg {
width: 60px;
height: 60px;
}
.floatTime {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 47px;
height: 16px;
border-radius: 3px;
font-family: 'Alimama FangYuanTi VF', sans-serif;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
--van-count-down-text-color: rgba(255, 240, 209, 1);
--van-count-down-font-size: 8.71px;
--van-count-down-line-height: normal;
}
}
.scrollHomepagePaymentFloat {
transform: translateX(80px);
transition: transform 0.3s;
}
</style>
@@ -0,0 +1,170 @@
<template>
<div class="homepagePaymentPopup">
<van-overlay :show="true" z-index="2001" @click="close">
<div class="imgWrapper">
<div class="popupContent" @click.stop="handleClick">
<lazyImg class="imgBox" :src="homepageImg" :key="homepageImg" />
<div v-if="showCountdown" class="countdownTimer">{{ countdownText }}</div>
</div>
<div class="close" @click="close"></div>
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config';
import { parseLastDiscountTime, resolvePaymentConfigImg } from '@/utils/paymentStatusPopup';
const emit = defineEmits(['close']);
const router = useRouter();
const configStore = useConfigStore();
const countdownExpired = ref(false);
const countdownText = ref('00:00:00');
let countdownTimer: number | null = null;
const paymentConfig = computed(() => configStore.appConfig?.paymentStatusPopupConfig);
const homepageImg = computed(() => resolvePaymentConfigImg(paymentConfig.value?.homepage));
const discountDeadline = computed(() => parseLastDiscountTime(paymentConfig.value?.lastDiscountTime));
const countdownSec = computed(() => {
if (!discountDeadline.value || countdownExpired.value) return 0;
const remaining = Math.floor((discountDeadline.value - Date.now()) / 1000);
return remaining > 0 ? remaining : 0;
});
const showCountdown = computed(() => discountDeadline.value > 0 && countdownSec.value > 0);
const vipCardId = computed(() => paymentConfig.value?.vipCard || '');
const formatCountdown = (totalSec: number) => {
const sec = Math.max(0, totalSec);
const hour = Math.floor(sec / 3600);
const minute = Math.floor((sec % 3600) / 60);
const second = sec % 60;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
};
const updateCountdownText = () => {
if (!discountDeadline.value) {
countdownText.value = '00:00:00';
return;
}
const remaining = Math.floor((discountDeadline.value - Date.now()) / 1000);
if (remaining <= 0) {
countdownText.value = '00:00:00';
countdownExpired.value = true;
stopCountdown();
return;
}
countdownText.value = formatCountdown(remaining);
};
const startCountdown = () => {
stopCountdown();
updateCountdownText();
if (!showCountdown.value) return;
countdownTimer = window.setInterval(updateCountdownText, 1000);
};
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
};
/** 跳转会员中心并选中分层配置的会员卡,不直接拉起支付 */
const handleClick = () => {
const id = vipCardId.value;
close();
router.push({
path: '/member',
query: {
...(id ? { id } : {}),
sourcePage: 'HOME_USER_SEGMENT',
},
});
};
const close = () => {
emit('close');
};
watch(
() => paymentConfig.value?.lastDiscountTime,
() => {
countdownExpired.value = false;
startCountdown();
},
);
onMounted(() => {
startCountdown();
});
onUnmounted(() => {
stopCountdown();
});
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
}
.homepagePaymentPopup {
height: 100%;
width: 100%;
position: relative;
.imgWrapper {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
.popupContent {
position: relative;
width: 285px;
}
.imgBox {
width: 285px;
display: block;
}
.countdownTimer {
position: absolute;
top: 0;
right: 0;
min-width: 103px;
padding: 2px 4px;
border-radius: 8px;
background: linear-gradient(135deg, #e03017 0%, #f27952 100%);
border: 0.52px solid rgba(234, 184, 101, 1);
color: rgba(255, 240, 209, 1);
font-size: 20px;
font-weight: 600;
line-height: 28px;
text-align: center;
white-space: nowrap;
letter-spacing: 0.5px;
box-sizing: border-box;
}
.close {
height: 36px;
width: 36px;
background: url('@/assets/images/png/closeDialog.png') no-repeat;
background-size: 100% 100%;
margin: 10px auto;
}
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="horizontalGameBox" @click="openVideo">
<lazyImg class="gameCover" :src="state.videoInfo.cover">
<div class="box flex-vcenter">
<SvgIcon name="eyeWhite" class="icon" />
{{ watchCount(state.videoInfo.pageViewCount) }}
</div>
</lazyImg>
<div class="gameTitle ellipsis">
{{ state.videoInfo.title }}
</div>
<div class="tagBox ellipsis">
<div class="tag" @click.stop="jumpTag(tag)" v-for="tag in state.videoInfo.tags" :key="tag.id">
{{ tag.name }}
</div>
</div>
<!-- 编辑时 -->
<!-- <div class="checked" @click.stop v-if="isEdit">
<van-checkbox :name="videoInfo.id">
<template #icon="props">
<img class="imgIcon" :src="props.checked ? activeIcon : inactiveIcon" />
</template>
</van-checkbox>
</div> -->
<div class="delBtn" v-if="isEdit" @click.stop="handleDel">
<SvgIcon name="clear-red" class="selectActive" />
</div>
</div>
</template>
<script lang="ts" setup>
import activeIcon from '@/assets/images/png/mine/icheck-active.png';
import inactiveIcon from '@/assets/images/png/mine/icheck.png';
import { reactive, inject, computed, type ComputedRef } from 'vue';
import { useRouter } from 'vue-router';
import { watchCount, jumpAdv } from '@/utils';
const emit = defineEmits(['select', 'del']);
const router = useRouter();
const props = withDefaults(
defineProps<{
videoInfo: Types.Video.VInfosItem;
isEdit?: boolean;
}>(),
{
videoInfo: () => <Types.Video.VInfosItem>{},
isEdit: false,
},
);
const injectedEdit = inject<ComputedRef<boolean> | boolean>('isEdit', false);
const isEdit = computed(() => {
if (props.isEdit) return true;
if (typeof injectedEdit === 'object' && injectedEdit && 'value' in injectedEdit) {
return Boolean(injectedEdit.value);
}
return Boolean(injectedEdit);
});
const state = reactive({
videoInfo: props.videoInfo,
});
const openVideo = () => {
// 编辑时返回id
if (isEdit.value) {
emit('select', state.videoInfo?.id);
return;
}
if (state.videoInfo.type) {
jumpAdv(state.videoInfo);
return;
}
if (state.videoInfo.newsType == 'SEED_LINK') {
router.push(`gameDetails?id=${state.videoInfo.id}`);
} else if (state.videoInfo.newsType == 'COVER') {
router.push(`postDetail?id=${state.videoInfo.id}`);
} else {
router.push({
path: '/movieDetails',
query: {
vid: state.videoInfo.id,
uid: state.videoInfo.publisher?.uid,
},
});
}
};
// 标签跳转
const jumpTag = (item: any) => {
// router.push({
// name: 'gameTagDetails',
// query: {
// id: item.id,
// name: item.name,
// },
// });
};
const handleDel = () => {
emit('del', state.videoInfo?.id);
};
</script>
<style lang="scss" scoped>
.horizontalGameBox {
height: 100%;
width: 100%;
position: relative;
.gameCover {
width: 100%;
height: 101px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 6px;
position: relative;
.box {
position: absolute;
bottom: 0;
left: 0;
height: 30px;
width: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%);
padding: 0 8px;
font-size: 10px;
.icon {
margin-right: 2px;
width: 16px;
height: 16px;
}
}
}
.gameTitle {
color: #fff;
font-size: 14px;
margin-bottom: 6px;
}
.tagBox {
display: flex;
gap: 5px;
width: 100%;
.tag {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
width: fit-content;
}
}
.checked {
position: absolute;
right: 6px;
top: 6px;
:deep(.van-checkbox__icon) {
width: 24px;
height: 24px;
}
.imgIcon {
width: 24px;
height: 24px;
display: block;
}
}
}
.delBtn {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
top: 0;
left: 0;
z-index: 10;
.selectActive {
width: 24px;
height: 24px;
position: absolute;
top: 6px;
right: 6px;
}
}
</style>
+553
View File
@@ -0,0 +1,553 @@
<template>
<div class="horizontalVideoBox" @click="openVideo">
<div class="coverBox">
<!-- 暗网权限膜层 -->
<div class="awPermissions-popup" v-if="showAwPopup">
<div class="blur-background"></div>
<div class="awPermissions-content">
<div>
需要开通
<span>{{ configStore.appConfig.darkWebVipName }}</span>
</div>
<div>即可观看</div>
</div>
</div>
<div
v-if="isEdit && editMode === 'select'"
class="selectBtn"
:class="{ active: selected }"
@click.stop="toggleSelect"
/>
<div class="delBtn" v-else-if="isEdit" @click.stop="del(videoInfo)">
<SvgIcon name="clear-red" class="selectActive" />
</div>
<lazyImg
class="avatar"
:src="videoInfo?.cover || (acgHorizontal ? videoInfo?.horizontalCover : videoInfo?.verticalCover)"
/>
<!-- <div-->
<!-- class="vip_icon free_icon"-->
<!-- v-if="-->
<!-- !videoInfo.type &&-->
<!-- configStore.appConfig.freeMark &&-->
<!-- ((isLongFormVideo(videoInfo) && !videoInfo.mediaType && videoInfo.freeArea === true) ||-->
<!-- (['video', 'image', 'text'].includes(videoInfo.mediaType) && videoInfo.permission === 2))-->
<!-- "-->
<!-- >-->
<!-- 免费-->
<!-- </div>-->
<div class="vip_icon free_trial_icon" v-if="showFreeTrialIcon">免费</div>
<template v-else-if="['video', 'image', 'text'].includes(videoInfo.mediaType)">
<div class="vip_icon" v-if="configStore.appConfig.vipMark && videoInfo.permission == 0">VIP</div>
<div
class="vip_icon coins_icon"
v-if="configStore.appConfig.coinMark && videoInfo.permission === 1 && videoInfo.price"
>
金币
</div>
</template>
<template v-else>
<div
class="vip_icon"
v-if="configStore.appConfig.vipMark && videoInfo?.originCoins == 0 && !videoInfo?.freeArea"
>
VIP
</div>
<div
class="vip_icon coins_icon"
v-if="configStore.appConfig.coinMark && videoInfo?.originCoins != 0 && !videoInfo?.freeArea"
>
金币
</div>
</template>
<div class="videoInfo flex-center-between" v-if="!videoInfo.type && showTimeAndComment">
<div class="playCount flex-vcenter">
<SvgIcon name="playCountIcon" color="#fff" />
{{ watchCount(videoInfo?.playCount) }}
</div>
<div class="playDuration flex-vcenter">
<template v-if="isPic">
<SvgIcon v-if="isPic" name="cover_pic" color="#fff" style="font-size: 16px; margin-right: 2px" />
{{ watchCount(videoInfo.seriesCover?.length) || 0 }}
</template>
<template v-else>
{{ videoTime(videoInfo?.playTime) }}
</template>
</div>
</div>
<div class="subscriptInfoAdv" v-else-if="videoInfo.type">广告</div>
</div>
<div
class="videoTitleBox"
:class="{
'videoTitleBox--compact':
hideVideoCardTagsAndComments && !videoInfo.type && !videoInfo.mediaType && showType !== 5,
}"
>
<div class="videoTitle">{{ videoInfo?.title }}</div>
<!-- acg -->
<div class="acg-info" v-if="['video', 'image'].includes(videoInfo.mediaType)">
<span v-if="videoInfo.updateStatus != 1">共{{ videoInfo.totalEpisode }}话 · 已完结</span>
<span class="loading" v-else>更新{{ videoInfo.currentEpisode || videoInfo.totalEpisode }}话 · 连载中</span>
</div>
<div
class="videoTitleInfo flex-center-between"
v-else-if="
!videoInfo.type &&
showTimeAndComment &&
(!hideVideoCardTagsAndComments || showType === 5) &&
videoInfo.newsType !== 'PIC'
"
>
<template v-if="showType && [5].includes(showType)">
<span class="collectCount">收藏:{{ watchCount(videoInfo?.collectCount) }}</span>
</template>
<template v-else>
<div class="tagBox ellipsis" @click.stop="jumpTag()" v-if="getTag()">
{{ getTag()?.name }}
</div>
<div v-else></div>
<div class="commentCount">
评论{{ watchCount(videoInfo?.commentCount > 0 ? videoInfo?.commentCount : 0) }}
</div>
</template>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { watchCount, videoTime, jumpAdv, isLongFormVideo } from '@/utils/index';
import { inject, computed, onMounted, type ComputedRef } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { useVideoStore } from '@/stores/video';
import { useConfigStore } from '@/stores/config';
import { sharedFreeWatchCount, ensureSharedFreeWatchCount } from '@/utils/sharedFreeWatchCount';
const configStore = useConfigStore();
const userStore = useUserStore();
const router = useRouter();
const videoStore = useVideoStore();
const hideVideoCardTagsAndComments = inject('hideVideoCardTagsAndComments', false);
const props = withDefaults(
defineProps<{
videoInfo: any;
videoList: any[];
isMovie?: boolean;
isNoDelPageCache?: string; // 是否需要不删除缓存
checkIsOpen?: boolean;
showTimeAndComment?: boolean; // 是否展示评论数和上架时间 默认true
awPermissions?: boolean; // 是否开启暗网视频权限, 默认false
isPic?: boolean; // 是否是图集 默认false
showType?: number;
acgHorizontal?: boolean;
/** 外部可强制编辑态,优先于 inject */
isEdit?: boolean;
/** delete: 单项删除图标;select: 多选圆圈 */
editMode?: 'delete' | 'select';
selected?: boolean;
}>(),
{
videoInfo: () => <any>{},
videoList: () => [],
isMovie: () => false,
isNoDelPageCache: () => '',
checkIsOpen: () => false,
showTimeAndComment: () => true,
awPermissions: () => false,
isPic: () => false,
isEdit: undefined,
editMode: undefined,
selected: () => false,
},
);
/** getPlayCount.watchCount <= 1 时不展示免费试看角标 */
const showFreeTrialIcon = computed(() => {
if (!configStore.appConfig.freeMark || !props.videoInfo?.showFreeTrialBadge) return false;
const remaining =
sharedFreeWatchCount.value !== null && sharedFreeWatchCount.value >= 0
? sharedFreeWatchCount.value
: Number(props.videoInfo.freeTrialRemaining);
if (Number.isFinite(remaining) && remaining <= 1) return false;
return true;
});
const showAwPopup = computed(() => {
if (props.awPermissions && props.videoInfo?.tags?.length && userStore.userInfo.vipLevel < 3) {
let tags = props.videoInfo.tags;
return tags.some((item: any) => item?.name?.indexOf('暗网') > -1);
}
return false;
});
const emit = defineEmits(['changeVideo', 'del', 'select', 'click']);
onMounted(() => {
if (configStore.appConfig.freeMark && props.videoInfo?.showFreeTrialBadge) {
ensureSharedFreeWatchCount(props.videoInfo?.id);
}
});
// 获取标签:默认第二个,第二个没有就取第一个,第一个没有就空
const getTag = () => {
const tags = props.videoInfo?.tags;
if (tags?.length) {
return tags[0] || '';
}
return undefined;
};
const jumpTag = () => {
router.push({
path: '/tagVideoList',
query: {
id: getTag()?.id,
tagName: getTag()?.name,
newsType: 'SP',
},
});
};
const buildVideoQuery = (video: Types.Video.VInfosItem) => {
const query: Record<string, string> = {
vid: video.id,
uid: String(video.publisher?.uid || ''),
};
if (video.searchAccessTicket) {
query.searchAccessTicket = video.searchAccessTicket;
}
return query;
};
const openVideo = () => {
if (isEdit.value) {
if (editMode.value === 'select') {
toggleSelect();
}
return;
}
emit('click');
// 必须读 props:列表刷新后组件可能复用,禁止缓存 setup 时的 videoInfo
const videoInfo = props.videoInfo;
if (videoInfo.type) {
jumpAdv(videoInfo);
return;
}
// 点击的暗网视频,需要权限
if (showAwPopup.value) {
router.push(`/member?id=${configStore.appConfig.darkWebVipId}`);
return;
}
if (videoInfo.newsType == 'COVER') {
router.push({
path: '/postDetail',
query: {
postid: videoInfo.id,
},
});
} else if (videoInfo.mediaType == 'video') {
router.push({
path: '/AcgVideoDetail',
query: {
id: videoInfo.id,
},
});
} else if (videoInfo.mediaType == 'image') {
router.push({
path: '/AcgImageDetail',
query: {
id: videoInfo.id,
},
});
} else if (videoInfo.newsType == 'PIC') {
router.push({ path: '/coverPreview', query: { index: 1, id: videoInfo.id } });
} else if (videoInfo.mediaType == 'text') {
router.push({ path: '/AcgTextDetail', query: { id: videoInfo.id } });
} else {
if (isLongFormVideo(videoInfo)) {
if (props.isMovie) {
emit('changeVideo', videoInfo);
} else {
router.push({
path: '/movieDetails',
query: buildVideoQuery(videoInfo),
});
}
return;
} else {
videoStore.deleteVideoList();
videoStore.saveVideoList(props.videoList, videoInfo);
const query: Record<string, string> = { id: videoInfo.id };
if (videoInfo.searchAccessTicket) {
query.searchAccessTicket = videoInfo.searchAccessTicket;
}
router.push({ path: '/shortVideoDetails', query });
}
}
};
const injectedEdit = inject<ComputedRef<boolean> | boolean>('isEdit', false);
const injectedEditMode = inject<'delete' | 'select' | ComputedRef<'delete' | 'select'>>('editMode', 'delete');
const isEdit = computed(() => {
if (typeof props.isEdit === 'boolean') return props.isEdit;
if (typeof injectedEdit === 'object' && injectedEdit && 'value' in injectedEdit) {
return Boolean(injectedEdit.value);
}
return Boolean(injectedEdit);
});
const editMode = computed(() => {
if (props.editMode === 'select' || props.editMode === 'delete') return props.editMode;
if (typeof injectedEditMode === 'object' && injectedEditMode && 'value' in injectedEditMode) {
return injectedEditMode.value || 'delete';
}
return injectedEditMode || 'delete';
});
const toggleSelect = () => {
emit('select', props.videoInfo?.id);
};
const del = async (info: any) => {
emit('del', info.id);
return;
};
</script>
<style lang="scss" scoped>
.horizontalVideoBox {
height: 100%;
width: 100%;
position: relative;
.coverBox {
height: 98px;
overflow: hidden;
position: relative;
border-radius: 6px;
transform: translateZ(0px);
.awPermissions-popup {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 20;
backdrop-filter: blur(8px);
.blur-background {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 30;
}
.awPermissions-content {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 88;
color: #fafafa;
font-size: 10px;
line-height: 16px;
width: 100%;
span {
color: #f68804;
}
}
}
.avatar {
position: relative;
border-radius: 8px 8px 0 0;
overflow: hidden;
}
.vip_icon {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
width: 27px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 10px;
border-radius: 3px;
font-family: 'PingFang SC';
color: #fff;
background: linear-gradient(72deg, #FF8000 10.74%, #FF7A01 66.04%);
// 免费
&.free_icon {
width: 32px;
color: #fff;
background: #1CD302;
}
// 免费试看
&.free_trial_icon {
color: #fff;
background: rgba(28, 211, 2, 1);
}
// 金币
&.coins_icon {
color: #1f2029;
background: linear-gradient(135deg, #FFE580 0%, #FACC15 100%);
}
}
.videoInfo {
height: 20px;
width: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.6));
position: absolute;
left: 0;
bottom: 0;
padding: 0 9px 0 4px;
transform: translateZ(0px);
font-size: 10px;
color: #fff;
.playCount {
> svg {
height: 14px;
width: 14px;
margin-right: 2px;
}
}
}
.subscriptInfo {
position: absolute;
top: 0;
left: 0;
z-index: 1;
height: 16px;
width: 34px;
border-radius: 0 0 4px 0;
font-size: 11px;
font-weight: 500;
text-align: center;
line-height: 16px;
.vipLabel {
width: 34px;
height: 18px;
border-radius: 0 0 4px 0;
background: #fb415c;
font-size: 12px;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
}
.goldLabel {
height: 19px;
background: #fb415c;
padding: 0 6px;
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: 0 0 4px 0;
img {
width: 9px;
}
span {
font-size: 12px;
color: #ffffff;
padding-left: 3px;
}
}
}
.subscriptInfoAdv {
transform: translateZ(0px);
position: absolute;
top: 0;
left: 0;
z-index: 1;
height: 16px;
width: 34px;
border-radius: 0 0 4px 0;
font-size: 11px;
font-weight: 500;
color: rgb(0, 0, 0);
background-color: rgb(255, 235, 58);
text-align: center;
line-height: 16px;
}
}
.videoTitleBox {
margin-bottom: 12px;
.videoTitle {
height: 35px;
font-size: 12px;
color: #ffffff;
letter-spacing: 0.24px;
font-weight: 500;
margin: 4px 0;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&--compact .videoTitle {
height: auto;
}
.acg-info {
span {
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
}
.loading {
color: #eec76b;
}
}
.videoTitleInfo {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
.tagBox {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 3px;
padding: 4px 6px 4px;
max-width: 95px;
}
.collectCount {
font-size: 11px;
}
}
}
}
.delBtn {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
top: 0;
left: 0;
z-index: 50;
.selectActive {
width: 24px;
height: 24px;
position: absolute;
top: 6px;
right: 6px;
z-index: 100;
}
}
.selectBtn {
position: absolute;
top: 8px;
right: 8px;
z-index: 50;
width: 24px;
height: 24px;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid #989898;
background: rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
&.active {
border-color: #ffc000;
background: #ffc000;
&::after {
content: '';
width: 6px;
height: 10px;
border: 2px solid #1f2029;
border-top: 0;
border-left: 0;
transform: rotate(45deg) translateY(-1px);
}
}
}
</style>
@@ -0,0 +1,368 @@
<template>
<div class="horizontalVideoBox">
<HorizontalVideo
:videoInfo="videoInfo"
:searchAccessTicket="searchAccessTicket"
@getFreeCount="getFreeCount"
@jumpOverHandle="jumpOverHandle"
@showPreviewTime="showPreviewTime"
@timeupdate="emit('timeupdate', $event)"
:hasDarkWebTag="hasDarkWebTag"
@play="play"
ref="verticalVideoRef"
class="videoBox"
/>
<div v-if="isPreSaleCoin" v-show="isVipFreeShow" class="tipBox">
免费金币观影数: {{ useGlobalStore().advanceStatus.privilegeLimit.remain.todayCoinVideoCount }}次
</div>
<template v-else>
<div class="tipBox" v-if="videoInfo.freeArea && isVipFreeShow">免费视频</div>
<div
class="tipBox"
v-else-if="
!isVip && userFreeCount.watchCount > 0 && videoInfo.originCoins == 0 && !videoInfo.freeArea && isVipFreeShow
"
>
免费观影数: {{ userFreeCount.watchCount
}}{{ userFreeCount.totalWatchCount ? `/${userFreeCount.totalWatchCount}` : '' }}次
</div>
<div class="tipBox" v-else-if="isVip && videoInfo.originCoins == 0 && !videoInfo.freeArea && isVipFreeShow">
已享VIP免费特权
</div>
<div
class="tipBox"
v-else-if="!isVip && userFreeCount.isCan && videoInfo.originCoins == 0 && !videoInfo.freeArea && isVipFreeShow"
>
已享免费特权
</div>
<div
class="tipBox"
v-else-if="
((videoInfo.originCoins != 0 && videoInfo.coins == 0) ||
(isVip && userInfo.vipLevel >= 2 && isVipFreeShow && !hasDarkWebTag) ||
(hasDarkWebTag && userInfo.vipLevel == 4)) &&
isVipFreeShow
"
>
已享金币视频免费特权
</div>
<div class="tipBox" v-else-if="videoInfo.vidStatus.hasPaid && isVipFreeShow">已购买完整版</div>
<div
class="tipBox"
@click="jumpOverHandle('manual')"
v-else-if="
isFreeCountLoaded &&
!isVip &&
!userFreeCount.isCan &&
videoInfo.originCoins == 0 &&
videoInfo.freeArea == false &&
!videoInfo.vidStatus.hasPaid
"
>
跳过预览
</div>
<!-- 金币视频 -->
<template
v-else-if="
videoInfo.originCoins > 0 &&
!videoInfo.vidStatus.hasPaid &&
!videoInfo.freeArea &&
!(isVip && userInfo.vipLevel >= 2)
"
>
<div class="tipBox" @click="jumpOverHandle('manual')" v-if="!(videoInfo.originCoins != 0 && videoInfo.coins == 0)">
跳过预览
</div>
</template>
</template>
<VideoPermissionMask
:visible="showVideoMask"
:videoInfo="videoInfo"
:triggerSource="videoMaskTriggerSource"
enable-coin-pay-popup
@buySuccess="buySuccess"
@showVipPopUpImmediately="openPaymentStatusPopup"
@openCoinPayPopup="showCoinVideoPayPopup = true"
/>
<CoinVideoPayPopup
:is-open="showCoinVideoPayPopup"
:video-info="videoInfo"
@close="showCoinVideoPayPopup = false"
@buySuccess="buySuccess"
/>
<AppSharePopup :is-open="visibleShare" @close="visibleShare = false" :cover="props.videoInfo.cover"></AppSharePopup>
</div>
</template>
<script lang="ts" setup>
import { Ref, ref, computed, onDeactivated, watch } from 'vue';
import { useUserStore } from '@/stores/user';
import { showToast } from 'vant';
import { ProductType } from '@/enums/app';
import { useRouter } from 'vue-router';
import VideoApi from '@/service/video';
import { judgeTime } from '@/utils/index';
import { useConfigStore } from '@/stores/config';
import { useGlobalStore } from '@/stores/global';
import MineApi from '@/service/mine';
const verticalVideoRef = ref();
const configStore = useConfigStore();
const globalStore = useGlobalStore();
const router = useRouter();
let isPreview = ref(false); // 是否预览视频
let previewTime = ref(0); // 预览视频时间
let isVipFreeShow = ref(true); //VIP免费特权
let isPreSaleCoin = ref(false); // 是否显示预售金币免费提示
const showVideoMask = ref(false); // 是否显示视频遮罩
const showCoinVideoPayPopup = ref(false);
const videoMaskTriggerSource = ref<'manual' | 'previewEnd'>('previewEnd');
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
searchAccessTicket?: string;
}>();
const userStore = useUserStore();
const { isVip, freeVideo30, userInfo, walletInfo, updateWallet } = userStore;
let userFreeCount: Ref<Types.Video.UserFreeCount> = ref({
isCan: false,
watchCount: 0,
});
// 免费次数接口返回前不渲染会员视频「跳过预览」,避免先闪再切成免费观影数
const isFreeCountLoaded = ref(false);
const emit = defineEmits([
'getFreeCount',
'play',
'showVipPopUp',
'showVipPopUpImmediately',
'buySuccess',
'timeupdate',
]);
// 是否含有暗网
const hasDarkWebTag = computed(() => props.videoInfo?.tags?.some((item) => item.name.indexOf('暗网') > -1));
/**
* @description 判断是否预览视频
*/
const checkPreview = () => {
if (
!props.videoInfo.vidStatus.hasPaid &&
!(freeVideo30 && props.videoInfo.originCoins < 30 && props.videoInfo.originCoins != 0) &&
!(props.videoInfo.originCoins === 0 && isVip) &&
!(userInfo.uid === props.videoInfo.publisher.uid) &&
!(userFreeCount.value.isCan === true && props.videoInfo.originCoins === 0) &&
props.videoInfo.previewURL
) {
isPreview.value = true;
} else {
isPreview.value = false;
}
};
let isFirst = false;
const play = () => {
emit('play');
if (!isFirst) {
isFirst = true;
setTimeout(() => {
isVipFreeShow.value = false;
}, 3000);
}
};
checkPreview();
//免费观看视频次数
const getFreeCount = (userCount: Types.Video.UserFreeCount) => {
userFreeCount.value = userCount;
isFreeCountLoaded.value = true;
emit('getFreeCount', userCount);
};
const showPreviewTime = (time: number) => {
previewTime.value = parseInt(time.toString());
};
const shouldAutoShowMemberPopup = () => {
const info = props.videoInfo;
if (isVip || info.freeArea || info.vidStatus.hasPaid) return false;
return info.originCoins === 0;
};
const jumpOverHandle = (source: 'manual' | 'previewEnd' = 'previewEnd') => {
verticalVideoRef.value?.pause?.();
updateWallet();
videoMaskTriggerSource.value = source;
// 遮罩已展示时半屏可能刚被用户关掉;勿重复 emit,否则点遮罩外会「关了又弹」
const alreadyShowingMask = showVideoMask.value;
showVideoMask.value = true;
// 试看结束:由详情页分流(无免费次数 → VIP 列表;其余仅遮罩)
if (source === 'previewEnd' && shouldAutoShowMemberPopup() && !alreadyShowingMask) {
emit('showVipPopUp');
}
};
const openPaymentStatusPopup = () => {
verticalVideoRef.value?.pause?.();
emit('showVipPopUpImmediately');
};
//获取当前视频价格
const getGoldCoin = () => {
return isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
};
/**
* @description 视频购买成功
*/
const buySuccess = () => {
showToast('购买成功');
showVideoMask.value = false;
showCoinVideoPayPopup.value = false;
useUserStore().updateWallet();
verticalVideoRef.value.success();
emit('buySuccess');
};
/**
* @description 视频购买
*/
const nowBuy = () => {
try {
let productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
VideoApi.videoBuying({ productType: ProductType.VIDEO, productID: productID })
.then(async () => {
buySuccess();
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
router.push({
path: '/wallet',
query: {
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
}
});
} catch (e) {
console.log(e);
}
};
// 预售活动相关
const requestPrivilegeConsume = async () => {
if (useGlobalStore().presaleObj.status != 0 && globalStore.advanceStatus.privilegeLimit.hasLimit) {
if (globalStore.advanceStatus.privilegeLimit.remain.todayCoinVideoCount > 0) {
try {
let req = {
count: 1,
privilegeType: 'coinVideo',
};
await MineApi.privilegeConsume(req);
isPreSaleCoin.value = true;
globalStore.setPreSaleCoinVideoPlay(true);
globalStore.advanceStatus.privilegeLimit.remain.todayCoinVideoCount -= 1;
} catch (e) {
globalStore.setPreSaleCoinVideoPlay(false);
}
} else {
globalStore.setPreSaleCoinVideoPlay(false);
}
} else {
globalStore.setPreSaleCoinVideoPlay(false);
}
};
const init = async () => {
// 预售活动开启了,获取预售信息
if (configStore.appConfig.advanceStatus && configStore.appConfig.advanceStatus.activityStatus) {
if (
!(props.videoInfo.originCoins != 0 && props.videoInfo.coins == 0) &&
props.videoInfo.originCoins > 0 &&
!props.videoInfo.vidStatus.hasPaid &&
!props.videoInfo.freeArea
) {
requestPrivilegeConsume();
}
}
};
init();
const closeOverlays = () => {
showCoinVideoPayPopup.value = false;
showVideoMask.value = false;
};
watch(
() => userStore.isVip,
(vip) => {
if (!vip || !showVideoMask.value) return;
const info = props.videoInfo;
if (info.originCoins === 0 && !info.freeArea && !info.vidStatus.hasPaid) {
showVideoMask.value = false;
verticalVideoRef.value?.play?.();
}
},
);
onDeactivated(closeOverlays);
defineExpose({
buySuccess,
closeOverlays,
pause: () => verticalVideoRef.value?.pause?.(),
play: () => verticalVideoRef.value?.play?.(),
});
const visibleShare = ref(false);
</script>
<style lang="scss" scoped>
.horizontalVideoBox {
height: 100%;
width: 100%;
position: relative;
.tipBox {
position: absolute;
z-index: 999;
top: 12px;
right: 16px;
background: rgba(0, 0, 0, 0.6);
font-size: 12px;
font-weight: 400;
color: rgb(255, 255, 255);
height: 26px;
line-height: 26px;
padding: 0 12px;
border-radius: 6px;
span {
color: rgb(255, 13, 44);
}
}
.goldPopup {
position: absolute;
left: 0;
top: 0;
z-index: 999;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.7);
font-size: 14px;
padding-top: 42px;
text-align: center;
.content {
margin: 26px 0;
span {
color: rgb(62, 100, 234);
}
}
.btnBox {
.btn {
padding: 6px 18px;
line-height: 20px;
border-radius: 30px;
background: linear-gradient(90deg, #ffe8be 1.65%, #e6b764 99.23%);
text-align: center;
font-size: 14px;
color: #694923;
font-weight: 400;
&.primary {
margin-left: 15px;
background: linear-gradient(286deg, #ff4d4d 0%, #ff6e6e 100%);
color: #fff;
}
}
}
}
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<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>
+71
View File
@@ -0,0 +1,71 @@
<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>
+129
View File
@@ -0,0 +1,129 @@
<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>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div
class="adv_swiper"
:class="showTitle ? 'showTitleBox' : ''"
v-if="props.list.length > 0"
@touchstart.stop
@touchmove.stop
@touchend.stop
ref="advRef"
>
<div class="adv-container">
<van-swipe
class="my-swipe"
:show-indicators="isShowIndicators"
:indicator-color="indicatorColor"
:autoplay="2000"
:key="props.swiperKey"
>
<van-swipe-item
@click="handleJumpAdv(item, props.clickType)"
v-for="item in props.list"
:key="item.id"
class="adv-swiper-slide"
>
<lazyImg :src="item.cover" class="cover" />
<div class="advTitle ellipsis2" v-if="showTitle">{{ item.title }}</div>
</van-swipe-item>
</van-swipe>
</div>
</div>
</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<{
list: Types.App.AdsInfoListItem[];
height?: string;
isShowIndicators?: boolean;
clickType?: 'ads' | 'recreation';
indicatorColor?: string;
showTitle?: boolean;
swiperKey?: string;
adImpression?: AdImpressionType;
}>(),
{
list: () => [],
isShowIndicators: true,
clickType: 'ads',
indicatorColor: '#fff',
showTitle: () => true,
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>
<style lang="scss" scoped>
:deep(.van-swipe__indicator--active) {
display: flex;
width: 100%;
justify-content: flex-end;
padding-right: 12px;
}
.adv_swiper {
overflow: hidden;
border-radius: 6px;
.adv-container {
width: 100%;
overflow: hidden;
:deep(.van-swipe__track) {
width: 100% !important;
}
.adv-swiper-slide {
width: 100% !important;
border-radius: 6px;
overflow: hidden;
.cover {
height: 96px;
border-radius: 6px;
overflow: hidden;
}
.advTitle {
color: #fff;
padding: 4px 0;
font-size: 12px;
letter-spacing: 0.24px;
font-weight: 500;
line-height: 18px;
}
}
}
}
:deep(.van-swipe__indicator) {
width: 2px;
height: 2px;
border: none;
background-color: #fff;
}
:deep(.van-swipe__indicator--active) {
width: 4px;
height: 2px;
border-radius: 3px;
}
.showTitleBox {
:deep(.van-swipe__indicators) {
top: 80px;
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="persalePopup">
<van-overlay :show="isShow" z-index="1998" @click="emit('close',true)">
<div class="wrapper" @click.stop>
<div class="btn" @click="emit('close',false)"></div>
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { reactive,computed,nextTick } from 'vue';
const state = reactive({
});
const props = defineProps<{
isOpen: boolean;
}>();
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
}
.persalePopup {
height: 100%;
width: 100%;
position: relative;
.wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 342px;
height: 456px;
box-sizing: border-box;
background: url('@/assets/images/png/jointGameing/popup.png') no-repeat;
background-size: 100% 100%;
.btn{
position: absolute;
width: 185px;
height: 50px;
bottom: 32px;
left: 50%;
border-radius: 30px;
transform: translate(-50%, 0);
}
}
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="kingKongBox" v-if="kingKongList.length">
<div class="kingkongScroll" @touchstart.stop @touchmove.stop @touchend.stop @scroll="kingkongScroll">
<div class="kingKong flex-center-between" v-for="item in kingKongList" :key="item.id" @click="jumpAdv(item)">
<lazyImg class="kingKongIcon" :src="item.img" :key="item.id" />
<div class="kingKongName">{{ item.name }}</div>
</div>
</div>
<!-- <van-progress pivot-text="" color="#FD2D55" :percentage="percentage" /> -->
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useConfigStore } from '@/stores/config';
import { jumpAdv } from '@/utils/index';
const configStore = useConfigStore();
const props = withDefaults(
defineProps<{
moduleId: string;
}>(),
{},
);
const percentage = ref(0);
const kingKongList = computed(() => {
let arr = configStore.appConfig?.jgArea || []; // 获取金刚区数据
if (props.moduleId) {
return arr.filter((item) => item.mid == props.moduleId);
}
return [];
});
const kingkongScroll = (event: any) => {
let scrollLeft = event.target.scrollLeft;
let scrollWidth = event.target.scrollWidth;
let clientWidth = event.target.clientWidth;
percentage.value = (scrollLeft / (scrollWidth - clientWidth)) * 100;
};
</script>
<style lang="scss" scoped>
.kingKongBox {
width: 100%;
margin: 12px 0 8px 0px;
position: relative;
.kingkongScroll {
padding-left: 16px;
display: flex;
overflow-x: auto;
}
.kingKong {
margin-right: 20px;
flex-direction: column;
.kingKongIcon {
height: 40px;
width: 40px;
border-radius: 8px;
overflow: hidden;
:deep(.loading) {
background-size: 18px 18px;
}
}
.kingKongName {
width: 48px;
overflow: hidden;
white-space: nowrap;
font-size: 12px;
margin-top: 8px;
text-align: center;
color: rgba(255, 255, 255, 0.55);
}
}
:deep(.van-progress) {
margin: 10px auto 0;
width: 26px;
}
}
</style>
@@ -0,0 +1,372 @@
<template>
<Teleport to="body">
<van-popup :show="show" round position="bottom" @click-overlay="emit('close')" @open="open">
<div class="limitedTimeOfferBox">
<div class="line"></div>
<div class="title-box">
<div :class="`title_${state.title}`"></div>
<div class="time-box">
<van-count-down :time="countdown">
<template #default="timeData">
<div class="h">{{ displayHours(timeData) }}</div>
<span>:</span>
<div class="m">{{ timeData.minutes < 10 ? '0' + timeData.minutes : timeData.minutes }}</div>
<span>:</span>
<div class="s">{{ timeData.seconds < 10 ? '0' + timeData.seconds : timeData.seconds }}</div>
</template>
</van-count-down>
</div>
</div>
<template v-if="state.currentCard && Object.keys(state.currentCard).length">
<div class="permissions" @touchstart.stop @touchmove.stop @touchend.stop>
<div class="item" v-for="(item, index) in state.currentCard.newPrivilege" :key="index">
<lazyImg class="item-img" :src="item.img" :key="item.img"></lazyImg>
{{ item.privilegeName }}
</div>
</div>
<div class="card-box">
<div class="item" v-for="item in state.vipCards" :key="item.productID" @click="selectCard(item)">
<lazyImg class="img" :src="item.newBgImg" :key="item.newBgImg"></lazyImg>
<SvgIcon name="radio-select" color="#F68804" v-if="item.productID == state.currentCard.productID" />
<SvgIcon name="radio-unselect" color="rgb(255,255,255)" v-else />
</div>
</div>
<div class="price-box">
<div class="item">
<span class="l">原价</span>
<span class="r">¥{{ state.currentCard.originalPrice / 10 }}</span>
</div>
<div class="item">
<span class="l">限时优惠</span>
<span class="r">
¥{{
((state.currentCard.originalPrice || 0) * 1000 - (state.currentCard.discountedPriceIos || 0) * 1000) /
10000
}}
</span>
</div>
<div class="item total">
<span class="l">总计</span>
<span class="r">¥{{ state.currentCard.discountedPriceIos / 10 }}</span>
</div>
</div>
<div class="footter">
<div class="btn" @click.stop="showPay">立即支付</div>
</div>
</template>
<div v-else class="isLoading">
<Loading />
</div>
</div>
</van-popup>
</Teleport>
<RechargeBox ref="rechargeBoxRef" :is-open="state.isRecharge" @closePay="handleClosePay" />
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { useUserStore } from '@/stores/user';
import MineApi from '@/service/mine';
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore();
const userStore = useUserStore();
const countdown = ref(0);
const rechargeBoxRef = ref();
const props = defineProps({
show: {
type: Boolean,
default: () => false,
},
});
const emit = defineEmits(['close']);
const state = reactive({
vipCards: <any>[],
dcInfo: <any>{},
isRecharge: false,
currentCard: <any>{},
loading: false,
title: 'new',
isNewbie: true,
});
const selectCard = (item: any) => {
state.currentCard = item;
};
// 限时优惠
const newcomerActivities = async () => {
let createAt = new Date(userStore.userInfo.createdAt).getTime();
let now = Date.now();
if (now - createAt < configStore.appConfig?.newbieSaleTime * 60 * 60 * 1000) {
let newbieSaleTime = configStore.appConfig?.newbieSaleTime * 60 * 60 * 1000;
countdown.value = newbieSaleTime + createAt - now;
state.title = 'new';
state.isNewbie = true;
} else if (now - createAt > 3600000 * 24) {
let oldReturnSaleTime = configStore.appConfig?.oldReturnSaleTime * 60 * 60 * 1000;
let firstLoginTime = localStorage.getItem(`first_login_time_${userStore.userInfo.uid}`);
if (firstLoginTime) {
countdown.value = oldReturnSaleTime + Number(firstLoginTime) - now;
}
state.title = 'return';
state.isNewbie = false;
}
};
// 查询会员卡列表
const getVipList = async () => {
let res: any = await MineApi.queryVipProduct({
isNewUser: true,
});
let arr: any = [];
if (res?.list?.length) {
res.list.forEach((item: any) =>
item.list.forEach((v: any) => {
if (state.isNewbie) {
if (configStore.appConfig?.recommendVipIds && configStore.appConfig?.recommendVipIds?.includes(v.productID)) {
arr.push(v);
}
} else {
if (
configStore.appConfig?.returnSaleVipIds &&
configStore.appConfig?.returnSaleVipIds?.includes(v.productID)
) {
arr.push(v);
}
}
}),
);
state.vipCards = arr;
state.currentCard = arr[0] ?? {};
state.dcInfo = res.daichong;
}
};
const open = () => {
newcomerActivities();
};
const showPay = () => {
let payData = {
rechargeList: state.currentCard.rchgType, // 支付方式列表
dcInfo: state.dcInfo, // 代充信息
popType: 'vip', // 弹窗类型
productType: state.currentCard.productType, // 商品类型
productID: state.currentCard.productID, // 商品ID
dcType: 1, // 代充商品类型
buyType: 4, // 商品类型 1-是金币 2-是游戏币 3-是果币 4-是商品
discountedPrice: state.currentCard.discountedPrice / 10, // 充值金额
isAmountPay: state.currentCard.isAmountPay, // 是否支持金币兑换
finalPayStatus: null, //预售卡是否支付尾款
sourcePage: 'VIDEO_BOTTOM_SHEET' as const,
};
rechargeBoxRef.value.changeItem(payData);
state.isRecharge = true;
};
const handleClosePay = () => {
state.isRecharge = false;
};
const displayHours = (timeData: any) => {
if (timeData.days && timeData.days > 0) {
const total = timeData.days * 24 + timeData.hours;
return total < 10 ? '0' + total : total;
}
return timeData.hours < 10 ? '0' + timeData.hours : timeData.hours;
};
watch(
() => props.show,
async (newVal: boolean) => {
if (newVal) {
state.loading = true;
await newcomerActivities();
await getVipList();
state.loading = false;
}
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.limitedTimeOfferBox {
border-radius: 20px 20px 0px 0px;
background: linear-gradient(180deg, #0f0f0f 50.09%, #251d0b 99.95%);
padding: 14px 16px 0px;
display: flex;
flex-direction: column;
align-items: center;
max-height: 80vh;
.line {
width: 36px;
height: 5px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.2);
}
.title-box {
margin: 14px 0;
display: flex;
align-items: center;
.title_new {
background: url('@/assets/images/png/new-discount-title.png') no-repeat;
background-size: 100% 100%;
width: 123px;
height: 22px;
margin-right: 12px;
}
.title_return {
background: url('@/assets/images/png/return-discount-title.png') no-repeat;
background-size: 100% 100%;
width: 123px;
height: 22px;
margin-right: 12px;
}
.time-box {
display: flex;
align-items: center;
color: rgba(255, 255, 255, 0.9);
font-family: Inter;
font-style: normal;
:deep(.van-count-down) {
display: flex;
align-items: center;
}
span {
font-size: 20px;
margin: 0 5px;
color: rgba(255, 255, 255, 0.9);
}
.h,
.m,
.s {
border-radius: 3px;
background: #f68804;
display: flex;
width: 24px;
height: 24px;
flex-direction: column;
justify-content: center;
align-items: center;
color: #fff;
text-align: center;
font-feature-settings: 'liga' off, 'clig' off;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
}
}
.isLoading {
height: 400px;
}
.permissions {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
display: flex;
align-items: center;
flex-wrap: nowrap;
.item {
flex-shrink: 0;
margin-right: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 20px;
.item-img {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 4px;
}
}
}
.card-box {
margin-top: 14px;
width: 100%;
display: flex;
align-items: center;
width: 100%;
justify-content: center;
:deep(.van-swipe) {
width: 100%;
height: 152px;
}
:deep(.van-swipe-item) {
height: 152px;
}
// padding: 0 30px;
.item {
width: 118px;
height: 152px;
position: relative;
margin-right: 18px;
svg {
position: absolute;
width: 18px;
height: 18px;
right: 6px;
top: 6px;
}
:deep(img) {
width: 100%;
height: 100%;
object-fit: contain !important;
}
}
}
.price-box {
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
padding: 12px;
width: 100%;
margin-top: 14px;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
.item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&:nth-of-type(1) {
.r {
color: rgba(255, 255, 255, 0.55);
text-decoration: line-through;
}
}
&:nth-of-type(2) {
.r {
color: #ff9000;
font-weight: 500;
}
}
&:nth-of-type(3) {
.r {
// color: rgba(255, 255, 255, 1);
}
}
}
.total {
margin-bottom: 0;
padding-top: 14px;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
}
}
.footter {
padding: 14px 0;
width: 100%;
.btn {
width: 100%;
border-radius: 3px;
background: #f68804;
height: 44px;
line-height: 44px;
text-align: center;
color: #fff;
font-weight: 500;
font-size: 16px;
}
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="loading_box">
<img class="loading" :src="loadingSrc" rel="external nofollow" />
</div>
</template>
<script lang="ts" setup>
import loadingSrc from '@/assets/gif/loading.gif';
</script>
<style lang="scss" scoped>
.loading_box {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
img {
width: 30px;
}
}
</style>
@@ -0,0 +1,87 @@
<!-- 大横屏无限制 -->
<template>
<div class="bigHorizontalScreenUnlimit">
<HorizontalVideoBox :videoInfo="item" :videoList="videoList" v-for="item in videoList" :key="item.id" />
<!-- 首页仅保留 101、102、103 的底部操作,107 暂时停用。
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<div class="optName">更换一批</div>
</div>
</div>
-->
</div>
</template>
<script lang="ts" setup>
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { computed } from 'vue';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoList = computed(() => state.section.allVideoInfo || state.section.allMediaInfo || []);
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
let num = userStore.userInfo.uid % 2;
list = list.filter(
(item: any) => !item.showType || (num == 0 && item.showType == 2) || (num == 1 && item.showType == 1),
);
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox .coverBox) {
height: 197px !important;
}
</style>
@@ -0,0 +1,113 @@
<!-- 大横屏 + 横版滑动(小)布局 -->
<template>
<div class="bigScreenHorizontalSlidingSmall">
<HorizontalVideoBox :videoList="videoInfoList" :videoInfo="videoInfo" :key="videoInfo.id" />
<div class="slideBox" @touchstart.stop @touchmove.stop @touchend.stop>
<HorizontalVideoBox
class="slideItem"
:videoList="videoInfoList"
:videoInfo="item"
v-for="item in videoInfoList"
:key="item.id"
/>
</div>
<!-- <div class="holidays_qw_area_bg flex-center-between">
<div class="flex-center-center" @click="jumpMore">
<div class="holidays_qw_vmore" />
<div class="holidays_qw_area_name">看更多</div>
</div>
<div class="holidays_qw_vertical" />
<div class="flex-center-center" @click="change">
<div class="holidays_qw_refresh" :id="'refresh' + state.section.sectionID" />
<div class="holidays_qw_area_name">换一批</div>
</div>
</div> -->
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoInfo: any = computed(() => {
if (state.section.videoInfo) {
if (state.section.videoInfo.length > 1) {
return state.section.videoInfo[0];
} else {
return null;
}
} else {
return null;
}
});
const videoInfoList: any = computed(() => {
if (state.section.videoInfo) {
if (state.section.videoInfo.length > 1) {
return state.section.videoInfo.slice(1, state.section.videoInfo.length);
} else {
return [];
}
} else {
return [];
}
});
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
let svg: any = document.querySelector('#refresh' + state.section.sectionID);
svg.classList.add('refresh-icon');
try {
let res: any = await HomeApi.queryVidChange(state.section.sectionID);
if (res.videos) {
state.section.videoInfo = res.videos;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
svg?.classList.remove('refresh-icon');
state.lock = false;
}
};
const jumpMore = () => {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox .coverBox) {
height: 197px;
}
:deep(.slideBox .horizontalVideoBox .coverBox) {
height: 94px;
width: 147px;
}
:deep(.slideBox .horizontalVideoBox .videoTitle) {
width: 147px;
}
.bigScreenHorizontalSlidingSmall {
.slideBox {
display: flex;
overflow-x: auto;
box-sizing: border-box;
.slideItem {
margin-right: 10px;
}
}
}
</style>
@@ -0,0 +1,105 @@
<!-- 四宫格(横) -->
<template>
<div class="fourGridHorizontal">
<HorizontalVideoBox
class="videoBox"
:videoList="videoList"
:videoInfo="item"
v-for="item in videoList"
:key="item.id"
acgHorizontal
:showTimeAndComment="!item.mediaType"
/>
<!-- 此组件也用于未知 showType 的兜底,底部操作仅在 102 时保留。 -->
<div v-if="section.showType == 102" class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<!-- <SvgIcon name="viewMore" class="optIcon"></SvgIcon> -->
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<!-- <SvgIcon name="refreshIcon" class="optIcon" :id="'refresh' + section.sectionID"></SvgIcon> -->
<div class="optName">更换一批</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = reactive({
section: props.section,
lock: false, // 换一换的锁
});
const videoList = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo || [];
return arr.slice(0, 4);
});
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
let num = userStore.userInfo.uid % 2;
list = list.filter(
(item: any) => !item.showType || (num == 0 && item.showType == 2) || (num == 1 && item.showType == 1),
);
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
.fourGridHorizontal {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.videoBox {
width: 168px;
}
}
</style>
@@ -0,0 +1,119 @@
<!-- 四宫格(竖) -->
<template>
<div class="fourGridVertical">
<HorizontalVideoBox
class="videoBox"
:videoList="videoInfoList"
:videoInfo="item"
v-for="item in videoInfoList"
:key="item.id"
:isCollection="true"
:showTimeAndComment="false"
/>
<!-- 首页仅保留 101、102、103 的底部操作,201 暂时停用。
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<div class="optName">更换一批</div>
</div>
</div>
-->
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const videoInfoList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.slice(0, 4);
} else {
return [];
}
});
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
let num = userStore.userInfo.uid % 2;
list = list.filter(
(item: any) => !item.showType || (num == 0 && item.showType == 2) || (num == 1 && item.showType == 1),
);
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox) {
.coverBox {
height: 224px;
}
.videoTitleBox {
.videoTitle {
height: 20px;
-webkit-line-clamp: 1;
}
}
}
.fourGridVertical {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.videoBox {
width: 168px;
}
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="guessYouLike">
<div class="title">
<div class="title-icon"></div>
</div>
<van-tabs
v-model:active="state.tabActive"
class="guess-tabs"
title-active-color="rgba(255, 255, 255, 0.90)"
title-inactive-color="rgba(255, 255, 255, 0.45)"
animated
:scrollspy="false"
:lazy-render="true"
@change="getList()"
>
<van-tab :name="item.key" title="" v-for="(item, index) in state.tabList" :key="index">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="moreMoviesBox">
<HorizontalVideoBox
class="videoBox"
:videoInfo="item"
:videoList="state.list"
v-for="item in state.list"
:key="item.id"
/>
</div>
</PullRefresh>
</van-tab>
</van-tabs>
</div>
</template>
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import VideoApi from '@/service/video';
const props = defineProps<{
moduleId: number | string;
}>();
const state = reactive({
tabActive: 0,
tabList: [
// {
// label: '最多收藏',
// key: 3,
// },
// {
// label: '最新上架',
// key: 1,
// },
{
label: '最多观看',
key: 0,
},
],
pageNumber: 1,
pageSize: 10,
list: <any>[],
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const getList = async () => {
let req = {
pageNumber: state.pageNumber,
pageSize: state.pageSize,
type: state.tabActive,
};
try {
let res: any = await VideoApi.getGuessYouLike({ id: props.moduleId, data: req });
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.allVideoInfo || [];
if (state.pageNumber === 1) {
state.list = list;
} else {
state.list = state.list.concat(list);
}
moreParams.isNoData = state.list.length === 0;
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
watch(
() => props.moduleId,
(v) => {
if (v) {
getList();
}
},
{
immediate: true,
},
);
const onLoad = () => {
if (moreParams.refreshing) return;
moreParams.loading = true;
state.pageNumber++;
getList();
};
const onRefresh = () => {
state.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList();
};
</script>
<style lang="scss" scoped>
.guessYouLike {
height: 100%;
background: linear-gradient(180deg, rgba(225, 53, 31, 0) 0%, rgba(225, 53, 31, 0.2) 50.5%, rgba(225, 53, 31, 0) 62%);
background-size: 375px 212px;
background-position: -16px 0px;
.title {
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
margin-bottom: 10px;
height: 52px;
background: url('@/assets/images/png/guessYouLike-bg.png') no-repeat;
background-size: 100% 100%;
position: relative;
.title-icon {
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-50%);
background: url('@/assets/images/png/guessYouLike-title.png') no-repeat;
background-size: 100% 100%;
width: 63.5px;
height: 16px;
}
}
.guess-tabs {
:deep(.van-tabs__wrap) {
display: none;
padding-right: 70px;
.van-tabs__nav {
.van-tab {
font-size: 14px !important;
font-weight: 400;
justify-content: flex-start;
}
.van-tabs__line {
display: none;
}
}
}
}
}
.moreMoviesBox {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 7px;
padding: 0;
.videoBox {
width: 174px;
}
}
</style>
@@ -0,0 +1,91 @@
<!-- 横版滑动(大)布局 -->
<template>
<div class="horizontalSlidingBig">
<div class="slideBox" @touchstart.stop @touchmove.stop @touchend.stop>
<HorizontalVideoBox
class="slideItem"
:videoInfo="item"
:videoList="videoList"
v-for="item in videoList"
:key="item.id"
/>
</div>
<!-- <div class="holidays_qw_area_bg flex-center-between">
<div class="flex-center-center" @click="jumpMore">
<div class="holidays_qw_vmore" />
<div class="holidays_qw_area_name">看更多</div>
</div>
<div class="holidays_qw_vertical" />
<div class="flex-center-center" @click="change">
<div class="holidays_qw_refresh" :id="'refresh' + state.section.sectionID" />
<div class="holidays_qw_area_name">换一批</div>
</div>
</div> -->
</div>
</template>
<script lang="ts" setup>
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { computed } from 'vue';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoList = computed(() => state.section.allVideoInfo || state.section.allMediaInfo || []);
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
let svg: any = document.querySelector('#refresh' + state.section.sectionID);
svg.classList.add('refresh-icon');
try {
let res: any = await HomeApi.queryVidChange(state.section.sectionID);
if (res.videos) {
state.section.videoInfo = res.videos;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
svg?.classList.remove('refresh-icon');
state.lock = false;
}
};
const jumpMore = () => {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox) {
width: 235px;
.coverBox {
height: 132px;
}
}
.horizontalSlidingBig {
width: 100dvw;
.slideBox {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
width: 100%;
.slideItem {
margin-right: 10px;
flex-shrink: 0;
}
}
}
</style>
@@ -0,0 +1,91 @@
<!-- 横版滑动(小)布局 -->
<template>
<div class="horizontalSlidingSmall">
<div class="slideBox" @touchstart.stop @touchmove.stop @touchend.stop>
<HorizontalVideoBox
class="slideItem"
:videoInfo="item"
:videoList="videoList"
v-for="item in videoList"
:key="item.id"
/>
</div>
<!-- <div class="holidays_qw_area_bg flex-center-between">
<div class="flex-center-center" @click="jumpMore">
<div class="holidays_qw_vmore" />
<div class="holidays_qw_area_name">看更多</div>
</div>
<div class="holidays_qw_vertical" />
<div class="flex-center-center" @click="change">
<div class="holidays_qw_refresh" :id="'refresh' + state.section.sectionID" />
<div class="holidays_qw_area_name">换一批</div>
</div>
</div> -->
</div>
</template>
<script lang="ts" setup>
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { computed } from 'vue';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoList = computed(() => state.section.allVideoInfo || state.section.allMediaInfo || []);
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
let svg: any = document.querySelector('#refresh' + state.section.sectionID);
svg.classList.add('refresh-icon');
try {
let res: any = await HomeApi.queryVidChange(state.section.sectionID);
if (res.videos) {
state.section.videoInfo = res.videos;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
svg?.classList.remove('refresh-icon');
state.lock = false;
}
};
const jumpMore = () => {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox) {
width: 147px;
.coverBox {
height: 82px;
}
}
.horizontalSlidingSmall {
width: 100dvw;
.slideBox {
display: flex;
overflow-x: auto;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
width: 100%;
.slideItem {
margin-right: 10px;
flex-shrink: 0;
}
}
}
</style>
@@ -0,0 +1,55 @@
<!-- 三横版布局 -->
<template>
<div class="horizontalThreeBig">
<div class="slideBox openX">
<HorizontalVideoBox
:videoInfo="item"
:videoList="videoList"
v-for="item in videoList.slice(0, 3)"
:key="item.id"
:showType="5"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { useVideoStore } from '@/stores/video';
import { computed } from 'vue';
const router = useRouter();
const videoStore = useVideoStore();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
pageNumber: 1,
pageSize: 6,
section: props.section,
};
const videoList = computed(() => state.section.allVideoInfo || state.section.allMediaInfo || []);
</script>
<style lang="scss" scoped>
.horizontalThreeBig {
.slideBox {
:deep(.horizontalVideoBox) {
display: flex;
align-items: center;
margin-bottom: 12px;
.coverBox {
height: 96px;
width: 170px;
}
.videoTitleBox {
flex: 1;
height: 96px;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 12px;
box-sizing: border-box;
}
}
}
}
</style>
@@ -0,0 +1,138 @@
<!-- 一大四小布局 -->
<template>
<div class="oneBigFourSmall">
<HorizontalVideoBox :videoList="videoInfoPlayList" :videoInfo="allVideoInfo" :key="allVideoInfo?.id" />
<div class="fourGrid">
<HorizontalVideoBox
class="videoBox"
:videoInfo="item"
:videoList="videoInfoPlayList"
v-for="item in videoInfoList"
:key="item.id"
/>
</div>
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<!-- <SvgIcon name="viewMore" class="optIcon"></SvgIcon> -->
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<!-- <SvgIcon name="refreshIcon" class="optIcon" :id="'refresh' + section.sectionID"></SvgIcon> -->
<div class="optName">更换一批</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import HomeApi from '@/service/home';
const router = useRouter();
const userStore = useUserStore();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const allVideoInfo: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
if (arr.length > 0) {
return arr[0];
} else {
return {};
}
} else {
return {};
}
});
const videoInfoList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.slice(1, 5) || [];
} else {
return [];
}
});
const videoInfoPlayList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.slice(0, 5);
} else {
return [];
}
});
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
let num = userStore.userInfo.uid % 2;
list = list.filter(
(item: any) => !item.showType || (num == 0 && item.showType == 2) || (num == 1 && item.showType == 1),
);
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox .coverBox) {
height: 197px !important;
}
:deep(.fourGrid .horizontalVideoBox .coverBox) {
height: 94px !important;
width: 168px;
}
.oneBigFourSmall {
.fourGrid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.videoBox {
width: 168px;
}
}
}
</style>
@@ -0,0 +1,106 @@
<!-- 六宫格 -->
<template>
<div class="sixGrid">
<HorizontalVideoBox
class="videoBox"
:videoInfo="item"
:videoList="videoInfoList"
v-for="item in videoInfoList"
:key="item.id"
/>
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<!-- <SvgIcon name="viewMore" class="optIcon"></SvgIcon> -->
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<!-- <SvgIcon name="refreshIcon" class="optIcon" :id="'refresh' + section.sectionID"></SvgIcon> -->
<div class="optName">更换一批</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const videoInfoList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.length > 6 ? arr.slice(0, 6) : arr;
} else {
return [];
}
});
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
let num = userStore.userInfo.uid % 2;
list = list.filter(
(item: any) => !item.showType || (num == 0 && item.showType == 2) || (num == 1 && item.showType == 1),
);
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
.sixGrid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.videoBox {
width: 168px;
}
}
</style>
@@ -0,0 +1,108 @@
<template>
<div class="updateDaily" v-if="state.list && state.list.length">
<div class="title">{{ moduleName }}精品-每日更新</div>
<div class="main">
<PullRefresh :params="moreParams" @onLoad="onLoad">
<div class="videoSlide">
<HorizontalVideoBox
class="videoBox"
:videoInfo="item"
:videoList="state.list"
v-for="item in state.list"
:key="item.id"
/>
</div>
</PullRefresh>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import HomeApi from '@/service/home';
const props = withDefaults(
defineProps<{
tagId: string;
moduleName: string;
}>(),
{
tagId: '',
moduleName: '',
},
);
const state = reactive({
list: <Array<Types.Video.VInfosItem>>[],
pageNumber: 1,
pageSize: 10,
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
disabled: true,
});
// 获取数据
const getList = async (type: any) => {
try {
let req = {
type: 0,
model: 1,
tag: props.tagId || undefined,
paymentType: 0,
pageNumber: state.pageNumber,
pageSize: state.pageSize,
time: new Date().toISOString(),
};
let res: any = await HomeApi.queryRecommendVidList(req);
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.list || [];
if (type === 'refresh') {
state.list = list;
} else {
state.list = state.list.concat(list);
}
if (state.pageNumber == 1 && state.list.length == 0) {
moreParams.isNoData = true;
}
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
getList('');
const onLoad = () => {
moreParams.loading = true;
state.pageNumber++;
getList(null);
};
</script>
<style lang="scss" scoped>
.updateDaily {
padding: 0 14px;
height: 100%;
.title {
font-size: 14px;
color: $titleColor;
margin-bottom: 10px;
}
.main {
height: calc(100vh - 100px);
.videoSlide {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.videoBox {
width: 168px;
}
}
}
}
</style>
@@ -0,0 +1,134 @@
<!-- 竖排9宫格 -->
<template>
<div class="verticalNineGrid">
<div class="videoBox">
<HorizontalVideoBox
class="slideItem"
:videoInfo="item"
:videoList="videoList"
v-for="item in videoList"
:key="item.id"
:isCollection="true"
:showTimeAndComment="false"
/>
<!-- 首页仅保留 101、102、103 的底部操作,203 暂时停用。
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<div class="optName">更换一批</div>
</div>
</div>
-->
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.length > 9 ? arr.slice(0, 9) : arr;
} else {
return [];
}
});
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox) {
margin-bottom: 10px;
}
:deep(.horizontalVideoBox .coverBox) {
height: 150px;
}
.verticalNineGrid {
width: 100%;
.videoBox {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.slideItem {
width: 111px;
:deep(.coverBox) {
height: 148px;
}
:deep(.videoTitleBox) {
.videoTitle {
height: 20px;
-webkit-line-clamp: 1;
}
}
}
}
}
// width: 100dvw;
// .videoBox {
// width: 100%;
// display: flex;
// overflow-x: auto;
// -webkit-overflow-scrolling: touch;
// box-sizing: border-box;
// .slideItem {
// flex-shrink: 0;
// margin-right: 10px;
// }
// }
</style>
@@ -0,0 +1,118 @@
<!-- 竖排六宫格 -->
<template>
<div class="verticalSixGrid">
<div class="videoBox">
<HorizontalVideoBox
class="videoItem"
:videoInfo="item"
:videoList="videoInfoList"
v-for="item in videoInfoList"
:key="item.id"
:isCollection="true"
:showTimeAndComment="false"
/>
</div>
<!-- 首页仅保留 101、102、103 的底部操作,202 暂时停用。
<div class="layoutDisplay-optArea flex-center-between">
<div class="optBox flex-center-center" @click="jumpMore">
<div class="optName">更多片源</div>
</div>
<div class="optBox change flex-center-center" @click="change">
<div class="optName">更换一批</div>
</div>
</div>
-->
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const videoInfoList: any = computed(() => {
let arr = state.section.allVideoInfo || state.section.allMediaInfo;
if (arr) {
return arr.length > 6 ? arr.slice(0, 6) : arr;
} else {
return [];
}
});
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
// let svg: any = document.querySelector('#refresh' + state.section.sectionID);
// svg.classList.add('refresh-icon');
try {
let res: any;
let list: any = [];
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
list = res.medias || [];
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
list = res.videos || [];
}
if (list?.length) {
state.section.allVideoInfo = list;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
state.lock = false;
}
};
const jumpMore = () => {
if (state.section?.allMediaInfo?.length) {
router.push({
path: '/AcgTopicList',
query: {
id: state.section.sectionID,
title: state.section.sectionName,
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
}
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox .coverBox) {
height: 162px;
}
.verticalSixGrid {
.videoBox {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-column-gap: 5px;
.videoItem {
width: 111px;
:deep(.coverBox) {
height: 148px;
}
:deep(.videoTitleBox) {
.videoTitle {
height: 20px;
-webkit-line-clamp: 1;
}
}
}
}
}
</style>
@@ -0,0 +1,106 @@
<!-- 竖版滑动(大)布局 -->
<template>
<div class="verticalSlideBig">
<div class="videoBox" @touchstart.stop @touchmove.stop @touchend.stop>
<HorizontalVideoBox
class="slideItem"
:videoInfo="item"
:videoList="videoList"
v-for="item in videoList"
:key="item.id"
:isCollection="true"
:showTimeAndComment="false"
/>
</div>
<!-- <div class="holidays_qw_area_bg flex-center-between">
<div class="flex-center-center" @click="jumpMore">
<div class="holidays_qw_vmore" />
<div class="holidays_qw_area_name">看更多</div>
</div>
<div class="holidays_qw_vertical" />
<div class="flex-center-center" @click="change">
<div class="holidays_qw_refresh" :id="'refresh' + state.section.sectionID" />
<div class="holidays_qw_area_name">换一批</div>
</div>
</div> -->
</div>
</template>
<script lang="ts" setup>
import { showToast } from 'vant';
import { useRouter } from 'vue-router';
import HomeApi from '@/service/home';
import { computed } from 'vue';
const router = useRouter();
const props = defineProps<{
section: Types.Home.SectionsItem;
}>();
const state = {
section: props.section,
lock: false, // 换一换的锁
};
const videoList = computed(() => state.section.allVideoInfo || state.section.allMediaInfo || []);
const change = async () => {
if (state.lock) return showToast('请勿频繁操作');
state.lock = true;
let svg: any = document.querySelector('#refresh' + state.section.sectionID);
svg.classList.add('refresh-icon');
try {
let res: any;
if (state.section?.allMediaInfo?.length) {
res = await HomeApi.queryMediaChange(state.section.sectionID);
} else {
res = await HomeApi.queryVidChange(state.section.sectionID);
}
if (res.videos) {
state.section.videoInfo = res.videos;
} else {
showToast('没有更多数据了');
}
} catch (e) {
console.log(e);
} finally {
svg?.classList.remove('refresh-icon');
state.lock = false;
}
};
const jumpMore = () => {
router.push({
path: '/tagShortVideo',
query: {
id: state.section.sectionID,
name: state.section.sectionName,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.horizontalVideoBox .coverBox) {
height: 205px;
width: 146px;
}
:deep(.horizontalVideoBox) {
width: 146px;
}
.verticalSlideBig {
width: 100dvw;
.videoBox {
width: 100%;
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
.slideItem {
flex-shrink: 0;
margin-right: 10px;
:deep(.videoTitleBox) {
.videoTitle {
height: 20px;
-webkit-line-clamp: 1;
}
}
}
}
}
</style>
@@ -0,0 +1,215 @@
<!-- 二竖屏左右滑动 -->
<template>
<div class="horizontalTwoSlid" v-if="state.newArr?.length" @touchstart.stop @touchmove.stop @touchend.stop>
<swiper class="swiper" :loop="true" :effect="'creative'" :slidesPerView="'auto'">
<swiper-slide class="swiperSlide" v-for="(arr, index) in state.newArr" :key="index">
<div class="actorBox" v-for="item in arr" :key="item.id" @click="jumpPage(item)">
<lazyImg class="actorAvatar" :src="item.cover ?? item.verticalCover" :key="item.cover ?? item.verticalCover">
<template v-if="['video', 'image', 'text'].includes(item.mediaType)">
<div class="vip_icon free_icon" v-if="configStore.appConfig.freeMark && item.permission === 2">免费</div>
<div class="vip_icon" v-if="configStore.appConfig.vipMark && item.permission == 0">VIP</div>
<div
class="vip_icon coins_icon"
v-if="configStore.appConfig.coinMark && item.permission === 1 && item.price"
>
金币
</div>
</template>
<template v-else>
<div class="vip_icon" v-if="configStore.appConfig.vipMark && item?.originCoins == 0 && !item?.freeArea">
VIP
</div>
<div
class="vip_icon coins_icon"
v-if="configStore.appConfig.coinMark && item?.originCoins != 0 && !item?.freeArea"
>
金币
</div>
</template>
</lazyImg>
<div class="infoBox">
<div class="actorName ellipsis2">{{ item.title }}</div>
<p class="desc" v-if="item.mediaType">
共{{ item.totalEpisode }}话 · {{ item.updateStatus === 2 ? '已完结' : '更新中' }}
</p>
<p class="desc" v-else></p>
</div>
</div>
</swiper-slide>
</swiper>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed } from 'vue';
import { useRouter } from 'vue-router';
import { Swiper, SwiperSlide } from 'swiper/vue';
import 'swiper/swiper.min.css';
import { jumpAdv, isLongFormVideo } from '@/utils';
import { useVideoStore } from '@/stores/video';
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore();
const videoStore = useVideoStore();
const props = defineProps<{
section: any;
}>();
const router = useRouter();
const state = reactive({
section: props.section,
newArr: <Array<any>>[],
});
// 切割数组
const group = (array: any, subGroupLength: any) => {
let index = 0;
let newArray = [];
while (index < array.length) {
newArray.push(array.slice(index, (index += subGroupLength)));
}
return newArray;
};
const init = () => {
state.newArr = group(state.section.allMediaInfo || state.section.allVideoInfo || [], 2);
};
init();
const jumpPage = (videoInfo: any) => {
if (videoInfo.mediaType) {
if (videoInfo.mediaType == 'video') {
router.push({
path: '/AcgVideoDetail',
query: {
id: videoInfo?.id,
},
});
return;
} else if (videoInfo.mediaType == 'image') {
router.push({
path: '/AcgImageDetail',
query: {
id: videoInfo?.id,
},
});
}
return;
}
if (videoInfo.type) {
jumpAdv(videoInfo);
return;
}
if (videoInfo.newsType == 'COVER') {
router.push({
path: '/postDetail',
query: {
postid: videoInfo.id,
},
});
} else {
if (isLongFormVideo(videoInfo)) {
router.push({
path: '/movieDetails',
query: {
vid: videoInfo.id,
uid: videoInfo.publisher?.uid,
},
});
return;
} else {
videoStore.deleteVideoList();
videoStore.saveVideoList(state.section.allMediaInfo || state.section.allVideoInfo, videoInfo);
router.push(`/shortVideoDetails?id=${videoInfo.id}`);
}
}
};
</script>
<style lang="scss" scoped>
.horizontalTwoSlid {
margin-top: 18px;
.titleBox {
padding: 0 10px;
.title {
color: #333333;
font-size: 18px;
font-weight: 600;
}
span {
color: #999999;
font-size: 12px;
.moreRight {
width: 16px;
height: 16px;
color: rgba(51, 51, 51, 1);
}
}
}
// :deep(.van-swipe-item) {
// width: 320px !important;
// }
.swiperSlide {
width: 276px;
}
.actorBox {
display: flex;
//margin-left: 10px;
//margin-top: 12px;
margin-bottom: 12px;
width: 256px;
.actorAvatar {
height: 110px;
width: 82px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
position: relative;
.vip_icon {
position: absolute;
top: 4px;
right: 4px;
width: 27px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 10px;
border-radius: 3px;
font-family: 'PingFang SC';
color: #292929;
background: linear-gradient(135deg, #f8f3ae 0%, #ddac44 100%);
// 免费
&.free_icon {
width: 32px;
color: #fff;
background: #1cd302;
}
// 金币
&.coins_icon {
color: #1f2029;
background: linear-gradient(135deg, #ffe580 0%, #facc15 100%);
}
}
}
.infoBox {
padding-left: 12px;
width: 160px;
display: flex;
justify-content: center;
flex-direction: column;
.actorName {
color: rgba(255, 255, 255, 1);
font-size: 12px;
font-weight: 500;
margin-bottom: 3px;
line-height: 18px;
}
.desc {
line-height: 18px;
//overflow: hidden;
//text-overflow: ellipsis;
//white-space: nowrap;
color: rgba(152, 152, 152, 1);
font-weight: 400;
font-size: 11px;
// width: 228px;
}
}
}
}
</style>
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="homeItem" ref="homeItemRef" :id="state.setId">
<van-pull-refresh
v-if="!state.loading"
v-model="moreParams.refreshing"
@refresh="onRefresh"
:disabled="moreParams.disabled"
success-text="刷新成功"
:class="[{ 'my-refresh': moreParams.isHigehtMax, none: moreParams.refreshing }]"
>
<ModuleBanners :banners="banners" :moduleType="type" />
<HomeHeader :advType="AdType.HOME" class="advBox" v-if="!props.pureVersion" />
<!-- 公告 -->
<!-- <MarqueeBar :announce="state.announce" /> -->
<!-- 金刚区 -->
<KingKong :moduleId="moduleId" />
<van-list
v-if="!moreParams.isNoData"
v-model:loading="moreParams.loading"
:finished="moreParams.finished"
finished-text="没有更多数据了"
@load="onLoad"
:offset="10"
v-model:error="moreParams.error"
:immediate-check="false"
error-text="请求失败,点击重新加载"
>
<!-- <template v-slot:finished>
<div class="noMoreData" v-if="!userInfo.isVip && moreParams.isFindMore" @click="$router.push('member')">
点击发现更多刺激内容
</div>
</template> -->
<div class="listBox" v-for="(item, index) in state.list" :key="item.sectionID">
<div class="titleBox flex-center-between" @click="jumpMore(item)" v-if="item.showType != 301">
<div class="title" :class="{ red: state.isAlong }">
{{ item.sectionName }}
<div class="titleLine" v-if="state.isAlong"></div>
</div>
<div class="moreText">
<!-- 更多 -->
<SvgIcon class="moreBox" v-if="state.isAlong" name="moreIcon1" />
<SvgIcon class="moreBox" v-else name="moreIcon" />
</div>
</div>
<!-- 一大四小布局 -->
<OneBigFourSmall :section="item" v-if="item.showType == 101" />
<!-- 四宫格(横) -->
<FourGridHorizontal :section="item" v-else-if="item.showType == 102" />
<!-- 六宫格(横-长视频) -->
<SixGrid :section="item" v-else-if="item.showType == 103" />
<!-- 横屏1.2滑动 -->
<HorizontalSlidingSmall :section="item" v-else-if="item.showType == 104" />
<!-- 横屏2.2滑动 -->
<HorizontalSlidingBig :section="item" v-else-if="item.showType == 105" />
<!-- 三横版样式布局 -->
<HorizontalThreeBig :section="item" v-else-if="item.showType == 106" />
<!-- 大横屏无限制 -->
<BigHorizontalScreenUnlimit :section="item" v-else-if="item.showType == 107" />
<!-- 四宫格(竖) -->
<FourGridVertical :section="item" v-else-if="item.showType == 201" />
<!-- 竖排六宫格 -->
<VerticalSixGrid :section="item" v-else-if="item.showType == 202" />
<!-- acg九竖版布局 -->
<VerticalNineGrid :section="item" v-else-if="item.showType == 203" />
<!-- 竖版滑动(大)布局 -->
<VerticalSlideBig :section="item" v-else-if="item.showType == 204" />
<!-- acg二竖版左右滑动布局 -->
<VerticalTwoSlid :section="item" v-else-if="item.showType == 205" />
<!-- 猜你喜欢 -->
<GuessYouLike :moduleId="moduleId" v-else-if="item.showType == 301" />
<!-- 大横屏 + 横版滑动(小)布局 -->
<!-- <BigScreenHorizontalSlidingSmall :section="item" v-else-if="item.showType == 8" /> -->
<!-- 四宫格(横) -->
<FourGridHorizontal :section="item" v-else />
<!-- 广告 -->
<template v-if="state.advsList.length && !props.pureVersion">
<FiveGridAdvVal
v-if="index > 0 && (index + 1) % 2 == 0"
:adv-list="state.gridAdvsList[((index - 1) / 2) % state.gridAdvsList.length]"
/>
</template>
</div>
</van-list>
<Empty class="p14" v-if="moreParams.isNoData" />
<div class="sizeBox"></div>
</van-pull-refresh>
<Loading v-else />
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, ref, provide } from 'vue';
import { useRouter } from 'vue-router';
import { AdType } from '@/enums/app';
import { getAdItem } from '@/utils/getConfig';
import { jumpAdv } from '@/utils/index';
import { sliceArray } from '@/utils/index';
import { useGlobalStore } from '@/stores/global';
import { useConfigStore } from '@/stores/config';
import { useUserStore } from '@/stores/user';
import HomeApi from '@/service/home';
import BigHorizontalScreenUnlimit from './components/BigHorizontalScreenUnlimit.vue';
import BigScreenHorizontalSlidingSmall from './components/BigScreenHorizontalSlidingSmall.vue';
import FourGridHorizontal from './components/FourGridHorizontal.vue';
import FourGridVertical from './components/FourGridVertical.vue';
import HorizontalSlidingBig from './components/HorizontalSlidingBig.vue';
import HorizontalSlidingSmall from './components/HorizontalSlidingSmall.vue';
import OneBigFourSmall from './components/OneBigFourSmall.vue';
import SixGrid from './components/SixGrid.vue';
import VerticalSixGrid from './components/VerticalSixGrid.vue';
import VerticalSlideBig from './components/VerticalSlideBig.vue';
import HorizontalThreeBig from './components/HorizontalThreeBig.vue';
import VerticalNineGrid from './components/VerticalNineGrid.vue';
import VerticalTwoSlid from './components/VerticalTwoSlid.vue';
import GuessYouLike from './components/GuessYouLike.vue';
import useCommon from '@/hooks/common';
provide('hideVideoCardTagsAndComments', true);
const { routeLeaveRecord, routeBackRecord } = useCommon();
const homeItemRef = ref();
const props = withDefaults(
defineProps<{
moduleId: string;
isAlong?: boolean;
tabIndex: number;
type: number;
pureVersion?: boolean;
banners?: Types.Home.ModuleBanner[] | null;
}>(),
{
moduleId: '',
isAlong: false,
tabIndex: 0,
pureVersion: false,
},
);
const router = useRouter();
const globalStore = useGlobalStore();
const configStore = useConfigStore();
const userInfo = useUserStore().userInfo;
const state = reactive({
list: <Array<Types.Home.SectionsItem>>[],
// sectionPage: 1,
// sectionSize: 10,
pageSize: 6,
pageNumber: 1,
announce: {},
advsList: <Array<Types.App.AdsInfoListItem>>[], // 专栏之间的广告
gridAdvsList: <any>[],
isNoticeShow: true,
loading: true, // loading
isAlong: props.isAlong,
setId: '', // id,用于缓存
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
disabled: false,
isFindMore: true,
});
onMounted(() => {
routeLeaveRecord(homeItemRef.value);
routeBackRecord(homeItemRef.value);
});
// 获取模块数据
const getList = async () => {
const data = {
id: props.moduleId,
data: {
pageNumber: state.pageNumber,
pageSize: state.pageSize,
},
};
try {
let res: any = await HomeApi.getModuleList(data);
state.loading = false;
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.allSection || [];
if (list[0]?.allVideoInfo?.length) {
let num = userInfo.uid % 2;
list = list.map((item: any) => ({
...item,
allVideoInfo: item.allVideoInfo.filter(
(v: any) => !v.showType || (num == 0 && v.showType == 2) || (num == 1 && v.showType == 1),
),
}));
}
if (state.pageNumber == 1) {
state.list = list;
} else {
state.list = state.list.concat(list);
}
if (state.pageNumber == 1 && state.list.length == 0) {
moreParams.isNoData = true;
}
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
state.loading = false;
moreParams.loading = false;
moreParams.error = true;
}
};
// 初始化
const init = () => {
// 专题之间的广告
state.advsList = getAdItem(AdType.TopicGridAdv);
state.gridAdvsList = sliceArray(state.advsList, 5);
// 跑马灯
// state.announce = getAnnounce(1);
state.setId = 'mainItem' + props.tabIndex;
getList();
};
// const getAnnounce = (position: number) => {
// let obj: any = {};
// if (globalStore.announceArr.length) {
// globalStore.announceArr.map((item: any) => {
// if (item.type == position) {
// obj = item;
// }
// });
// }
// return obj;
// };
init();
const jumpMore = (item: any) => {
if ([4, 5].includes(props.type)) {
router.push({
path: '/AcgTopicList',
query: {
id: item.sectionID,
title: item.sectionName,
// showType: ![201, 202, 203, 204, 205].includes(item.showType) ? '0' : '',
},
});
} else {
router.push({
path: '/tagShortVideo',
query: {
id: item.sectionID,
name: item.sectionName,
showType: [201, 202, 203, 204, 205].includes(item.showType) ? 'vertical' : '',
},
});
}
};
const onLoad = () => {
if (moreParams.refreshing) return;
moreParams.loading = true;
state.pageNumber++;
getList();
};
const onRefresh = () => {
state.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList();
};
</script>
<style lang="scss" scoped>
.homeItem {
width: 100%;
height: calc(100vh - 44px * 2 - 55px);
-webkit-overflow-scrolling: touch;
overflow-y: scroll;
:deep(.my-refresh) {
padding: 0;
}
.my-refresh {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.advBox) {
margin: 12px 0;
.firstRow {
padding: 0 16px;
}
.adv-line-2 {
padding: 0 16px;
}
.banner_box {
padding: 0 16px;
}
}
.fiveGrid {
padding: 0 16px;
margin: 12px 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-row-gap: 12px;
grid-column-gap: 10px;
.rectAdvBox {
width: 60px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 14.6px;
overflow: hidden;
}
.rectName {
font-size: 12px;
color: white;
margin-top: 4px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
}
.listBox {
width: 100%;
padding: 0 14px;
box-sizing: border-box;
margin-bottom: 20px;
.titleBox {
font-size: 18px;
color: rgb(255, 255, 255);
font-weight: 500;
margin: 10px 0 14px;
box-sizing: border-box;
.title {
position: relative;
color: rgba(255, 255, 255, 0.9);
.titleLine {
height: 11px;
width: 36px;
background: url('./../../assets/images/png/titleLine.png') no-repeat;
background-size: 100% 100%;
position: absolute;
left: 50%;
bottom: -12px;
transform: translate(-50%, 0);
}
}
.red {
font-family: 'AaLongYanTi';
color: rgb(146, 7, 7);
}
.moreText {
display: flex;
align-items: center;
color: #989898;
font-size: 12px;
}
.moreBox {
height: 20px;
width: 26px;
}
}
.advImg {
height: 72.3px;
border-radius: 6px;
overflow: hidden;
margin-top: 20px;
transform: translateZ(0px);
position: relative;
}
}
.sizeBox {
height: 70px;
}
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<!-- 跑马灯 -->
<div class="homeNoticeBox flex-vcenter" v-if="state.marquee && state.marquee.length && state.showMarquee">
<div class="not_horn">
<img src="@/assets/images/ui/notice.svg" alt="公告" />
</div>
<van-notice-bar scrollable class="homeNotice" color="#ffffff" :speed="60">
<template #default>
<span v-for="(item, index) in state.marquee" :key="item.id" @click="jumpAdv(item)">
{{ item.content }}
<span v-if="index < state.marquee.length - 1">
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</span>
</span>
</template>
</van-notice-bar>
<img src="@/assets/images/ui/close.svg" alt="关闭公告" class="closeMarquee" @click="closeMarquee" />
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue';
import { useGlobalStore } from '@/stores/global';
import { jumpAdv } from '@/utils/index';
import { useRoute } from 'vue-router';
const props = withDefaults(
defineProps<{
type?: number;
/** 与接口下发的 item.active 对应,用于筛选当前场景跑马灯 */
active?: boolean | number | string;
}>(),
{
type: 0,
},
);
const globalStore = useGlobalStore();
const state = reactive({
marquee: <any[]>[],
showMarquee: true,
});
const syncMarquee = () => {
const list = globalStore.marqueeDatas;
if (!Array.isArray(list) || !list.length) {
state.marquee = [];
return;
}
state.marquee = list.filter((item: any) => {
if (item.type != props.type) return false;
// 未传 active 时只按 type 筛;传入后精确匹配 item.active
if (props.active === undefined) return true;
return item.active ;
});
};
watch(() => [globalStore.marqueeDatas, props.type, props.active] as const, syncMarquee, {
immediate: true,
deep: true,
});
const route = useRoute();
const emit = defineEmits(['closeIcon']);
const closeMarquee = () => {
state.showMarquee = false;
emit('closeIcon', route.name);
};
</script>
<style scoped lang="scss">
.homeNoticeBox {
background: rgba(0, 0, 0, 0.8);
padding: 0 16px;
width: 100%;
.not_horn {
position: relative;
height: 18px;
width: 18px;
> img {
width: 18px;
height: 14px;
}
}
.homeNotice {
padding: 0 8px;
height: 36px;
width: 100%;
font-size: 12px;
background: transparent;
}
.closeMarquee {
height: 10px;
width: 10px;
color: rgba(93, 101, 115, 1);
}
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<template>
<div class="meTabPaymentBanner" @click="handleClick">
<lazyImg class="bannerImg" :src="bannerImg" :key="bannerImg" />
<div v-if="showCountdown" class="countdown">
<van-count-down :time="countdownMs" @finish="onCountdownFinish">
<template #default="timeData">
<div class="h">{{ formatCountdownUnit(timeData.hours, timeData.days) }}</div>
<span class="colon">:</span>
<div class="m">{{ formatCountdownUnit(timeData.minutes) }}</div>
<span class="colon">:</span>
<div class="s">{{ formatCountdownUnit(timeData.seconds) }}</div>
</template>
</van-count-down>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config';
import {
parseLastDiscountTime,
resolvePaymentConfigImg,
} from '@/utils/paymentStatusPopup';
import defaultBannerImg from '@/assets/images/png/payment/me-tab-banner-vip200.png';
const router = useRouter();
const configStore = useConfigStore();
const countdownExpired = ref(false);
const paymentConfig = computed(() => configStore.appConfig?.paymentStatusPopupConfig);
const bannerImg = computed(
() => resolvePaymentConfigImg(paymentConfig.value?.meTab) || defaultBannerImg,
);
const discountDeadline = computed(() => parseLastDiscountTime(paymentConfig.value?.lastDiscountTime));
const countdownMs = computed(() => {
if (!discountDeadline.value || countdownExpired.value) return 0;
const remaining = discountDeadline.value - Date.now();
return remaining > 0 ? remaining : 0;
});
const showCountdown = computed(() => countdownMs.value > 0);
const formatCountdownUnit = (value: number, days = 0) => {
const total = days > 0 ? days * 24 + value : value;
return total < 10 ? `0${total}` : `${total}`;
};
const handleClick = () => {
const id = paymentConfig.value?.vipCard || '';
router.push(`/member${id ? `?id=${id}` : ''}`);
};
const onCountdownFinish = () => {
countdownExpired.value = true;
};
watch(
() => paymentConfig.value?.lastDiscountTime,
() => {
countdownExpired.value = false;
},
);
</script>
<style lang="scss" scoped>
.meTabPaymentBanner {
position: relative;
width: 100%;
border-radius: 8px;
.bannerImg {
width: 100%;
display: block;
line-height: 0;
:deep(.lazy_img),
:deep(.laz_com) {
width: 100%;
display: block;
line-height: 0;
}
:deep(img) {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
}
.countdown {
position: absolute;
left: 20.55%;
bottom: 7.35%;
z-index: 1;
height: 19px;
:deep(.van-count-down) {
display: flex;
align-items: center;
height: 19px;
}
.h,
.m,
.s {
min-width: 21px;
height: 19px;
line-height: 17px;
padding: 1px 3px;
box-sizing: border-box;
border-radius: 3px;
background: #1b1b1b;
border: 0.5px solid #ffe381;
color: #fff;
font-size: 12px;
font-weight: 400;
text-align: center;
font-family: 'PingFang SC', sans-serif;
}
.colon {
width: 11px;
margin: 0;
color: #fff;
font-size: 12px;
line-height: 19px;
text-align: center;
font-family: 'PingFang SC', sans-serif;
}
}
}
</style>
@@ -0,0 +1,734 @@
<template>
<Teleport to="body">
<van-popup :show="show" round position="bottom" @click-overlay="emit('close')" style="height: 90%">
<div class="limitedTimeOfferBox">
<div class="line"></div>
<div class="main overflow-y-auto" v-if="!state.loading" @touchstart.stop @touchmove.stop @touchend.stop>
<div class="title-box">完整版视频需要成为VIP会员才能观看! 请选择会员类型</div>
<div class="card-box">
<div class="swiper pop_swiper" ref="mySwiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(item, index) in state.vipList" :key="item.productID">
<div class="vipCard">
<div class="gift ellipsis" v-if="item.actionDesc">{{ item.actionDesc }}</div>
<AnimationImg
v-show="state.selectedItem.productID == item.productID"
:src="
item.productType == 21 && state.payOrreserve == 'pay' && item.endBgSelectImg
? item.endBgSelectImg
: item.newBgImg || item.bgImg
"
class="bgImg"
:key="index"
/>
<AnimationImg
v-show="state.selectedItem.productID != item.productID"
:src="
item.productType == 21 && state.payOrreserve == 'pay' && item.endBgImg
? item.endBgImg
: item.bgImg
"
:key="index"
class="bgImg"
/>
<!-- v-if="item.productType == 5" -->
<div class="countdownBox" v-if="item.productType == 5 && state.timeStr">
{{ state.timeStr }}
<!-- <div class="hour">{{ state.hr }}</div>
<div class="minute">{{ state.min }}</div>
<div class="second">{{ state.sec }}</div> -->
</div>
<div
class="timeStrPay"
v-if="item.productType == 21 && state.payOrreserve == 'pay' && state.showPresal"
>
{{ state.timeStrPay }}
</div>
<div class="timeStrPay" v-if="item.productType == 21 && !state.showPresal">活动已结束</div>
<div class="timeStrPay alBooked" v-if="item.productType == 21 && state.payOrreserve == 'wait'">
已预定 请在{{ dateFormatMmDd(state.activityDetail.startTime) }}支付尾款
</div>
</div>
</div>
</div>
</div>
</div>
<div class="horizontalLine"></div>
<!-- 预售入口 -->
<template
v-if="
configStore.appConfig.advanceStatus &&
configStore.appConfig.advanceStatus.activityStatus &&
state.activityDetail.status != 4 &&
state.showPresal
"
>
<!-- 未购买 -->
<lazyImg
class="preSaleEntBox"
:src="configStore?.appConfig?.advancePage?.banner"
:key="configStore?.appConfig?.advancePage?.banner"
@click="$router.push('/presale')"
v-if="!state.payOrreserve"
/>
<!-- 已预定 -->
<div class="preSaledEntBox" @click="$router.push('/presale')" v-else>
<lazyImg
:src="configStore?.appConfig?.advancePage?.bannerWait"
:key="configStore?.appConfig?.advancePage?.bannerWait"
class="payBtn"
v-if="state.payOrreserve == 'wait'"
></lazyImg>
<lazyImg
:src="configStore?.appConfig?.advancePage?.bannerProc"
:key="configStore?.appConfig?.advancePage?.bannerProc"
class="payBtn"
v-else
>
<div class="time">{{ state.timeStrPay }}</div>
</lazyImg>
</div>
</template>
<div class="rights-box">
<div class="subTitleBox">
<img src="@/assets/images/png/mine/privilege_text.png" alt="" />
</div>
<div class="privilegeBox overflow-y-auto" @touchstart.stop @touchmove.stop @touchend.stop>
<div class="privilegeItem" v-for="item in state.currentPrivilege" :key="item.privilegeName">
<LazyImg :src="item.img" class="cover" />
<div class="subTitle">{{ item.privilegeName }}</div>
<div class="desc ellipsis2" v-html="item.privilegeDesc.replace(/\n/g, '<br>')"></div>
</div>
</div>
</div>
</div>
<Loading class="loading" v-else />
<div class="bottomBox">
<div class="payBtnBox" v-if="state.selectedItem.productType == 21">
<template v-if="state.payOrreserve == 'wait'">
<DecryptImg
@click="showToast('尾款支付暂未开放,请您耐心等待~')"
:src="configStore?.appConfig?.advancePage?.buttonWaitBackground"
:key="configStore?.appConfig?.advancePage?.buttonWaitBackground"
/>
</template>
<template v-else>
<template v-if="state.payOrreserve != 'pay'">
<template v-if="state.selectedItem.isUpgrade">
<div class="nowPay" @click="showPay">{{ state.selectedItem.balanceAmount / 10 }}元/立即升级</div>
</template>
<template v-else>
<DecryptImg
@click="showPay"
:src="configStore?.appConfig?.advancePage?.buttonBackground"
:key="configStore?.appConfig?.advancePage?.buttonBackground"
/>
</template>
</template>
<template v-else-if="state.payOrreserve == 'pay'">
<DecryptImg
@click="showPay"
:src="configStore?.appConfig?.advancePage?.buttonProcBackground"
:key="configStore?.appConfig?.advancePage?.buttonProcBackground"
/>
</template>
</template>
</div>
<!-- 普通会员卡支付按钮 -->
<div class="nowPay" v-else @click="showPay">
{{ state.selectedItem.discountedPrice / 10 }}元/{{ state.selectedItem.isUpgrade ? '立即升级' : '立即支付' }}
</div>
</div>
</div>
</van-popup>
</Teleport>
<RechargeBox ref="rechargeBoxRef" :is-open="state.isRecharge" @closePay="closePay" />
</template>
<script lang="ts" setup>
import { reactive, ref, onDeactivated, watch } from 'vue';
import MineApi from '@/service/mine';
import HomeApi from '@/service/home';
import AppApi from '@/service/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useConfigStore } from '@/stores/config';
import { dateFormatMmDd, judgeTime, dateFormatYyyyMmDd } from '@/utils/index';
import { parseVipProductResponse } from '@/utils/vipProductList';
import Swiper from 'swiper';
import 'swiper/swiper.min.css';
const userStore = useUserStore();
const rechargeBoxRef = ref();
const configStore = useConfigStore();
const props = defineProps({
show: {
type: Boolean,
default: () => false,
},
});
const emit = defineEmits(['close']);
const mySwiper = ref();
const state = reactive({
activeValue: 'member',
userWallet: <Types.User.WalletInfo>{},
vipList: <Array<Types.Recharge.VipItem>>[], // vip列表
currentPrivilege: <Array<Types.Recharge.PrivilegeItem>>[], // 当前vip卡特权
integralList: <Array<Types.Recharge.IntegralList>>[], // 积分列表
dcInfo: <Types.Recharge.Daichong>{}, // 代充信息
isRecharge: false, // 充值界面
selectedItem: <Types.Recharge.VipItem>{}, // 选中的会员卡
payData: <any>{}, // 支付对象
countdownSec: 0, // 新手倒计时时间 单位秒
day: '', // 新手倒计时-天
hr: '', // 新手倒计时-小时
min: '', // 新手倒计时-分钟
sec: '', // 新手倒计时-秒
timeStr: '',
timeoutTimer: <any>null, // 倒计时计时器
timeStrPay: '',
timeoutTimerPay: <any>null, // 支付尾款倒计时
loading: true, // 加载loading
activityDetail: <any>{},
payOrreserve: '', //支付尾款或者已预定
isPayPresal: false, //是否支付尾款
nowData: <any>{},
showPresal: true, // 默认true
});
const initSwiper = () => {
new Swiper('.pop_swiper', {
direction: 'horizontal',
// loop: true,
slidesPerView: 'auto',
spaceBetween: 6,
// centeredSlides: true,
// watchSlidesProgress: true,
on: {
click: (swiper) => {
if (mySwiper.value.swiper) {
let index = swiper.clickedIndex % state.vipList.length;
let vip = state.vipList[index];
selectVip(vip);
swiper.slideTo(index);
}
},
slideChange: (swiper) => {
// 滑动结束后切换选中的vip
let vip = state.vipList[swiper.realIndex];
selectVip(vip);
},
},
});
};
// 图片解密
const imgDecypt = (list: any) => {
try {
let newBgImg = '',
bgImg = '',
endBgSelectImg = '',
endBgImg = '';
list.forEach((item: any) => {
const isHttp = new RegExp('http').test(item.newBgImg);
isHttp == true ? (newBgImg = item.newBgImg) : (newBgImg = configStore.sourceInfo.IMAGE + '/' + item.newBgImg);
AppApi.imgDecypt(newBgImg).then((url: any) => {
item.newBgImg = url;
});
const isHttp1 = new RegExp('http').test(item.bgImg);
isHttp1 == true ? (bgImg = item.bgImg) : (bgImg = configStore.sourceInfo.IMAGE + '/' + item.bgImg);
AppApi.imgDecypt(bgImg).then((url: any) => {
item.bgImg = url;
});
if (item.endBgSelectImg) {
const isHttp2 = new RegExp('http').test(item.endBgSelectImg);
isHttp2 == true
? (endBgSelectImg = item.endBgSelectImg)
: (endBgSelectImg = configStore.sourceInfo.IMAGE + '/' + item.endBgSelectImg);
AppApi.imgDecypt(endBgSelectImg).then((url: any) => {
item.endBgSelectImg = url;
});
}
if (item.endBgImg) {
const isHttp3 = new RegExp('http').test(item.endBgImg);
isHttp3 == true ? (endBgImg = item.endBgImg) : (endBgImg = configStore.sourceInfo.IMAGE + '/' + item.endBgImg);
AppApi.imgDecypt(endBgImg).then((url: any) => {
item.endBgImg = url;
});
}
});
} catch (err) {
console.log(err);
}
};
// 查询会员卡列表
const getVipList = async () => {
const res = await MineApi.queryVipProduct();
state.loading = false;
state.integralList = res.integralList;
const { cards, dcInfo } = parseVipProductResponse(res, {
includePresale: true,
presaleActivityActive: !!configStore.appConfig.advanceStatus?.activityStatus,
showPresal: state.showPresal,
activityDetailStatus: state.activityDetail?.status,
});
state.vipList = cards;
state.dcInfo = dcInfo;
};
// 查询预售信息
const getPresaleData = async () => {
try {
let req = {
pageNumber: 1,
pageSize: 1,
};
let res: any = await HomeApi.getPresaleList(req);
state.activityDetail = res.activityDetail;
// 判断预售状态 如果章台是2或者3可进行尾款支付 但是要判断时间
// 付尾款的时间未开始,但是已经预定了 就展示已预订
if (state.activityDetail.status == 2 || state.activityDetail.status == 3) {
// 如果当前时间大于尾款支付时间 -- 支付尾款 反之 已预定
if (judgeTime(state.activityDetail.startTime)) {
state.payOrreserve = 'pay';
// 尾款倒计时
const timers = new Date(state.activityDetail.endTime).getTime();
countdownPay(timers);
} else {
state.payOrreserve = 'wait';
}
}
// 判断剩余支付时间是否过期
const endTime = new Date(state.activityDetail.endTime).getTime();
const newTime = new Date().getTime();
const diffTime = (endTime - newTime) / 1000;
if (diffTime < 0) {
state.showPresal = false;
}
// 用于支付传参判断
if (!state.activityDetail.balancePayment) {
//如果是预付
state.isPayPresal = false;
} else {
//支付尾款
state.isPayPresal = true;
}
} catch (e) {
console.log(e);
}
};
watch(
() => props.show,
async (newVal: boolean) => {
if (newVal) {
// 如果有预售活动则调用
if (configStore.appConfig.advanceStatus && configStore.appConfig.advanceStatus.activityStatus) {
await getPresaleData();
}
state.userWallet = userStore.walletInfo;
await getVipList();
await initSwiper();
selectVip(state.vipList[0]);
}
},
{ immediate: true },
);
// 选中会员卡
const selectVip = (item: any) => {
state.selectedItem = item;
state.currentPrivilege = item.newPrivilege;
let payData = {
rechargeList: item.rchgType, // 支付方式列表
dcInfo: state.dcInfo, // 代充信息
popType: 'vip', // 弹窗类型
productType: item.productType, // 商品类型
productID: item.productID, // 商品ID
dcType: 1, // 代充商品类型
buyType: 4, // 商品类型 1-是金币 2-是游戏币 3-是果币 4-是商品
discountedPrice:
item.productType == 21
? state.isPayPresal
? item.balanceAmount / 10
: item.advanceAmount / 10
: item.discountedPrice / 10, // 充值金额
isAmountPay: item.isAmountPay, // 是否支持金币兑换
finalPayStatus: item.productType == 21 ? state.isPayPresal : null, //预售卡是否支付尾款
};
state.payData = payData;
};
const showPay = () => {
if (state.payData.productType == 21 && state.payOrreserve == 'wait') {
showToast('尾款支付暂未开放,请您耐心等待~');
return false;
}
if (state.payData.productType == 21 && state.activityDetail.status == 4) {
showToast('尾款已支付,请勿重复购买~');
return false;
}
if (state.payData.productType == 21 && !state.showPresal) {
showToast('活动已结束');
return false;
}
rechargeBoxRef.value.changeItem(state.payData);
state.isRecharge = true;
};
// 查询新手优惠时间
const queryTiroCountdown = async () => {
try {
let res: any = await MineApi.queryNewcomerCountdown();
state.countdownSec = res.countdownSec;
countdown(res.countdownSec);
} catch (e) {
console.log(e);
}
};
// 倒计时
const countdown = (timer: number) => {
if (timer <= 0) return;
// 相差的毫秒数
const msec: any = timer;
// 计算时分秒数
let hr: any = parseInt((msec / 60 / 60).toString());
let min: any = parseInt(((msec / 60) % 60).toString());
let sec: any = parseInt((msec % 60).toString());
// 个位数前补零
hr = hr > 9 ? hr : '0' + hr;
min = min > 9 ? min : '0' + min;
sec = sec > 9 ? sec : '0' + sec;
state.timeStr = `倒计时:${hr}:${min}:${sec}`;
// 一秒后递归
state.timeoutTimer = setTimeout(() => {
timer--;
countdown(timer);
}, 1000);
};
queryTiroCountdown();
// 支付尾款剩余时间--倒计时
const countdownPay = (timer: number) => {
const endTime = timer;
const newTime = new Date().getTime();
const diffTime = (endTime - newTime) / 1000;
const day = parseInt((diffTime / 60 / 60 / 24).toString());
const honur = parseInt(((diffTime / 60 / 60) % 24).toString());
const min = parseInt(((diffTime / 60) % 60).toString());
const sen = parseInt((diffTime % 60).toString());
if (diffTime > 0) {
state.timeStrPay = `剩余时间:${showTime(day)}天${showTime(honur)}:${showTime(min)}:${showTime(sen)}`;
} else {
state.showPresal = false;
}
// 一秒后递归
state.timeoutTimerPay = setTimeout(() => {
timer--;
countdownPay(timer);
}, 1000);
};
const showTime = (t: number) => {
const time = t >= 10 ? t : '0' + t;
return time;
};
const closePay = () => {
state.isRecharge = false;
};
onDeactivated(() => {
clearTimeout(state.timeoutTimer);
clearTimeout(state.timeoutTimerPay);
state.timeoutTimer = <any>null;
state.timeoutTimerPay = <any>null;
});
</script>
<style lang="scss" scoped>
.limitedTimeOfferBox {
border-radius: 20px 20px 0px 0px;
background: linear-gradient(180deg, #0f0f0f 50.09%, #251d0b 99.95%);
padding: 14px 16px 0px;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
.line {
width: 36px;
height: 5px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.2);
}
.main {
height: calc(100% - 30px);
padding: 0px 0 70px;
box-sizing: border-box;
.title-box {
color: rgba(255, 255, 255, 0.9);
font-size: 16px;
font-weight: 500;
width: 271px;
text-align: center;
line-height: 22px;
margin: 16px auto;
}
.horizontalLine {
height: 1px;
width: 355px;
margin: 10px auto;
}
.rights-box {
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.05);
width: 343px;
padding: 12px;
box-sizing: border-box;
margin: 0 auto;
}
.subTitleBox {
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
img {
height: 25px;
}
span {
color: rgba(255, 224, 207, 1);
font-size: 12px;
font-weight: 400;
margin-left: 10px;
}
}
.gift {
height: 20px;
// background: linear-gradient(94deg, #eabfa0 0%, #f2d3b9 100%);
background: rgba(225, 53, 31, 1);
border-radius: 12px 0 12px 0;
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
position: absolute;
top: -9px;
left: 0;
z-index: 1;
max-width: 111px;
line-height: 20px;
padding: 0 5px;
overflow: hidden;
white-space: nowrap;
}
.privilegeBox {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-column-gap: 10px;
grid-row-gap: 12px;
margin-top: 18px;
height: 202px;
.privilegeItem {
// width: 66px;
display: flex;
flex-direction: column;
align-items: center;
.cover {
height: 36px;
width: 36px;
border-radius: 50%;
overflow: hidden;
position: relative;
transform: translateZ(0px);
:deep(.loading) {
background-size: 28px 21px;
}
}
.subTitle {
// font-size: 14px;
font-size: 10.91px;
margin-top: 6px;
line-height: 16px;
color: rgba(255, 255, 255, 0.9);
}
.desc {
color: rgba(255, 255, 255, 0.45);
// font-size: 12px;
font-size: 9.35px;
margin-top: 6px;
line-height: 16px;
text-align: center;
}
}
}
.integralCardArea {
margin-top: 20px;
display: flex;
overflow-x: auto;
overflow-y: hidden;
padding-left: 10px;
.integralCardBox {
.integralCard {
height: 100px;
width: 111px;
background: linear-gradient(148deg, #ffeedc 0%, #eec7b2 101.54%, #faddc2 101.55%);
border-radius: 8px;
color: #421a11;
text-align: center;
padding: 25px 0;
margin-right: 11px;
.integralTitle {
font-size: 16px;
}
.integralDesc {
font-size: 18px;
font-weight: 500;
margin-top: 14px;
}
}
.exchangeBtn {
height: 28px;
width: 72px;
background-color: rgb(35, 42, 60);
text-align: center;
line-height: 28px;
border-radius: 28px;
margin: 8px auto;
font-size: 12px;
}
}
}
}
.loading {
height: calc(100vh - 120px);
}
.bottomBox {
width: 100%;
position: fixed;
bottom: 0;
left: 0;
box-sizing: border-box;
padding: 11px 10px;
.nowPay {
width: 343px;
background: #f68804;
border-radius: 3px;
text-align: center;
line-height: 42px;
font-size: 16px;
font-weight: 500;
margin: 0 auto;
color: #fff;
}
.payBtnBox {
width: 343px;
// height: 44px;
margin: 0 auto;
}
}
.preSaleEntBox {
height: 72px;
width: 343px;
margin: 18px auto;
}
.preSaledEntBox {
height: 72px;
width: 343px;
margin: 18px auto;
position: relative;
.payBtn {
position: absolute;
width: 100%;
.time {
position: absolute;
bottom: 14.5px;
left: 87px;
font-size: 11px;
font-weight: 700;
letter-spacing: -0.4px;
background: linear-gradient(180deg, #fffefd 0%, #fefdda 47.5%, #fdfcd4 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
}
.pop_swiper {
margin-top: 10px;
overflow: unset;
padding-right: 180px;
.swiper-wrapper {
width: 140px;
height: 200px;
.swiper-slide {
width: 100%;
height: 100%;
// transform: scale(0.9);
.vipCard {
width: 100%;
height: 100%;
position: relative;
.bgImg {
width: 100%;
height: 100%;
// background: rgba(37, 31, 21, 1);
}
.countdownBox {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -11px;
height: 22px;
min-width: 86px;
background: #f68804;
border-radius: 4px;
overflow: hidden;
color: rgba(0, 0, 0, 1);
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
// background: url('@/assets/images/png/countdownBg.png') no-repeat;
// background-size: 100% 100%;
.hour,
.minute,
.second {
font-size: 12px;
color: white;
position: absolute;
z-index: 10;
left: 0;
top: 0;
}
.hour {
left: 5px;
top: 5px;
}
.minute {
left: 36px;
top: 5px;
}
.second {
left: 70px;
top: 5px;
}
}
.timeStrPay {
position: absolute;
left: 42px;
bottom: 13.5px;
color: white;
font-weight: 600;
font-size: 9px;
}
.alBooked {
left: 30px;
}
}
}
.swiper-slide-active,
.swiper-slide-duplicate-active {
// transform: scale(1);
}
}
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!-- 模块内容轮播:没有有效图片时不渲染,也不保留占位。 -->
<template>
<div v-if="visibleBanners.length" class="module-banners" @touchstart.stop @touchmove.stop @touchend.stop>
<van-swipe :key="bannerKey" :autoplay="visibleBanners.length > 1 ? 5000 : 0">
<van-swipe-item v-for="(banner, index) in visibleBanners" :key="index" @click="openContent(banner)">
<LazyImg :src="banner.image" />
</van-swipe-item>
</van-swipe>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { getModuleBannerTarget } from '@/utils/moduleBanner';
const props = defineProps<{
banners?: Types.Home.ModuleBanner[] | null;
moduleType: number;
imageFit?: 'cover' | 'contain';
}>();
const router = useRouter();
const visibleBanners = computed(() =>
Array.isArray(props.banners) ? props.banners.filter((banner) => banner?.image?.trim()) : [],
);
const bannerKey = computed(() => visibleBanners.value.map((banner) => `${banner.contentId}:${banner.image}`).join('|'));
const openContent = (banner: Types.Home.ModuleBanner) => {
const target = getModuleBannerTarget(props.moduleType, banner.contentId);
if (target) void router.push(target);
};
</script>
<style scoped lang="scss">
.module-banners {
padding: 12px 16px;
.van-swipe {
width: 100%;
height: 193px;
border-radius: 12px;
}
.van-swipe-item::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 50px;
background: linear-gradient(to bottom, transparent, #000);
pointer-events: none;
}
:deep(.img-decypt) {
width: 100%;
height: 100%;
}
:deep(.van-swipe__indicators) {
transform: translateX(0);
left: auto;
right: 12px;
bottom: 12px;
.van-swipe__indicator {
width: 4px;
height: 4px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.6);
opacity: 0.3;
&:not(:last-child) {
margin-right: 4px;
}
}
.van-swipe__indicator--active {
width: 12px;
background: $themeColor;
opacity: 1;
}
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="nineGridPopup" v-if="appList.length > 0">
<van-overlay :show="isShow" z-index="2001" @click="emit('close')">
<div class="wrapper" ref="advRef">
<!-- <AdvSwiper
v-if="topList.length"
@click.stop
class="topAdv"
:isShowIndicators="topList.length != 1"
:list="topList"
/> -->
<div class="appList overflow-y-auto" @touchmove.stop>
<div v-for="item in appList" :key="item.id">
<div class="appItem" @click.stop="handleJumpAdv(item)">
<lazyImg class="appIcon" :src="item.cover" />
<div class="download">{{ item.title }}</div>
</div>
</div>
</div>
<div class="close"></div>
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } 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(() => {
return props.isOpen;
});
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);
};
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.9);
}
:deep(.adv-container) {
height: 104px !important;
.van-swipe-item {
height: 104px !important;
}
}
.nineGridPopup {
height: 100%;
width: 100%;
position: relative;
.wrapper {
width: 100%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
// .topAdv {
// width: 384px;
// height: 104px;
// border-radius: 4px;
// overflow: hidden;
// margin: 20px auto;
// box-sizing: border-box;
// }
.appList {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-row-gap: 6px;
grid-column-gap: 5px;
padding: 0 25px;
max-height: 470px;
.appItem {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// height: 140px;
.appIcon {
width: 70px;
height: 70px;
border-radius: 12px;
overflow: hidden;
margin-bottom: 8px;
}
.download {
height: 30px;
width: 70px;
// background-color: #f4454d;
border-radius: 6px;
text-align: center;
line-height: 26px;
color: white;
font-size: 13px;
}
}
}
.close {
height: 32px;
width: 32px;
background: url('./../../assets/images/png/closeDialog.png') no-repeat;
background-size: 100% 100%;
margin: 20px auto;
}
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="persalePopup">
<van-overlay :show="isShow" z-index="1998" @click="emit('close', true)">
<div class="wrapper" @click.stop>
<!-- <div class="cartoon-gif"></div> -->
<lazyImg
class="bg"
:src="configStore?.appConfig?.advancePage?.popBgProc"
:key="configStore?.appConfig?.advancePage?.popBgProc"
@click="emit('close', false)"
/>
<!-- <div class="mess">尾款剩余时间 {{ state.timeStrPay }}</div> -->
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed, nextTick } from 'vue';
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore();
const state = reactive({
timeStrPay: '',
timeoutTimerPay: <any>null, // 支付尾款倒计时
});
const props = defineProps<{
isOpen: boolean;
}>();
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
const setTime = () => {
const timers = new Date(configStore.appConfig.advanceStatus.endTime).getTime();
countdownPay(timers);
};
nextTick(() => {
setTime();
});
// 支付尾款剩余时间--倒计时
const countdownPay = (timer: number) => {
const endTime = timer;
const newTime = new Date().getTime();
const diffTime = (endTime - newTime) / 1000;
const day = parseInt((diffTime / 60 / 60 / 24).toString());
const honur = parseInt(((diffTime / 60 / 60) % 24).toString());
const min = parseInt(((diffTime / 60) % 60).toString());
const sen = parseInt((diffTime % 60).toString());
state.timeStrPay = `${showTime(day)}天 ${showTime(honur)}:${showTime(min)}:${showTime(sen)}`;
// 一秒后递归
state.timeoutTimerPay = setTimeout(() => {
timer--;
countdownPay(timer);
}, 1000);
};
const showTime = (t: number) => {
const time = t >= 10 ? t : '0' + t;
return time;
};
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
}
.persalePopup {
height: 100%;
width: 100%;
position: relative;
.wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 283px;
height: 381px;
box-sizing: border-box;
.cartoon-gif {
width: 107px;
height: 107px;
background: url('@/assets/gif/cartoon-gif.gif') no-repeat;
background-size: contain;
position: absolute;
left: 50%;
top: 20px;
transform: translateX(-50%);
z-index: 2000;
}
.mess {
position: absolute;
text-align: center;
bottom: 130px;
left: 50%;
transform: translate(-50%, 0);
color: white;
font-size: 12px;
width: 170px;
height: 26px;
line-height: 26px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
}
.bg {
height: auto;
width: 100%;
}
}
}
</style>
@@ -0,0 +1,141 @@
<template>
<van-popup
:show="show"
position="bottom"
teleport="body"
class="guideBottomSheet"
:style="{ background: 'transparent' }"
:z-index="2001"
@click-overlay="emit('close')"
>
<div class="panel">
<div class="indicator" />
<div class="title">
<p v-for="(line, lineIndex) in titleLines" :key="lineIndex">
<span v-for="(part, partIndex) in line" :key="partIndex" :class="part.tone">{{ part.text }}</span>
</p>
</div>
<div class="freeCount" v-if="freeCountText">{{ freeCountText }}</div>
<lazyImg v-if="cardCover" class="card" :src="cardCover" :key="cardCover" />
<img v-else class="card" :src="defaultVipCardImg" alt="" />
<p class="desc">{{ descText }}</p>
<div class="payBtn" @click="emit('pay')">{{ payBtnText }}</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import defaultVipCardImg from '@/assets/images/png/paymentGuide/vip-card.png';
import type { GuideTitleLine } from '../types';
defineProps<{
show: boolean;
titleLines: GuideTitleLine[];
descText: string;
payBtnText: string;
freeCountText?: string;
/** 接口下发的会员卡封面,空则用本地默认图 */
cardCover?: string;
}>();
const emit = defineEmits(['close', 'pay']);
</script>
<style lang="scss" scoped>
.guideBottomSheet {
background: transparent;
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(228, 187, 109, 0.3);
border-radius: 20px 20px 0 0;
background: linear-gradient(180deg, #0f0f0f 0%, #000000 24.81%);
overflow: hidden;
.indicator {
width: 36px;
height: 5px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.2);
}
.title {
width: 100%;
text-align: center;
font-size: 18px;
line-height: 28px;
color: rgba(255, 255, 255, 0.9);
word-break: break-word;
.danger {
color: #e94335;
}
.gold {
background: linear-gradient(90deg, #ffe8be 0%, #e6b764 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
}
.freeCount {
width: 271px;
padding: 4px 0;
border: 1px solid rgba(231, 71, 42, 0.2);
border-radius: 20px;
background: rgba(233, 67, 53, 0.1);
font-size: 16px;
line-height: 26px;
color: #e94335;
text-align: center;
}
.card {
display: block;
width: 270px;
height: 122px;
overflow: hidden;
border-radius: 8px;
object-fit: cover;
:deep(.lazy_img),
:deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.desc {
width: 100%;
text-align: center;
font-size: 20px;
line-height: 28px;
word-break: break-word;
background: linear-gradient(90deg, #ffffff 0%, #efd394 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.payBtn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 52px;
border-radius: 26px;
background: linear-gradient(90deg, #ffb03f 0%, #f2680c 100%);
box-shadow: 0 6px 18px 0 rgba(242, 104, 12, 0.45);
font-size: 18px;
font-weight: bold;
color: #ffffff;
}
}
</style>
@@ -0,0 +1,251 @@
<template>
<van-overlay :show="true" :z-index="zIndex" class="guideCenterModal" @click="emit('close')">
<div class="modalStack" @click.stop>
<div class="wrapper">
<lazyImg v-if="cardCover" class="card" :src="cardCover" :key="cardCover" />
<img v-else class="card" :src="defaultVipCardImg" alt="" />
<div class="panel">
<div class="divider" />
<div class="body">
<div class="titleRow">
<span class="normal">现在下单获得</span>
<span class="highlight">优惠</span>
<span class="normal">福利</span>
</div>
<div v-if="remainSec > 0" class="countdownRow">
<span class="label">距优惠结束</span>
<span class="unit">{{ padStart(hours) }}</span>
<span class="colon">:</span>
<span class="unit">{{ padStart(minutes) }}</span>
<span class="colon">:</span>
<span class="unit">{{ padStart(seconds) }}</span>
</div>
<div class="payBtn" @click="emit('pay')">{{ payBtnText }}</div>
</div>
</div>
</div>
<button type="button" class="closeBtn" aria-label="关闭" @click="emit('close')">
<SvgIcon name="payment-guide-close" class="closeIcon" />
</button>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
/**
* 仅由父级在 show=true 时挂载;关闭即销毁。
* 不在 keep-alive 缓存树上复用,避免二次打开倒计时行消失 / 停表。
*/
import { computed, onBeforeUnmount, ref } from 'vue';
import defaultVipCardImg from '@/assets/images/png/paymentGuide/vip-card.png';
import {
ensureDiscountCountdownDeadlineMs,
getDiscountCountdownRemainingMs,
} from '@/utils/discountCountdownLocal';
const props = defineProps<{
show: boolean;
payBtnText: string;
/** 打开瞬间剩余毫秒;优先用 window 单例校准 */
countdownMs: number;
cardCover?: string;
zIndex?: number;
}>();
const emit = defineEmits(['close', 'pay']);
const zIndex = computed(() => props.zIndex ?? 2001);
ensureDiscountCountdownDeadlineMs();
const remainMs = ref(
(() => {
const local = getDiscountCountdownRemainingMs();
if (local > 0) return local;
return Number(props.countdownMs) > 0 ? Number(props.countdownMs) : 0;
})(),
);
const remainSec = computed(() => Math.floor(remainMs.value / 1000));
const hours = computed(() => Math.floor(remainSec.value / 3600));
const minutes = computed(() => Math.floor((remainSec.value % 3600) / 60));
const seconds = computed(() => remainSec.value % 60);
const padStart = (value: number) => String(value).padStart(2, '0');
/** 打开后短暂忽略到期关窗,避免挂载瞬间误关 */
let allowFinishClose = false;
const allowFinishTimer = window.setTimeout(() => {
allowFinishClose = true;
}, 800);
const timer = window.setInterval(() => {
const local = getDiscountCountdownRemainingMs();
remainMs.value = local > 0 ? local : Math.max(0, remainMs.value - 250);
if (allowFinishClose && remainMs.value <= 0) {
emit('close');
}
}, 250);
onBeforeUnmount(() => {
window.clearInterval(timer);
window.clearTimeout(allowFinishTimer);
});
</script>
<style lang="scss" scoped>
.guideCenterModal {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.modalStack {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.wrapper {
position: relative;
width: 311px;
padding-top: 61px;
.card {
position: absolute;
left: 20px;
top: 0;
display: block;
width: 270px;
height: 122px;
overflow: hidden;
border-radius: 8px;
object-fit: cover;
z-index: 1;
:deep(.lazy_img),
:deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.panel {
display: flex;
flex-direction: column;
align-items: center;
width: 311px;
min-height: 261px;
padding: 80px 24px 24px;
border-radius: 16px;
background: #0f0f0f;
box-sizing: border-box;
overflow: hidden;
}
.divider {
width: 100%;
height: 0;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
margin-bottom: 12px;
}
.body {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
width: 100%;
}
.titleRow {
display: flex;
align-items: baseline;
justify-content: center;
gap: 2px;
font-weight: 900;
white-space: nowrap;
line-height: normal;
.normal {
font-size: 20px;
color: #ffffff;
}
.highlight {
font-size: 27px;
background: linear-gradient(90deg, #ffe3a0 0%, #f0a93c 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
}
.countdownRow {
display: flex;
align-items: center;
gap: 8px;
color: #e85b4f;
line-height: normal;
.label {
font-size: 13px;
font-weight: 500;
color: #b8ae9c;
white-space: nowrap;
}
.unit {
padding: 4px 7px;
border-radius: 6px;
background: #e23b2e;
font-size: 15px;
font-weight: bold;
color: #ffffff;
line-height: normal;
}
.colon {
font-size: 15px;
font-weight: bold;
line-height: normal;
}
}
.payBtn {
display: flex;
align-items: center;
justify-content: center;
width: 269px;
height: 52px;
border-radius: 26px;
background: linear-gradient(90deg, #ffb03f 0%, #f2680c 100%);
box-shadow: 0 6px 18px 0 rgba(242, 104, 12, 0.45);
font-size: 18px;
font-weight: bold;
color: #ffffff;
line-height: normal;
}
}
.closeBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
:deep(.closeIcon) {
width: 36px;
height: 36px;
}
}
</style>
@@ -0,0 +1,283 @@
<template>
<van-popup
:show="show"
position="top"
teleport="body"
class="guideTopSheet"
:style="{ background: 'transparent' }"
:z-index="2001"
:duration="popupDuration"
:close-on-click-overlay="false"
:lock-scroll="false"
>
<div
class="panel"
:class="{ 'panel--dismissing': dismissing }"
:style="panelStyle"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<img class="hotTag" :src="hotTagImg" alt="" />
<p class="title">{{ titleText }}</p>
<div class="videoBox">
<div class="videoList">
<div class="videoItem" v-for="item in videos" :key="item.id" @click="emit('videoClick', item)">
<div class="videoItem__cover">
<lazyImg :src="item.coverThumb || item.cover" />
<div class="videoItem__meta">
<span class="videoItem__hot">
<SvgIcon name="playCountIcon" color="#fff" />
{{ watchCount(item.playCount) }}
</span>
<span>{{ videoTime(item.playTime) }}</span>
</div>
</div>
<p class="videoItem__title">{{ item.title }}</p>
</div>
</div>
</div>
<div class="payBtn" @click="emit('pay')">{{ payBtnText }}</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import hotTagImg from '@/assets/images/png/paymentGuide/hot-tag.png';
import { watchCount, videoTime } from '@/utils/index';
const SWIPE_UP_DISMISS_PX = 48;
const DISMISS_MS = 220;
const props = defineProps<{
show: boolean;
titleText: string;
payBtnText: string;
videos: Types.Payment.GuideVideoItem[];
}>();
const emit = defineEmits(['close', 'pay', 'videoClick']);
const dragOffsetY = ref(0);
const dismissing = ref(false);
let touchStartY = 0;
let tracking = false;
let dismissTimer: number | undefined;
/** 自定义上滑淡出后再关:禁用 van-popup 离场动画,避免淡出后又滑一次闪一下 */
const popupDuration = computed(() => (dismissing.value ? 0 : 0.3));
const panelStyle = computed(() => {
if (!dragOffsetY.value && !dismissing.value) return undefined;
return {
transform: `translateY(${dragOffsetY.value}px)`,
transition: dismissing.value ? `transform ${DISMISS_MS}ms ease-out, opacity ${DISMISS_MS}ms ease-out` : 'none',
opacity: dismissing.value ? 0 : Math.max(0.35, 1 + dragOffsetY.value / 160),
};
});
const resetDismissState = () => {
if (dismissTimer != null) {
window.clearTimeout(dismissTimer);
dismissTimer = undefined;
}
dragOffsetY.value = 0;
dismissing.value = false;
tracking = false;
};
watch(
() => props.show,
(visible) => {
if (visible) {
// 再次打开时才复位;关闭过程中保持淡出态,避免闪回
resetDismissState();
return;
}
if (dismissTimer != null) {
window.clearTimeout(dismissTimer);
dismissTimer = undefined;
}
},
);
const onTouchStart = (e: TouchEvent) => {
if (!props.show || dismissing.value) return;
const touch = e.touches[0];
if (!touch) return;
touchStartY = touch.clientY;
tracking = true;
dragOffsetY.value = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (!tracking || dismissing.value) return;
const touch = e.touches[0];
if (!touch) return;
// 仅允许上滑位移(负向)
dragOffsetY.value = Math.min(0, touch.clientY - touchStartY);
};
const onTouchEnd = () => {
if (!tracking || dismissing.value) return;
tracking = false;
if (dragOffsetY.value <= -SWIPE_UP_DISMISS_PX) {
dismissing.value = true;
dragOffsetY.value = -220;
dismissTimer = window.setTimeout(() => {
dismissTimer = undefined;
emit('close');
// 保持 dismissing/offset,等 show=false 再复位
}, DISMISS_MS);
return;
}
dragOffsetY.value = 0;
};
</script>
<style lang="scss" scoped>
.guideTopSheet {
background: transparent;
}
.panel {
position: relative;
height: 242px;
border-bottom: 1px solid #b71e03;
border-radius: 0 0 20px 20px;
background: #000000;
overflow: hidden;
touch-action: pan-y;
will-change: transform, opacity;
&--dismissing {
pointer-events: none;
}
}
.hotTag {
position: absolute;
left: 24px;
top: 44px;
width: 113px;
height: 42px;
z-index: 1;
}
.title {
position: absolute;
left: 156px;
right: 29px;
top: 69px;
z-index: 1;
font-size: 12px;
line-height: 17px;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: linear-gradient(89.38deg, #ffe8be 1.65%, #e6b764 99.23%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.videoBox {
position: absolute;
left: 16px;
right: 16px;
top: 61px;
height: 102px;
border: 1px solid #b71e03;
border-radius: 20px;
background: rgba(183, 30, 3, 0.1);
box-sizing: border-box;
overflow: hidden;
}
.videoList {
display: flex;
gap: 6px;
justify-content: center;
padding: 35px 10px 0;
}
.videoItem {
width: 76px;
flex-shrink: 0;
&__cover {
position: relative;
width: 76px;
height: 42.5px;
border-radius: 3.6px;
overflow: hidden;
:deep(.lazy_img),
:deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__meta {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 3.6px 2.3px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%);
font-size: 7px;
line-height: 12px;
color: #ffffff;
}
&__hot {
display: flex;
align-items: center;
gap: 1px;
min-width: 0;
:deep(.svg-icon) {
flex-shrink: 0;
font-size: 8px;
}
}
&__title {
margin-top: 4px;
font-size: 8px;
font-weight: 500;
line-height: 8px;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.payBtn {
position: absolute;
left: 50%;
top: 183px;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
height: 39px;
padding: 0 78px;
border-radius: 19.5px;
background: linear-gradient(90deg, #ffb03f 0%, #f2680c 100%);
box-shadow: 0 4.5px 13.5px 0 rgba(242, 104, 12, 0.45);
font-size: 13.5px;
font-weight: bold;
color: #ffffff;
white-space: nowrap;
}
</style>
+282
View File
@@ -0,0 +1,282 @@
<template>
<GuideTopSheet
v-if="styleType === PaymentGuideStyle.TOP_SHEET"
:show="show"
:title-text="topTitleText"
:pay-btn-text="payBtnText"
:videos="videos"
@close="emit('close')"
@pay="handlePay"
@videoClick="handleVideoClick"
/>
<!-- 仅打开时挂载:关掉销毁,避免详情 keep-alive 复用导致倒计时二次不显示 -->
<GuideCenterModal
v-else-if="styleType === PaymentGuideStyle.CENTER_MODAL && show"
:key="`center-${countdownOpenSeq}`"
:show="true"
:pay-btn-text="payBtnText"
:countdown-ms="countdownMs"
:card-cover="cardCover"
:z-index="centerModalZIndex"
@close="emit('close')"
@pay="handlePay"
/>
<GuideBottomSheet
v-else-if="styleType === PaymentGuideStyle.BOTTOM_SHEET"
:show="show"
:title-lines="titleLines"
:desc-text="descText"
:pay-btn-text="payBtnText"
:free-count-text="freeCountText"
:card-cover="cardCover"
@close="emit('close')"
@pay="handlePay"
/>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VideoApi from '@/service/video';
import { PaymentGuideScene, PaymentGuideStyle } from '@/enums/home';
import { usePaymentStatusDirectPay } from '@/hooks/usePaymentStatusDirectPay';
import { getDiscountCountdownRemainingMs } from '@/utils/discountCountdownLocal';
import GuideBottomSheet from './components/GuideBottomSheet.vue';
import GuideCenterModal from './components/GuideCenterModal.vue';
import GuideTopSheet from './components/GuideTopSheet.vue';
import type { GuideTitleLine } from './types';
const MAX_TOP_SHEET_VIDEOS = 4;
const props = defineProps<{
show: boolean;
guide: Types.Payment.PaymentGuide | null;
/** 触发场景,用于缺省文案 */
scene?: PaymentGuideScene | '';
/** 免费试看次数,传入后展示「剩余免费次数」样式 */
freeCount?: Types.Payment.GuideFreeCount | null;
}>();
/** 须用运行时数组:Vue 3.2 type-only defineEmits 易被 Volar 推成无调用签名的 __VLS_Emit */
const emit = defineEmits(['close', 'pay']);
const router = useRouter();
const route = useRoute();
const { prepareCard } = usePaymentStatusDirectPay();
const videos = ref<Types.Payment.GuideVideoItem[]>([]);
const priceText = ref('');
/** 会员卡图:优先接口 cover,空则子组件回退本地默认图 */
const cardCover = computed(() => props.guide?.cover?.trim() || '');
/**
* 弹层样式只跟场景 UI,不读接口 style:
* - VIP 上新 → 顶部横幅
* - 返回拦截 / 会员中心优惠 / 优惠倒计时 → 居中弹窗
* - 试看结束 / 免费次数 / 首页新老用户 → 底部半屏
*/
const styleType = computed(() => {
if (props.scene === PaymentGuideScene.VIP_CONTENT_UPDATE) {
return PaymentGuideStyle.TOP_SHEET;
}
if (
props.scene === PaymentGuideScene.VIDEO_BACK ||
props.scene === PaymentGuideScene.VIP_CENTER ||
props.scene === PaymentGuideScene.DISCOUNT_COUNTDOWN
) {
return PaymentGuideStyle.CENTER_MODAL;
}
return PaymentGuideStyle.BOTTOM_SHEET;
});
/** 返回引导需盖过播放器/遮罩等高层级元素 */
const centerModalZIndex = computed(() =>
props.scene === PaymentGuideScene.VIDEO_BACK ? 100000 : 2001,
);
const isVideoScene = computed(
() => props.scene === PaymentGuideScene.VIDEO_PREVIEW_END || props.scene === PaymentGuideScene.VIDEO_BACK,
);
const vipCardId = computed(() => {
const action = props.guide?.action;
if (action?.type === 'VIP_PRODUCT' && action.value) return action.value;
return props.guide?.productId || '';
});
/**
* 每次打开居中弹窗递增,配合 v-if + :key 强制新实例。
* countdownMs 在打开瞬间取剩余,不随关窗清成 0(关窗时子组件已卸载)。
*/
const countdownOpenSeq = ref(0);
const countdownMs = ref(0);
watch(
() => [props.show, props.scene, props.guide?.durationSeconds] as const,
([visible]) => {
if (!visible) return;
countdownOpenSeq.value += 1;
const fromGuide = Number(props.guide?.durationSeconds) || 0;
if (
props.scene === PaymentGuideScene.VIDEO_BACK ||
props.scene === PaymentGuideScene.VIP_CENTER ||
props.scene === PaymentGuideScene.DISCOUNT_COUNTDOWN
) {
const local = getDiscountCountdownRemainingMs();
countdownMs.value = local > 0 ? local : fromGuide > 0 ? fromGuide * 1000 : 0;
return;
}
countdownMs.value = fromGuide > 0 ? fromGuide * 1000 : 0;
},
{ immediate: true },
);
/** 是否拿到过免费次数数据(含剩余 0) */
const hasFreeCountInfo = computed(() => props.freeCount != null);
/** 免费次数引导:传入 freeCount,或场景为 HOME_NEW_USER_FREE_TRIAL */
const useFixedFreeCountCopy = computed(
() => hasFreeCountInfo.value || props.scene === PaymentGuideScene.HOME_NEW_USER_FREE_TRIAL,
);
/** 剩余次数:只认 watchCount,不认 isCan(已扣过次的片也会 isCan=true) */
const freeRemaining = computed(() => {
const n = Number(props.freeCount?.watchCount);
return Number.isFinite(n) ? n : 0;
});
const freeTotal = computed(() => {
const n = Number(props.freeCount?.totalWatchCount);
return Number.isFinite(n) && n > 0 ? n : 0;
});
const hasRemainingFreeQuota = computed(() => hasFreeCountInfo.value && freeRemaining.value > 0);
const FREE_COUNT_DESC = '开通会员即可 畅看 “VIP全部视频”';
/** 免费次数红条文案:有次数「免费观看次数剩余1/2次」;用尽「免费看额度没有了」 */
const freeCountText = computed(() => {
if (!hasFreeCountInfo.value) return '';
if (!hasRemainingFreeQuota.value) return '免费看额度没有了';
if (!freeTotal.value) return `免费观看次数剩余${freeRemaining.value}次`;
return `免费观看次数剩余${freeRemaining.value}/${freeTotal.value}次`;
});
/** 标题优先读接口 title,缺省按场景兜底 */
const titleLines = computed<GuideTitleLine[]>(() => {
const apiTitle = props.guide?.title?.trim();
if (apiTitle) {
return [[{ text: apiTitle }]];
}
if (hasRemainingFreeQuota.value) {
return [[{ text: '免费观看次数 ' }, { text: '即将用尽!', tone: 'danger' }]];
}
if (useFixedFreeCountCopy.value) {
return [[{ text: '免费观看次数' }]];
}
if (isVideoScene.value) {
// Figma 21694:90491 试看结束前置购买提示缺省文案
return [[{ text: '该视频为' }, { text: 'VIP内容', tone: 'gold' }], [{ text: '只能观看试看内容哦~' }]];
}
if (props.scene === PaymentGuideScene.HOME_OLD_USER) {
return [[{ text: 'VIP影片可以试看' }]];
}
return [[{ text: 'VIP影片免费试看' }]];
});
/** 描述优先读接口 description,缺省按场景兜底 */
const descText = computed(() => {
const apiDesc = props.guide?.description?.trim();
if (apiDesc) return apiDesc;
if (useFixedFreeCountCopy.value) return FREE_COUNT_DESC;
// Figma 21694:90491 试看结束前置购买提示缺省文案
if (isVideoScene.value) return '想要查看完整内容,需要开通会员';
if (props.scene === PaymentGuideScene.HOME_OLD_USER) return '查看完整影片需要购买会员';
return '开通会员 查看完整影片';
});
const topTitleText = computed(
() => props.guide?.description?.trim() || 'VIP会员“特享内容”更新上架啦~',
);
const payBtnText = computed(() => {
if (styleType.value === PaymentGuideStyle.TOP_SHEET) return '开通会员 · 查看劲爆视频';
if (priceText.value) return `立即开通 · 仅需¥${priceText.value}`;
return '开通会员 · 查看完整内容';
});
const formatAmount = (amount: number) => (Number.isInteger(amount) ? String(amount) : amount.toFixed(2));
const mapGuideVideos = (list: Types.Payment.GuideVideoItem[]) =>
list.slice(0, MAX_TOP_SHEET_VIDEOS).map((item) => ({
...item,
cover: item.coverThumb || item.cover,
}));
/** 优先用接口直出的 videos;无 videos 时回退 videoIds 拉详情(兼容旧响应) */
const loadTopSheetVideos = async () => {
const guideVideos = props.guide?.videos;
if (guideVideos?.length) {
videos.value = mapGuideVideos(guideVideos);
return;
}
const videoIds = props.guide?.videoIds || [];
if (!videoIds.length) {
videos.value = [];
return;
}
const list = await Promise.all(
videoIds.slice(0, MAX_TOP_SHEET_VIDEOS).map((videoID) =>
VideoApi.queryVideoInfo({ videoID }).catch((e) => {
console.log(e);
return null;
}),
),
);
videos.value = list.filter(Boolean).map((item) => {
const video = item as unknown as Types.Video.VInfosItem;
return {
id: video.id,
title: video.title,
cover: video.coverThumb || video.cover,
coverThumb: video.coverThumb,
playTime: video.playTime,
playCount: video.playCount,
};
});
};
/** 开通按钮:跳转会员中心并选中引导配置的卡,不直接拉起支付 */
const handlePay = async () => {
const id = vipCardId.value ? String(vipCardId.value) : '';
emit('pay');
emit('close');
// 等弹窗关闭(含 van-count-down 卸载)后再跳转,避免详情 keep-alive 失活时倒计时进入 deactivated 残留
await nextTick();
// 已在会员页:只更新选中卡,避免再 push 一层 /member
if (route.path === '/member') {
if (!id) return;
if (String(route.query.id || '') !== id) {
router.replace({ path: '/member', query: { ...route.query, id } });
}
return;
}
router.push(`/member${id ? `?id=${id}` : ''}`);
};
const handleVideoClick = (video: Types.Payment.GuideVideoItem) => {
emit('close');
router.push(`/movieDetails?vid=${video.id}`);
};
watch(
() => props.show,
async (visible) => {
if (!visible) return;
priceText.value = '';
const context = await prepareCard(vipCardId.value);
priceText.value = context && context.amount > 0 ? formatAmount(context.amount) : '';
if (styleType.value === PaymentGuideStyle.TOP_SHEET) {
loadTopSheetVideos();
}
},
{ immediate: true },
);
</script>
@@ -0,0 +1,9 @@
/** 标题高亮色调:gold=金色渐变,danger=警示红 */
export type GuideTitleTone = 'gold' | 'danger';
export interface GuideTitlePart {
text: string;
tone?: GuideTitleTone;
}
export type GuideTitleLine = GuideTitlePart[];
+71
View File
@@ -0,0 +1,71 @@
<template>
<div class="persalePopup">
<van-overlay :show="isShow" z-index="1998" @click="emit('close', true)">
<div class="wrapper" @click.stop>
<!-- <div class="cartoon-gif"></div> -->
<lazyImg
class="bg"
:src="configStore?.appConfig?.advancePage?.popBgWait"
:key="configStore?.appConfig?.advancePage?.popBgWait"
@click="emit('close', false)"
/>
<!-- <div class="btn" @click="emit('close', false)"></div> -->
</div>
</van-overlay>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useConfigStore } from '@/stores/config';
const configStore: any = useConfigStore();
const props = defineProps<{
isOpen: boolean;
}>();
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
}
.persalePopup {
height: 100%;
width: 100%;
position: relative;
.wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
width: 283px;
height: 381px;
box-sizing: border-box;
.cartoon-gif {
width: 107px;
height: 107px;
background: url('@/assets/gif/cartoon-gif.gif') no-repeat;
background-size: contain;
position: absolute;
left: 50%;
top: 20px;
transform: translateX(-50%);
z-index: 2000;
}
.btn {
position: absolute;
width: 90%;
height: 50px;
bottom: 40px;
left: 20px;
}
.bg {
width: 100%;
height: auto;
}
}
}
</style>
+700
View File
@@ -0,0 +1,700 @@
<template>
<!-- <div class="post-box" @click="$router.push({ path: '/postDetail', query: { postid: state.postInfo.id } })"> -->
<div class="post-box" @click="jumpDetail">
<div class="rank-box" v-if="rank">
<div v-if="rank < 4" class="rank-icon" :class="`rank-icon-${rank}`"></div>
<div v-else class="sortbox">
<div class="sortbox-icon"></div>
NO.{{ props.rank }}
</div>
</div>
<!-- header-->
<div class="post-top">
<div class="post-top-left flex-center-center">
<div class="post-avatar">
<lazyImg
:src="state.postInfo.publisher?.portrait"
round
:key="state.postInfo.publisher?.portrait"
@click.stop="toUserpage"
/>
</div>
<div class="post-info">
<div class="username_box ellipsis">
<span>{{ state.postInfo.publisher?.name }}</span>
<img v-if="state.postInfo.publisher?.superUser" src="@/assets/images/png/isCentral.png" alt="" />
<!-- <SvgIcon class="vipIcon icon" name="vipIcon" v-if="state.publisher?.vipLevel > 0" />-->
</div>
<!--显示时间-->
<div class="location-box flex-vcenter">
<!-- <span v-if="state.postInfo.publisher?.vipName">{{state.postInfo.publisher?.vipName}}</span> -->
{{ formatPostTime(state.postInfo.reviewAt) }}
</div>
</div>
</div>
<!-- 关注 -->
<!-- <div
@click.stop="followUp"
v-if="userStore.userInfo.uid != state.postInfo.publisher?.uid && route.path != '/personal'"
>
<div class="post-top-right-follow flex-center-center" v-if="state.postInfo.publisher?.hasFollowed">已关注</div>
<div class="post-top-right flex-center-center" v-else>
<svgIcon name="follow_add"></svgIcon>
关注
</div>
</div> -->
<!-- 审核状态 -->
<div
class="statusClass"
v-if="userStore.userInfo.uid == state.postInfo.publisher?.uid && route.path == '/minepostpage'"
>
<div @click.stop="state.postInfo?.status == 2 ? showReason() : ''">
{{ setText(state.postInfo?.status) }}
</div>
</div>
</div>
<!--content -->
<div class="post-text">
<TextOverflow
:text="state.postInfo.title"
:maxLines="3"
:isChoosen="state.postInfo.chosen"
:isTopping="state.postInfo.isTopping"
>
<template #default="{ clickToggle, expanded }">
<div @click.stop="clickToggle" class="text-btn">
{{ expanded ? '收起' : '全文' }}
</div>
</template>
</TextOverflow>
</div>
<!-- 图片+视频 -->
<div
class="post-media"
:class="{ image_one: displaySeriesCover.length == 1 && !state.postInfo.sourceURL }"
>
<!-- 如果只有图片 没有视频 -->
<template v-if="displaySeriesCover.length && !state.postInfo.sourceURL">
<lazyImg
:src="src"
v-for="(src, index) in displaySeriesCover.slice(0, 3)"
:key="src + index"
class="img_item"
>
<div v-if="index == 2 && displaySeriesCover.length > 3" class="filter">
+{{ displaySeriesCover.length - 3 }}
</div>
</lazyImg>
</template>
<!-- 有图片也有视频:seriesCover 已剔除封面 -->
<template v-else-if="displaySeriesCover.length && state.postInfo.sourceURL">
<template v-if="displaySeriesCover.length >= 3">
<lazyImg
:src="src"
v-for="(src, index) in displaySeriesCover.slice(0, 2)"
:key="src + index"
class="img_item"
/>
<lazyImg :src="state.postInfo.cover" :key="state.postInfo.cover" class="img_item">
<div class="play_box">
<SvgIcon name="playIcon" />
</div>
</lazyImg>
</template>
<template v-else>
<lazyImg
:src="src"
v-for="(src, index) in displaySeriesCover"
:key="src + index"
class="img_item"
></lazyImg>
<lazyImg :src="state.postInfo.cover" :key="state.postInfo.cover" class="img_item">
<div class="play_box">
<SvgIcon name="playIcon" />
</div>
</lazyImg>
</template>
</template>
<!-- 只有视频(无图,或图全是封面已被剔除) -->
<template v-else-if="state.postInfo.sourceURL">
<lazyImg :src="state.postInfo.cover" :key="state.postInfo.cover" class="img_item img_item2">
<div class="play_box">
<SvgIcon name="playIcon" />
</div>
</lazyImg>
</template>
</div>
<!-- 操作栏 -->
<div class="post_menu">
<div class="post_menu_left">
<div class="item">
<SvgIcon class="icon" name="post_eye" />
<span class="ellipsis">{{ watchCount(state.postInfo.pageViewCount) }}</span>
</div>
<div class="item">
<SvgIcon class="community_info icon" name="post_commit" />
<span class="ellipsis">
{{ watchCount(state.postInfo.commentCount > 0 ? state.postInfo.commentCount : 0) }}
</span>
</div>
<div class="item">
<SvgIcon class="icon" v-if="state.postInfo.vidStatus?.hasLiked" name="post_inheart" />
<SvgIcon class="icon" v-else name="post_heart" />
<span class="ellipsis">{{ watchCount(state.postInfo.likeCount) }}</span>
</div>
</div>
<div class="post_menu_right" @click.stop="goTagPage" v-if="!props.isNoShowTags">
{{ state.postInfo.tags && state.postInfo.tags.length ? '#' + state.postInfo.tags[0].name : '' }}
</div>
</div>
<!-- 新增删除 / 多选 -->
<div
v-if="isEdit && editMode === 'select'"
class="selectBtn"
:class="{ active: selected }"
@click.stop="emitSelect"
/>
<div class="delBtn" v-else-if="isEdit" @click.stop="del(state.postInfo)">
<SvgIcon name="clear-red" class="selectActive" />
</div>
</div>
</template>
<script lang="ts" setup>
import { inject, nextTick, reactive, computed, type ComputedRef } from 'vue';
import { watchCount } from '@/utils/index';
import { useRouter, useRoute } from 'vue-router';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import CommunityApi from '@/service/community';
import { LikeType } from '@/enums/app';
import { useGlobalStore } from '@/stores/global';
import UserApi from '@/service/user';
import moment from 'moment';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const globalStore = useGlobalStore();
const props = defineProps<{
postInfo: any;
currentIndex: any;
isNoShowTags?: any;
list: Array<Types.Video.VInfosItem>;
checkIsOpen?: boolean;
rank?: number;
isListMode?: boolean; //列表or 详情
isEdit?: boolean;
editMode?: 'delete' | 'select';
selected?: boolean;
}>();
const state = reactive({
postInfo: props.postInfo,
isListMode: props.isListMode || false,
});
const emit = defineEmits(['showReason', 'setNewData', 'del', 'select', 'click']);
/** 有视频且封面在 seriesCover 中时,展示前剔除 cover,避免封面重复 */
const displaySeriesCover = computed(() => {
const list = state.postInfo?.seriesCover || [];
const cover = state.postInfo?.cover;
if (!state.postInfo?.sourceURL || !cover) return list;
if (!list.includes(cover)) return list;
return list.filter((src: string) => src !== cover);
});
/** 帖子相对时间:刚刚 / n分钟前 / n小时前 / n天前 / n周前 / 月日 / 年月日 */
const formatPostTime = (time: any) => {
if (!time) return '';
const now = moment();
const target = moment(time);
if (!target.isValid()) return '';
const diffSec = Math.max(now.diff(target, 'seconds'), 0);
if (diffSec < 60) return '刚刚';
const diffMin = now.diff(target, 'minutes');
if (diffMin < 60) return `${diffMin}分钟前`;
const diffHour = now.diff(target, 'hours');
if (diffHour < 24) return `${diffHour}小时前`;
const diffDay = now.diff(target, 'days');
if (diffDay < 7) return `${diffDay}天前`;
const diffMonth = now.diff(target, 'months');
if (diffMonth < 1) {
const diffWeek = Math.max(now.diff(target, 'weeks'), 1);
return `${diffWeek}周前`;
}
if (diffMonth < 12) {
return target.format('MM月DD日');
}
return target.format('YYYY年MM月DD日');
};
const goTagPage = () => {
const [oneTag] = state.postInfo.tags;
if (oneTag) {
router.push({
path: '/communityTagDeatil',
query: { id: oneTag.id, title: oneTag.name, newsType: state.postInfo.newsType },
});
}
};
const toUserpage = () => {
router.push({ path: '/userInfo', query: { uid: state.postInfo.publisher.uid } });
localStorage.removeItem('isChange');
};
const setText = (data: any) => {
if (data == 0) {
return '待审核';
} else if (data == 1) {
return '已通过';
} else if (data == 2) {
return '未过审·查看原因';
}
};
const showReason = () => {
emit('showReason', state.postInfo?.reason);
};
const jumpDetail = () => {
if (isEdit.value) {
if (editMode.value === 'select') {
emit('select', state.postInfo?.id);
}
return;
}
emit('click');
globalStore.savePostList(props.list, props.currentIndex);
router.push(`/postDetail?postid=${state.postInfo.id}`);
};
const injectedEdit = inject<ComputedRef<boolean> | boolean>('isEdit', false);
const injectedEditMode = inject<'delete' | 'select' | ComputedRef<'delete' | 'select'>>('editMode', 'delete');
const isEdit = computed(() => {
if (typeof props.isEdit === 'boolean') return props.isEdit;
if (typeof injectedEdit === 'object' && injectedEdit && 'value' in injectedEdit) {
return Boolean(injectedEdit.value);
}
return Boolean(injectedEdit);
});
const editMode = computed(() => {
if (props.editMode === 'select' || props.editMode === 'delete') return props.editMode;
if (typeof injectedEditMode === 'object' && injectedEditMode && 'value' in injectedEditMode) {
return injectedEditMode.value || 'delete';
}
return injectedEditMode || 'delete';
});
const selected = computed(() => Boolean(props.selected));
const emitSelect = () => {
emit('select', state.postInfo?.id);
};
// const del = async (info: any) => {
// // 二次确认弹窗
// showConfirmDialog({
// title: '收藏记录',
// message: '是否确认删除该收藏',
// })
// .then(async () => {
// showToast('正在清除,请等待...');
// let params = {
// objID: info.id,
// isCollect: false,
// type: 'img',
// };
// try {
// const res = await UserApi.delCollect(params); // 假设这是你的清除操作
// await nextTick();
// emit('del');
// showToast('清除成功');
// } catch (error) {
// showToast('清除失败');
// console.error('清除操作失败:', error);
// }
// })
// .catch(() => {
// console.log('取消');
// });
// };
//
const del = async (info: any) => {
emit('del', info.id);
return;
// if (props.checkIsOpen) {
// emit('del', info.id);
// return;
// }
// showToast('正在清除,请等待...');
// let params = {
// objID: info.id,
// isCollect: false,
// type: 'img',
// };
// try {
// const res = await UserApi.delCollect(params); // 假设这是你的清除操作
// await nextTick();
// emit('del');
// showToast('清除成功');
// } catch (error) {
// showToast('清除失败');
// console.error('清除操作失败:', error);
// }
};
// 点赞
const clickLike = async () => {
// 列表不显示是否点赞,只有详情页才有这种操作
if (state.isListMode) {
return false;
}
try {
if (!state.postInfo.vidStatus.hasLiked) {
let req = {
type: LikeType.COVER,
objID: state.postInfo.id,
};
await CommunityApi.zan(req);
state.postInfo.vidStatus.hasLiked = true;
state.postInfo.likeCount++;
showToast('点赞成功');
localStorage.setItem('operateData', JSON.stringify(state.postInfo));
} else {
let req = {
type: LikeType.COVER,
objIDs: [state.postInfo.id],
};
await CommunityApi.cancleZan(req);
state.postInfo.vidStatus.hasLiked = false;
state.postInfo.likeCount--;
showToast('取消成功');
localStorage.setItem('operateData', JSON.stringify(state.postInfo));
}
} catch (e) {
console.log(e);
}
};
</script>
<style lang="scss" scoped>
.post-box {
width: 100%;
padding: 12px 0;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.rank-box {
position: absolute;
top: 18px;
right: 16px;
.rank-icon {
width: 59px;
height: 24px;
&-1 {
background: url('@/assets/images/png/rank_one.png') no-repeat center center;
background-size: 100% 100%;
}
&-2 {
background: url('@/assets/images/png/rank_two.png') no-repeat center center;
background-size: 100% 100%;
}
&-3 {
background: url('@/assets/images/png/rank_three.png') no-repeat center center;
background-size: 100% 100%;
}
&-4 {
background: url('@/assets/images/png/rank_four.png') no-repeat center center;
background-size: 100% 100%;
}
}
.sortbox {
display: flex;
align-items: center;
color: #bdbdbd;
font-family: 'PingFang SC';
font-size: 14px;
.sortbox-icon {
margin-right: 4px;
width: 24px;
height: 24px;
background: url('@/assets/images/png/rank_other.png') no-repeat center center;
background-size: 100% 100%;
}
}
}
.post-top {
display: flex;
justify-content: space-between;
align-items: flex-end;
.post-top-left {
.post-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 8px;
:deep(.loading) {
background-size: 28px 21px;
}
}
.post-info {
width: 200px;
display: flex;
flex-direction: column;
.username_box {
display: flex;
align-items: center;
margin-bottom: 5px;
span {
display: inline-block;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.36px;
margin-right: 5px;
}
img {
width: 20px;
}
}
.location-box {
padding-top: 5px;
font-size: 10px;
color: rgba(255, 255, 255, 0.55);
span {
font-size: 10px;
color: rgb(102, 56, 0);
display: inline-block;
border-radius: 3px;
background: linear-gradient(to right, rgb(238, 217, 180), rgb(174, 138, 95));
padding: 2px 5px;
box-sizing: border-box;
margin: 0 5px 0 0;
}
}
}
}
.post-top-right {
svg {
width: 15px;
height: 15px;
margin: 0 3px 0 0;
}
background: #ffc000;
border: solid 1px #ffc000;
border-radius: 3px;
font-size: 12px;
color: #1f2029;
width: 62px;
height: 24px;
}
.post-top-right-follow {
width: 62px;
height: 24px;
color: #989898;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
font-size: 12px;
}
}
.post-text {
padding-top: 10px;
padding-bottom: 10px;
display: flex;
align-items: center;
width: 100%;
}
.post-media {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-row-gap: 8px;
grid-column-gap: 5px;
&.image_one {
grid-template-columns: 1fr;
.img_item {
width: 100%;
height: 193px;
}
}
.img_item {
height: 111px;
width: 111px;
border-radius: 8px;
overflow: hidden;
position: relative;
z-index: 1;
.filter {
position: absolute;
bottom: 5px;
right: 5px;
background: rgba(255, 255, 255, 0.3);
color: #141414;
padding: 2px 8px;
box-sizing: border-box;
font-size: 12px;
border-radius: 99px;
}
.play_box {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
> svg {
width: 24px;
height: 24px;
}
}
}
.img_item2 {
width: 343px;
height: 193px;
.play_box {
> svg {
width: 36px;
height: 36px;
}
}
}
}
.post_menu {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
.post_menu_left {
display: flex;
align-items: center;
justify-content: space-between;
.item {
display: flex;
align-items: center;
margin-right: 22px;
.icon {
width: 20px;
height: 20px;
margin-right: 3px;
color: #989898;
}
.ellipsis {
font-size: 12px;
color: #989898;
}
}
}
.post_menu_right {
font-size: 12px;
padding: 0 6px;
height: 26px;
line-height: 26px;
color: #7c8287;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
}
.statusClass {
padding: 10px 0 0 0;
box-sizing: border-box;
font-size: 14px;
margin: 10px 0 0 0;
color: rgb(62, 100, 234);
p {
margin: 6px 0 0 0;
}
}
}
// 删除按钮
.delBtn {
position: absolute;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.2);
top: 0;
left: 0;
z-index: 10;
.selectActive {
width: 24px;
height: 24px;
position: absolute;
top: 6px;
right: 6px;
}
}
.selectBtn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
width: 24px;
height: 24px;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid #989898;
background: rgba(0, 0, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
&.active {
border-color: #ffc000;
background: #ffc000;
&::after {
content: '';
width: 6px;
height: 10px;
border: 2px solid #1f2029;
border-top: 0;
border-left: 0;
transform: rotate(45deg) translateY(-1px);
}
}
}
</style>
@@ -0,0 +1,155 @@
<template>
<van-overlay :show="isShow" z-index="2001" @click.stop="emit('close')" :lock-scroll="false">
<div class="wrapper flex-center-center">
<div class="content">
<div class="title">提示</div>
<div class="horizontalLine"></div>
<div class="text">当前免费观影次数已用完!</div>
<div class="text">支付尾款 享受完整超级会员特权</div>
<div class="btnBox flex-center-between">
<div class="inviteBtn" @click.stop="unlockNow">{{ gold }}金币支付</div>
<div class="btn flex-center-center" @click.stop="$router.push('/presale')">前往活动</div>
</div>
<template v-if="useGlobalStore().presaleObj.status == 2">
<div class="startActivity">
{{ dateFormatMmDd(useGlobalStore().presaleObj.activityDetail.startTime) }} 限时开启
</div>
</template>
<template v-if="useGlobalStore().presaleObj.status == 3">
<div class="countdown">{{ useGlobalStore().presaleObj.timeStrCoinPay }}</div>
</template>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { computed } from 'vue';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useGlobalStore } from '@/stores/global';
import { useRouter } from 'vue-router';
import { dateFormatMmDd } from '@/utils/index';
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
isOpen: boolean;
productType?: ProductType;
}>();
const userStore = useUserStore();
const userInfo = userStore.userInfo;
const router = useRouter();
const { openLoading, closeLoading } = useLoading();
const emit = defineEmits(['close', 'buySuccess']);
const isShow = computed(() => {
return props.isOpen;
});
const gold = computed(() => {
return userInfo.isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
});
const unlockNow = () => {
try {
openLoading();
let productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
VideoApi.videoBuying({ productType: ProductType.VIDEO, productID: productID })
.then(async () => {
showToast('购买成功');
useUserStore().updateWallet();
emit('buySuccess');
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
router.push({
path: '/wallet',
query: {
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
}
});
} catch (e) {
console.log(e);
} finally {
closeLoading();
}
};
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
color: #fff;
.content {
width: 311px;
height: 240px;
background-color: #202133;
position: relative;
border-radius: 8px;
padding: 18px;
.title {
color: #3476ff;
font-size: 16px;
line-height: 22px;
text-align: center;
}
.horizontalLine {
height: 1px;
background-color: rgb(54, 55, 70);
margin: 18px 0;
}
.text {
font-size: 16px;
text-align: center;
line-height: 32px;
}
.btnBox {
margin-top: 18px;
.inviteBtn {
height: 42px;
width: 112px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
color: #e0e0e0;
background: rgba(255, 255, 255, 0.1);
}
.btn {
height: 42px;
width: 147px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
background: #3476ff;
}
}
}
.startActivity {
color: #ffcc14;
text-align: right;
font-size: 12px;
line-height: 18px;
padding: 12px 22px 0 0;
}
.countdown {
opacity: 0.9;
background: var(---, linear-gradient(135deg, #35debc 0%, #22bb9c 100%));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-align: right;
font-size: 12px;
line-height: 18px;
padding: 12px 22px 0 0;
}
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<van-overlay :show="isShow" z-index="2001" @click.stop="emit('close')" :lock-scroll="false">
<div class="wrapper flex-center-center">
<div class="content">
<div class="title">提示</div>
<div class="horizontalLine"></div>
<div class="text">开通限时预售 全站视频免费观看</div>
<div class="text">查看更多会员特权</div>
<div class="btnBox flex-center-between">
<div class="inviteBtn" @click.stop="unlockNow">{{ gold }}金币支付</div>
<div class="btn flex-center-center" @click.stop="$router.push('/presale')">前往活动</div>
</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { computed } from 'vue';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
isOpen: boolean;
productType?: ProductType;
}>();
const userStore = useUserStore();
const userInfo = userStore.userInfo;
const router = useRouter();
const { openLoading, closeLoading } = useLoading();
const emit = defineEmits(['close', 'buySuccess']);
const isShow = computed(() => {
return props.isOpen;
});
const gold = computed(() => {
return userInfo.isVip ? props.videoInfo.coins : props.videoInfo.originCoins;
});
const unlockNow = () => {
try {
openLoading();
let productID = props.videoInfo.id;
if (!productID) {
showToast('购买出错~');
return;
}
VideoApi.videoBuying({ productType: ProductType.VIDEO, productID: productID })
.then(async () => {
showToast('购买成功');
useUserStore().updateWallet();
emit('buySuccess');
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
router.push({
path: '/wallet',
query: {
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
}
});
} catch (e) {
console.log(e);
} finally {
closeLoading();
}
};
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
color: #fff;
.content {
width: 311px;
height: 240px;
background-color: #202133;
position: relative;
border-radius: 8px;
padding: 18px;
.title {
color: #3476ff;
font-size: 16px;
line-height: 22px;
text-align: center;
}
.horizontalLine {
height: 1px;
background-color: rgb(54, 55, 70);
margin: 18px 0;
}
.text {
font-size: 16px;
text-align: center;
line-height: 32px;
}
.btnBox {
margin-top: 18px;
.inviteBtn {
height: 42px;
width: 112px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
color: #e0e0e0;
background: rgba(255, 255, 255, 0.1);
}
.btn {
height: 42px;
width: 147px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
background: #3476ff;
}
}
}
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<van-overlay :show="isShow" z-index="2001" @click.stop="emit('close')">
<div class="wrapper flex-center-center">
<div class="content">
<div class="title">提示</div>
<div class="horizontalLine"></div>
<div class="text">开通限时预售 全站视频免费观看</div>
<div class="text">查看更多会员特权</div>
<div class="btnBox flex-center-between">
<div class="inviteBtn" @click.stop="$router.push('/member')">会员中心</div>
<div class="btn flex-center-center" @click.stop="$router.push('/presale')">前往活动</div>
</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const isShow = computed(() => {
return props.isOpen;
});
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
color: #fff;
.content {
width: 311px;
height: 240px;
background-color: #202133;
position: relative;
border-radius: 8px;
padding: 18px;
.title {
color: #3476ff;
font-size: 16px;
line-height: 22px;
text-align: center;
}
.horizontalLine {
height: 1px;
background-color: rgb(54, 55, 70);
margin: 18px 0;
}
.text {
font-size: 16px;
text-align: center;
line-height: 32px;
}
.btnBox {
margin-top: 18px;
.inviteBtn {
height: 42px;
width: 112px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
color: #e0e0e0;
background: rgba(255, 255, 255, 0.1);
}
.btn {
height: 42px;
width: 147px;
text-align: center;
line-height: 42px;
font-size: 16px;
border-radius: 42px;
background: #3476ff;
}
}
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="promote_box flex-vcenter">
<lazyImg :src="userStore.userInfo.portrait" round class="promote_portrait" />
<div class="promote_code">
账号凭证
<div>ID {{ userStore.userInfo.uid }}</div>
</div>
<div class="line"></div>
<div class="invit_quantity">提示:可通过凭证在设置找回账号</div>
<div class="qr_code_bg">
<div class="code_box flex-center-center">
<qrcode-vue id="qrCode" class="code_img" v-if="state.qrUrl" :value="state.qrUrl" level="M" />
<van-loading size="50px" type="spinner" color="#c0c9ea" v-else />
</div>
</div>
<div class="promote_info">
APP更新可能会导致会员账号掉线,
<span>请勿删除此凭证</span>
,此凭证是您永久有效的登陆途径。
</div>
<div class="promote_info">
永久官方地址 <span class="guide">{{ configStore.sourceInfo.GUIDE }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, reactive } from 'vue';
import UserApi from '@/service/user';
import { useUserStore } from '@/stores/user';
import { useConfigStore } from '@/stores/config';
const userStore = useUserStore();
const configStore = useConfigStore();
const state = reactive({
qrUrl: <string>'',
});
nextTick(async () => {
let ret: any = await UserApi.queryCertificate();
state.qrUrl = ret.content;
});
</script>
<style scoped lang="scss">
.promote_box {
height: 474px;
width: 296px;
display: flex;
flex-direction: column;
align-items: center;
background: url('@/assets/images/png/mine/figma/ticket-bg.png') no-repeat;
background-size: 100% 100%;
margin: 0 auto 24px;
padding: 27px 24px 0;
box-sizing: border-box;
.promote_portrait {
width: 90px;
height: 90px;
border: 4px solid rgba(243, 121, 82, 0.45);
border-radius: 99px;
margin-bottom: 12px;
box-sizing: border-box;
}
.promote_code {
display: flex;
flex-direction: column;
align-items: center;
color: rgba(255, 255, 255, 0.9);
font-size: 20px;
font-weight: 500;
line-height: 28px;
margin-bottom: 12px;
div {
margin-top: 12px;
font-size: 16px;
font-weight: 400;
line-height: 22px;
color: rgba(255, 255, 255, 0.55);
}
}
.line {
background: url('@/assets/images/png/mine/share_line.png') no-repeat;
background-size: 100% 100%;
width: 248px;
height: 1px;
opacity: 0.3;
margin-bottom: 12px;
}
.invit_quantity {
font-size: 12px;
line-height: 18px;
color: #f37952;
text-align: center;
}
.qr_code_bg {
height: 120px;
width: 120px;
margin: 12px 0;
display: flex;
justify-content: center;
align-items: center;
background: url('@/assets/images/png/frame.png') no-repeat;
background-size: contain;
.code_box {
background-color: white;
width: 108px;
height: 108px;
.code_img {
width: 98px !important;
height: 98px !important;
}
}
}
.promote_info {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
line-height: 18px;
text-align: center;
width: 196px;
> span {
color: #f52c56;
}
.guide {
font-weight: 500;
text-decoration: underline;
}
}
.promote_info + .promote_info {
margin-top: 4px;
width: auto;
}
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<van-popup v-model:show="isShow">
<div class="warp">
<div class="content_warp">
<div class="content">
<p class="title">
发布成功
</p>
<div class="mess">
帖子发布成功后将在24小时内进行审核,您可在【我的】·【我的帖子】中进行查看
</div>
<div class="btn_box">
<div class="ISee flex-center-center" @click="emit('close')">确定</div>
</div>
</div>
</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
});
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
</script>
<style lang="scss" scoped>
.warp {
color:#fff;
.content_warp {
width: 350px;
border-radius: 10px;
background: #23262f;
.content {
font-size: 12px;
.title {
font-size: 18px;
text-align: center;
padding: 30px 0;
box-sizing: border-box;
}
.mess{
width: 100%;
font-size: 15px;
padding: 0 30px;
box-sizing: border-box;
line-height: 20px;
color: #a2a4ac;
}
}
.btn_box {
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
margin: 30px 0 0 0;
padding-bottom: 20px;
box-sizing: border-box;
.ISee {
width: 205px;
height: 35px;
background: linear-gradient(to right,rgb( 132, 164, 249),rgb( 43, 93, 222));
color: #fff;
margin: 0 10px 0 0;
border-radius: 10px;
}
.ISee:nth-child(2){
margin: 0;
}
}
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="refreshing_warp">
<van-pull-refresh
v-model="params.refreshing"
@refresh="onRefresh"
:disabled="params.disabled"
success-text="刷新成功"
:class="[{ 'my-refresh': params.isHigehtMax, none: params.refreshing }, params.className]"
:id="params.id"
>
<van-list
v-if="!params.isNoData"
v-model:loading="loadingc"
:finished="params.finished"
:finished-text="finishedText"
@load="onLoad"
:offset="100"
v-model:error="params.error"
:immediate-check="false"
error-text="请求失败,点击重新加载"
>
<slot></slot>
</van-list>
<div class="sizeBox" v-if="!params.isNoData"></div>
<Empty v-if="params.isNoData" />
<!-- <div v-if="params.isNoData" class="dataNull">暂无数据</div> -->
</van-pull-refresh>
</div>
</template>
<script lang="ts" setup>
import { computed, toRefs, watchEffect } from 'vue';
const props = withDefaults(
defineProps<{
finishedText?: string;
params: {
className?: string; //自定义class
disabled?: boolean; //是否禁止下拉刷新 默认为false
refreshing: boolean; //下拉刷新loading
loading: boolean; //上滑刷新loading
finished: boolean; //列表数据是否记载完毕
isNoData: boolean; //数据是否为空
error?: boolean; //请求错误
/**
* 占满父级100%
* 组件内部 overflow-y:auto
* 此参数设置为true的话,父级不要设置overflow-y:auto
*/
isHigehtMax?: boolean;
id?: string; //自定义class
};
}>(),
{
finishedText: '没有更多数据了',
},
);
const { params } = toRefs(props);
const loadingc = computed({
get() {
return params.value.loading;
},
set() {
return params.value.loading;
},
});
let isNeedOnLoad = false;
watchEffect(() => {
if (!props.params.refreshing && isNeedOnLoad) {
isNeedOnLoad = false;
onLoad();
}
});
const emit = defineEmits(['onLoad', 'onRefresh']);
function onLoad() {
if (props.params.refreshing) {
isNeedOnLoad = true;
return;
}
if (props.params.finished) return;
emit('onLoad');
}
function onRefresh() {
emit('onRefresh');
}
</script>
<style scoped lang="scss">
:deep(.van-loading__circular) {
display: none;
}
.none {
:deep(.van-list) {
.van-loading {
display: none;
}
}
}
:deep(.van-loading__spinner) {
background: url('../../assets/gif/loading.gif');
width: 25px !important;
height: 25px !important;
background-size: cover;
animation: none;
}
.refreshing_warp {
height: 100%;
.my-refresh {
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 11px 16px;
}
.loading_warp {
display: flex;
align-items: center;
justify-content: center;
.loading_box {
width: 200px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
.loading {
width: 25px;
}
.loading_text {
padding-left: 5px;
}
}
}
.sizeBox {
height: 100px;
}
}
</style>
+764
View File
@@ -0,0 +1,764 @@
<template>
<Teleport to="body">
<div class="recharge-box">
<!-- 支付方式弹窗 -->
<van-popup
class="recharge-popup"
v-model:show="isOpenRecharge"
position="bottom"
:z-index="zIndex"
@close="emits('closePay')"
>
<div class="recharge-bg">
<div class="recharge_line" />
<div class="recharge_title flex-center-center">选择支付方式</div>
<van-radio-group v-model="state.activeItem" direction="horizontal" class="rechargeList">
<div v-for="item in state.rechargeList" :key="item.channel" @click.stop="selectPay(item)">
<div class="rechargeItem" v-if="item.type !== 'daichong'">
<SvgIcon class="payIcon" :name="item.type"></SvgIcon>
<div class="payText">{{ item.typeName }}</div>
<van-radio checked-color="#F68804" icon-size="16px" :name="item.type"></van-radio>
</div>
<!-- 代充 -->
<div v-if="state.dcInfo.traders && state.dcInfo.traders.length > 0 && item.type === 'daichong'">
<div
v-for="dcItem in state.dcInfo.traders[0].payInfos"
:key="dcItem.payMethod"
@click="selectDcPay(item, dcItem)"
>
<div class="rechargeItem" v-if="item.type == 'daichong'">
<SvgIcon class="payIcon" :name="getSvgPayIcon(dcItem.payMethod)"></SvgIcon>
<div class="payText">
{{ getPayName(dcItem.payMethod) }}
<span>(人工充值)</span>
</div>
<van-radio checked-color="#F68804" icon-size="16px" :name="'dc' + dcItem.payMethod"></van-radio>
</div>
</div>
</div>
</div>
<div class="rechargeItem" v-if="state.popType == 'vip' && state.isAmountPay" @click="selectGoldPay">
<div class="goldIcon"></div>
<div class="payText">金币</div>
<van-radio checked-color="#F68804" icon-size="16px" name="gold"></van-radio>
</div>
</van-radio-group>
<div class="couponBox" v-if="state.couponList.length > 0">
<div class="couponTitle">加赠券</div>
<van-list
class="couponList"
v-model="state.loading"
:finished="state.finished"
:offset="10"
v-if="state.couponList.length > 0"
>
<div class="coupon-item" v-for="couponItem in state.couponList" :key="couponItem.id">
<div class="coupon-title">{{ couponItem.source }}</div>
<div class="coupon-amount">{{ couponItem.amount }}金币</div>
<div class="coupon-selset">
<van-radio-group v-model="state.radioValue">
<van-radio
:name="couponItem.id"
checked-color="#E1351F"
icon-size="16px"
@click="clickRadio(couponItem.id)"
></van-radio>
</van-radio-group>
</div>
</div>
</van-list>
</div>
<div class="subTitle">支付小贴士:</div>
<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>
<!-- 支付提示(暂时省去该流程) -->
<!-- <van-popup class="kindTips" v-model:show="state.showKindTips">
<div class="kindTipsBox">
<div class="title flex-center-center">温馨提示</div>
<div class="contents">
<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>
</div>
</div>
</van-popup> -->
<!-- 订单确认弹窗 -->
<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">2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。</div>
<div class="tipItem">3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用。</div>
<div class="question flex-center-center" @click="$router.push('paymentIssues')">
支付遇到问题
<SvgIcon name="greyRightArrow"></SvgIcon>
</div>
<div class="confirmBtn app-popup-primary" @click="confirmPaySuccess">支付成功</div>
</div>
</van-popup>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import { useUserStore } from '@/stores/user';
import MineApi from '@/service/mine';
import { Buffer } from 'buffer';
import { showToast } from 'vant';
import { useRoute } from 'vue-router';
import { compatibleAppStoreJump } from '@/utils/index';
import { refreshPaymentGuideConfig } from '@/utils/paymentGuide';
import { refreshPaymentPopupConfig } from '@/utils/paymentStatusPopup';
const route = useRoute();
const userStore = useUserStore();
const userInfo = userStore.userInfo;
const props = withDefaults(
defineProps<{
isOpen: boolean;
color?: string;
zIndex?: number;
}>(),
{
zIndex: 2000,
},
);
const emits = defineEmits(['closePay', 'paySuccess']);
const isOpenRecharge = computed(() => {
return props.isOpen;
});
const state = reactive({
rechargeList: <Array<Types.Recharge.RchgTypeItem>>[], // 支付方式列表
activeItem: '', // 支付方式选中
dcInfo: <Types.Recharge.Daichong>{}, // 代充信息
dcType: 1, // 代充商品类型 会员-1 金币-0
popType: 'vip', // 弹窗类型 会员卡-vip 金币-gold
payMethodType: 'dc', // 支付方式类型 支付渠道-channel 代充-dc 金币兑换-gold
productType: '', // 商品类型
productID: '', // 商品ID
buyType: '', // 商品类型
discountedPrice: 0, // 充值金额
payInfo: <any>{}, // 支付渠道信息
rchgType: <any>{}, // 充值类型
dcPayInfo: <any>{}, // 代充支付信息
showPayTip: false, // 支付提示弹窗状态
showKindTips: false, // 温馨提示
isAmountPay: false, // 是否支持金币兑换
graphicsId: '', // 埋点ID
graphicsTitle: '', // 埋点标题
isOpenRecharge: false,
couponList: <Array<Types.Recharge.CouponItem>>[], // 金币加购券列表
pageNumber: 1,
loading: false,
finished: false,
radioValue: '',
curRadioValue: '', // 金币加购券
show: false,
isPayLoading: false, // 支付加载中
finalPayStatus: <any>'no', //是否支付尾款
isUpgrade: false, // 当前会员卡是否补差价升级
payTracking: <Types.Recharge.PayTrackingFields>{}, // 下单来源 / A/B 追踪字段
});
const payBtnLabel = computed(() => {
if (state.popType === 'vip' && state.isUpgrade) {
return '补差价升级';
}
return '立即支付';
});
// 查询优惠券
const queryCouponList = async () => {
let req = {
pageSize: '50',
pageNumber: state.pageNumber,
int: 1, // 0-全部 1-有效
};
state.loading = true;
try {
let res: any = await MineApi.getCouponList(req);
state.couponList = res.list.filter((item: any) => {
if (new Date(item.expired).getTime() > new Date().getTime() && !item.used) {
return true;
} else {
return false;
}
});
state.finished = true;
setTimeout(() => {
state.loading = false;
}, 200);
} catch (error) {
state.loading = false;
state.finished = true;
}
};
const init = () => {
if (state.popType == 'gold') {
queryCouponList();
}
};
init();
/**
* @description 显示支付方式弹窗
* @param rechargeList - 支付方式列表
* @param dcInfo - 代充信息
* @param popType - 弹窗类型 会员卡-vip 金币-gold
*/
const changeItem = (payData: any) => {
state.rechargeList = payData.rechargeList;
if (payData.dcInfo) {
state.dcInfo = payData.dcInfo;
}
state.popType = payData.popType;
state.dcType = payData.dcType;
state.productType = payData.productType;
state.productID = payData.productID;
state.discountedPrice = payData.discountedPrice;
state.buyType = payData.buyType;
state.isAmountPay = payData.isAmountPay;
state.isUpgrade = !!payData.isUpgrade;
const trackingEntries = Object.entries({
sourcePage: payData.sourcePage || 'UNKNOWN',
sourceRef: payData.sourceRef,
videoId: payData.videoId,
mediaId: payData.mediaId,
contentId: payData.contentId,
activityId: payData.activityId,
experimentId: payData.experimentId,
experimentVariant: payData.experimentVariant,
sessionId: payData.sessionId,
}).filter(([, value]) => value !== undefined && value !== '');
state.payTracking = Object.fromEntries(trackingEntries) as Types.Recharge.PayTrackingFields;
// eslint-disable-next-line no-prototype-builtins
state.finalPayStatus = payData.hasOwnProperty('finalPayStatus') ? payData.finalPayStatus : 'no';
let dcArr = state.rechargeList.filter((i: any) => {
return i.type == 'daichong';
});
let channel = state.rechargeList.filter((i: any) => {
return i.type != 'daichong';
});
if (channel.length > 0) {
selectPay(channel[0]);
} else if (dcArr.length > 0 && state.dcInfo.traders.length > 0 && state.dcInfo.traders[0].payInfos.length > 0) {
let arr = state.rechargeList.filter((i: any) => {
return i.type == 'daichong';
});
selectDcPay(arr[0], state.dcInfo.traders[0].payInfos[0]);
} else {
if (state.isAmountPay) {
selectGoldPay();
}
}
};
// 选中支付方式
const selectPay = (item: any) => {
if (item.type != 'daichong') {
state.payInfo = item;
state.rchgType = {};
state.dcPayInfo = {};
state.payMethodType = 'channel';
state.activeItem = item.type;
}
};
// 选中代充支付方式
const selectDcPay = (rchgType: any, dcPayInfo: Types.Recharge.Daichong) => {
state.payInfo = {};
state.rchgType = rchgType;
state.dcPayInfo = dcPayInfo;
state.payMethodType = 'dc';
state.activeItem = 'dc' + state.dcPayInfo.payMethod;
};
// 选中金币兑换
const selectGoldPay = () => {
state.payInfo = { typeName: '金币' };
state.rchgType = {};
state.dcPayInfo = {};
state.payMethodType = 'gold';
state.activeItem = 'gold';
};
// 点击金币加购券
const clickRadio = (id: any) => {
if (state.curRadioValue === id) {
state.radioValue = '';
state.curRadioValue = '';
} else {
state.curRadioValue = id;
}
};
// 立即支付
const toPay = () => {
if (!state.rechargeList.length && state.payMethodType != 'gold') return showToast('暂无支付方式');
if (state.payMethodType == 'gold') {
goldExchange();
return;
}
// 暂时省去温馨提示流程,直接发起支付
// state.showKindTips = true;
comfinPay();
};
const comfinPay = () => {
state.isPayLoading = true;
if (state.payMethodType == 'channel') {
channelPay();
} else if (state.payMethodType == 'dc') {
dcToPay();
}
};
const onPurchaseSuccess = async () => {
emits('paySuccess');
emits('closePay');
await Promise.all([refreshPaymentPopupConfig(), refreshPaymentGuideConfig()]);
};
const confirmPaySuccess = async () => {
state.showPayTip = false;
await onPurchaseSuccess();
};
// 金币兑换(会员购买时附带 A/B 实验追踪字段,与 /mine/topay 对齐)
const goldExchange = async () => {
const { experimentId, experimentVariant, sessionId } = state.payTracking;
const trackingEntries = Object.entries({ experimentId, experimentVariant, sessionId }).filter(
([, value]) => value !== undefined && value !== '',
);
let req: Record<string, unknown> = {
productType: state.productType,
productID: state.productID,
isH5: true,
...Object.fromEntries(trackingEntries),
};
if (state.finalPayStatus != 'no') {
Object.assign(req, { finalPayStatus: state.finalPayStatus });
}
try {
userStore.updateWallet();
let walletInfo = userStore.walletInfo;
let walletBalance = ((walletInfo.amount || 0) + (walletInfo.income || 0)).toFixed(2);
if (state.discountedPrice * 10 > Number(walletBalance)) {
showToast('金币不足请充值');
return;
}
await MineApi.goldExchange(req);
showToast('购买成功');
await onPurchaseSuccess();
} catch (e) {
console.log(e);
}
};
const goToPayUrl = (payUrl: string) => {
state.show = false;
state.showKindTips = false;
state.isPayLoading = false;
emits('closePay');
if (localStorage.getItem('isAppStore')) {
window.webkit?.messageHandlers?.getMessage?.postMessage({
id: 'openOutLink',
data: { url: payUrl },
});
} else {
window.location.href = payUrl;
}
};
// 支付渠道支付
const channelPay = async () => {
state.show = true;
let req: Types.Recharge.PayTrackingFields & Record<string, unknown> = {
uid: userInfo.uid,
rechargeType: state.payInfo.type,
productID: state.productID,
buyType: state.buyType,
goldExtraID: state.curRadioValue ? state.curRadioValue : undefined,
...state.payTracking,
};
if (state.finalPayStatus != 'no') {
Object.assign(req, { finalPayStatus: state.finalPayStatus });
}
try {
let ret: any = await MineApi.getPay(req);
state.payInfo = {};
state.curRadioValue = '';
state.finalPayStatus = 'no';
goToPayUrl(ret.payUrl);
} catch (e) {
state.isPayLoading = false;
state.show = false;
console.log(e);
}
};
// 代充
const dcToPay = () => {
state.show = true;
let payMoney = state.discountedPrice;
if (state.finalPayStatus != 'no') {
Object.assign(state.dcInfo, { finalPayStatus: state.finalPayStatus });
}
let dcData = JSON.parse(JSON.stringify(state.dcInfo)); // 代充信息
let payInfoModel = JSON.parse(JSON.stringify(state.dcPayInfo)); // 代充支付类型
let channel = state.rchgType.channel;
let productInfo = {
id: state.productID,
type: state.dcType,
version: '3.0.0',
};
//设置默认值
if (dcData.limit == 0) {
dcData.limit = 500;
}
if (payInfoModel.payType.includes(2) && payInfoModel.payType.includes(3)) {
if (payMoney > dcData.limit) {
payInfoModel.payType = [3];
} else {
payInfoModel.payType = [2];
}
}
if (payInfoModel.payType.length >= 3) {
if (payMoney > dcData.limit) {
payInfoModel.payType = [1, 3];
} else {
payInfoModel.payType = [1, 2];
}
}
dcData.traders[0].payInfos = [payInfoModel];
dcData.productInfo = productInfo;
dcData.chargeMoney = payMoney;
dcData.channel = channel;
let models = JSON.stringify(dcData);
state.isPayLoading = false;
let payUrl = `${channel}/?data=${encodeBase64(models)}&token=${userStore.token}`;
state.finalPayStatus = 'no';
goToPayUrl(payUrl);
};
// 转base64
const encodeBase64 = (str: any) => {
let base64Str = Buffer.from(str, 'utf-8').toString('base64');
return base64Str;
};
// 获取支付方式名称
const getPayName = (type: any) => {
switch (type) {
case 'alipay':
return '支付宝';
case 'wechat':
return '微信';
case 'union':
return '银联';
case 101:
return '支付宝';
case 102:
return '微信';
case 103:
return '银行卡';
case 104:
return '信用卡';
case 105:
return '花呗';
case 106:
return '云闪付';
case 107:
return 'QQ钱包';
case 108:
return '京东支付';
default:
return '金币兑换';
}
};
// 获取支付方式图标
const getSvgPayIcon = (type: any) => {
switch (type) {
case 'alipay':
return 'alipay';
case 'wechat':
return 'wechat';
case 'union':
return 'bankcard';
case 101:
return 'alipay';
case 102:
return 'wechat';
case 103:
return 'bankcard';
case 104:
return 'bankcard';
case 105:
return 'alipay';
case 106:
return 'bankcard';
case 107:
return 'bankcard';
case 108:
return 'bankcard';
default:
return 'gold';
}
};
defineExpose({
changeItem,
});
</script>
<style lang="scss" scoped>
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
}
:deep(.van-popup) {
background-color: transparent;
}
.recharge-box {
.recharge-popup {
.recharge-bg {
padding-top: 20px;
border-radius: 18px 18px 0 0;
color: white;
background: #0f0f0f;
.recharge_line {
width: 36px;
height: 5px;
background: #ffffff33;
border-radius: 99px;
margin: 0 auto;
}
.recharge_title {
text-align: center;
font-weight: 500;
height: 65px;
}
.payMethod {
font-size: 13px;
text-align: center;
span {
font-size: 16px;
color: #fb415c;
}
}
.title {
font-size: 12px;
color: #aab2b7;
margin-bottom: 20px;
}
.rechargeList {
max-height: 310px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 8px 20px 8px 42px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
flex-wrap: nowrap;
}
.rechargeItem {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0;
box-sizing: border-box;
border-radius: 4px;
.payIcon {
width: 36px;
height: 36px;
border-radius: 50%;
}
.goldIcon {
width: 36px;
height: 36px;
background: url('@/assets/images/png/goldIcon.png') no-repeat;
background-size: 100% 100%;
}
.payText {
flex: 2;
font-size: 14px;
margin-left: 12px;
}
}
.couponBox {
padding: 0 30px;
box-sizing: border-box;
height: 90px;
.couponTitle {
font-size: 16px;
font-weight: 500;
color: $titleColor;
margin-bottom: 6px;
}
.couponList {
height: 60px;
overflow-y: auto;
}
.coupon-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
.coupon-title {
font-size: 14px;
color: $greyDeepColor;
}
.coupon-amount {
flex: 2;
text-align: right;
margin: 0 10px;
font-size: 14px;
color: $titleColor;
}
}
}
.subTitle {
padding: 0 42px;
box-sizing: border-box;
font-size: 14px;
font-weight: 500;
margin: 10px 0;
}
.payTip {
padding: 0 42px;
box-sizing: border-box;
font-size: 12px;
line-height: 20px;
color: rgba(255, 255, 255, 0.55);
}
.payBtn {
background: #ffc000;
color: #270000;
font-weight: 500;
font-size: 16px;
width: 343px;
height: 44px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 43px;
margin: 20px auto;
}
.customerService {
font-size: 12px;
text-align: center;
padding-bottom: 30px;
.contactCustomer {
color: #ffc000;
}
}
}
}
:deep(.van-popup) {
overflow-y: visible;
}
.kindTips {
border-radius: 8px;
.kindTipsBox {
width: 311px;
height: 486px;
background: url('@/assets/images/png/recharge-bg.png') no-repeat;
background-size: 100% 100%;
border-radius: 12px;
padding: 32px 24px;
.title {
font-size: 20px;
font-weight: 500;
text-align: center;
margin-bottom: 6px;
background: linear-gradient(91.16deg, #fff0c2 35.22%, #ffffff 64.61%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.horizontalLine {
height: 1px;
background-color: rgba(255, 255, 255, 0.1);
}
.contents {
font-size: 14px;
padding: 10px 0px;
line-height: 24px;
p {
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;
font-size: 16px;
font-weight: 500;
border-radius: 43px;
margin: 0 auto;
background: #ffc000;
text-align: center;
line-height: 44px;
color: #270000;
}
.close {
position: absolute;
top: -40px;
right: 0;
img {
width: 24px;
height: 24px;
}
}
}
}
.payTipPop {
.payTipBox {
height: 361px;
width: 303px;
background-color: rgb(34, 34, 34);
border-radius: 8px;
padding: 14px 12px;
.paySubTitle {
font-size: 18px;
color: rgb(251, 219, 165);
text-align: center;
}
.tipItem {
font-size: 12px;
color: rgb(255, 255, 255);
line-height: 25px;
margin-top: 12px;
}
.question {
font-size: 12px;
color: rgb(153, 153, 153);
text-align: center;
margin-top: 12px;
> svg {
height: 12px;
width: 12px;
margin-left: 3px;
}
}
.confirmBtn {
width: 279px;
height: 41px;
font-size: 14px;
border-radius: 41px;
margin: 25px auto 0;
color: rgb(0, 0, 0);
background: linear-gradient(to right, rgb(253, 239, 165), rgb(249, 180, 91));
text-align: center;
line-height: 41px;
}
}
}
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="recommendedList">
<van-tabs
v-model:active="state.tabActive"
class="oepnX"
title-active-color="rgba(255, 255, 255, 0.90)"
title-inactive-color="rgba(255, 255, 255, 0.55)"
:scrollspy="false"
:swipeable="false"
:lazy-render="true"
>
<van-tab :name="index" :title="item.label" v-for="(item, index) in tabList" :key="item.value">
<Video
:type="type"
:tagId="tagId"
:currentType="item.value"
v-if="index == state.tabActive"
@changeVideo="
(videoInfo) => {
emit('changeVideo', videoInfo);
}
"
/>
</van-tab>
</van-tabs>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed } from 'vue';
import Video from './video.vue';
const emit = defineEmits(['changeVideo']);
const props = withDefaults(
defineProps<{
type: string;
tagId?: string;
}>(),
{
type: 'SP',
},
);
const state = reactive({
tabActive: 0,
});
const tabList = computed(() => {
if (props.type == 'video') {
return [
{
label: '动漫推荐',
value: 'video',
},
{
label: '视频推荐',
value: 'SP',
},
{
label: '漫画推荐',
value: 'image',
},
];
}
if (props.type == 'image') {
return [
{
label: '漫画推荐',
value: 'image',
},
{
label: '视频推荐',
value: 'SP',
},
{
label: '动漫推荐',
value: 'video',
},
];
}
return [
{
label: '视频推荐',
value: 'SP',
},
{
label: '动漫推荐',
value: 'video',
},
{
label: '漫画推荐',
value: 'image',
},
];
});
</script>
<style lang="scss" scoped>
.recommendedList {
height: calc(100vh - 250px);
--van-tabs-line-height: 44px;
:deep(.van-tabs) {
.van-tabs__wrap {
position: sticky;
top: -1px;
background-color: var(--app-background);
border-bottom: 0 none;
padding: 0 10px;
z-index: 1;
.van-tabs__nav {
background: unset;
}
.van-tab {
flex: 1 !important;
width: unset !important;
justify-content: center !important;
}
.van-tabs__line {
width: 13px;
height: 3px;
border-radius: 12px;
background: linear-gradient(90deg, rgba(255, 192, 0, 0) 0%, var(--app-primary) 100%);
bottom: 22px;
}
}
}
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="videos">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="moreMoviesBox" :class="['video', 'image'].includes(currentType) ? 'moreAcgBox' : ''">
<HorizontalVideoBox
class="videoBox"
:videoList="state.list"
:videoInfo="item"
:isMovie="type == 'SP' && type == currentType"
v-for="item in state.list"
:key="item.id"
:showTimeAndComment="currentType == 'SP'"
@changeVideo="changeVideo"
/>
</div>
</PullRefresh>
</div>
</template>
<script lang="ts" setup>
import VideoApi from '@/service/video';
import AcgApi from '@/service/acg';
import { reactive } from 'vue';
const props = withDefaults(
defineProps<{
type: string;
tagId?: string;
currentType: string;
}>(),
{
type: 'SP',
},
);
const state = reactive({
tabActive: 0,
pageNumber: 1,
pageSize: 10,
list: <any>[],
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const emit = defineEmits(['changeVideo']);
const changeVideo = (videoInfo: any) => {
emit('changeVideo', videoInfo);
};
// 获取更多视频
const getList = async () => {
try {
let req: any = {
pageNumber: state.pageNumber,
pageSize: state.pageSize,
};
let ajax: any = VideoApi.queryRecommend;
if (props.currentType == 'video' || props.currentType == 'image') {
req.mediaType = props.currentType;
if (props.type == props.currentType) {
req.tagId = props.tagId || null;
}
ajax = AcgApi.getMediaRecommend;
} else {
req.newsType = props.currentType;
if (props.type == 'SP') {
req.tagId = props.tagId || null;
}
}
let res: any = await ajax(req);
let list = res.list || [];
if (state.pageNumber == 1) {
state.list = list;
} else {
state.list = state.list.concat(list);
}
if (state.pageNumber == 1 && state.list.length == 0) {
moreParams.isNoData = true;
}
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
moreParams.error = true;
} finally {
moreParams.loading = false;
moreParams.refreshing = false;
}
};
const onLoad = () => {
if (moreParams.refreshing) return;
moreParams.loading = true;
state.pageNumber++;
getList();
};
const onRefresh = () => {
state.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
moreParams.isNoData = false;
getList();
};
const init = () => {
getList();
};
init();
</script>
<style lang="scss" scoped>
.videos {
.moreMoviesBox {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-column-gap: 7px;
width: 100%;
box-sizing: border-box;
.videoBox {
width: 100%;
min-width: 0;
:deep(.coverBox) {
height: 93px;
}
}
}
.moreAcgBox {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-column-gap: 5px;
.videoBox {
width: 100%;
min-width: 0;
:deep(.coverBox) {
height: 148px;
}
:deep(.videoTitleBox) {
.videoTitle {
height: 20px;
-webkit-line-clamp: 1;
}
}
}
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="rich-preview">
<div ref="content" class="content" v-html="html" @click="onElementClick"></div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import AppApi from '@/service/app';
import { useConfigStore } from '@/stores/config';
import placeholderImage from '@/assets/images/png/placeholder.png';
import { jumpAdv } from '@/utils';
function isFullURL(text: string) {
return /^(http(s)?:\/\/)\w+[^\s]+(\.[^\s]+){1,}$/.test(text);
}
const props = defineProps<{ text?: string }>();
const configStore = useConfigStore();
const html = computed(() => {
nextTick(handleDynamicElements);
return replaceImageSrc(props.text || '');
});
const content = ref<HTMLElement>();
const handleDynamicElements = () => {
const imgs = content.value?.getElementsByTagName('img');
observeImages(imgs);
};
const onElementClick = (e: Event) => {
const ele = e.target as HTMLElement;
const aTag = findNearestElement('a', ele);
if (aTag) {
const href = aTag.getAttribute('href');
jumpAdv({ href });
}
};
const findNearestElement = (nodeName: string, element: HTMLElement | null) => {
const upperNodeName = nodeName.toUpperCase();
if (!element) return null;
if (element.nodeName === upperNodeName) return element;
return findNearestElement(upperNodeName, element.parentElement);
};
// 缓存监听的元素
const observedElements: HTMLElement[] = [];
const observeImages = (imgs?: HTMLCollectionOf<HTMLImageElement>) => {
// 先移除原来的监听
unobserveImages();
if (!imgs) return;
for (let index = 0; index < imgs.length; index++) {
const element = imgs[index];
observer.observe(element);
observedElements.push(element);
}
};
const unobserveImages = () => {
observedElements.forEach((v) => observer.unobserve(v));
observedElements.splice(0);
};
const unobserveImage = (img: HTMLElement) => {
observer.unobserve(img);
const idx = observedElements.indexOf(img);
if (idx >= 0) {
observedElements.splice(idx, 1);
}
};
const unobserveEntry = (entry: IntersectionObserverEntry) => {
unobserveImage(entry.target as HTMLElement);
};
onBeforeUnmount(() => {
unobserveImages();
observer.disconnect();
});
const observer = new IntersectionObserver(
function (entries) {
entries.forEach((entry) => {
observeEntry(entry);
});
},
{ threshold: 0.1 },
);
const observeEntry = async (entry: IntersectionObserverEntry) => {
const { intersectionRatio } = entry;
if (intersectionRatio <= 0) return;
const target = entry.target as HTMLElement;
const tempSrc = target.getAttribute('temp-src');
if (!tempSrc) {
// 没有临时路径,清除监听
unobserveEntry(entry);
return;
}
// 是完整url,无需解密直接src加载
if (isFullURL(tempSrc)) {
target.style.height = target.style.height || 'auto';
target.setAttribute('src', tempSrc);
target.removeAttribute('temp-src');
unobserveEntry(entry);
return;
}
try {
const url = await AppApi.imgDecypt(configStore.sourceInfo.IMAGE + '/' + tempSrc);
target.style.height = target.style.height || 'auto';
target.setAttribute('src', url);
target.removeAttribute('temp-src');
unobserveEntry(entry);
} catch (error) {
console.error('img decypt error:', error);
}
};
const replaceImageSrc = (htmlContent: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const images = doc.getElementsByTagName('img');
for (const img of Array.from(images)) {
let imgSrc = img.getAttribute('src') || '';
img.setAttribute('src', placeholderImage);
img.setAttribute('temp-src', imgSrc);
}
return doc.documentElement.outerHTML;
};
</script>
<style lang="scss" scoped>
.rich-preview {
.content {
margin: 0 0 10px 0;
color: #888;
font-size: 12px;
line-height: 1.3;
font-weight: 400;
white-space: pre-line;
:deep() {
img {
width: 100%;
height: 200px;
object-fit: scale-down;
}
}
}
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<van-overlay :show="isShow" z-index="2001">
<div class="wrapper flex-center-center">
<div class="content">
<p>充值VIP,获取会员线路</p>
<div class="name">蜜桃传媒</div>
<div class="close" @click="emit('close')">
<img src="@/assets/images/png/pop_close.png" alt="" />
</div>
</div>
<div class="btnBox flex-center-between">
<div class="btn" @click="$router.push('/share')">邀请好友</div>
<div class="btn nowOpenBtn" @click="$router.push('/member')">立即开通</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const isShow = computed(() => {
return props.isOpen;
});
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
flex-direction: column;
.content {
width: 281px;
height: 334px;
background: url('./../../assets/images/png/popVip.png') no-repeat;
background-size: 100% 100%;
color: #383838;
padding: 135px 30px 0 50px;
position: relative;
p {
font-size: 17px;
line-height: 22px;
}
.name {
text-align: right;
font-size: 12px;
padding-right: 20px;
margin-top: 20px;
}
.close {
height: 24px;
width: 24px;
position: absolute;
top: 0;
right: 0;
> img {
height: 100%;
width: 100%;
}
}
}
.btnBox {
.btn {
height: 46px;
width: 126px;
background: url('./../../assets/images/png/popBtn2.png') no-repeat;
background-size: 100% 100%;
text-align: center;
line-height: 46px;
font-size: 17px;
}
.nowOpenBtn {
background: url('./../../assets/images/png/popBtn.png') no-repeat;
background-size: 100% 100%;
margin-left: 30px;
}
}
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<!-- 选择路线弹窗 -->
<Teleport to="body">
<van-popup class="selectionPopup" v-model:show="isOpenSelectionLine" position="bottom" @close="emits('close')">
<div class="selectionLineBox">
<!-- <div class="titleBox">
选择播放线路
<SvgIcon name="closeBlack" class="close" @click="emits('close')"></SvgIcon>
</div> -->
<div class="line_top" />
<div class="lineBox">
<div
class="line"
:class="{ selectedLine: item.desc == globalStore.videoRoadLine.desc }"
@click="selectLine(item)"
v-for="item in state.roadLines"
:key="item.desc"
>
{{ item.desc }}
</div>
</div>
</div>
</van-popup>
<!-- 线路vip弹框 -->
<VipLinePopup :is-open="state.visibleVipLinePopup" @close="closeVipLine"></VipLinePopup>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import { useConfigStore } from '@/stores/config';
import { useGlobalStore } from '@/stores/global';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
const { isVip } = useUserStore();
const configStore = useConfigStore();
const globalStore = useGlobalStore();
const props = defineProps<{
isOpen: boolean;
}>();
const emits = defineEmits(['close', 'closeSelectLine']);
const isOpenSelectionLine = computed(() => {
return props.isOpen;
});
const state = reactive({
roadLines: <any>configStore.sourceInfo.VID,
visibleVipLinePopup: false,
});
const closeVipLine = () => {
state.visibleVipLinePopup = false;
};
const selectLine = (item: any) => {
if (!isVip && globalStore.videoRoadLine.desc !== item.desc) {
state.visibleVipLinePopup = true;
emits('close');
return;
}
globalStore.setVideoRoadLine(item);
showToast('切换成功');
emits('closeSelectLine');
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
:deep(.van-overlay) {
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
}
:deep(.van-popup) {
background-color: transparent;
}
.selectionPopup {
.selectionLineBox {
background: #0f0f0f;
background-size: 150%;
background-position-y: -20px;
padding: 18px 14px;
border-radius: 18px 18px 0 0;
@include SafeArea('padding', 'bottom', 18px);
// .titleBox {
// color: black;
// font-size: 18px;
// font-weight: 500;
// height: 50px;
// text-align: center;
// line-height: 50px;
// border-bottom: 1px solid #f1f3f5;
// position: relative;
// .close {
// height: 15px;
// width: 15px;
// position: absolute;
// right: 10px;
// top: 10px;
// }
// }
.line_top {
width: 36px;
height: 5px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.2);
margin: 0 auto;
}
.lineBox {
.line {
padding: 18px 0;
width: 100%;
text-align: center;
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
border-bottom: $borderSplit;
}
.selectedLine {
color: $themeColor;
}
}
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<van-popup
:show="isShow"
position="bottom"
round
class="set-price-popup"
@click-overlay="emit('close')"
@update:show="(v) => !v && emit('close')"
>
<div class="content">
<div class="line" />
<p class="title">设置价格</p>
<div class="mess">
<div
:class="['item', state.activeData.id == item.id ? 'active' : '']"
v-for="(item, index) in state.list"
@click="setPrice(item)"
:key="index"
>
<img src="@/assets/images/png/coins.png" alt="" />
<span>{{ item.num }}金币</span>
</div>
</div>
<div class="mess2 flex-center-center">
<span>自定义</span>
<van-field @blur="blur" class="titleText" v-model="state.price" placeholder="" type="number" />
<span>金币</span>
</div>
<div class="btn_box">
<div class="ISee flex-center-center" @click="emit('close', state.priceVal)">确定设置</div>
</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
const state = reactive({
list: [
{ num: 10, id: 1 },
{ num: 20, id: 2 },
{ num: 30, id: 3 },
{ num: 40, id: 4 },
{ num: 50, id: 5 },
{ num: 60, id: 6 },
{ num: 70, id: 7 },
{ num: 80, id: 8 },
],
price: '',
activeData: <any>{},
priceVal: '',
});
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
});
const isShow = computed(() => props.isOpen);
const setPrice = (data: any) => {
if (state.activeData.id == data.id) {
state.activeData = {};
state.priceVal = '';
} else {
state.activeData = data;
state.priceVal = data.num;
}
};
const blur = () => {
state.price ? (state.activeData = {} && (state.priceVal = state.price)) : (state.priceVal = '');
emit('blur');
};
const emit = defineEmits(['close', 'blur']);
</script>
<style lang="scss" scoped>
.content {
width: 100%;
background: #0f0f0f;
border-radius: 20px 20px 0 0;
padding: 18px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
@include SafeArea('padding', 'bottom', 21px);
.line {
width: 36px;
height: 5px;
background: rgba(255, 255, 255, 0.2);
border-radius: 100px;
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 500;
line-height: 28px;
color: rgba(255, 255, 255, 0.9);
text-align: center;
}
.mess {
width: 300px;
display: grid;
grid-template-columns: repeat(4, 66px);
gap: 12px;
justify-content: center;
.item {
width: 66px;
height: 72px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
line-height: 18px;
img {
width: 24px;
height: 24px;
display: block;
}
}
.active {
border-color: transparent;
background: #ffc000;
color: #fefce8;
}
}
.mess2 {
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
font-weight: 500;
line-height: 20px;
gap: 0;
.titleText {
width: 47px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
padding: 0;
margin: 0 6px;
border-radius: 5px;
:deep(.van-field__control) {
height: 24px;
text-align: center;
font-size: 14px;
color: rgba(255, 255, 255, 0.55) !important;
}
}
}
.btn_box {
width: 100%;
.ISee {
width: 100%;
height: 44px;
border-radius: 39px;
background: #ffc000;
color: #231600;
font-size: 16px;
font-weight: 500;
line-height: 22px;
}
}
}
</style>
<style lang="scss">
.set-price-popup {
background: #0f0f0f !important;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<template>
<div
class="vertical_video_warp"
:class="{
'vertical_video_warp--hide-progress': props.hideProgress,
'vertical_video_warp--hide-fullscreen': props.hideFullscreen,
'vertical_video_warp--hide-background': props.hideBackground,
}"
>
<div id="video">
<div class="avoid_sliding" @click="pause"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted } from 'vue';
import { useConfigStore } from '@/stores/config';
import { useUserStore } from '@/stores/user';
import HlsJSPlayer from 'xgplayer-hls.js';
import { bindH265PlaybackFallback, resolvePlayResourceKey } from '@/utils/h265Playback';
const props = defineProps<{
sourceURL: string;
/** H.265 资源 key;有值且设备支持时优先播放 */
h265Url?: string | null;
hideProgress?: boolean;
hideFullscreen?: boolean;
hideBackground?: boolean;
}>();
let player: any = null; //播放器实例
let playResourceKey = '';
let isPlayingH265 = false;
let forceH264 = false;
let pendingResumeAt = 0;
let unbindH265Fallback: (() => void) | null = null;
onMounted(() => {
//初始化播放器
initVideo();
});
/**
* @description 暂停
*/
const pause = () => {
player.pause();
};
const resolveCurrentResourceKey = () => {
const resolved = resolvePlayResourceKey({
h264Key: props.sourceURL,
h265Key: props.h265Url,
forceH264,
});
playResourceKey = resolved.resourceKey;
isPlayingH265 = resolved.isH265;
};
const destroyPlayer = () => {
unbindH265Fallback?.();
unbindH265Fallback = null;
if (!player) return;
try {
player.src = '';
player.destroy(true);
} catch {
/* ignore */
}
player = null;
};
const fallbackH265ToH264 = (resumeAt: number) => {
if (forceH264 || !isPlayingH265) return;
forceH264 = true;
isPlayingH265 = false;
pendingResumeAt = resumeAt;
destroyPlayer();
initVideo();
};
/**
* @description 初始化播放器
*/
const initVideo = () => {
if (player) destroyPlayer();
resolveCurrentResourceKey();
player = new HlsJSPlayer({
id: 'video',
url: getPath(),
fluid: true,
playsinline: true,
videoInit: true,
loop: true,
autoplay: true,
controls: true,
lang: 'zh-cn',
ignores: ['i18n', 'play', 'volume', 'definition'],
allowPlayAfterEnded: true,
allowSeekAfterEnded: true,
closeVideoPreventDefault: true,
closeVideoStopPropagation: true,
closeInactive: true,
enableVideoDbltouch: true,
});
unbindH265Fallback = bindH265PlaybackFallback(player, {
shouldFallback: () => isPlayingH265 && !forceH264,
onFallback: fallbackH265ToH264,
});
player.on('loadedmetadata', () => {
if (pendingResumeAt > 0) {
const resumeAt = pendingResumeAt;
pendingResumeAt = 0;
try {
player.currentTime = resumeAt;
} catch {
/* ignore */
}
}
});
};
/**
* @description 获取播放链接
*/
const getPath = () => {
const { baseUrl } = useConfigStore();
return `${baseUrl}/api/app/vid/h5/m3u8/${playResourceKey || props.sourceURL}?token=${getToken()}`;
};
/**
* @description 获取token
*/
const getToken = () => {
const { token } = useUserStore();
return token;
};
onBeforeUnmount(() => {
destroyPlayer();
});
</script>
<style lang="scss" scoped>
.vertical_video_warp {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
background: #000000;
align-items: center;
:deep(.xgplayer-progress-played) {
background: rgb(39, 213, 236);
}
:deep(.xgplayer-progress) {
top: 10px;
}
:deep(.xgplayer-start),
:deep(.xgplayer-controls) {
transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
}
:deep(.xgplayer-progress-btn) {
display: none !important;
}
:deep(.xgplayer-time-current:after) {
content: ' ';
}
:deep(.xgplayer-progress) {
left: 43%;
right: 0;
width: 60%;
transform: translate(-50%, 0);
}
:deep(.xgplayer-time) {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
// margin-top: 10px;
font-size: 11px;
}
:deep(.xgplayer-time-current)::after {
display: none;
}
&.vertical_video_warp--hide-progress {
:deep(.xgplayer-progress),
:deep(.xgplayer-time) {
display: none !important;
}
}
&.vertical_video_warp--hide-fullscreen {
:deep(.xgplayer-fullscreen) {
display: none !important;
}
}
&.vertical_video_warp--hide-background {
background: transparent;
}
// :deep(.xgplayer-icon-play) {
// > svg {
// height: 40px;
// width: 40px;
// }
// }
// :deep(.xgplayer-skin-default .xgplayer-start) {
// height: 40px;
// width: 40px;
// margin: -20px;
// }
#video {
width: 100%;
height: 100% !important;
position: absolute;
top: 0;
left: 0;
position: relative;
:deep(.xgplayer-enter-spinner) {
width: 50px;
height: 50px;
}
.avoid_sliding {
position: absolute;
width: 100%;
height: 100%;
background: transparent;
top: 0;
}
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="adv_swiper">
<div class="adv-container" v-if="props.list.length > 0" ref="advRef">
<van-swipe ref="swipeRef" :show-indicators="false" :autoplay="autoTime" class="custom-swipe" @change="onChange">
<van-swipe-item
@click="handleJumpAdv(item, props.clickType)"
v-for="item in props.list"
:key="item.id"
:style="{ height: props.height }"
class="adv-swiper-slide"
>
<DecryptImg @load="load" :src="item.cover" />
</van-swipe-item>
</van-swipe>
<!-- 自定义指示器 -->
<div class="indicator-wrapper" v-if="isShowIndicators">
<div class="custom-indicators">
<div
v-for="(_, index) in props.list"
:key="index"
:class="['indicator-dot', { active: currentIndex === index }]"
@click="swipeRef?.swipeTo(index)"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { jumpAdv } from '@/utils/index';
import { nextTick, ref, watch } 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>();
const props = withDefaults(
defineProps<{
list: Types.App.AdsInfoListItem[];
height?: string;
isShowIndicators?: boolean;
clickType?: 'ads' | 'recreation';
autoTime: number;
adImpression?: AdImpressionType;
}>(),
{
list: () => [],
height: '156px',
isShowIndicators: true,
clickType: 'ads',
autoTime: 5000,
},
);
const currentIndex = ref(0);
const onChange = (index: number) => {
currentIndex.value = index;
};
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>
<style lang="scss" scoped>
.adv_swiper {
overflow: hidden;
border-radius: 6px;
position: relative;
height: 100%;
.adv-container {
width: 100%;
position: relative;
height: 100%;
.custom-swipe {
height: 100%;
.adv-swiper-slide {
width: 100%;
border-radius: 6px;
overflow: hidden;
:deep(img) {
height: 100%;
width: 100%;
object-fit: cover;
}
}
}
.indicator-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 40px;
pointer-events: none;
z-index: 99;
.custom-indicators {
position: absolute;
bottom: 30px;
right: 12px;
display: flex;
gap: 4px;
pointer-events: auto;
.indicator-dot {
width: 7px;
height: 7px;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
transition: all 0.3s;
cursor: pointer;
&.active {
width: 17px;
background: #3476ff;
border-radius: 4px;
}
}
}
}
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<svg
aria-hidden="true"
class="svg-icon"
:width="props.size"
:height="props.size"
:style="props.color ? { color: props.color } : undefined"
>
<use :xlink:href="symbolId" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
prefix: {
type: String,
default: 'icon',
},
name: {
type: String,
required: true,
},
color: {
type: String,
default: '',
},
size: {
type: String,
default: '1em',
},
});
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
</script>
@@ -0,0 +1,170 @@
<template>
<div
v-if="showing"
class="ai-drama-float-bubble"
:style="{ '--tab-count': String(tabCount) }"
>
<button
type="button"
class="ai-drama-float-bubble__hit"
aria-label="AI爽剧来袭"
@click.stop="onClick"
>
<img
class="ai-drama-float-bubble__img"
src="@/assets/images/png/short-drama/ai-drama-float-bubble.png"
alt="AI爽剧来袭!"
width="131"
height="34"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config';
import { unlockShortVideoAudio } from '@/utils/shortVideoAudioUnlock';
/** 终身只展示 1 次(含老用户更新后的首次启动) */
const STORAGE_KEY = 'ai_drama_float_bubble_once_v1';
const props = defineProps<{
/** 底部 Tab 数量(含暗网时为 6,否则 5) */
tabCount?: number;
/** 当前是否已在短剧 Tab */
activeOnShortVideo?: boolean;
}>();
const route = useRoute();
const router = useRouter();
const configStore = useConfigStore();
const tabCount = computed(() => Math.max(5, Number(props.tabCount) || 6));
const isOnShortVideo = () =>
Boolean(props.activeOnShortVideo) || route.name === 'shortVideo';
const readOnceFlag = () => {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
};
const writeOnceFlag = () => {
try {
localStorage.setItem(STORAGE_KEY, '1');
} catch {
/* ignore */
}
};
/** 本次是否正在展示(仅首次启动且非短剧 Tab) */
const showing = ref(false);
onMounted(() => {
// 已消耗过首次机会 → 永不出现
if (readOnceFlag()) {
showing.value = false;
return;
}
// Ping 运营全局关闭:不展示,也不消耗本地 once(日后重新开启仍可出现一次)
if (configStore.appConfig?.entryPopupEnabled === false) {
showing.value = false;
return;
}
// 消耗首次机会(老用户更新后第一次进 App 也算)
writeOnceFlag();
// 启动时默认就在短剧 Tab → 不出现
if (isOnShortVideo()) {
showing.value = false;
return;
}
showing.value = true;
});
// 进入短剧 Tab 后立刻消失
watch(
() => [props.activeOnShortVideo, route.name] as const,
() => {
if (isOnShortVideo()) {
showing.value = false;
}
},
);
const onClick = () => {
unlockShortVideoAudio();
showing.value = false;
writeOnceFlag();
void router.replace({ path: '/shortVideo', query: { type: '4' } });
};
</script>
<style scoped lang="scss">
/*
* 与 TabBar 同坐标系:fixed + max-width:780 居中。
* 原先 absolute 相对 tab_wrapper,和 fixed 底栏/安全区错位,真机易乱。
* Figma 23190:99055:三角 Vector x=46 w=12,尖端约在气泡左侧 52px,对准第 2 个 Tab「短剧」中心。
*/
.ai-drama-float-bubble {
--tab-count: 6;
/* 气泡左缘 → 三角尖端(46 + 12/2) */
--arrow-offset: 6px;
position: fixed;
left: 0;
right: 0;
margin: 0 auto;
width: 100%;
max-width: 780px;
/* 与 :deep(.van-tabbar) height:55px + safe-area 对齐,再上抬 6px */
bottom: calc(55px + constant(safe-area-inset-bottom) + 6px);
bottom: calc(55px + env(safe-area-inset-bottom, 0px) + 6px);
z-index: 1002;
height: 34px;
pointer-events: none;
&__hit {
position: absolute;
/* 第 2 个 Tab 中心:兼容部分 WebView 对 calc 除法顺序的限制 */
left: calc(100% * 1.5 / var(--tab-count));
bottom: 0;
width: 50px;
height: 34px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
transform: translateX(calc(-1 * var(--arrow-offset)));
animation: ai-drama-bubble-bob 1.8s ease-in-out infinite;
}
&__img {
display: block;
width: 50px;
height: 50px;
object-fit: contain;
pointer-events: none;
user-select: none;
}
}
@keyframes ai-drama-bubble-bob {
0%,
100% {
transform: translateX(calc(-1 * var(--arrow-offset))) translateY(0);
}
50% {
transform: translateX(calc(-1 * var(--arrow-offset))) translateY(-3px);
}
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div class="tab_wrapper">
<AiDramaFloatBubble :tab-count="tabCount" :active-on-short-video="activeName === 'shortVideo'" />
<van-tabbar
v-model="activeName"
route
fixed
placeholder
:border="false"
:safe-area-inset-bottom="true"
@change="onChange"
data-ignore-track
>
<!-- :icon="activeName == 'home' ? homeIconActived : homeIcon" -->
<van-tabbar-item to="/home" name="home" replace>
<span class="tabbar-item-text" v-if="isHomeTop" @click="backTop('home')">顶部</span>
<span v-else class="tabbar-item-text">首页</span>
<template #icon="props">
<div
class="tabbar-item"
@click="backTop('home')"
:class="[props.active ? (isHomeTop ? 'top' : 'homeActive') : 'home']"
></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/shortVideo" name="shortVideo" replace>
<span class="tabbar-item-text">短剧</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'liveActive' : 'live']"></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/darkNet" name="darkNet" replace v-if="darkWebEnable">
<span class="tabbar-item-text" v-if="isDarkNetTop" @click="backTop('darkNet')">顶部</span>
<span v-else class="tabbar-item-text">
<template v-if="darkWebIconName">
{{ darkWebIconName }}
</template>
<template v-else>暗网</template>
</span>
<template #icon="props">
<template v-if="darkWebIcon">
<lazyImg :src="darkWebIcon" alt="darkWebIcon" v-if="props.active && !isDarkNetTop" />
<div
v-else
class="tabbar-item"
@click="backTop('darkNet')"
:class="[props.active ? (isDarkNetTop ? 'top' : 'darkNetActive') : 'darkNet']"
></div>
</template>
<template v-else>
<div
class="tabbar-item"
@click="backTop('darkNet')"
:class="[props.active ? (isDarkNetTop ? 'top' : 'darkNetActive') : 'darkNet']"
></div>
</template>
</template>
</van-tabbar-item>
<van-tabbar-item to="/aiUndress2" name="aiUndress2" replace>
<span class="tabbar-item-text">AI</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'aiActive' : 'ai']"></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/community" name="community" replace>
<span class="tabbar-item-text" v-if="isCommunityTop" @click="backTop('community')">顶部</span>
<span v-else class="tabbar-item-text">社区</span>
<template #icon="props">
<div
class="tabbar-item"
@click="backTop('community')"
:class="[props.active ? (isCommunityTop ? 'top' : 'communityActive') : 'community']"
></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/mine" name="mine" replace>
<span class="tabbar-item-text">我的</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'mineActive' : 'mine']"></div>
</template>
<div v-if="showRedDot" class="newsTip"></div>
</van-tabbar-item>
</van-tabbar>
<!-- warning提示 -->
<van-popup class="waringBox" v-model:show="state.showWarning" :close-on-click-overlay="false">
<div class="waringBoxTip">
<div class="title">WANRNING</div>
<div class="content">该板块内容极度危险,心理承受弱者请自行离开,切记挑战自己好奇害死猫,请您确认还要继续吗?</div>
<div class="btnBox flex-center-between">
<div class="btn" @click.stop="leave">离开</div>
<div class="btn red" @click="state.showWarning = false">确认</div>
</div>
</div>
</van-popup>
</div>
</template>
<script lang="ts">
export default {
name: 'TabBar',
};
</script>
<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';
const configStore = useConfigStore();
const globalStore = useGlobalStore();
setTheme(configStore.appConfig.festivalUi);
const darkWebEnable = computed(() => {
return configStore.appConfig.darkWebEnable;
});
const darkWebIcon = computed(() => {
return configStore.appConfig.darkWebIcon;
});
const darkWebIconName = computed(() => {
return configStore.appConfig.darkWebIconName;
});
/** 底部 Tab 数量:暗网开启时 6,否则 5(用于悬浮气泡对准「短剧」) */
const tabCount = computed(() => (darkWebEnable.value ? 6 : 5));
const router = useRouter();
const route = useRoute();
const activeName = ref('home');
const state = reactive({
showWarning: false,
userInfo: <Types.User.UserInfoType>{},
});
const refreshRoute = (name: any) => {
activeName.value = name;
};
refreshRoute(route.name);
watch(route, (newVal) => {
refreshRoute(newVal.name);
});
/**
* 获取用户信息
* @param uid
*/
const getInfo = async () => {
try {
let res: any = await UserApi.queryUserInfo(null);
state.userInfo = res;
} catch (e) {
console.log(e);
}
};
getInfo();
const change = (activeName: any) => {
if (activeName == 'darkNet') {
if (state.userInfo.isVip) {
state.showWarning = true;
}
}
};
const leave = () => {
activeName.value = 'home';
router.replace('/home');
state.showWarning = false;
};
const UserStore = useUserStore();
onActivated(() => {
refreshRoute(route.name);
UserStore.getCheckMessageTip();
});
const onChange = (name: string) => {
UserStore.getCheckMessageTip();
if (name == 'home') {
analyticsEventNavigation('Home', '首页');
} else 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 backTop = (path: string) => {
let dom: any = null;
if (path == 'home') {
if (!isHomeTop.value) return;
dom = document.getElementById(path + globalStore.activeIndex);
} else if (path == 'darkNet') {
if (!isDarkNetTop.value) return;
dom = document.getElementById(path + globalStore.darkNetActiveIndex);
} else if (path == 'community') {
if (!isCommunityTop.value) return;
dom = document.getElementById(path + globalStore.communityActiveIndex);
}
dom?.scrollTo({ top: 0, behavior: 'smooth' });
const arr = globalStore.keepScroll[`/${path}`];
arr.map((item: any) => {
if (item.scrollObj == globalStore.activeIndex) {
item.showBackTopBtn = false;
}
return item;
});
globalStore.keepScroll[`/${path}`] = arr;
};
const isHomeTop = computed(() => {
return (
activeName.value == 'home' &&
globalStore.keepScroll['/home']?.find((item) => item.scrollObj == globalStore.activeIndex)?.showBackTopBtn
);
});
const isDarkNetTop = computed(() => {
return (
activeName.value == 'darkNet' &&
globalStore.keepScroll['/darkNet']?.find((item) => item.scrollObj == globalStore.darkNetActiveIndex)?.showBackTopBtn
);
});
const isCommunityTop = computed(() => {
return (
activeName.value == 'community' &&
globalStore.keepScroll['/community']?.find((item) => item.scrollObj == globalStore.communityActiveIndex)
?.showBackTopBtn
);
});
</script>
<style lang="scss" scoped>
@media (min-height: 700px) {
.tab_wrapper {
.marqueeBar {
top: -64px !important;
}
}
}
:deep(.van-tabbar) {
background: rgb(0, 0, 0);
height: 55px;
z-index: 1000;
}
.van-tabbar-item--active {
background: rgb(0, 0, 0);
}
:deep(.van-tabbar--fixed) {
max-width: 780px;
margin: 0 auto;
left: 50%;
transform: translateX(-50%);
}
:deep(.van-tabbar__placeholder) {
margin: 0 auto;
}
:deep(.van-icon__image) {
width: 24px;
height: 24px;
}
:deep(.van-tabbar-item__text) {
font-size: 12px;
}
.tab_wrapper {
position: relative;
.marqueeBar {
height: 31px;
width: 100%;
padding: 6px 10px;
position: absolute;
left: 0;
top: -32px;
z-index: 999;
}
}
.waringBox {
.waringBoxTip {
height: 235px;
width: 307px;
background-color: rgb(34, 34, 34);
border-radius: 8px;
overflow: hidden;
.title {
height: 60px;
background-color: rgb(255, 50, 48);
font-size: 24px;
text-align: center;
line-height: 60px;
}
.content {
font-size: 16px;
padding: 23px 20px;
line-height: 20px;
}
.btnBox {
padding: 0 20px;
.btn {
font-size: 15px;
height: 40px;
width: 120px;
background-color: rgb(61, 61, 61);
text-align: center;
line-height: 40px;
border-radius: 40px;
}
.red {
background-color: rgb(255, 50, 48);
}
}
}
}
.newsTip {
position: absolute;
bottom: 16px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fd0563;
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<template>
<div ref="textOverflow" class="text-overflow" :style="boxStyle">
<!-- <span ref="overEllipsis" class="overEllipsis">{{ realText }}</span> -->
<span v-if="isTopping" class="choosen">置顶</span>
<span v-else-if="!isTopping && isChoosen" class="choosen">精华</span>
<span ref="overEllipsis" class="overEllipsis" v-html="rearrangeText(realText)"></span>
<span class="slot-box" ref="slotRef" v-if="state.showSlotNode">
<slot :click-toggle="toggle" :expanded="state.expanded"></slot>
</span>
</div>
</template>
<script lang="ts" setup>
import { jumpAdv } from '@/utils/index';
import { ref, reactive, computed, nextTick, onMounted } from 'vue';
const props = defineProps({
text: {
type: String,
default: '',
},
maxLines: {
type: Number,
default: 3,
},
width: {
type: Number,
default: 0,
},
isChoosen: {
type: Boolean,
default: false,
},
isTopping: {
type: Boolean,
default: false,
},
});
const overEllipsis = ref<HTMLElement>();
const slotRef = ref<HTMLElement>();
const textOverflow = ref<HTMLElement>();
const state = reactive({
offset: props.text.length,
expanded: false,
slotBoxWidth: 0,
textBoxWidth: props.width,
showSlotNode: false,
isChoosen: props.isChoosen,
});
const boxStyle = computed(() => {
if (props.width) {
return {
width: props.width + 'px',
};
}
return '0px';
});
const realText = computed(() => {
// 是否被截取
const isCutOut = state.offset !== props.text.length;
let realText = props.text;
if (isCutOut && !state.expanded) {
realText = props.text.slice(0, state.offset) + '...';
}
return realText;
});
const calculateOffset = (from: number, to: number) => {
nextTick(() => {
if (Math.abs(from - to) <= 1) return;
if (isOverflow()) {
to = state.offset;
} else {
from = state.offset;
}
state.offset = Math.floor((from + to) / 2);
calculateOffset(from, to);
});
};
const isOverflow = () => {
const { len, lastWidth } = getLines();
if (len < props.maxLines) {
return false;
}
if (props.maxLines) {
// 超出部分 行数 > 最大行数 或则 已经是最大行数但最后一行宽度 + 后面内容超出正常宽度
const lastLineOver = !!(len === props.maxLines && lastWidth + state.slotBoxWidth > state.textBoxWidth);
if (len > props.maxLines || lastLineOver) {
return true;
}
}
return false;
};
const getLines = () => {
const clientRects: any = overEllipsis.value?.getClientRects();
return {
len: clientRects.length,
lastWidth: clientRects[clientRects.length - 1]?.width,
};
};
const toggle = () => {
state.expanded = !state.expanded;
};
const rearrangeText = (text: any) => {
let originalText = '';
if (props.text && props.text.indexOf('##') != -1) {
let startIndex = props.text.indexOf('{');
let endIndex = props.text.indexOf('}');
let jsonText = props.text.substring(startIndex, endIndex + 1);
let jsonObj = JSON.parse(jsonText);
let texts = '##' + jsonText + '##';
originalText = text.replace(
texts,
`<div onclick="jumpKeywords('${jsonObj.type}', '${jsonObj.id}')" style='color:rgb(162,203,240);display: inline-block;'>${jsonObj.txt}<div style="height: 9px;width: 9px;display: inline-block;margin-bottom: 3px;background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAYCAMAAADJYP15AAAAOVBMVEVHcEyh0fuh0fyi0f2h0fyg0Pyf1f+f0/yfz/+h0fyfz/+h0Pyg0Pyh0fyf0vyi0vyh0fyh0fyg0fw8+yy6AAAAEXRSTlMAkLBwgPAwPxDGIODfoFBgXzlWcyEAAAC6SURBVCjPbZDbEsMgCEQNarxU027+/2MLFQ3pZF9kduQA65wof7aCRmF3VqlB1fMy84ZLLU1b3JdPrh6dq6igxDVpb3oBZSCYSwtYIxCkeDPhmuM8Y5Ts7VaMqfwUIFmbgIMfHni7IQy4Njmzrr+alhTKTd24WaE7bjNp3sMXt2oGYlyfeWgcq2c6zzPMWKMkR4Ejx/os+GKSXREKMg6rSGcxEdUjeFloj3OVuzhRbA++/09J1R9///QF6MAK320wOcMAAAAASUVORK5CYII=');background-size: 100% 100%;"></div></div>`,
);
return originalText;
}
return text;
};
function jumpKeywords(type: any, id: any) {
window.event.stopPropagation();
let item = {
href: '',
};
switch (type) {
case '1':
item.href = 'yinseinner://memberCentre';
break;
case '2':
item.href = 'yinseinner://sharePromote';
break;
case '3':
item.href = 'yinseinner://incomeCenter';
break;
case '4':
item.href = 'yinseinner://taskHall';
break;
case '5':
item.href = `yinseinner://taskDetails?id=${id}`;
break;
case '6':
item.href = 'yinseinner://wishList';
break;
case '7':
item.href = 'yinseinner://creationCenter';
break;
case '8':
item.href = 'yinseinner://myCertification';
break;
case '9':
item.href = 'yinseinner://announcement';
break;
case '10':
item.href = 'yinseinner://kefu';
break;
case '11':
item.href = `yinseinner://horizontalVideo?id=${id}`;
break;
case '12':
item.href = `yinseinner://userHomePage?uid=${id}`;
break;
case '13':
item.href = 'yinseinner://gameWallet';
break;
// case '14':
// case '15':
// item.href = `yinseinner://postDetails?id=${id}`;
// break;
}
jumpAdv(item);
}
onMounted(() => {
window.jumpKeywords = jumpKeywords;
const { len } = getLines();
if (len > props.maxLines) {
state.showSlotNode = true;
nextTick(() => {
state.slotBoxWidth = <number>slotRef.value?.clientWidth;
state.textBoxWidth = <number>textOverflow.value?.clientWidth;
calculateOffset(0, props.text.length);
});
}
});
</script>
<style scoped lang="scss">
.text-overflow {
line-height: 20px;
}
.slot-box {
display: inline-block;
color: rgba(62, 100, 234);
}
.overEllipsis {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
white-space: pre-wrap;
}
.choosen {
font-size: 12px;
height: 18px;
width: 37px;
text-align: center;
line-height: 18px;
border-radius: 3px;
color: #ffffff;
// background: linear-gradient(to right, rgb(132, 164, 249), rgb(43, 93, 222));
background: #f52c56;
display: inline-block;
margin: 0px 5px 0px 0;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div ref="textOverflow" class="text-overflow" :style="boxStyle">
<span ref="overEllipsis" class="overEllipsis" v-html="rearrangeText(realText)"></span>
<span class="slot-box" ref="slotRef" v-if="state.showSlotNode">
<slot :click-toggle="toggle" :expanded="state.expanded"></slot>
</span>
</div>
</template>
<script lang="ts" setup>
import { jumpAdv } from '@/utils/index';
import { ref, reactive, computed, nextTick, onMounted } from 'vue';
const props = defineProps({
text: {
type: String,
default: '',
},
maxLines: {
type: Number,
default: 3,
},
width: {
type: Number,
default: 0,
},
isChoosen: {
type: Boolean,
default: false,
},
});
const overEllipsis = ref<HTMLElement>();
const slotRef = ref<HTMLElement>();
const textOverflow = ref<HTMLElement>();
const state = reactive({
offset: props.text.length,
expanded: true,
slotBoxWidth: 0,
textBoxWidth: props.width,
showSlotNode: false,
isChoosen: props.isChoosen,
});
const boxStyle = computed(() => {
if (props.width) {
return {
width: props.width + 'px',
};
}
return '0px';
});
const realText = computed(() => {
// 是否被截取
const isCutOut = state.offset !== props.text.length;
let realText = props.text;
if (isCutOut && !state.expanded) {
realText = props.text.slice(0, state.offset) + '...';
}
return realText;
});
const calculateOffset = (from: number, to: number) => {
nextTick(() => {
if (Math.abs(from - to) <= 1) return;
if (isOverflow()) {
to = state.offset;
} else {
from = state.offset;
}
state.offset = Math.floor((from + to) / 2);
calculateOffset(from, to);
});
};
const isOverflow = () => {
const { len, lastWidth } = getLines();
if (len < props.maxLines) {
return false;
}
if (props.maxLines) {
// 超出部分 行数 > 最大行数 或则 已经是最大行数但最后一行宽度 + 后面内容超出正常宽度
const lastLineOver = !!(len === props.maxLines && lastWidth + state.slotBoxWidth > state.textBoxWidth);
if (len > props.maxLines || lastLineOver) {
return true;
}
}
return false;
};
const getLines = () => {
const clientRects: any = overEllipsis.value?.getClientRects();
return {
len: clientRects.length,
lastWidth: clientRects[clientRects.length - 1]?.width,
};
};
const toggle = () => {
state.expanded = !state.expanded;
if (!state.expanded) {
calculateOffset(0, props.text.length);
}
};
const rearrangeText = (text: any) => {
let originalText = '';
if (props.text && props.text.indexOf('##') != -1) {
let startIndex = props.text.indexOf('{');
let endIndex = props.text.indexOf('}');
let jsonText = props.text.substring(startIndex, endIndex + 1);
let jsonObj = JSON.parse(jsonText);
let texts = '##' + jsonText + '##';
originalText = text.replace(
texts,
`<div onclick="jumpKeywords('${jsonObj.type}', '${jsonObj.id}')" style='color:rgb(162,203,240);display: inline-block;'>${jsonObj.txt}<div style="height: 9px;width: 9px;display: inline-block;margin-bottom: 3px;background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAYCAMAAADJYP15AAAAOVBMVEVHcEyh0fuh0fyi0f2h0fyg0Pyf1f+f0/yfz/+h0fyfz/+h0Pyg0Pyh0fyf0vyi0vyh0fyh0fyg0fw8+yy6AAAAEXRSTlMAkLBwgPAwPxDGIODfoFBgXzlWcyEAAAC6SURBVCjPbZDbEsMgCEQNarxU027+/2MLFQ3pZF9kduQA65wof7aCRmF3VqlB1fMy84ZLLU1b3JdPrh6dq6igxDVpb3oBZSCYSwtYIxCkeDPhmuM8Y5Ts7VaMqfwUIFmbgIMfHni7IQy4Njmzrr+alhTKTd24WaE7bjNp3sMXt2oGYlyfeWgcq2c6zzPMWKMkR4Ejx/os+GKSXREKMg6rSGcxEdUjeFloj3OVuzhRbA++/09J1R9///QF6MAK320wOcMAAAAASUVORK5CYII=');background-size: 100% 100%;"></div></div>`,
);
return originalText;
}
return text;
};
function jumpKeywords(type: any, id: any) {
window.event.stopPropagation();
let item = {
href: '',
};
switch (type) {
case '1':
item.href = 'yinseinner://memberCentre';
break;
case '2':
item.href = 'yinseinner://sharePromote';
break;
case '3':
item.href = 'yinseinner://incomeCenter';
break;
case '4':
item.href = 'yinseinner://taskHall';
break;
case '5':
item.href = `yinseinner://taskDetails?id=${id}`;
break;
case '6':
item.href = 'yinseinner://wishList';
break;
case '7':
item.href = 'yinseinner://creationCenter';
break;
case '8':
item.href = 'yinseinner://myCertification';
break;
case '9':
item.href = 'yinseinner://announcement';
break;
case '10':
item.href = 'yinseinner://kefu';
break;
case '11':
item.href = `yinseinner://horizontalVideo?id=${id}`;
break;
case '12':
item.href = `yinseinner://userHomePage?uid=${id}`;
break;
case '13':
item.href = 'yinseinner://gameWallet';
break;
// case '14':
// case '15':
// item.href = `yinseinner://postDetails?id=${id}`;
// break;
}
jumpAdv(item);
}
onMounted(() => {
window.jumpKeywords = jumpKeywords;
const { len } = getLines();
if (len > props.maxLines) {
console.log(state.expanded);
state.showSlotNode = true;
nextTick(() => {
state.slotBoxWidth = <number>slotRef.value?.clientWidth;
state.textBoxWidth = <number>textOverflow.value?.clientWidth;
});
}
});
</script>
<style scoped lang="scss">
.slot-box {
display: inline-block;
color: rgba(62, 100, 234);
}
.overEllipsis {
font-size: 14px;
line-height: 24px;
// color: $greyDeepColor;
color: rgba(255, 255, 255, 0.8);
white-space: pre-wrap;
}
.choosen {
font-size: 12px;
padding: 2px 5px;
border-radius: 3px;
color: #ffffff;
background: linear-gradient(to right, rgb(132, 164, 249), rgb(43, 93, 222));
display: inline-block;
margin: 0 5px 0 0;
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<template>
<Teleport to="body">
<van-popup v-model:show="isShow" @close="emit('close')">
<div class="warp" :class="{ 'warp--glow': glow }">
<div class="content_warp" :class="{ 'app-popup-surface': glow }">
<div class="title_box">
<div class="title" :class="{ 'app-popup-title': glow }">{{ props.title }}</div>
</div>
<div class="divider" :class="{ 'app-popup-divider': glow }" />
<div class="content" :class="{ 'app-popup-body': glow }">
<p class="tip" v-html="props.tip"></p>
<div class="btn_box">
<div
v-if="showCancel"
class="ISee flex-center-center"
:class="glow ? 'app-popup-secondary' : ''"
@click="emit('close')"
>
{{ cancelText }}
</div>
<div
class="ISee flex-center-center"
:class="[glow ? 'app-popup-primary' : '', { 'wd-full': !showCancel }]"
@click="jumpPath('/member')"
>
{{ btnText }}
</div>
</div>
</div>
</div>
</div>
</van-popup>
</Teleport>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
// Only the matching Figma nickname/avatar prompts opt into the glow style.
glow: {
type: Boolean,
default: false,
},
isOpen: {
type: Boolean,
default: false,
},
tip: {
type: String,
default: '',
},
showCancel: {
type: Boolean,
default: false,
},
cancelText: {
type: String,
default: '取消',
},
btnText: {
type: String,
default: '立即开通',
},
title: {
type: String,
default: '温馨提示',
},
});
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
function jumpPath(path: any) {
router.push(path);
emit('close');
}
</script>
<style lang="scss" scoped>
.warp {
color: #fff;
.content_warp {
width: 303px;
background: #0f0f0f;
border-radius: 12px;
padding: 24px;
box-sizing: border-box;
.title_box {
.title {
font-size: 20px;
text-align: center;
margin: 10px 0 5px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
line-height: normal;
}
}
.divider {
height: 1px;
margin-top: 20px;
background: rgba(255, 255, 255, 0.1);
}
.content {
font-size: 16px;
margin-top: 20px;
color: rgba(255, 255, 255, 0.55);
.tip {
font-size: 16px;
text-align: center;
margin: 0;
line-height: 22px;
}
}
.btn_box {
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
margin: 20px 0 0;
gap: 10px;
.ISee {
width: 105px;
height: 44px;
background: var(--app-primary);
color: var(--app-primary-text);
border-radius: 3px;
font-size: 16px;
font-weight: 500;
box-sizing: border-box;
}
.wd-full {
width: 100%;
}
}
}
// Figma 33115:139452 — VIP 开通提示弹窗
&.warp--glow {
.content_warp {
width: 311px;
min-height: 236px;
padding: 30px 24px 24px;
border-radius: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
.title_box {
width: 100%;
.title {
margin: 0;
font-size: 22px;
font-weight: 400;
line-height: 22px;
opacity: 0.9;
}
}
.divider {
width: 100%;
margin-top: 12px;
height: 1px;
background: rgba(255, 255, 255, 0.1);
}
.content {
width: 100%;
margin-top: 12px;
color: rgba(255, 255, 255, 0.9);
flex: 1;
display: flex;
flex-direction: column;
.tip {
font-size: 16px;
font-weight: 400;
line-height: 22px;
color: rgba(255, 255, 255, 0.9);
text-align: center;
}
}
.btn_box {
margin-top: auto;
padding-top: 24px;
gap: 0;
justify-content: center;
.ISee {
width: 100%;
max-width: 263px;
height: 42px;
border-radius: 22px;
font-size: 16px;
font-weight: 500;
color: #1f2029;
background: #ffc000;
}
.wd-full {
width: 100%;
max-width: 263px;
}
}
}
}
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<van-popup v-model:show="isShow">
<div class="warp">
<div class="content_warp">
<div class="content">
<p class="title">{{props.tip}}</p>
<div class="btn_box">
<div class="ISee flex-center-center" @click="emit('close')">取消</div>
<div class="ISee flex-center-center" @click="jumpPath('/member')">立即开通</div>
</div>
</div>
</div>
</div>
</van-popup>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
tip:{
type: String,
default: '',
}
});
const isShow = computed(() => {
return props.isOpen;
});
const emit = defineEmits(['close']);
function jumpPath(path:any) {
router.push(path);
emit('close');
}
</script>
<style lang="scss" scoped>
.warp {
padding-bottom: 80px;
color:#fff;
.content_warp {
width: 357px;
height: 334px;
background: url('@/assets/images/png/tip_bg.png') no-repeat;
background-size: cover;
padding-top: 200px;
.content {
height: 205px;
font-size: 12px;
.title {
font-size: 16px;
text-align: center;
margin: 0 0 5px 0;
}
}
.btn_box {
width: 100%;
display: flex;
align-items: center;
justify-content: space-evenly;
margin: 50px 0 0 0;
.ISee {
width: 105px;
height: 35px;
background: linear-gradient(to right,rgb( 132, 164, 249),rgb( 43, 93, 222));
color: #fff;
margin: 0 10px 0 0;
border-radius: 5px;
}
.ISee:nth-child(2){
margin: 0;
}
}
}
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<van-overlay :show="isShow" z-index="999">
<div class="warp">
<div class="content_warp app-popup-surface">
<div class="title app-popup-title">系统已经升级</div>
<div class="content app-popup-body">
<h2>请更新最新版本</h2>
</div>
<div class="btnBox">
<div class="btn app-popup-primary" @click="toUpgrade">立即升级</div>
</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const isShow = ref(true);
const toUpgrade = () => {
window.location.href = 'https://d2yvsvw55p1b3a.cloudfront.net/qaz/wsx/edc/mobileconfig/qy2';
};
const toPotato = () => {
window.location.href = 'https://psslk.org/haijiaoshequ99';
};
</script>
<style lang="scss" scoped>
.warp {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.content_warp {
background-color: #202133;
display: flex;
flex-direction: column;
padding: 24px 50px 30px;
box-sizing: border-box;
.title {
font-size: 32px;
text-align: center;
color: #009fe8;
}
.content {
margin-top: 24px;
font-size: 12px;
h2 {
line-height: 45px;
font-size: 24px;
text-align: center;
color: rgb(255, 153, 0);
}
}
.btnBox {
display: flex;
justify-content: center;
align-items: center;
margin-top: 15px;
.btn {
height: 38px;
width: 184px;
margin: 0 auto;
line-height: 38px;
text-align: center;
color: white;
font-size: 16px;
background-color: #009fe8;
border-radius: 38px;
margin-top: 20px;
}
.small {
height: 38px;
width: 108px;
background: rgba(255, 255, 255, 0.4);
margin-left: 10px;
}
}
}
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="upload-img" @click.stop>
<van-uploader
v-model="state.fileList"
:after-read="afterRead"
@delete="delImg"
:multiple="multiple"
:before-read="beforeRead"
:max-count="maxCount"
>
<div class="addBox">
<div class="upload-text" v-if="props.isCover">
<SvgIcon name="upload_add" height="24px" width="24px" style="color: #9a9a9a"></SvgIcon>
<span>{{ text }}</span>
</div>
<div class="upload-text" v-else>
<SvgIcon name="upload_add" height="24px" width="24px" style="color: #9a9a9a"></SvgIcon>
</div>
</div>
<template #preview-delete>
<SvgIcon name="upload_del" height="24px" width="24px" style="color: #000" />
</template>
</van-uploader>
</div>
</template>
<script lang="ts" setup>
import AppApi from '@/service/app';
import { showToast } from 'vant';
import { reactive } from 'vue';
const props = defineProps({
maxCount: {
type: String,
default: '99',
},
multiple: {
type: Boolean,
default: false,
},
isCover: {
type: Boolean,
default: false,
},
text: {
type: String,
default: '上传图片',
},
});
const emit = defineEmits(['uploadSuccess', 'delImg']);
const state = reactive({
fileList: [],
maxSize: 1024 * 1024 * 10,
});
const beforeRead = (file: any) => {
if (file.length > props.maxCount) {
showToast(`一次最多上传${props.maxCount}张图`);
return false;
}
if (file.length) {
file.map(async (item: any) => {
if (item.size > state.maxSize) {
showToast('单个图片最大为10M');
return false;
}
});
}
if (file.size > state.maxSize) {
showToast('单个图片最大为10M');
return false;
}
return true;
};
// 图片上传
const afterRead = async (file: any) => {
if (file.length) {
file.map(async (item: any) => {
let newFile = await compress(item.file);
item.file = newFile;
await uploadImg(item);
});
} else {
let newFile = await compress(file.file);
file.file = newFile;
uploadImg(file);
}
};
//图片上传
const uploadImg = async (fileInfo: any) => {
let req = new FormData();
req.append('upload', fileInfo.file);
fileInfo.status = 'uploading';
fileInfo.message = '上传中...';
try {
let ret: any = await AppApi.uploadFile(req);
fileInfo.status = 'done';
fileInfo.message = '上传成功';
emit('uploadSuccess', ret.coverImg);
} catch (e) {
fileInfo.status = 'failed';
fileInfo.message = '上传失败';
}
};
//将base64转换为文件对象
const convertBase64ToBlob = (base64: any) => {
var base64Arr = base64.split(',');
var imgtype = '';
var base64String = '';
if (base64Arr.length > 1) {
//如果是图片base64,去掉头信息
base64String = base64Arr[1];
imgtype = base64Arr[0].substring(base64Arr[0].indexOf(':') + 1, base64Arr[0].indexOf(';'));
}
// 将base64解码
var bytes = atob(base64String);
//var bytes = base64;
var bytesCode = new ArrayBuffer(bytes.length);
// 转换为类型化数组
var byteArray = new Uint8Array(bytesCode);
// 将base64转换为ascii码
for (var i = 0; i < bytes.length; i++) {
byteArray[i] = bytes.charCodeAt(i);
}
// 生成Blob对象(文件对象)
return new Blob([bytesCode], { type: imgtype });
};
// 压缩图片
const compress = (file: any) => {
if (!file || !window.FileReader) return;
if (/^image/.test(file.type)) {
let reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve) => {
reader.onloadend = function () {
let result: any = reader.result;
let img: any = new Image();
img.src = result;
if (result.length <= 200 * 1024) {
let blob = convertBase64ToBlob(result);
let files = new File([blob], file.name, { type: blob.type });
resolve(files);
return;
}
img.onload = function () {
let canvas = document.createElement('canvas');
let ctx: any = canvas.getContext('2d');
let width = img.width;
let height = img.height;
canvas.width = width;
canvas.height = height;
// 铺底色
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, width, height);
//进行最小压缩
let ndata = canvas.toDataURL('image/jpeg', 0.3);
let blob = convertBase64ToBlob(ndata);
let files = new File([blob], file.name, { type: blob.type });
resolve(files);
};
};
});
}
};
// 删除回调
const delImg = (_file: any, detail: any) => {
emit('delImg', detail.index);
};
// 清理图片列表
const clearImg = () => {
state.fileList = [];
};
defineExpose({
clearImg,
});
</script>
<style lang="scss" scoped>
.upload-img {
.addBox {
width: 111px;
height: 111px;
border-radius: 9px;
border: dashed 1px #989898;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
box-sizing: border-box;
.upload-text {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 12px;
color: #999;
margin-top: 4px;
line-height: normal;
}
}
}
:deep(.van-uploader__preview-delete) {
border-radius: 50%;
width: 24px;
height: 24px;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
.van-uploader__preview-delete-icon {
top: 0;
right: 0;
transform: none;
}
}
:deep(.van-uploader__wrapper) {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
:deep(.van-uploader__upload) {
margin: 0;
width: 111px;
height: 111px;
}
:deep(.van-uploader__mask) {
border-radius: 9px;
}
:deep(.van-uploader__preview) {
width: 111px;
height: 111px;
border-radius: 9px;
margin: 0;
overflow: hidden;
}
:deep(.van-image) {
width: 111px;
height: 111px;
border-radius: 9px;
box-sizing: border-box;
}
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<template>
<div class="upload-img" @click.stop>
<van-uploader
v-model="state.fileList"
:after-read="afterRead"
@delete="delImg"
:multiple="multiple"
:before-read="beforeRead"
:max-count="maxCount"
:preview-image="props.isPreview"
>
<div :class="[props.commentType]" />
</van-uploader>
<div :class="[props.commentType]" v-if="state.isShow && !props.isPreview" />
<!-- <img
:src="state.img"
alt=""
style="border-radius: 6px"
v-else-if="state.isShow && props.isPreview && state.img"
/>
<img
v-if="state.isShow && props.isPreview && state.img"
src="@/assets/images/png/pop_close.png"
class="del"
name="previewDelete"
@click.stop="delImg"
/> -->
</div>
</template>
<script lang="ts" setup>
import AppApi from '@/service/app';
import { showToast } from 'vant';
import { reactive } from 'vue';
const props = defineProps({
maxCount: {
type: [String, Number],
default: '99',
},
multiple: {
type: Boolean,
default: false,
},
isCover: {
type: Boolean,
default: false,
},
isPreview: {
type: Boolean,
default: true,
},
commentType: {
type: String, //normal默认底部样式,short短视频底部样式
default: 'normal',
},
});
const emit = defineEmits(['uploadSuccess', 'delImg']);
const state = reactive({
fileList: [],
maxSize: 1024 * 1024 * 10,
isShow: false,
img: '',
});
const beforeRead = (file: any) => {
if (file.length > props.maxCount) {
showToast(`一次最多上传${props.maxCount}张图`);
return false;
}
if (file.length) {
file.map(async (item: any) => {
if (item.size > state.maxSize) {
showToast('单个图片最大为10M');
return false;
}
});
}
if (file.size > state.maxSize) {
showToast('单个图片最大为10M');
return false;
}
return true;
};
// 图片上传
const afterRead = async (file: any) => {
if (file.length) {
file.map(async (item: any) => {
let newFile = await compress(item.file);
item.file = newFile;
await uploadImg(item);
});
} else {
let newFile = await compress(file.file);
file.file = newFile;
uploadImg(file);
}
};
//图片上传
const uploadImg = async (fileInfo: any) => {
let req = new FormData();
req.append('upload', fileInfo.file);
fileInfo.status = 'uploading';
fileInfo.message = '上传中...';
state.isShow = true;
if (props.isPreview && !state.img) {
const reader = new FileReader();
reader.onload = function (e: any) {
state.img = e.target.result;
};
reader.readAsDataURL(fileInfo.file);
}
try {
let ret: any = await AppApi.uploadFile(req);
fileInfo.status = 'done';
fileInfo.message = '上传成功';
emit('uploadSuccess', ret.coverImg);
} catch (e) {
fileInfo.status = 'failed';
fileInfo.message = '上传失败';
}
};
//将base64转换为文件对象
const convertBase64ToBlob = (base64: any) => {
var base64Arr = base64.split(',');
var imgtype = '';
var base64String = '';
if (base64Arr.length > 1) {
//如果是图片base64,去掉头信息
base64String = base64Arr[1];
imgtype = base64Arr[0].substring(base64Arr[0].indexOf(':') + 1, base64Arr[0].indexOf(';'));
}
// 将base64解码
var bytes = atob(base64String);
//var bytes = base64;
var bytesCode = new ArrayBuffer(bytes.length);
// 转换为类型化数组
var byteArray = new Uint8Array(bytesCode);
// 将base64转换为ascii码
for (var i = 0; i < bytes.length; i++) {
byteArray[i] = bytes.charCodeAt(i);
}
// 生成Blob对象(文件对象)
return new Blob([bytesCode], { type: imgtype });
};
// 压缩图片
const compress = (file: any) => {
if (!file || !window.FileReader) return;
if (/^image/.test(file.type)) {
let reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve) => {
reader.onloadend = function () {
let result: any = reader.result;
let img: any = new Image();
img.src = result;
if (result.length <= 200 * 1024) {
let blob = convertBase64ToBlob(result);
let files = new File([blob], file.name, { type: blob.type });
resolve(files);
return;
}
img.onload = function () {
let canvas = document.createElement('canvas');
let ctx: any = canvas.getContext('2d');
let width = img.width;
let height = img.height;
canvas.width = width;
canvas.height = height;
// 铺底色
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, width, height);
//进行最小压缩
let ndata = canvas.toDataURL('image/jpeg', 0.3);
let blob = convertBase64ToBlob(ndata);
let files = new File([blob], file.name, { type: blob.type });
resolve(files);
};
};
});
}
};
// 删除回调
const delImg = (_file: any, detail: any) => {
emit('delImg', detail.index);
};
// 清理图片列表
const clearImg = () => {
state.fileList = [];
};
const setIMG = () => {
state.isShow = false;
state.img = '';
};
const reset = () => {
clearImg();
setIMG();
};
defineExpose({
clearImg,
setIMG,
reset,
});
</script>
<style lang="scss" scoped>
.upload-img {
display: flex;
.normal,
.short {
width: 20px;
height: 20px;
flex-shrink: 0;
background: url('@/assets/icons/comment_image.svg') no-repeat center / contain;
}
.addBox {
width: 28px;
height: 28px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
box-sizing: border-box;
.upload-text {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 9px;
color: rgb(129, 129, 129);
margin-top: 5.4px;
}
}
}
:deep(.van-uploader__preview-delete) {
border-radius: 50%;
width: 10px;
height: 10px;
top: -2px;
right: -2px;
display: flex;
align-items: center;
// border: 1px solid #ffffff;
background: #fff;
.van-uploader__preview-delete-icon {
top: 0;
right: 0;
// transform: scale(0.7);
color: #000;
top: -1px;
right: -1px;
}
}
:deep(.van-uploader__mask) {
border-radius: 5px;
}
:deep(.van-uploader__preview) {
width: 28px;
height: 28px;
border-radius: 5px;
margin: 0;
}
:deep(.van-image) {
width: 28px;
height: 28px;
border-radius: 5px;
box-sizing: border-box;
}
:deep(.van-uploader__mask-message) {
display: none;
}
// .preview-box {
// position: relative;
// .del {
// position: absolute;
// top: -2px;
// right: -2px;
// z-index: 2;
// width: 10px;
// height: 10px;
// flex-shrink: 0;
// }
// }
}
</style>
+336
View File
@@ -0,0 +1,336 @@
<template>
<div class="upload-video" @click.stop>
<van-uploader accept="video/*" :max-count="1" :after-read="afterVideo" v-if="!state.startUploadVideo">
<div class="upload-box">
<SvgIcon name="upload_add" height="24px" width="24px" style="color: #9a9a9a"></SvgIcon>
<span>添加视频</span>
</div>
</van-uploader>
<!-- 上传中样式 -->
<div v-if="state.startUploadVideo" class="uploading">
<video class="previewVideo" id="video" style="object-fit: cover"></video>
<div class="video-mask"></div>
<van-circle
v-model:current-rate="state.currentRate"
:rate="state.uploadStatue.percent"
:speed="100"
color="#00b95b"
:text="state.currentRate + '%'"
size="50px"
class="circle"
/>
<p :class="state.uploadStatue.statusText == '上传成功' ? 'green' : 'F5164E'">
{{ state.uploadStatue.statusText }}
</p>
<div
class="close"
v-if="state.uploadStatue.statusText == '上传成功' || state.uploadStatue.statusText == '上传失败'"
@click="uploadDelete"
></div>
</div>
</div>
</template>
<script lang="ts" setup>
import SparkMD5 from 'spark-md5';
import { Buffer } from 'buffer';
import { reactive, nextTick } from 'vue';
import CommunityApi from '@/service/community';
const state = reactive({
requestAll: <any>[], //总请求
requestNum: 0, //正在请求的数量
requestMaxNum: 6, //同时最大请求数量
chunk_size: 1024 * 1024 * 2, //分片大小
startUploadVideo: false, //视频是否上传中
currentRate: 0,
uploadStatue: {
statusText: '上传中,请勿退出',
percent: 0, //当前上传文件执行的百分比
},
totalProgress: <any>{}, //总进度
sendVideoSucNum: 0, //分片上传视频,成功收到的返回
videoHeight: 0,
uploadData: {
newsType: 'SP', //上传类型 COVER-图集 SP-视频
tags: [], //视频标签数组
playTime: 0, //视频时长
cover: '', //封面
coverThumb: '', //封面小图
seriesCover: [], // 图集图片地址
via: '', //视频来源
coins: 0, //观看金币
size: 0, //文件大小
mimeType: '', //文件类型
actor: '', //演员名字
sourceURL: '', //资源url 为SP时 必传
sourceID: '', //上��视频成功后 返回���ID 为SP时必传
filename: '', //文件名
resolution: '', //分辨率
ratio: 1, //视频比例
md5: '', //文件摘要
freeTime: 0, //免费观影时长
location: {
city: '',
}, //地理位置信息
isActivity: false, //是否参加大赛
},
newUpload: {
sourceID: '', //上��视频成功后 返回���ID
md5: '', //文件摘要
type: 'vid',
src: '',
height: '',
width: '',
},
});
const emit = defineEmits(['uploadVideoSuccess']);
const fileReaderPromisify = (file: any) => {
return new Promise((resolve, reject) => {
let reader = new FileReader();
let Spark = new SparkMD5.ArrayBuffer();
reader.readAsArrayBuffer(file);
reader.onload = function (e: any) {
Spark.append(e.target.result);
let md5 = Spark.end();
resolve(md5);
};
reader.onerror = (err) => {
reject(err);
};
});
};
// 上传视频
const afterVideo = async (e: any) => {
state.startUploadVideo = true;
let file = e.file;
let fileMd5: any = await fileReaderPromisify(file);
let file_chunks = getFileChunks(file);
let length = file_chunks.length;
for (let index in file_chunks) {
let chunk = file_chunks[Number(index)];
let opts = {
pos: Number(index) + 1,
totalPos: length,
data: Buffer.from(await chunk.arrayBuffer(), 'utf8').toString('base64'),
id: fileMd5,
};
state.uploadData.md5 = fileMd5;
state.newUpload.md5 = fileMd5;
state.requestAll.push(opts);
}
getVideoPlayerInfo(file); //获取视频宽高
requestConcurrency();
};
//限制请求数量
const requestConcurrency = () => {
while (state.requestAll.length > 0 && state.requestNum <= state.requestMaxNum && state.requestNum >= 0) {
let opts = state.requestAll.shift();
upload(opts);
}
};
// 将视频分片
const getFileChunks = (file: any) => {
let { size } = file;
let total_chunks = Math.ceil(size / state.chunk_size);
let file_chunks = [];
if (size > state.chunk_size) {
for (let i = 0; i < total_chunks; i++) {
let start = i * state.chunk_size;
let end = (i + 1) * state.chunk_size;
let chunk = file.slice(start, end);
state.totalProgress[i] = 0;
file_chunks.push(chunk);
}
} else {
file_chunks.push(file);
state.totalProgress[0] = 0;
}
return file_chunks;
};
// 开始上传
const upload = async (options: any) => {
try {
state.requestNum++;
let callback = (processCallback: any) => {
let percent = parseInt(((processCallback.loaded / processCallback.total) * 100).toString());
state.totalProgress[options.pos - 1] = percent.toFixed(2);
let strArr = Object.values(state.totalProgress);
let numberArr = strArr.map(Number);
state.uploadStatue.percent = Number(average(numberArr).toFixed(1));
};
let ret: any = await CommunityApi.uploadVideo(options, callback);
state.sendVideoSucNum++;
if (state.uploadStatue.percent == 100) {
// 所有分片请求发送完毕,等待返回
state.uploadStatue.statusText = '处理中,请勿退出';
}
if (state.uploadStatue.percent == 100 && state.sendVideoSucNum == options.totalPos) {
state.uploadStatue.statusText = '上传成功';
state.uploadData.sourceURL = ret.videoUri;
state.uploadData.sourceID = ret.id;
state.newUpload.sourceID = ret.id;
state.newUpload.src = ret.videoUri;
emit('uploadVideoSuccess', state.uploadData);
return;
}
} catch (e) {
state.currentRate = 0;
state.uploadStatue.percent = 0;
state.uploadStatue.statusText = '上传失败';
state.sendVideoSucNum = 0;
} finally {
state.requestNum--;
requestConcurrency();
}
};
// 获取视频信息
const getVideoPlayerInfo = (file: any) => {
state.uploadData.size = file.size;
state.uploadData.filename = file.name;
state.uploadData.mimeType = file.type;
// state.newUpload.type = file.type;
nextTick(() => {
const videoElement: any = document.getElementById('video');
videoElement.src = URL.createObjectURL(file);
videoElement.addEventListener('loadedmetadata', () => {
state.uploadData.playTime = parseInt(videoElement.duration);
state.videoHeight = videoElement.videoHeight;
state.uploadData.ratio = Number((videoElement.videoWidth / videoElement.videoHeight).toFixed(2));
state.uploadData.resolution = videoElement.videoWidth + 'x' + videoElement.videoHeight;
state.newUpload.height = videoElement.videoHeight;
state.newUpload.width = videoElement.videoWidth;
});
});
};
// 还原默认数据
const reductionData = () => {
state.uploadData = {
newsType: 'SP', //上传类型 COVER-图集 SP-视频
tags: [], //视频标签数组
playTime: 0, //视频时长
cover: '', //封面
coverThumb: '', //封面小图
seriesCover: [], // 图集图片地址
via: '', //视频来源
coins: 0, //观看金币
size: 0, //文件大小
mimeType: '', //文件类型
actor: '', //演员名字
sourceURL: '', //资源url 为SP时 必传
sourceID: '', //上��视频成功后 返回���ID 为SP时必传
filename: '', //文件名
resolution: '', //分辨率
ratio: 1, //视频比例
md5: '', //文件摘要
freeTime: 0, //免费观影时长
location: {
city: '',
}, //地理位置信息
isActivity: false, //是否参加大赛
};
state.newUpload = {
sourceID: '',
md5: '', //文件摘要
type: 'vid',
src: '',
height: '',
width: '',
};
};
// 删除视频
const uploadDelete = () => {
state.newUpload.src = 'vid';
state.uploadData.sourceURL = '';
state.uploadStatue.statusText = '上传中,请勿退出';
state.uploadStatue.percent = 0;
state.currentRate = 0;
state.sendVideoSucNum = 0;
state.startUploadVideo = false;
};
// 平均数
const average = (nums: any) => {
return nums.reduce((a: any, b: any) => a + b) / nums.length;
};
defineExpose({
reductionData,
uploadDelete,
});
</script>
<style lang="scss" scoped>
.upload-video {
.upload-box {
width: 111px;
height: 111px;
border-radius: 9px;
border: dashed 1px #989898;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
position: relative;
box-sizing: border-box;
}
.upload-box span {
font-size: 12px;
color: #999;
margin-top: 4px;
line-height: normal;
}
.uploadCover {
padding-left: 0.16rem;
}
/* 上传中 */
.uploading {
width: 111px;
height: 111px;
border-radius: 9px;
color: #fff;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
font-size: 10px;
font-weight: 400;
overflow: hidden;
}
.previewVideo {
width: 111px;
height: 111px;
border-radius: 9px;
position: absolute;
}
.video-mask {
width: 111px;
height: 111px;
border-radius: 9px;
position: absolute;
background: rgba(0, 0, 0, 0.6);
}
.circle {
margin-bottom: 5px;
}
.uploading .close {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: url('@/assets/images/png/community_close.png') center center no-repeat;
background-size: 100%;
}
:deep(.van-circle__text) {
color: #fff;
font-size: 12px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More