374 lines
9.1 KiB
Vue
374 lines
9.1 KiB
Vue
<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>
|