Files
qitongxue-wx/src/components/vip/VipFloatAd.vue
2026-09-29 10:57:55 +08:00

374 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* VIP 悬浮广告(工具详情页右下角)。
*
* 用法:在任意页面模板末尾放一个 <VipFloatAd /> 即可,工具标识默认从当前路由推导,
* 也可以显式传 tool-key(例如放在非工具页时)。
*
* 展示策略(不打扰付费用户):
* - 非会员:常显,主推「开通会员」
* - 会员:只在当前工具额度即将用完(剩余 <= 1)时出现,主推「续次数包」
* - 用户当天点过关闭 → 当天不再出现(按天记 storage)
*/
import type { ToolQuota } from '@/api/quota'
import { computed, onMounted, ref } from 'vue'
import { useQuotaStore } from '@/stores/quota'
import { currentToolKey, fetchToolQuota, quotaSourceText } from '@/utils/quota'
import { Z } from '@/utils/z-index'
const props = withDefaults(defineProps<{
toolKey?: string
/** 距底部距离(rpx),用于避让底部操作区 */
bottom?: number
right?: number
}>(), {
toolKey: '',
bottom: 170,
right: 24,
})
const quotaStore = useQuotaStore()
const resolvedKey = computed(() => props.toolKey || currentToolKey())
const quota = ref<ToolQuota | null>(null)
const isMember = ref(false)
const expanded = ref(false)
const visible = ref(false)
const DISMISS_KEY = 'vip-float-dismiss'
function todayKey() {
const d = new Date()
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
}
function dismissedToday(): boolean {
try {
return (uni.getStorageSync(DISMISS_KEY) as string) === todayKey()
}
catch {
return false
}
}
const toolName = computed(() => quota.value?.name || '')
const remainText = computed(() => (quota.value ? quotaSourceText(quota.value) : ''))
/** 折叠胶囊上的小字:还剩几次 */
const shortRemain = computed(() => {
if (!quota.value)
return ''
return quota.value.can_use ? `剩 ${quota.value.total_left} 次` : '次数用完'
})
const headline = computed(() => (isMember.value ? '次数包' : '开通会员'))
const subline = computed(() => {
if (isMember.value)
return quota.value?.can_use ? '次数包可叠加,永久享会员价' : '次数用完,加购次数包继续用'
return '解锁全部工具,额度提升 10 倍'
})
onMounted(async () => {
if (dismissedToday())
return
const key = resolvedKey.value
if (!key)
return
quota.value = await fetchToolQuota(key)
if (!quota.value)
return
quotaStore.applyTool(quota.value)
isMember.value = quota.value.is_member
// 会员且额度充足时不打扰
visible.value = !isMember.value || quota.value.total_left <= 1
})
function toggle() {
expanded.value = !expanded.value
}
function close() {
expanded.value = false
visible.value = false
try {
uni.setStorageSync(DISMISS_KEY, todayKey())
}
catch {
/* storage 不可用则仅本次隐藏 */
}
}
function goMember() {
uni.navigateTo({ url: '/pkg-member/member/index' })
}
function goPack() {
const key = resolvedKey.value
uni.navigateTo({ url: key ? `/pkg-member/quota/index?tool_key=${key}` : '/pkg-member/quota/index' })
}
</script>
<template>
<view
v-if="visible"
class="vipad"
:style="{ bottom: `${props.bottom}rpx`, right: `${props.right}rpx`, zIndex: Z.float }"
>
<!-- 展开态:深色金边小卡 -->
<view v-if="expanded" class="panel">
<view class="panel__glow" />
<view class="panel__head">
<view class="crown">
<text class="crown__text">
VIP
</text>
</view>
<view class="panel__titles">
<text class="panel__title">
{{ headline }}
</text>
<text class="panel__sub">
{{ subline }}
</text>
</view>
<view class="panel__close press" hover-class="press-active" @tap.stop="close">
<wd-icon name="close" size="24rpx" color="rgba(245,197,107,0.75)" />
</view>
</view>
<view v-if="quota" class="panel__quota">
<text class="panel__quota-name">
{{ toolName }}
</text>
<text class="panel__quota-val">
{{ remainText }}
</text>
</view>
<view class="panel__acts">
<view class="pbtn pbtn--gold press" hover-class="press-active" @tap.stop="goMember">
<text>开通会员</text>
</view>
<view class="pbtn pbtn--ghost press" hover-class="press-active" @tap.stop="goPack">
<text>{{ quota && quota.paid_left > 0 ? '加购次数' : '买次数包' }}</text>
</view>
</view>
</view>
<!-- 折叠态:金色胶囊 -->
<view v-else class="pill press" hover-class="press-active" @tap="toggle">
<view class="pill__shine" />
<text class="pill__vip">
VIP
</text>
<text v-if="shortRemain" class="pill__tag">
{{ shortRemain }}
</text>
<view class="pill__close press" hover-class="press-active" @tap.stop="close">
<wd-icon name="close" size="20rpx" color="rgba(245,197,107,0.7)" />
</view>
</view>
</view>
</template>
<style scoped>
.vipad {
position: fixed;
display: flex;
flex-direction: column;
align-items: flex-end;
}
/* ===== 折叠胶囊 ===== */
.pill {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 22rpx 16rpx 26rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #2b1a10 0%, #4a2c16 100%);
border: 2rpx solid rgba(245, 197, 107, 0.62);
box-shadow: 0 12rpx 30rpx rgba(43, 26, 16, 0.42), 0 0 0 8rpx rgba(245, 197, 107, 0.12);
animation: vipBreath 2.6s ease-in-out infinite;
}
.pill__shine {
position: absolute;
top: 0;
left: -60%;
width: 45%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 226, 168, 0.5) 50%, rgba(255, 255, 255, 0) 100%);
animation: vipSweep 3.2s ease-in-out infinite;
}
.pill__vip {
font-size: 26rpx;
font-weight: 800;
letter-spacing: 0.14em;
background: linear-gradient(180deg, #ffe9b8 0%, #f5c56b 45%, #d89a35 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.pill__tag {
font-size: 20rpx;
color: rgba(255, 233, 184, 0.85);
padding-left: 8rpx;
border-left: 2rpx solid rgba(245, 197, 107, 0.35);
}
.pill__close {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 展开卡片 ===== */
.panel {
position: relative;
overflow: hidden;
width: 500rpx;
padding: 26rpx 26rpx 24rpx;
border-radius: 32rpx;
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 55%, #241408 100%);
border: 2rpx solid rgba(245, 197, 107, 0.55);
box-shadow: 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
animation: vipPanelIn 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.panel__glow {
position: absolute;
top: -90rpx;
right: -70rpx;
width: 260rpx;
height: 260rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(245, 197, 107, 0.28) 0%, rgba(245, 197, 107, 0) 70%);
}
.panel__head {
position: relative;
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.crown {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
background: linear-gradient(140deg, #ffe9b8 0%, #f5c56b 50%, #d89a35 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(216, 154, 53, 0.4);
}
.crown__text {
font-size: 24rpx;
font-weight: 900;
letter-spacing: 0.06em;
color: #3d2413;
}
.panel__titles {
flex: 1;
min-width: 0;
}
.panel__title {
display: block;
font-size: 32rpx;
font-weight: 800;
color: #ffe9b8;
letter-spacing: 0.02em;
}
.panel__sub {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 233, 184, 0.7);
}
.panel__close {
flex-shrink: 0;
width: 42rpx;
height: 42rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
display: flex;
align-items: center;
justify-content: center;
}
.panel__quota {
position: relative;
margin-top: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
border-radius: 20rpx;
background: rgba(245, 197, 107, 0.1);
border: 2rpx solid rgba(245, 197, 107, 0.22);
}
.panel__quota-name {
font-size: 24rpx;
color: rgba(255, 233, 184, 0.82);
}
.panel__quota-val {
font-size: 24rpx;
font-weight: 700;
color: #ffe9b8;
}
.panel__acts {
position: relative;
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.pbtn {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
}
.pbtn--gold {
color: #3d2413;
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 55%, #e0a63f 100%);
box-shadow: 0 10rpx 22rpx rgba(224, 166, 63, 0.35);
}
.pbtn--ghost {
color: #ffe9b8;
border: 2rpx solid rgba(245, 197, 107, 0.5);
background: rgba(255, 255, 255, 0.04);
}
@keyframes vipBreath {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes vipSweep {
0% {
left: -60%;
}
55%, 100% {
left: 130%;
}
}
@keyframes vipPanelIn {
from {
opacity: 0;
transform: translateY(16rpx) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>