[Human] chore(project): 初始化导入 91dj H5 客户端项目代码
从本地工程首次入库;.npmrc(含私有 npm 源 token)与 tsbuildinfo 已加入 .gitignore,不随仓库提交。
This commit is contained in:
@@ -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>
|
||||
@@ -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[];
|
||||
Reference in New Issue
Block a user