[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,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[];