小程序虚拟支付
This commit is contained in:
5
components.d.ts
vendored
5
components.d.ts
vendored
@@ -13,9 +13,14 @@ declare module 'vue' {
|
||||
PaneLibrary: typeof import('./src/components/home/PaneLibrary.vue')['default']
|
||||
PaneMine: typeof import('./src/components/home/PaneMine.vue')['default']
|
||||
PaneWorkbench: typeof import('./src/components/home/PaneWorkbench.vue')['default']
|
||||
QuotaBar: typeof import('./src/components/vip/QuotaBar.vue')['default']
|
||||
QuotaGate: typeof import('./src/components/vip/QuotaGate.vue')['default']
|
||||
Sticker: typeof import('./src/components/Sticker.vue')['default']
|
||||
TabBar: typeof import('./src/components/TabBar.vue')['default']
|
||||
TabIcon: typeof import('./src/components/TabIcon.vue')['default']
|
||||
VipCard: typeof import('./src/components/vip/VipCard.vue')['default']
|
||||
VipDock: typeof import('./src/components/vip/VipDock.vue')['default']
|
||||
VipFloatAd: typeof import('./src/components/vip/VipFloatAd.vue')['default']
|
||||
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
|
||||
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
|
||||
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
|
||||
|
||||
108
src/api/member.ts
Normal file
108
src/api/member.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { QuotaPack, ToolQuota } from '@/api/quota'
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/** 会员套餐(一个套餐 = 一次虚拟支付道具直购 + 一段等级有效期) */
|
||||
export interface MemberPlan {
|
||||
plan_key: string
|
||||
name: string
|
||||
subtitle: string
|
||||
level_key: string
|
||||
duration_days: number
|
||||
price_cents: number
|
||||
product_id: string
|
||||
badge: string
|
||||
}
|
||||
|
||||
/** 等级权益(用于权益对比) */
|
||||
export interface MemberLevel {
|
||||
level_key: string
|
||||
name: string
|
||||
modules: string[]
|
||||
module_names: string[]
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
/** 我的会员状态 */
|
||||
export interface MemberState {
|
||||
is_member: boolean
|
||||
level_key: string
|
||||
level_name: string
|
||||
/** 空串表示永久或未开通 */
|
||||
expire_at: string
|
||||
days_left: number
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
export interface MemberCenter {
|
||||
plans: MemberPlan[]
|
||||
levels: MemberLevel[]
|
||||
state: MemberState
|
||||
/** 平台规则告知(限额 / 费率 / 结算 / 退款) */
|
||||
notices: string[]
|
||||
/** 服务端虚拟支付参数(OfferID / AppKey)是否已配置 */
|
||||
xpay_ready: boolean
|
||||
/** 各工具额度(会员权益页据此展示开通前后对比) */
|
||||
quotas: ToolQuota[]
|
||||
/** 次数包通用档位 */
|
||||
packs: QuotaPack[]
|
||||
/** 当前生效的额度重置周期(天) */
|
||||
period_days: number
|
||||
}
|
||||
|
||||
/** 下单返回的支付参数,需原样交给 wx.requestVirtualPayment */
|
||||
export interface MemberPayData {
|
||||
out_trade_no: string
|
||||
mode: string
|
||||
signData: string
|
||||
paySig: string
|
||||
signature: string
|
||||
env: number
|
||||
}
|
||||
|
||||
export interface MemberOrder {
|
||||
out_trade_no: string
|
||||
/** 1=会员套餐 2=次数包 */
|
||||
order_type: number
|
||||
plan_key: string
|
||||
plan_name: string
|
||||
/** 统一展示标题(套餐名 / 次数包名) */
|
||||
title: string
|
||||
tool_key: string
|
||||
tool_name: string
|
||||
times: number
|
||||
price_cents: number
|
||||
status: number
|
||||
status_text: string
|
||||
pay_channel: string
|
||||
created_at: string
|
||||
delivered_at: string
|
||||
}
|
||||
|
||||
export interface MemberOrderCheck {
|
||||
status: number
|
||||
delivered: boolean
|
||||
state: MemberState
|
||||
}
|
||||
|
||||
/** 会员中心:套餐 + 会员状态 + 权益对比 + 额度概览 */
|
||||
export function apiMemberCenter() {
|
||||
return get<MemberCenter>('/member/center')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会员订单。
|
||||
* code 来自 wx.login,服务端用它换 session_key 生成用户态签名,并校验支付人与登录人一致。
|
||||
*/
|
||||
export function apiMemberOrder(planKey: string, code: string) {
|
||||
return post<MemberPayData>('/member/order', { plan_key: planKey, code })
|
||||
}
|
||||
|
||||
/** 查询订单状态(前端支付成功回调后轮询;服务端同时做查单兜底发货) */
|
||||
export function apiMemberOrderCheck(outTradeNo: string) {
|
||||
return post<MemberOrderCheck>('/member/order/check', { out_trade_no: outTradeNo })
|
||||
}
|
||||
|
||||
/** 我的会员订单(含次数包订单,用 order_type 区分) */
|
||||
export function apiMemberOrders(limit = 20) {
|
||||
return get<{ list: MemberOrder[] }>('/member/orders', { limit })
|
||||
}
|
||||
99
src/api/quota.ts
Normal file
99
src/api/quota.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { get, post } from '@/api/request'
|
||||
|
||||
/**
|
||||
* 工具额度(三池):免费 → 会员 → 付费(次数包),依次扣减。
|
||||
* 周期为滚动窗口,默认 7 天,后台可给「全局 / 会员套餐 / 单个用户」调整。
|
||||
*/
|
||||
export interface ToolQuota {
|
||||
tool_key: string
|
||||
name: string
|
||||
icon: string
|
||||
free_total: number
|
||||
free_used: number
|
||||
free_left: number
|
||||
member_total: number
|
||||
member_used: number
|
||||
member_left: number
|
||||
/** 付费额度余额(次数) */
|
||||
paid_left: number
|
||||
/** 付费额度到期日 yyyy-MM-dd */
|
||||
paid_expire_at: string
|
||||
/** 每次使用消耗的付费额度系数 */
|
||||
pay_cost: number
|
||||
is_member: boolean
|
||||
period_days: number
|
||||
period_end: string
|
||||
/** 下次扣减命中的池:free / member / paid / none */
|
||||
source: 'free' | 'member' | 'paid' | 'none'
|
||||
can_use: boolean
|
||||
/** 本周期内还能用几次(付费额度按系数折算) */
|
||||
total_left: number
|
||||
/** 额度用尽时的可读原因 */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 次数包档位(tool_key 为空表示通用档位,购买时选择工具) */
|
||||
export interface QuotaPack {
|
||||
pack_key: string
|
||||
name: string
|
||||
times: number
|
||||
price_cents: number
|
||||
valid_days: number
|
||||
badge: string
|
||||
tool_key: string
|
||||
/** 每 100 次均价(分),服务端算好 */
|
||||
unit_price_cents: number
|
||||
}
|
||||
|
||||
export interface QuotaMy {
|
||||
period_days: number
|
||||
is_member: boolean
|
||||
level_key: string
|
||||
level_name: string
|
||||
level_expire_at: string
|
||||
tools: ToolQuota[]
|
||||
total_left: number
|
||||
packs: QuotaPack[]
|
||||
}
|
||||
|
||||
/** 次数包下单返回的支付参数,需原样交给 wx.requestVirtualPayment */
|
||||
export interface QuotaPayData {
|
||||
out_trade_no: string
|
||||
mode: string
|
||||
signData: string
|
||||
paySig: string
|
||||
signature: string
|
||||
env: number
|
||||
pack_key: string
|
||||
tool_key: string
|
||||
times: number
|
||||
valid_days: number
|
||||
}
|
||||
|
||||
/** 我的额度总览 */
|
||||
export function apiQuotaMy() {
|
||||
return get<QuotaMy>('/quota/my')
|
||||
}
|
||||
|
||||
/** 单个工具的额度状态 */
|
||||
export function apiQuotaTool(toolKey: string) {
|
||||
return get<{ tool: ToolQuota }>('/quota/tool', { tool_key: toolKey })
|
||||
}
|
||||
|
||||
/** 扣减一次额度(产出结果时调用) */
|
||||
export function apiQuotaConsume(toolKey: string) {
|
||||
return post<{ tool: ToolQuota }>('/quota/consume', { tool_key: toolKey })
|
||||
}
|
||||
|
||||
/** 某工具可购买的次数包(通用档位 + 该工具专属档位) */
|
||||
export function apiQuotaPacks(toolKey?: string) {
|
||||
return get<{ list: QuotaPack[] }>('/quota/packs', toolKey ? { tool_key: toolKey } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建次数包订单。
|
||||
* code 来自 wx.login,服务端用它换 session_key 生成用户态签名,并校验支付人与登录人一致。
|
||||
*/
|
||||
export function apiQuotaOrder(packKey: string, toolKey: string, code: string) {
|
||||
return post<QuotaPayData>('/quota/order', { pack_key: packKey, tool_key: toolKey, code })
|
||||
}
|
||||
@@ -9,6 +9,12 @@ export interface ApiBody<T> {
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
|
||||
/**
|
||||
* 额度用尽错误码,与后端 internal/consts.CodeQuotaExhausted 一一对应。
|
||||
* 定义在这里而不是 utils/quota.ts,是为了避免 request → quota → request 的循环依赖。
|
||||
*/
|
||||
export const CODE_QUOTA_EXHAUSTED = 4002
|
||||
|
||||
export function getToken(): string {
|
||||
return (uni.getStorageSync(TOKEN_KEY) as string) || ''
|
||||
}
|
||||
@@ -59,7 +65,15 @@ function request<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown)
|
||||
reject(new Error(body.message || 'unauthorized'))
|
||||
return
|
||||
}
|
||||
// 4001 等业务错误码也走统一提示
|
||||
// 4002 额度用尽:由 utils/quota.ts 唤起「次数用完」引导弹层,
|
||||
// 这里不再弹 toast,避免同一个提示叠两层。
|
||||
if (body && body.code === CODE_QUOTA_EXHAUSTED) {
|
||||
const quotaErr = new Error(body.message || '可用次数已用完')
|
||||
;(quotaErr as Error & { code?: number }).code = body.code
|
||||
reject(quotaErr)
|
||||
return
|
||||
}
|
||||
// 4001 等业务错误码走统一提示
|
||||
uni.showToast({ title: (body && body.message) || '请求失败', icon: 'none' })
|
||||
const err = new Error((body && body.message) || 'request failed')
|
||||
;(err as Error & { code?: number }).code = body && body.code
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ToolQuota } from '@/api/quota'
|
||||
import { get, post, put } from '@/api/request'
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -34,6 +35,8 @@ export interface ToolOut {
|
||||
description: string
|
||||
is_hot: boolean
|
||||
locked: boolean
|
||||
/** 额度状态(免费/会员/付费),查询失败时为 null */
|
||||
quota: ToolQuota | null
|
||||
}
|
||||
|
||||
export interface ToolsListResult {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { searchTools, TOOL_REGISTRY } from '@/config/tools'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { moduleColor, toolPalette } from '@/utils/palette'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { showUnlockGuide } from '@/utils/unlock-guide'
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
@@ -66,11 +67,7 @@ function pickHotWord(word: string) {
|
||||
|
||||
function openTool(path: string, toolKey: string, locked: boolean) {
|
||||
if (locked) {
|
||||
uni.showModal({
|
||||
title: '功能未解锁',
|
||||
content: '该功能属于更高等级模块,请联系管理员升级后使用。',
|
||||
showCancel: false,
|
||||
})
|
||||
showUnlockGuide()
|
||||
return
|
||||
}
|
||||
apiToolsUsed(toolKey).catch(() => {})
|
||||
|
||||
@@ -106,6 +106,11 @@ function goEditWorkbench() {
|
||||
uni.navigateTo({ url: '/pages/workbench/edit' })
|
||||
}
|
||||
|
||||
/** 升级会员 / 会员中心(分包页) */
|
||||
function goMember() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
@@ -172,6 +177,20 @@ function logout() {
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
<view class="cell press" hover-class="press-active" @tap="goMember">
|
||||
<view class="cell__icon cell__icon--vip">
|
||||
<text class="cell__icon-text">
|
||||
VIP
|
||||
</text>
|
||||
</view>
|
||||
<text class="cell__label">
|
||||
升级会员
|
||||
</text>
|
||||
<text class="cell__value">
|
||||
解锁更多模块
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
|
||||
</view>
|
||||
<view class="cell press" hover-class="press-active" @tap="feedbackVisible = true">
|
||||
<view class="cell__icon" :style="{ background: CELL_BGS[1] }">
|
||||
<wd-icon name="chat" size="34rpx" color="#B57F00" />
|
||||
@@ -396,6 +415,16 @@ function logout() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 会员图标:金色渐变 + 文字标记,与其它模块的淡色块区分开 */
|
||||
.cell__icon--vip {
|
||||
background: linear-gradient(140deg, #ffe9b8, #ffd77a);
|
||||
}
|
||||
.cell__icon-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: #b57f00;
|
||||
}
|
||||
.cell__label {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
|
||||
@@ -3,11 +3,13 @@ import { computed } from 'vue'
|
||||
import { apiToolsUsed } from '@/api/user'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import Sticker from '@/components/Sticker.vue'
|
||||
import VipCard from '@/components/vip/VipCard.vue'
|
||||
import { TOOL_REGISTRY } from '@/config/tools'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { toolPalette } from '@/utils/palette'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { showUnlockGuide } from '@/utils/unlock-guide'
|
||||
|
||||
const props = defineProps<{ ready: boolean }>()
|
||||
const emit = defineEmits<{ library: [] }>()
|
||||
@@ -44,11 +46,7 @@ function toolPath(key: string): string {
|
||||
|
||||
function openTool(path: string, toolKey: string, locked: boolean) {
|
||||
if (locked) {
|
||||
uni.showModal({
|
||||
title: '功能未解锁',
|
||||
content: '该功能属于更高等级模块,请联系管理员升级后使用。',
|
||||
showCancel: false,
|
||||
})
|
||||
showUnlockGuide()
|
||||
return
|
||||
}
|
||||
// 使用统计(fire-and-forget)
|
||||
@@ -193,6 +191,11 @@ const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 会员卡片:首页的会员 / 次数包转化入口 -->
|
||||
<view style="margin-top: 28rpx">
|
||||
<VipCard />
|
||||
</view>
|
||||
|
||||
<!-- 发现工具 -->
|
||||
<view v-if="hotTools.length" class="tb-card sec sec--gap tb-rise" style="animation-delay: 80ms">
|
||||
<view class="sec__head">
|
||||
|
||||
310
src/components/vip/QuotaBar.vue
Normal file
310
src/components/vip/QuotaBar.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工具页额度胶囊(右下角悬浮广告的左侧搭档)。
|
||||
*
|
||||
* 折叠态:一个很小的金色数字圈 —— 让用户「一眼知道还剩几次」,不占视线;
|
||||
* 展开态:三池明细(免费 / 会员 / 次数包)+ 补充入口。
|
||||
*
|
||||
* 数据来源:quota store 的 toolMap(与悬浮广告、用尽弹层共用一份,避免重复请求),
|
||||
* 并监听 lastConsumedQuota —— 扣减发生在 utils/image/core.ts 内部,页面拿不到结果,
|
||||
* 靠这个 ref 把最新快照广播回来。
|
||||
*/
|
||||
import type { ToolQuota } from '@/api/quota'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { currentToolKey, fetchToolQuota, goBuyPack, goMemberCenter, lastConsumedQuota } from '@/utils/quota'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
toolKey?: string
|
||||
/** 距底部距离(rpx),与 VipFloatAd 同高可左右对齐 */
|
||||
bottom?: number
|
||||
left?: number
|
||||
}>(), {
|
||||
toolKey: '',
|
||||
bottom: 170,
|
||||
left: 24,
|
||||
})
|
||||
|
||||
const store = useQuotaStore()
|
||||
const key = computed(() => props.toolKey || currentToolKey())
|
||||
const quota = computed<ToolQuota | null>(() => store.toolMap[key.value] || null)
|
||||
const expanded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const k = key.value
|
||||
if (!k)
|
||||
return
|
||||
const q = await fetchToolQuota(k)
|
||||
if (q)
|
||||
store.applyTool(q)
|
||||
})
|
||||
|
||||
watch(lastConsumedQuota, (q) => {
|
||||
if (q && q.tool_key === key.value)
|
||||
store.applyTool(q)
|
||||
})
|
||||
|
||||
watch(quota, () => {
|
||||
// 额度用尽时主动展开一次,把「补充」入口摆到用户眼前
|
||||
if (quota.value && !quota.value.can_use)
|
||||
expanded.value = true
|
||||
})
|
||||
|
||||
/** 三池明细行 */
|
||||
const pools = computed(() => {
|
||||
const q = quota.value
|
||||
if (!q)
|
||||
return []
|
||||
return [
|
||||
{ key: 'free', label: '免费额度', value: `${q.free_left} / ${q.free_total}`, ratio: q.free_total ? q.free_left / q.free_total : 0 },
|
||||
{ key: 'member', label: '会员额度', value: q.is_member ? `${q.member_left} / ${q.member_total}` : '未开通', ratio: q.is_member && q.member_total ? q.member_left / q.member_total : 0 },
|
||||
{ key: 'paid', label: '次数包', value: q.paid_left > 0 ? `${q.paid_left} 次` : '无', ratio: q.paid_left > 0 ? 1 : 0 },
|
||||
]
|
||||
})
|
||||
|
||||
const totalLeft = computed(() => quota.value?.total_left ?? 0)
|
||||
const exhausted = computed(() => !!quota.value && !quota.value.can_use)
|
||||
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
function buyPack() {
|
||||
expanded.value = false
|
||||
goBuyPack(key.value)
|
||||
}
|
||||
|
||||
function buyMember() {
|
||||
expanded.value = false
|
||||
goMemberCenter()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="quota"
|
||||
class="qbar"
|
||||
:style="{ bottom: `${props.bottom}rpx`, left: `${props.left}rpx` }"
|
||||
>
|
||||
<!-- 展开:三池明细 -->
|
||||
<view v-if="expanded" class="qpanel">
|
||||
<view class="qpanel__glow" />
|
||||
<view class="qpanel__head">
|
||||
<text class="qpanel__title">
|
||||
可用额度
|
||||
</text>
|
||||
<view class="qpanel__close press" hover-class="press-active" @tap.stop="toggle">
|
||||
<wd-icon name="close" size="22rpx" color="rgba(245,197,107,0.75)" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="qpanel__cycle">
|
||||
每 {{ quota.period_days }} 天重置 · 本周期共剩 {{ totalLeft }} 次
|
||||
</text>
|
||||
|
||||
<view v-for="p in pools" :key="p.key" class="qrow">
|
||||
<view class="qrow__meta">
|
||||
<text class="qrow__label">
|
||||
{{ p.label }}
|
||||
</text>
|
||||
<text class="qrow__val" :class="{ 'qrow__val--off': p.ratio <= 0 }">
|
||||
{{ p.value }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="qrow__track">
|
||||
<view class="qrow__fill" :style="{ width: `${Math.max(p.ratio, 0) * 100}%` }" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="qpanel__acts">
|
||||
<view class="qbtn qbtn--gold press" hover-class="press-active" @tap.stop="buyPack">
|
||||
<text>补充次数</text>
|
||||
</view>
|
||||
<view class="qbtn qbtn--ghost press" hover-class="press-active" @tap.stop="buyMember">
|
||||
<text>{{ quota.is_member ? '会员中心' : '开通会员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 折叠:金色数字胶囊 -->
|
||||
<view class="qpill press" :class="{ 'qpill--off': exhausted }" hover-class="press-active" @tap="toggle">
|
||||
<view class="qpill__ring">
|
||||
<text class="qpill__num">
|
||||
{{ totalLeft > 99 ? '99+' : totalLeft }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="qpill__text">
|
||||
{{ exhausted ? '额度已用完' : '剩余次数' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qbar {
|
||||
position: fixed;
|
||||
z-index: 890;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ===== 折叠胶囊 ===== */
|
||||
.qpill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 10rpx 22rpx 10rpx 10rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #2b1a10 0%, #4a2c16 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.5);
|
||||
box-shadow: 0 12rpx 28rpx rgba(43, 26, 16, 0.4);
|
||||
}
|
||||
.qpill--off {
|
||||
border-color: rgba(255, 120, 90, 0.6);
|
||||
}
|
||||
.qpill__ring {
|
||||
flex-shrink: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, #ffe9b8 0%, #f5c56b 52%, #d89a35 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 18rpx rgba(216, 154, 53, 0.38);
|
||||
}
|
||||
.qpill__num {
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
}
|
||||
.qpill__text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 233, 184, 0.9);
|
||||
}
|
||||
|
||||
/* ===== 展开面板 ===== */
|
||||
.qpanel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 14rpx);
|
||||
width: 452rpx;
|
||||
overflow: hidden;
|
||||
padding: 24rpx 24rpx 22rpx;
|
||||
border-radius: 30rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 55%, #241408 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.5);
|
||||
box-shadow: 0 18rpx 44rpx rgba(43, 26, 16, 0.5);
|
||||
animation: qPanelIn 0.24s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.qpanel__glow {
|
||||
position: absolute;
|
||||
top: -80rpx;
|
||||
right: -60rpx;
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(245, 197, 107, 0.26) 0%, rgba(245, 197, 107, 0) 70%);
|
||||
}
|
||||
.qpanel__head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.qpanel__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.qpanel__close {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.qpanel__cycle {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-top: 8rpx;
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 233, 184, 0.6);
|
||||
}
|
||||
|
||||
.qrow {
|
||||
position: relative;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.qrow__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.qrow__label {
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 233, 184, 0.78);
|
||||
}
|
||||
.qrow__val {
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.qrow__val--off {
|
||||
color: rgba(255, 233, 184, 0.4);
|
||||
font-weight: 500;
|
||||
}
|
||||
.qrow__track {
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.qrow__fill {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #f5c56b, #ffe9b8);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.qpanel__acts {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.qbtn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 18rpx 0;
|
||||
border-radius: 999rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.qbtn--gold {
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 55%, #e0a63f 100%);
|
||||
box-shadow: 0 10rpx 22rpx rgba(224, 166, 63, 0.32);
|
||||
}
|
||||
.qbtn--ghost {
|
||||
color: #ffe9b8;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.5);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@keyframes qPanelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14rpx) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
319
src/components/vip/QuotaGate.vue
Normal file
319
src/components/vip/QuotaGate.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 额度用尽引导弹层(由 VipDock 挂在页面里,订阅全局 quotaGateState)。
|
||||
*
|
||||
* 为什么不用 uni.showModal:额度用尽是最需要「转化」的一刻,系统弹窗无法承载
|
||||
* 会员推荐位 + 次数包说明 + 价格锚点,转化率会明显偏低。
|
||||
*
|
||||
* 双路径(与产品要求一致):
|
||||
* 路径 A —— 开通会员:额度提升、周期重置,是主推项
|
||||
* 路径 B —— 购买次数包:按当前工具绑定,按次买断、一年有效,适合只用一两个工具的人
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { TOOL_REGISTRY } from '@/config/tools'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { goBuyPack, goMemberCenter, quotaGateState, toolDefOf } from '@/utils/quota'
|
||||
|
||||
const store = useQuotaStore()
|
||||
const state = computed(() => quotaGateState.value)
|
||||
const toolName = computed(() => toolDefOf(state.value.toolKey)?.name || '该工具')
|
||||
const periodDays = computed(() => store.periodDays || 7)
|
||||
const toolCount = TOOL_REGISTRY.length
|
||||
|
||||
function close() {
|
||||
quotaGateState.value = { ...quotaGateState.value, open: false }
|
||||
}
|
||||
|
||||
function buyMember() {
|
||||
close()
|
||||
goMemberCenter()
|
||||
}
|
||||
|
||||
function buyPack() {
|
||||
const key = state.value.toolKey
|
||||
close()
|
||||
goBuyPack(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="state.open" class="gate">
|
||||
<view class="gate__mask" @tap="close" />
|
||||
|
||||
<view class="gate__sheet">
|
||||
<view class="gate__glow" />
|
||||
<view class="gate__grab" />
|
||||
|
||||
<view class="gate__head">
|
||||
<view class="gate__crown">
|
||||
<text class="gate__crown-text">
|
||||
VIP
|
||||
</text>
|
||||
</view>
|
||||
<view class="gate__titles">
|
||||
<text class="gate__title">
|
||||
可用次数已用完
|
||||
</text>
|
||||
<text class="gate__sub">
|
||||
{{ state.message }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 路径 A:开通会员(主推) -->
|
||||
<view class="opt opt--gold press" hover-class="press-active" @tap="buyMember">
|
||||
<view class="opt__tag">
|
||||
推荐
|
||||
</view>
|
||||
<view class="opt__body">
|
||||
<text class="opt__name opt__name--dark">
|
||||
开通会员
|
||||
</text>
|
||||
<text class="opt__desc opt__desc--dark">
|
||||
解锁全部 {{ toolCount }} 个工具 · 会员额度每 {{ periodDays }} 天自动重置
|
||||
</text>
|
||||
</view>
|
||||
<view class="opt__go">
|
||||
<text class="opt__go-text opt__go-text--dark">
|
||||
立即开通
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="24rpx" color="#3D2413" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 路径 B:购买次数包 -->
|
||||
<view class="opt press" hover-class="press-active" @tap="buyPack">
|
||||
<view class="opt__body">
|
||||
<text class="opt__name">
|
||||
购买「{{ toolName }}」次数包
|
||||
</text>
|
||||
<text class="opt__desc">
|
||||
1 / 50 / 100 / 300 / 500 / 1000 / 10000 次可选,一年有效,不占用会员额度
|
||||
</text>
|
||||
</view>
|
||||
<view class="opt__go opt__go--ghost">
|
||||
<text class="opt__go-text">
|
||||
选购
|
||||
</text>
|
||||
<wd-icon name="arrow-right" size="24rpx" color="#FFE9B8" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="gate__foot">
|
||||
<text class="gate__foot-text">
|
||||
额度按周期自动重置,已购买的次数包不受影响
|
||||
</text>
|
||||
<view class="gate__later press" hover-class="press-active" @tap="close">
|
||||
<text>稍后再说</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gate {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.gate__mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(20, 10, 4, 0.62);
|
||||
animation: gateFade 0.22s ease both;
|
||||
}
|
||||
.gate__sheet {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20rpx 32rpx calc(32rpx + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 44rpx 44rpx 0 0;
|
||||
background: linear-gradient(170deg, #2b1a10 0%, #3d2413 50%, #1f1108 100%);
|
||||
border-top: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: 0 -20rpx 60rpx rgba(20, 10, 4, 0.5);
|
||||
animation: gateUp 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.gate__glow {
|
||||
position: absolute;
|
||||
top: -140rpx;
|
||||
right: -90rpx;
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(245, 197, 107, 0.26) 0%, rgba(245, 197, 107, 0) 70%);
|
||||
}
|
||||
.gate__grab {
|
||||
position: relative;
|
||||
width: 76rpx;
|
||||
height: 8rpx;
|
||||
margin: 0 auto 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(245, 197, 107, 0.28);
|
||||
}
|
||||
|
||||
.gate__head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.gate__crown {
|
||||
flex-shrink: 0;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(140deg, #ffe9b8 0%, #f5c56b 50%, #d89a35 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(216, 154, 53, 0.4);
|
||||
}
|
||||
.gate__crown-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
color: #3d2413;
|
||||
}
|
||||
.gate__titles {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.gate__title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.gate__sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 233, 184, 0.68);
|
||||
}
|
||||
|
||||
.opt {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 22rpx;
|
||||
padding: 24rpx 22rpx;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.opt--gold {
|
||||
border-color: rgba(255, 233, 184, 0.85);
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 58%, #e0a63f 100%);
|
||||
box-shadow: 0 14rpx 34rpx rgba(224, 166, 63, 0.3);
|
||||
}
|
||||
.opt__tag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 0 26rpx 0 22rpx;
|
||||
font-size: 19rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
background: rgba(61, 36, 19, 0.16);
|
||||
}
|
||||
.opt__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
.opt__name {
|
||||
display: block;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.opt__name--dark {
|
||||
color: #3d2413;
|
||||
}
|
||||
.opt__desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 233, 184, 0.68);
|
||||
}
|
||||
.opt__desc--dark {
|
||||
color: rgba(61, 36, 19, 0.72);
|
||||
}
|
||||
.opt__go {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(61, 36, 19, 0.1);
|
||||
}
|
||||
.opt__go--ghost {
|
||||
background: rgba(255, 233, 184, 0.12);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.42);
|
||||
}
|
||||
.opt__go-text {
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.opt__go-text--dark {
|
||||
color: #3d2413;
|
||||
}
|
||||
|
||||
.gate__foot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.gate__foot-text {
|
||||
flex: 1;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 233, 184, 0.45);
|
||||
}
|
||||
.gate__later {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 26rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 2rpx solid rgba(255, 233, 184, 0.22);
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 233, 184, 0.7);
|
||||
}
|
||||
|
||||
@keyframes gateFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes gateUp {
|
||||
from {
|
||||
transform: translateY(60rpx);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
280
src/components/vip/VipCard.vue
Normal file
280
src/components/vip/VipCard.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 首页会员卡片。
|
||||
*
|
||||
* 与工具页的悬浮广告是同一套视觉语言(深炭底 + 金边 + 呼吸光),
|
||||
* 区别是它承担「整体介绍」职责:会员状态、额度三池概览、两个转化入口。
|
||||
*
|
||||
* 会员且额度充足时不再「催买」,改为展示额度状态 + 续费入口,避免打扰付费用户。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
|
||||
const store = useQuotaStore()
|
||||
const ready = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.fetchMine()
|
||||
ready.value = true
|
||||
}
|
||||
catch {
|
||||
// 未登录 / 网络异常:退化为通用文案,不阻塞首页
|
||||
}
|
||||
})
|
||||
|
||||
/** 三池合计(跨工具求和,首页只看总量) */
|
||||
const agg = computed(() => {
|
||||
const list = store.mine?.tools || []
|
||||
return {
|
||||
free: list.reduce((sum, t) => sum + t.free_left, 0),
|
||||
member: list.reduce((sum, t) => sum + t.member_left, 0),
|
||||
paid: list.reduce((sum, t) => sum + t.paid_left, 0),
|
||||
}
|
||||
})
|
||||
|
||||
const isMember = computed(() => !!store.mine?.is_member)
|
||||
const levelName = computed(() => store.mine?.level_name || '')
|
||||
const totalLeft = computed(() => store.mine?.total_left ?? 0)
|
||||
const periodDays = computed(() => store.periodDays)
|
||||
|
||||
const title = computed(() => (isMember.value ? `${levelName.value}会员` : '开通会员 · 解锁全部工具'))
|
||||
const subline = computed(() => {
|
||||
if (isMember.value) {
|
||||
const expire = store.mine?.level_expire_at
|
||||
return expire ? `${expire} 到期 · 额度每 ${periodDays.value} 天重置` : `额度每 ${periodDays.value} 天重置`
|
||||
}
|
||||
return `免费额度每 ${periodDays.value} 天重置,会员再送 10 倍额度`
|
||||
})
|
||||
|
||||
const pools = computed(() => [
|
||||
{ key: 'free', label: '免费', value: ready.value ? String(agg.value.free) : '—' },
|
||||
{ key: 'member', label: '会员', value: isMember.value ? String(agg.value.member) : '未开通' },
|
||||
{ key: 'paid', label: '次数包', value: ready.value ? String(agg.value.paid) : '—' },
|
||||
])
|
||||
|
||||
function goMember() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
function goPacks() {
|
||||
uni.navigateTo({ url: '/pkg-member/quota/index' })
|
||||
}
|
||||
|
||||
function goMyQuota() {
|
||||
uni.navigateTo({ url: '/pkg-member/quota/my' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="vcard">
|
||||
<view class="vcard__aura" />
|
||||
<view class="vcard__sweep" />
|
||||
|
||||
<view class="vcard__top">
|
||||
<view class="vcard__crown">
|
||||
<text class="vcard__crown-text">
|
||||
VIP
|
||||
</text>
|
||||
</view>
|
||||
<view class="vcard__meta">
|
||||
<text class="vcard__title">
|
||||
{{ title }}
|
||||
</text>
|
||||
<text class="vcard__sub">
|
||||
{{ subline }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="vcard__mine press" hover-class="press-active" @tap.stop="goMyQuota">
|
||||
<text>我的额度</text>
|
||||
<wd-icon name="arrow-right" size="22rpx" color="rgba(255,233,184,0.75)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="vcard__pools">
|
||||
<view v-for="p in pools" :key="p.key" class="vpool">
|
||||
<text class="vpool__val">
|
||||
{{ p.value }}
|
||||
</text>
|
||||
<text class="vpool__label">
|
||||
{{ p.label }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="vpool vpool--total">
|
||||
<text class="vpool__val vpool__val--gold">
|
||||
{{ ready ? totalLeft : '—' }}
|
||||
</text>
|
||||
<text class="vpool__label">
|
||||
本周期可用
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="vcard__acts">
|
||||
<view class="vbtn vbtn--gold press" hover-class="press-active" @tap.stop="goMember">
|
||||
<text>{{ isMember ? '续费会员' : '立即开通会员' }}</text>
|
||||
</view>
|
||||
<view class="vbtn vbtn--ghost press" hover-class="press-active" @tap.stop="goPacks">
|
||||
<text>买次数包</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vcard {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 28rpx 26rpx 26rpx;
|
||||
border-radius: 34rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #3d2413 52%, #22130a 100%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: 0 18rpx 42rpx rgba(43, 26, 16, 0.34), 0 0 0 6rpx rgba(245, 197, 107, 0.08);
|
||||
}
|
||||
.vcard__aura {
|
||||
position: absolute;
|
||||
top: -120rpx;
|
||||
right: -80rpx;
|
||||
width: 340rpx;
|
||||
height: 340rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(245, 197, 107, 0.3) 0%, rgba(245, 197, 107, 0) 70%);
|
||||
}
|
||||
.vcard__sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -50%;
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 226, 168, 0.16) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
animation: vcardSweep 4.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vcard__top {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.vcard__crown {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(140deg, #ffe9b8 0%, #f5c56b 50%, #d89a35 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(216, 154, 53, 0.42);
|
||||
}
|
||||
.vcard__crown-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
color: #3d2413;
|
||||
}
|
||||
.vcard__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.vcard__title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.vcard__sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 233, 184, 0.62);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vcard__mine {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 2rpx solid rgba(255, 233, 184, 0.24);
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 233, 184, 0.78);
|
||||
}
|
||||
|
||||
.vcard__pools {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.vpool {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16rpx 8rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(245, 197, 107, 0.09);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.vpool--total {
|
||||
background: rgba(245, 197, 107, 0.16);
|
||||
border-color: rgba(255, 233, 184, 0.3);
|
||||
}
|
||||
.vpool__val {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.vpool__val--gold {
|
||||
background: linear-gradient(180deg, #fff6e0 0%, #f5c56b 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.vpool__label {
|
||||
font-size: 19rpx;
|
||||
color: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
|
||||
.vcard__acts {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.vbtn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.vbtn--gold {
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 55%, #e0a63f 100%);
|
||||
box-shadow: 0 12rpx 26rpx rgba(224, 166, 63, 0.34);
|
||||
}
|
||||
.vbtn--ghost {
|
||||
color: #ffe9b8;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.5);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@keyframes vcardSweep {
|
||||
0% {
|
||||
left: -50%;
|
||||
}
|
||||
55%, 100% {
|
||||
left: 140%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
src/components/vip/VipDock.vue
Normal file
56
src/components/vip/VipDock.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* VIP 挂载坞 —— 各页面只需要写一行 <VipDock />,就同时获得:
|
||||
* 1. VipFloatAd 工具页右下角悬浮广告
|
||||
* 2. QuotaBar 左下角额度胶囊(三池明细 + 补充入口)
|
||||
* 3. QuotaGate 额度用尽引导弹层(并把自己注册为弹层宿主)
|
||||
*
|
||||
* 三个子组件都是 fixed 定位,所以本组件可以放在页面模板的任意位置(惯例放在最末尾),
|
||||
* 不会影响任何既有布局 —— 这是它比「逐页手写额度条」更安全的地方。
|
||||
*
|
||||
* 注册宿主的意义:utils/quota.ts 的 openQuotaGate 在没有宿主时只能退回系统 showModal,
|
||||
* 有宿主才能展示带价格锚点的转化弹层。
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import QuotaBar from '@/components/vip/QuotaBar.vue'
|
||||
import QuotaGate from '@/components/vip/QuotaGate.vue'
|
||||
import VipFloatAd from '@/components/vip/VipFloatAd.vue'
|
||||
import { currentToolKey, registerGateHost } from '@/utils/quota'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 不传则从当前页面路由推导 */
|
||||
toolKey?: string
|
||||
/** 是否显示额度胶囊 */
|
||||
bar?: boolean
|
||||
/** 是否显示悬浮广告 */
|
||||
ad?: boolean
|
||||
/** 悬浮元素距底部距离(rpx) */
|
||||
bottom?: number
|
||||
}>(), {
|
||||
toolKey: '',
|
||||
bar: true,
|
||||
ad: true,
|
||||
bottom: 170,
|
||||
})
|
||||
|
||||
const resolvedKey = computed(() => props.toolKey || currentToolKey())
|
||||
|
||||
let unregister: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unregister = registerGateHost()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unregister?.()
|
||||
unregister = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view>
|
||||
<QuotaBar v-if="props.bar" :tool-key="resolvedKey" :bottom="props.bottom" />
|
||||
<VipFloatAd v-if="props.ad" :tool-key="resolvedKey" :bottom="props.bottom" />
|
||||
<QuotaGate />
|
||||
</view>
|
||||
</template>
|
||||
373
src/components/vip/VipFloatAd.vue
Normal file
373
src/components/vip/VipFloatAd.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<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'
|
||||
|
||||
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` }"
|
||||
>
|
||||
<!-- 展开态:深色金边小卡 -->
|
||||
<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;
|
||||
z-index: 900;
|
||||
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>
|
||||
@@ -65,5 +65,44 @@
|
||||
"backgroundColor": "#FFF9F2",
|
||||
"backgroundColorTop": "#FFF9F2",
|
||||
"backgroundColorBottom": "#FFF9F2"
|
||||
},
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pkg-member",
|
||||
"pages": [
|
||||
{
|
||||
"path": "member/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "开通会员"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "member/orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的订单",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "quota/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购买次数包"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "quota/my",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的额度"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"preloadRule": {
|
||||
"pages/index/index": {
|
||||
"network": "wifi",
|
||||
"packages": ["pkg-member"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { saveResult } from '@/utils/image/core'
|
||||
import { resultName, runTask } from '@/utils/image/task'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
@@ -429,6 +430,8 @@ function goBack() {
|
||||
</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FxShape } from '@/utils/image/avatarfx'
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import Sticker from '@/components/Sticker.vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { AVATAR_EFFECTS, drawEffect, drawPhoto } from '@/utils/image/avatarfx'
|
||||
import { beginDraw, bindImageCanvas, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
|
||||
import { resultName, runTask } from '@/utils/image/task'
|
||||
@@ -243,6 +244,8 @@ function goBack() {
|
||||
</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { COLLAGE_TEMPLATES, drawCollage } from '@/utils/image/collage'
|
||||
import { beginDraw, bindImageCanvas, chooseImages, exportFile, loadImage, saveResult } from '@/utils/image/core'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
@@ -187,6 +188,8 @@ function goBack() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { compressImage } from '@/utils/image/compress'
|
||||
import { bindImageCanvas, chooseImages } from '@/utils/image/core'
|
||||
import { formatSize, resultName, runTask } from '@/utils/image/task'
|
||||
@@ -171,6 +172,8 @@ async function saveAll() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { convertFormat } from '@/utils/image/convert'
|
||||
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
@@ -116,6 +117,8 @@ async function save() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { bindImageCanvas, chooseOneImage, loadImage, saveResult } from '@/utils/image/core'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
import { cropImage, transformImage } from '@/utils/image/transform'
|
||||
@@ -211,6 +212,8 @@ const hasImage = computed(() => !!src.value)
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { beginDraw, bindImageCanvas, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
@@ -106,6 +107,8 @@ function goBack() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { toolPalette } from '@/utils/palette'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
import { showUnlockGuide } from '@/utils/unlock-guide'
|
||||
|
||||
const toolsStore = useToolsStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -24,7 +25,7 @@ const tools = computed(() =>
|
||||
|
||||
function open(path: string, key: string, locked: boolean) {
|
||||
if (locked) {
|
||||
uni.showModal({ title: '功能未解锁', content: '该功能属于更高等级模块,请联系管理员升级。', showCancel: false })
|
||||
showUnlockGuide()
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: path })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
|
||||
import { removeBackgroundSmart, removeBackgroundSolid } from '@/utils/image/matting'
|
||||
import { resizeImage } from '@/utils/image/resize'
|
||||
@@ -141,6 +142,8 @@ async function saveSmart() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { beginDraw, bindImageCanvas, exportFile, saveResult } from '@/utils/image/core'
|
||||
import { makeMatrix } from '@/utils/image/qrcode'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
@@ -133,6 +134,8 @@ function goBack() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ResizeMode } from '@/utils/image/types'
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
|
||||
import { resizeImage } from '@/utils/image/resize'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
@@ -116,6 +117,8 @@ async function save() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onReady } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import VipDock from '@/components/vip/VipDock.vue'
|
||||
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
|
||||
import { runTask } from '@/utils/image/task'
|
||||
import { addWatermark } from '@/utils/image/watermark'
|
||||
@@ -123,6 +124,8 @@ async function save() {
|
||||
<!-- 图片引擎工作画布:页面内隐藏节点,onReady 时由 bindImageCanvas 绑定 -->
|
||||
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
|
||||
<!-- #endif -->
|
||||
|
||||
<VipDock />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
880
src/pkg-member/member/index.vue
Normal file
880
src/pkg-member/member/index.vue
Normal file
@@ -0,0 +1,880 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 会员中心。
|
||||
*
|
||||
* 视觉基调:深炭底 + 金边,与工具页悬浮广告、首页会员卡片保持同一套语言 ——
|
||||
* 三处入口视觉一致,用户才会把它们当成「同一个东西」(会员体系)而不是三个广告位。
|
||||
*
|
||||
* 页面结构:
|
||||
* 会员状态卡(金)+ 额度概览(三池)→ 等级权益 → 套餐 → 须知 → 底部购买条
|
||||
*/
|
||||
import type { MemberCenter, MemberPlan } from '@/api/member'
|
||||
import type { VirtualPayError } from '@/utils/vpay'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiMemberCenter } from '@/api/member'
|
||||
import Confetti from '@/components/Confetti.vue'
|
||||
import Sticker from '@/components/Sticker.vue'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { buyMembership, fenToYuan } from '@/utils/member-pay'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const toolsStore = useToolsStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
/** 头区顶部留白:MP 避开胶囊按钮 */
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
const loading = ref(true)
|
||||
const center = ref<MemberCenter | null>(null)
|
||||
const selected = ref('')
|
||||
const paying = ref(false)
|
||||
const burst = ref(0)
|
||||
|
||||
const plans = computed(() => center.value?.plans || [])
|
||||
const levels = computed(() => center.value?.levels || [])
|
||||
const notices = computed(() => center.value?.notices || [])
|
||||
const state = computed(() => center.value?.state || null)
|
||||
const periodDays = computed(() => center.value?.period_days || 7)
|
||||
const selectedPlan = computed<MemberPlan | undefined>(
|
||||
() => plans.value.find(p => p.plan_key === selected.value) || plans.value[0],
|
||||
)
|
||||
|
||||
/** 会员状态副标题:会员中显示剩余天数与到期时间,否则给开通引导 */
|
||||
const memberSub = computed(() => {
|
||||
const s = state.value
|
||||
if (s?.is_member)
|
||||
return `${s.days_left} 天后到期 · ${s.expire_at}`
|
||||
return '开通后解锁更多模块与工具'
|
||||
})
|
||||
|
||||
/** 额度三池合计:让用户直观看到「开通后会长什么样」 */
|
||||
const agg = computed(() => {
|
||||
const list = center.value?.quotas || []
|
||||
return {
|
||||
free: list.reduce((s, t) => s + t.free_left, 0),
|
||||
member: list.reduce((s, t) => s + t.member_left, 0),
|
||||
paid: list.reduce((s, t) => s + t.paid_left, 0),
|
||||
total: list.reduce((s, t) => s + t.total_left, 0),
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
const data = await apiMemberCenter()
|
||||
center.value = data
|
||||
if (!selected.value && data.plans.length)
|
||||
selected.value = data.plans[0].plan_key
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goOrders() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/orders' })
|
||||
}
|
||||
|
||||
function goMyQuota() {
|
||||
uni.navigateTo({ url: '/pkg-member/quota/my' })
|
||||
}
|
||||
|
||||
function goPacks() {
|
||||
uni.navigateTo({ url: '/pkg-member/quota/index' })
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (paying.value)
|
||||
return
|
||||
const plan = selectedPlan.value
|
||||
if (!plan)
|
||||
return
|
||||
if (!center.value?.xpay_ready) {
|
||||
uni.showModal({
|
||||
title: '暂时无法购买',
|
||||
content: '虚拟支付的 OfferID / 现网AppKey 尚未配置完成,请稍后再试。',
|
||||
showCancel: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
paying.value = true
|
||||
try {
|
||||
const result = await buyMembership(plan.plan_key)
|
||||
if (result.delivered) {
|
||||
burst.value++
|
||||
uni.showToast({ title: '开通成功', icon: 'success' })
|
||||
// 等级、工具锁定态、额度都必须立即刷新,否则用户看到权益没生效。
|
||||
// 刷新失败不应反过来把「已开通」误报成失败,故各自吞掉异常。
|
||||
await Promise.all([
|
||||
userStore.refreshLevel().catch(() => {}),
|
||||
toolsStore.fetchAll().catch(() => {}),
|
||||
quotaStore.fetchMine().catch(() => {}),
|
||||
load(),
|
||||
])
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: '支付已完成,正在开通', icon: 'none' })
|
||||
goOrders()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
const err = e as VirtualPayError
|
||||
// 用户主动关闭收银台不算失败,静默返回,避免弹窗打扰
|
||||
if (err?.cancelled)
|
||||
return
|
||||
const title = err?.message || '支付未完成'
|
||||
// 平台返回的排查说明较长,用模态才能完整展示
|
||||
if (err?.detail)
|
||||
uni.showModal({ title, content: err.detail, showCancel: false })
|
||||
else
|
||||
uni.showToast({ title, icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 深炭金头区 + 会员状态卡 -->
|
||||
<view class="hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="hero__aura" />
|
||||
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#FFE9B8" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
会员中心
|
||||
</text>
|
||||
<view class="nav__link press" hover-class="press-active" @tap="goOrders">
|
||||
<text>订单</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mb-card">
|
||||
<view class="mb-card__sweep" />
|
||||
<Sticker type="sparkle" :size="56" float :delay="600" class="mb-card__sticker" />
|
||||
<view class="mb-card__row">
|
||||
<view class="mb-card__crown">
|
||||
<text class="mb-card__crown-text">
|
||||
VIP
|
||||
</text>
|
||||
</view>
|
||||
<view class="mb-card__body">
|
||||
<text class="mb-card__name">
|
||||
{{ state?.is_member ? `${state.level_name}会员` : '尚未开通会员' }}
|
||||
</text>
|
||||
<text class="mb-card__sub">
|
||||
{{ memberSub }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state?.modules?.length" class="mb-card__tags">
|
||||
<view v-for="m in state.modules" :key="m" class="mb-card__tag">
|
||||
{{ m }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 额度概览:把三池摊开,说明「会员权益」到底体现在哪 -->
|
||||
<view class="qp-card">
|
||||
<view class="qp-card__head">
|
||||
<text class="qp-card__title">
|
||||
我的额度
|
||||
</text>
|
||||
<view class="qp-card__more press" hover-class="press-active" @tap="goMyQuota">
|
||||
<text>查看明细</text>
|
||||
<wd-icon name="arrow-right" size="22rpx" color="rgba(255,233,184,0.75)" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="qp-card__pools">
|
||||
<view class="qp">
|
||||
<text class="qp__val">
|
||||
{{ agg.free }}
|
||||
</text>
|
||||
<text class="qp__label">
|
||||
免费额度
|
||||
</text>
|
||||
</view>
|
||||
<view class="qp qp--member">
|
||||
<text class="qp__val">
|
||||
{{ state?.is_member ? agg.member : '—' }}
|
||||
</text>
|
||||
<text class="qp__label">
|
||||
会员额度
|
||||
</text>
|
||||
</view>
|
||||
<view class="qp">
|
||||
<text class="qp__val">
|
||||
{{ agg.paid }}
|
||||
</text>
|
||||
<text class="qp__label">
|
||||
次数包
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="qp-card__note">
|
||||
额度每 {{ periodDays }} 天自动重置;次数包为买断制,按工具绑定、不随周期清零。
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上浮内容 -->
|
||||
<view class="body">
|
||||
<!-- 等级权益 -->
|
||||
<view class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
等级权益
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="lv in levels"
|
||||
:key="lv.level_key"
|
||||
class="level"
|
||||
:class="{ 'level--on': lv.is_current }"
|
||||
>
|
||||
<view class="level__left">
|
||||
<text class="level__name">
|
||||
{{ lv.name }}
|
||||
</text>
|
||||
<view v-if="lv.is_current" class="level__now">
|
||||
当前
|
||||
</view>
|
||||
</view>
|
||||
<text class="level__modules">
|
||||
{{ lv.module_names.length ? lv.module_names.join(' · ') : '暂无模块' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 套餐选择 -->
|
||||
<view class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
选择套餐
|
||||
</text>
|
||||
<text class="sec__hint">
|
||||
虚拟支付 · 即时到账
|
||||
</text>
|
||||
</view>
|
||||
<view class="plans">
|
||||
<view
|
||||
v-for="p in plans"
|
||||
:key="p.plan_key"
|
||||
class="plan press"
|
||||
:class="{ 'plan--on': p.plan_key === selected }"
|
||||
hover-class="press-active"
|
||||
@tap="selected = p.plan_key"
|
||||
>
|
||||
<view v-if="p.badge" class="plan__badge">
|
||||
{{ p.badge }}
|
||||
</view>
|
||||
<text class="plan__name">
|
||||
{{ p.name }}
|
||||
</text>
|
||||
<view class="plan__price">
|
||||
<text class="plan__symbol">
|
||||
¥
|
||||
</text>
|
||||
<text class="plan__num">
|
||||
{{ fenToYuan(p.price_cents) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="plan__days">
|
||||
{{ p.duration_days }} 天
|
||||
</text>
|
||||
<text v-if="p.subtitle" class="plan__sub">
|
||||
{{ p.subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!plans.length && !loading" class="hint">
|
||||
<text class="hint__title">
|
||||
暂无可用套餐
|
||||
</text>
|
||||
<text class="hint__sub">
|
||||
请稍后重试或联系管理员
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 次数包入口:只用一两个工具的人不该被推销整年会员 -->
|
||||
<view class="card sec pack-entry press" hover-class="press-active" @tap="goPacks">
|
||||
<view class="pack-entry__body">
|
||||
<text class="pack-entry__title">
|
||||
只想用某几个工具?
|
||||
</text>
|
||||
<text class="pack-entry__sub">
|
||||
按工具购买次数包,1 次起购,一年有效、用不完不清零
|
||||
</text>
|
||||
</view>
|
||||
<view class="pack-entry__go">
|
||||
<text>去选购</text>
|
||||
<wd-icon name="arrow-right" size="24rpx" color="#3D2413" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户须知 -->
|
||||
<view v-if="notices.length" class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
购买须知
|
||||
</text>
|
||||
</view>
|
||||
<view v-for="(n, i) in notices" :key="i" class="notice">
|
||||
<view class="notice__dot" />
|
||||
<text class="notice__text">
|
||||
{{ n }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<!-- 底部购买条 -->
|
||||
<view class="buy">
|
||||
<view class="buy__info">
|
||||
<view class="buy__price">
|
||||
<text class="buy__symbol">
|
||||
¥
|
||||
</text>
|
||||
<text class="buy__num">
|
||||
{{ selectedPlan ? fenToYuan(selectedPlan.price_cents) : '--' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="buy__hint">
|
||||
{{ selectedPlan ? `${selectedPlan.name} · ${selectedPlan.duration_days} 天` : '请选择套餐' }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="buy__btn press"
|
||||
:class="{ 'buy__btn--off': !selectedPlan || loading || paying }"
|
||||
hover-class="press-active"
|
||||
@tap="pay"
|
||||
>
|
||||
<text>{{ paying ? '处理中…' : '立即开通' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Confetti :burst="burst" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(210rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- 头区:深炭 + 金 ---- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0 32rpx 100rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #4a2c16 55%, #2b1a10 100%);
|
||||
}
|
||||
.hero__aura {
|
||||
position: absolute;
|
||||
top: -160rpx;
|
||||
right: -110rpx;
|
||||
width: 460rpx;
|
||||
height: 460rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(245, 197, 107, 0.22) 0%, rgba(245, 197, 107, 0) 70%);
|
||||
}
|
||||
.nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8rpx 0 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.nav__link {
|
||||
flex-shrink: 0;
|
||||
min-width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
|
||||
/* ---- 会员状态卡 ---- */
|
||||
.mb-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 16rpx;
|
||||
padding: 30rpx 28rpx;
|
||||
border-radius: 34rpx;
|
||||
background: linear-gradient(150deg, rgba(255, 233, 184, 0.16) 0%, rgba(255, 255, 255, 0.06) 60%);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.55);
|
||||
box-shadow: 0 16rpx 40rpx rgba(20, 10, 4, 0.35);
|
||||
}
|
||||
.mb-card__sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -50%;
|
||||
width: 34%;
|
||||
height: 100%;
|
||||
background: linear-gradient(100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 226, 168, 0.18) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
animation: mbSweep 4.6s ease-in-out infinite;
|
||||
}
|
||||
.mb-card__sticker {
|
||||
position: absolute;
|
||||
right: 16rpx;
|
||||
top: 12rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.mb-card__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.mb-card__crown {
|
||||
flex-shrink: 0;
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(140deg, #ffe9b8 0%, #f5c56b 50%, #d89a35 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(216, 154, 53, 0.42);
|
||||
}
|
||||
/* 用文字而非图标字体:避免依赖 wot 图标库中不确定存在的会员图标 */
|
||||
.mb-card__crown-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
color: #3d2413;
|
||||
}
|
||||
.mb-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mb-card__name {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.mb-card__sub {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 233, 184, 0.66);
|
||||
}
|
||||
.mb-card__tags {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.mb-card__tag {
|
||||
background: rgba(245, 197, 107, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
color: #ffe9b8;
|
||||
font-size: 21rpx;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
/* ---- 额度概览卡 ---- */
|
||||
.qp-card {
|
||||
position: relative;
|
||||
margin-top: 20rpx;
|
||||
padding: 24rpx 26rpx 22rpx;
|
||||
border-radius: 30rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.3);
|
||||
}
|
||||
.qp-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.qp-card__title {
|
||||
font-size: 27rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.qp-card__more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 233, 184, 0.78);
|
||||
}
|
||||
.qp-card__pools {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.qp {
|
||||
flex: 1;
|
||||
padding: 14rpx 8rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(245, 197, 107, 0.1);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.qp--member {
|
||||
background: rgba(245, 197, 107, 0.18);
|
||||
border-color: rgba(255, 233, 184, 0.34);
|
||||
}
|
||||
.qp__val {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.qp__label {
|
||||
font-size: 19rpx;
|
||||
color: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
.qp-card__note {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 233, 184, 0.45);
|
||||
}
|
||||
|
||||
/* ---- 内容区上浮 ---- */
|
||||
.body {
|
||||
position: relative;
|
||||
margin-top: -64rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 22rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.sec__hint {
|
||||
font-size: 20rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
.hint__title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.hint__sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
/* ---- 等级权益 ---- */
|
||||
.level {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe9dd;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
.level:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.level--on {
|
||||
background: linear-gradient(140deg, #fff9ef, #fdf0d8);
|
||||
border-color: rgba(224, 166, 63, 0.6);
|
||||
box-shadow: 0 8rpx 20rpx rgba(224, 166, 63, 0.14);
|
||||
}
|
||||
.level__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.level__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.level__now {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(140deg, #ffe9b8, #e0a63f);
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.level__modules {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 23rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
/* ---- 套餐 ---- */
|
||||
.plans {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.plan {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 30rpx 12rpx 26rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #fff9f2;
|
||||
border: 3rpx solid #ffe9dd;
|
||||
}
|
||||
.plan--on {
|
||||
background: linear-gradient(170deg, #2b1a10 0%, #3d2413 100%);
|
||||
border-color: rgba(245, 197, 107, 0.72);
|
||||
box-shadow: 0 14rpx 30rpx rgba(43, 26, 16, 0.28);
|
||||
}
|
||||
.plan__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 0 24rpx 0 20rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8, #e0a63f);
|
||||
}
|
||||
.plan__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.plan--on .plan__name {
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.plan__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.plan--on .plan__price {
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.plan__symbol {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.plan__num {
|
||||
font-size: 46rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.plan__days {
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.plan--on .plan__days {
|
||||
color: rgba(255, 233, 184, 0.7);
|
||||
}
|
||||
.plan__sub {
|
||||
font-size: 20rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.plan--on .plan__sub {
|
||||
color: rgba(255, 233, 184, 0.5);
|
||||
}
|
||||
|
||||
/* ---- 次数包入口 ---- */
|
||||
.pack-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
background: linear-gradient(140deg, #fff9ef, #fdf0d8);
|
||||
border-color: rgba(224, 166, 63, 0.5);
|
||||
}
|
||||
.pack-entry__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.pack-entry__title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.pack-entry__sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.45;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.pack-entry__go {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 55%, #e0a63f 100%);
|
||||
box-shadow: 0 10rpx 22rpx rgba(224, 166, 63, 0.3);
|
||||
}
|
||||
|
||||
/* ---- 须知 ---- */
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
.notice:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.notice__dot {
|
||||
flex-shrink: 0;
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #e0a63f;
|
||||
}
|
||||
.notice__text {
|
||||
flex: 1;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* ---- 底部购买条 ---- */
|
||||
.buy {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-top: 2rpx solid #ffe9dd;
|
||||
box-shadow: 0 -10rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.buy__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.buy__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.buy__symbol {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.buy__num {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.buy__hint {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.buy__btn {
|
||||
flex-shrink: 0;
|
||||
padding: 24rpx 52rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 29rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 52%, #d89a35 100%);
|
||||
box-shadow: 0 12rpx 28rpx rgba(216, 154, 53, 0.36);
|
||||
}
|
||||
.buy__btn--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes mbSweep {
|
||||
0% {
|
||||
left: -50%;
|
||||
}
|
||||
55%, 100% {
|
||||
left: 140%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
src/pkg-member/member/orders.vue
Normal file
326
src/pkg-member/member/orders.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import type { MemberOrder } from '@/api/member'
|
||||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiMemberOrderCheck, apiMemberOrders } from '@/api/member'
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { fenToYuan } from '@/utils/member-pay'
|
||||
import { navPadTop } from '@/utils/safearea'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const toolsStore = useToolsStore()
|
||||
/** 导航栏顶部留白:MP 用真实状态栏高度 */
|
||||
const navPad = navPadTop()
|
||||
|
||||
const loading = ref(true)
|
||||
const orders = ref<MemberOrder[]>([])
|
||||
const checking = ref('')
|
||||
|
||||
const hasPending = computed(() => orders.value.some(o => o.status === 0))
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
orders.value = (await apiMemberOrders(30)).list
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await load()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goBuy(order?: MemberOrder) {
|
||||
// 单号不可复用(官方约束),待支付订单一律重新下单。
|
||||
// 次数包订单必须回到「同一个工具」的下单页,否则用户会买到别的工具上去。
|
||||
if (order?.order_type === 2 && order.tool_key)
|
||||
uni.navigateTo({ url: `/pkg-member/quota/index?tool_key=${order.tool_key}` })
|
||||
else
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
/** 手动触发一次服务端查单兜底:支付成功但推送未到达时用它确认发货 */
|
||||
async function checkOrder(order: MemberOrder) {
|
||||
if (checking.value)
|
||||
return
|
||||
checking.value = order.out_trade_no
|
||||
try {
|
||||
const res = await apiMemberOrderCheck(order.out_trade_no)
|
||||
if (res.delivered) {
|
||||
uni.showToast({ title: order.order_type === 2 ? '次数已到账' : '已开通', icon: 'success' })
|
||||
await Promise.all([
|
||||
load(),
|
||||
userStore.refreshLevel().catch(() => {}),
|
||||
toolsStore.fetchAll().catch(() => {}),
|
||||
])
|
||||
}
|
||||
else {
|
||||
uni.showToast({ title: '支付尚未确认,请稍后再试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
}
|
||||
finally {
|
||||
checking.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="nav" :style="{ paddingTop: navPad }">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
我的订单
|
||||
</text>
|
||||
<view class="nav__slot" />
|
||||
</view>
|
||||
|
||||
<view v-if="orders.length" class="list">
|
||||
<view v-for="o in orders" :key="o.out_trade_no" class="tb-card item">
|
||||
<view class="item__top">
|
||||
<text class="item__name">
|
||||
{{ o.title || o.plan_name || o.plan_key || '订单' }}
|
||||
</text>
|
||||
<view class="item__status" :class="`item__status--${o.status}`">
|
||||
{{ o.status_text }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="o.order_type === 2" class="item__row">
|
||||
<text class="item__label">
|
||||
次数包
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.tool_name || o.tool_key || '—' }} · {{ o.times }} 次
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
订单号
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.out_trade_no }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
金额
|
||||
</text>
|
||||
<text class="item__value item__value--price">
|
||||
¥{{ fenToYuan(o.price_cents) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item__row">
|
||||
<text class="item__label">
|
||||
下单时间
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.created_at || '—' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="o.delivered_at" class="item__row">
|
||||
<text class="item__label">
|
||||
{{ o.order_type === 2 ? '到账时间' : '开通时间' }}
|
||||
</text>
|
||||
<text class="item__value">
|
||||
{{ o.delivered_at }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="o.status === 0" class="item__actions">
|
||||
<view
|
||||
class="item__btn press"
|
||||
hover-class="press-active"
|
||||
@tap="checkOrder(o)"
|
||||
>
|
||||
<text>{{ checking === o.out_trade_no ? '查询中…' : '查询状态' }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="item__btn item__btn--primary press"
|
||||
hover-class="press-active"
|
||||
@tap="goBuy(o)"
|
||||
>
|
||||
<text>重新购买</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hasPending" class="tip">
|
||||
<text>待支付订单可点击「查询状态」确认是否已到账;订单号不可复用,未完成的订单请重新购买。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loading" class="empty-wrap">
|
||||
<view class="tb-empty">
|
||||
<text class="tb-empty__title">
|
||||
还没有订单
|
||||
</text>
|
||||
<text class="tb-empty__sub">
|
||||
开通会员后可在这里查看购买记录
|
||||
</text>
|
||||
</view>
|
||||
<view class="empty-btn press" hover-class="press-active" @tap="goBuy">
|
||||
<text>去开通会员</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ffe9dd;
|
||||
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.nav__slot {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
.item {
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.item__name {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.item__status {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #9a7b65;
|
||||
background: #f6efe8;
|
||||
}
|
||||
.item__status--0 {
|
||||
color: #b57f00;
|
||||
background: #fff4d6;
|
||||
}
|
||||
.item__status--1 {
|
||||
color: #0fa385;
|
||||
background: #dff7f0;
|
||||
}
|
||||
.item__status--2,
|
||||
.item__status--3 {
|
||||
color: #9a7b65;
|
||||
background: #f2eee9;
|
||||
}
|
||||
.item__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
.item__label {
|
||||
flex-shrink: 0;
|
||||
width: 140rpx;
|
||||
font-size: 24rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.item__value {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #3d2a1e;
|
||||
word-break: break-all;
|
||||
}
|
||||
.item__value--price {
|
||||
font-weight: 700;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.item__actions {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.item__btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 18rpx 0;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #9a7b65;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
}
|
||||
.item__btn--primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.28);
|
||||
}
|
||||
|
||||
.tip {
|
||||
padding: 8rpx 12rpx 0;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
color: #b3907c;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
padding-top: 100rpx;
|
||||
}
|
||||
.empty-btn {
|
||||
margin-top: 40rpx;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||||
border-radius: 999rpx;
|
||||
padding: 24rpx 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
|
||||
}
|
||||
</style>
|
||||
655
src/pkg-member/quota/index.vue
Normal file
655
src/pkg-member/quota/index.vue
Normal file
@@ -0,0 +1,655 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 次数包购买页(按工具绑定)。
|
||||
*
|
||||
* 与会员中心的分工:
|
||||
* 会员中心 = 卖「周期额度」(订阅制,额度每周期重置)
|
||||
* 本页 = 卖「永久可用的次数」(买断制,一年有效,用不完不浪费)
|
||||
*
|
||||
* 档位来自服务端:通用档位(tool_key 为空)+ 该工具的专属档位,价格与有效期都在后台配。
|
||||
* 入口有三处:首页卡片、工具页额度胶囊、额度用尽弹层;都可以带上 ?tool_key= 直接定位工具。
|
||||
*/
|
||||
import type { QuotaPack, ToolQuota } from '@/api/quota'
|
||||
import type { VirtualPayError } from '@/utils/vpay'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiMemberCenter } from '@/api/member'
|
||||
import { apiQuotaPacks } from '@/api/quota'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { fenToYuan } from '@/utils/member-pay'
|
||||
import { buyQuotaPack } from '@/utils/quota-pay'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
const loading = ref(true)
|
||||
const paying = ref(false)
|
||||
const toolKey = ref('')
|
||||
const packs = ref<QuotaPack[]>([])
|
||||
const selected = ref('')
|
||||
const xpayReady = ref(true)
|
||||
|
||||
const tools = computed<ToolQuota[]>(() => quotaStore.mine?.tools || [])
|
||||
const currentTool = computed<ToolQuota | null>(
|
||||
() => tools.value.find(t => t.tool_key === toolKey.value) || null,
|
||||
)
|
||||
const totalLeft = computed(() => currentTool.value?.total_left ?? 0)
|
||||
const periodDays = computed(() => quotaStore.periodDays)
|
||||
|
||||
const selectedPack = computed<QuotaPack | undefined>(
|
||||
() => packs.value.find(p => p.pack_key === selected.value) || packs.value[0],
|
||||
)
|
||||
|
||||
/** 当前工具的次数包余额(已买过就提示叠加) */
|
||||
const paidLeft = computed(() => currentTool.value?.paid_left ?? 0)
|
||||
|
||||
/** 有效期文案:后台按天配置,365 天说「一年」更像人话 */
|
||||
function validText(days: number): string {
|
||||
if (days >= 365)
|
||||
return '有效期一年'
|
||||
return `有效期 ${days} 天`
|
||||
}
|
||||
|
||||
async function loadPacks() {
|
||||
const res = await apiQuotaPacks(toolKey.value || undefined)
|
||||
// 已买过的余额也要展示,所以优先带 tool_key 查询;没有工具时退化为通用档位
|
||||
packs.value = res.list
|
||||
if (!packs.value.some(p => p.pack_key === selected.value))
|
||||
selected.value = packs.value[0]?.pack_key || ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
const [, center] = await Promise.all([
|
||||
quotaStore.fetchMine().catch(() => null),
|
||||
apiMemberCenter().catch(() => null),
|
||||
])
|
||||
if (center)
|
||||
xpayReady.value = center.xpay_ready
|
||||
// 未指定工具时默认选第一个,避免用户面对空状态
|
||||
if (!toolKey.value && tools.value.length)
|
||||
toolKey.value = tools.value[0].tool_key
|
||||
await loadPacks()
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
toolKey.value = (options?.tool_key as string) || ''
|
||||
})
|
||||
|
||||
onShow(load)
|
||||
|
||||
function pickTool(key: string) {
|
||||
if (key === toolKey.value)
|
||||
return
|
||||
toolKey.value = key
|
||||
selected.value = ''
|
||||
loadPacks().catch(() => {})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goMember() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (paying.value)
|
||||
return
|
||||
const pack = selectedPack.value
|
||||
if (!pack || !toolKey.value)
|
||||
return
|
||||
if (!xpayReady.value) {
|
||||
uni.showModal({
|
||||
title: '暂时无法购买',
|
||||
content: '虚拟支付的 OfferID / 现网AppKey 尚未配置完成,请稍后再试。',
|
||||
showCancel: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
paying.value = true
|
||||
try {
|
||||
const result = await buyQuotaPack(pack.pack_key, toolKey.value)
|
||||
// 无论服务端是否已确认发货,都刷新一次额度;未发货时后续查单兜底会补上
|
||||
await Promise.all([
|
||||
quotaStore.fetchMine().catch(() => {}),
|
||||
quotaStore.fetchTool(toolKey.value).catch(() => {}),
|
||||
])
|
||||
await loadPacks().catch(() => {})
|
||||
if (result.delivered)
|
||||
uni.showToast({ title: `已到账 ${result.times} 次`, icon: 'success' })
|
||||
else
|
||||
uni.showToast({ title: '支付已完成,正在到账', icon: 'none' })
|
||||
}
|
||||
catch (e) {
|
||||
const err = e as VirtualPayError
|
||||
// 用户主动关闭收银台不算失败,静默返回,避免弹窗打扰
|
||||
if (err?.cancelled)
|
||||
return
|
||||
const title = err?.message || '支付未完成'
|
||||
if (err?.detail)
|
||||
uni.showModal({ title, content: err.detail, showCancel: false })
|
||||
else
|
||||
uni.showToast({ title, icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 渐变头区 -->
|
||||
<view class="hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
次数包
|
||||
</text>
|
||||
<view class="nav__link press" hover-class="press-active" @tap="goMember">
|
||||
<text>会员</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="hero__sum">
|
||||
<view class="hero__sum-left">
|
||||
<text class="hero__sum-label">
|
||||
当前工具本周期可用
|
||||
</text>
|
||||
<view class="hero__sum-val">
|
||||
<text class="hero__sum-num">
|
||||
{{ totalLeft }}
|
||||
</text>
|
||||
<text class="hero__sum-unit">
|
||||
次
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero__sum-right">
|
||||
<text class="hero__sum-label">
|
||||
已购次数包
|
||||
</text>
|
||||
<text class="hero__sum-sub">
|
||||
{{ paidLeft }} 次
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<!-- 工具选择:次数包按工具绑定,必须让用户明确「买给谁」 -->
|
||||
<view v-if="tools.length" class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
买给哪个工具
|
||||
</text>
|
||||
<text class="sec__hint">
|
||||
次数包按工具绑定,不通用
|
||||
</text>
|
||||
</view>
|
||||
<scroll-view scroll-x class="tools" :show-scrollbar="false">
|
||||
<view class="tools__inner">
|
||||
<view
|
||||
v-for="t in tools"
|
||||
:key="t.tool_key"
|
||||
class="tool press"
|
||||
:class="{ 'tool--on': t.tool_key === toolKey }"
|
||||
hover-class="press-active"
|
||||
@tap="pickTool(t.tool_key)"
|
||||
>
|
||||
<text>{{ t.name }}</text>
|
||||
<text class="tool__left">
|
||||
剩 {{ t.total_left }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 档位 -->
|
||||
<view class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
选择次数
|
||||
</text>
|
||||
<text class="sec__hint">
|
||||
买断制 · 一年有效
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="hint">
|
||||
正在加载档位…
|
||||
</view>
|
||||
<view v-else-if="!packs.length" class="hint">
|
||||
暂无可购买的次数包,请稍后再试
|
||||
</view>
|
||||
<view v-else class="packs">
|
||||
<view
|
||||
v-for="p in packs"
|
||||
:key="p.pack_key"
|
||||
class="pack press"
|
||||
:class="{ 'pack--on': p.pack_key === selected }"
|
||||
hover-class="press-active"
|
||||
@tap="selected = p.pack_key"
|
||||
>
|
||||
<view v-if="p.badge" class="pack__badge">
|
||||
{{ p.badge }}
|
||||
</view>
|
||||
<view class="pack__times">
|
||||
<text class="pack__num">
|
||||
{{ p.times }}
|
||||
</text>
|
||||
<text class="pack__unit">
|
||||
次
|
||||
</text>
|
||||
</view>
|
||||
<view class="pack__price">
|
||||
<text class="pack__symbol">
|
||||
¥
|
||||
</text>
|
||||
<text class="pack__yuan">
|
||||
{{ fenToYuan(p.price_cents) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="pack__unit-price">
|
||||
每 100 次 ¥{{ fenToYuan(p.unit_price_cents) }}
|
||||
</text>
|
||||
<text class="pack__valid">
|
||||
{{ validText(p.valid_days) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 须知 -->
|
||||
<view class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
购买须知
|
||||
</text>
|
||||
</view>
|
||||
<view class="notice">
|
||||
<view class="notice__dot" />
|
||||
<text class="notice__text">
|
||||
次数包按工具绑定,仅在「{{ currentTool?.name || '所选工具' }}」内消耗;优先扣免费额度与会员额度,用尽后才扣次数包。
|
||||
</text>
|
||||
</view>
|
||||
<view class="notice">
|
||||
<view class="notice__dot" />
|
||||
<text class="notice__text">
|
||||
{{ validText(selectedPack?.valid_days || 365) }},有效期内未用完的次数不会清零;重复购买有效期顺延。
|
||||
</text>
|
||||
</view>
|
||||
<view class="notice">
|
||||
<view class="notice__dot" />
|
||||
<text class="notice__text">
|
||||
免费额度与会员额度每 {{ periodDays }} 天自动重置,与次数包互不影响。
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
|
||||
<!-- 底部购买条 -->
|
||||
<view class="buy">
|
||||
<view class="buy__info">
|
||||
<view class="buy__price">
|
||||
<text class="buy__symbol">
|
||||
¥
|
||||
</text>
|
||||
<text class="buy__num">
|
||||
{{ selectedPack ? fenToYuan(selectedPack.price_cents) : '--' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="buy__hint">
|
||||
{{ selectedPack ? `${selectedPack.times} 次 · ${validText(selectedPack.valid_days)}` : '请选择次数' }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="paying"
|
||||
:disabled="!selectedPack || !toolKey || loading"
|
||||
custom-style="border-radius: 999rpx; height: 84rpx; font-size: 30rpx; padding: 0 56rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
|
||||
@click="pay"
|
||||
>
|
||||
立即购买
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(210rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- 头区:深炭 + 金,与悬浮广告同一套语言 ---- */
|
||||
.hero {
|
||||
padding: 0 32rpx 96rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #4a2c16 55%, #2b1a10 100%);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8rpx 0 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.nav__link {
|
||||
flex-shrink: 0;
|
||||
min-width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.hero__sum {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.hero__sum-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 233, 184, 0.6);
|
||||
}
|
||||
.hero__sum-val {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.hero__sum-num {
|
||||
font-size: 60rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
background: linear-gradient(180deg, #fff6e0 0%, #f5c56b 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero__sum-unit {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 233, 184, 0.7);
|
||||
}
|
||||
.hero__sum-right {
|
||||
text-align: right;
|
||||
}
|
||||
.hero__sum-sub {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
|
||||
/* ---- 内容区上浮 ---- */
|
||||
.body {
|
||||
position: relative;
|
||||
margin-top: -64rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 28rpx;
|
||||
margin-bottom: 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.sec__hint {
|
||||
font-size: 21rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 50rpx 0;
|
||||
font-size: 25rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
/* ---- 工具选择 ---- */
|
||||
.tools {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tools__inner {
|
||||
display: inline-flex;
|
||||
gap: 14rpx;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
.tool {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 14rpx 26rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
font-size: 25rpx;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.tool--on {
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #4a2c16 100%);
|
||||
border-color: rgba(245, 197, 107, 0.65);
|
||||
color: #ffe9b8;
|
||||
box-shadow: 0 10rpx 22rpx rgba(43, 26, 16, 0.24);
|
||||
}
|
||||
.tool__left {
|
||||
font-size: 19rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.tool--on .tool__left {
|
||||
color: rgba(255, 233, 184, 0.6);
|
||||
}
|
||||
|
||||
/* ---- 档位 ---- */
|
||||
.packs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
.pack {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 26rpx 12rpx 22rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #fff9f2;
|
||||
border: 3rpx solid #ffe3d3;
|
||||
}
|
||||
.pack--on {
|
||||
background: linear-gradient(170deg, #2b1a10 0%, #3d2413 100%);
|
||||
border-color: rgba(245, 197, 107, 0.72);
|
||||
box-shadow: 0 14rpx 30rpx rgba(43, 26, 16, 0.28);
|
||||
}
|
||||
.pack__badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 5rpx 16rpx;
|
||||
border-radius: 0 22rpx 0 20rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8, #e0a63f);
|
||||
}
|
||||
.pack__times {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.pack__num {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.pack--on .pack__num {
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.pack__unit {
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.pack--on .pack__unit {
|
||||
color: rgba(255, 233, 184, 0.7);
|
||||
}
|
||||
.pack__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 4rpx;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.pack--on .pack__price {
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.pack__symbol {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pack__yuan {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pack__unit-price {
|
||||
font-size: 19rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.pack--on .pack__unit-price {
|
||||
color: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
.pack__valid {
|
||||
font-size: 19rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
.pack--on .pack__valid {
|
||||
color: rgba(255, 233, 184, 0.45);
|
||||
}
|
||||
|
||||
/* ---- 须知 ---- */
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
.notice:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.notice__dot {
|
||||
flex-shrink: 0;
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: #e0a63f;
|
||||
}
|
||||
.notice__text {
|
||||
flex: 1;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
/* ---- 底部购买条 ---- */
|
||||
.buy {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-top: 2rpx solid #ffe9dd;
|
||||
box-shadow: 0 -10rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.buy__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.buy__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: #fb4e14;
|
||||
}
|
||||
.buy__symbol {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.buy__num {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.buy__hint {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
</style>
|
||||
502
src/pkg-member/quota/my.vue
Normal file
502
src/pkg-member/quota/my.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 我的额度(总览页)。
|
||||
*
|
||||
* 把「免费 / 会员 / 次数包」三个池子摊开给用户看 —— 额度体系最容易引起客诉的
|
||||
* 不是「扣了」,而是「不知道为什么扣、还剩多少」。所以这里按工具逐条列出:
|
||||
* 免费用了几个、会员额度剩多少、次数包还有几次、什么时候到期。
|
||||
*/
|
||||
import type { ToolQuota } from '@/api/quota'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuotaStore } from '@/stores/quota'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { heroPadTop } from '@/utils/safearea'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const quotaStore = useQuotaStore()
|
||||
const heroPad = heroPadTop()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const tools = computed<ToolQuota[]>(() => quotaStore.mine?.tools || [])
|
||||
const periodDays = computed(() => quotaStore.periodDays)
|
||||
const isMember = computed(() => !!quotaStore.mine?.is_member)
|
||||
const levelName = computed(() => quotaStore.mine?.level_name || '')
|
||||
const totalLeft = computed(() => quotaStore.mine?.total_left ?? 0)
|
||||
|
||||
/** 三池合计,用于头区概览 */
|
||||
const agg = computed(() => ({
|
||||
free: tools.value.reduce((s, t) => s + t.free_left, 0),
|
||||
member: tools.value.reduce((s, t) => s + t.member_left, 0),
|
||||
paid: tools.value.reduce((s, t) => s + t.paid_left, 0),
|
||||
}))
|
||||
|
||||
const stateText = computed(() => {
|
||||
if (isMember.value) {
|
||||
const expire = quotaStore.mine?.level_expire_at
|
||||
return `${levelName.value}会员${expire ? ` · ${expire} 到期` : ''}`
|
||||
}
|
||||
return '未开通会员 · 仅享免费额度'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.ensureLogin()
|
||||
await quotaStore.fetchMine()
|
||||
}
|
||||
catch {
|
||||
/* 错误已由 request 层统一提示 */
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function goMember() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
function goPack(toolKey: string) {
|
||||
uni.navigateTo({ url: `/pkg-member/quota/index?tool_key=${toolKey}` })
|
||||
}
|
||||
|
||||
function sourceText(t: ToolQuota): string {
|
||||
switch (t.source) {
|
||||
case 'free':
|
||||
return `下次扣免费额度`
|
||||
case 'member':
|
||||
return t.is_member ? '下次扣会员额度' : '开通会员解锁会员额度'
|
||||
case 'paid':
|
||||
return `每次消耗 ${t.pay_cost} 次次数包`
|
||||
default:
|
||||
return t.message || '本周期额度已用完'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 头区:深炭金 -->
|
||||
<view class="hero" :style="{ paddingTop: heroPad }">
|
||||
<view class="nav">
|
||||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||||
<wd-icon name="arrow-left" size="36rpx" color="#FFE9B8" />
|
||||
</view>
|
||||
<text class="nav__title">
|
||||
我的额度
|
||||
</text>
|
||||
<view class="nav__link press" hover-class="press-active" @tap="goMember">
|
||||
<text>{{ isMember ? '续费' : '开通会员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="hero__card">
|
||||
<view class="hero__glow" />
|
||||
<view class="hero__row">
|
||||
<view class="hero__meta">
|
||||
<text class="hero__label">
|
||||
本周期可用
|
||||
</text>
|
||||
<view class="hero__val">
|
||||
<text class="hero__num">
|
||||
{{ totalLeft }}
|
||||
</text>
|
||||
<text class="hero__unit">
|
||||
次
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero__cycle">
|
||||
<text class="hero__cycle-title">
|
||||
周期 {{ periodDays }} 天
|
||||
</text>
|
||||
<text class="hero__cycle-sub">
|
||||
到期自动重置
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero__pools">
|
||||
<view class="hpool">
|
||||
<text class="hpool__val">
|
||||
{{ agg.free }}
|
||||
</text>
|
||||
<text class="hpool__label">
|
||||
免费额度
|
||||
</text>
|
||||
</view>
|
||||
<view class="hpool">
|
||||
<text class="hpool__val">
|
||||
{{ isMember ? agg.member : '—' }}
|
||||
</text>
|
||||
<text class="hpool__label">
|
||||
会员额度
|
||||
</text>
|
||||
</view>
|
||||
<view class="hpool">
|
||||
<text class="hpool__val">
|
||||
{{ agg.paid }}
|
||||
</text>
|
||||
<text class="hpool__label">
|
||||
次数包
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hero__state">
|
||||
{{ stateText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
<view class="card sec">
|
||||
<view class="sec__head">
|
||||
<text class="sec__title">
|
||||
按工具明细
|
||||
</text>
|
||||
<text class="sec__hint">
|
||||
扣减顺序:免费 → 会员 → 次数包
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="hint">
|
||||
正在加载额度…
|
||||
</view>
|
||||
<view v-else-if="!tools.length" class="hint">
|
||||
暂无额度数据,请稍后重试
|
||||
</view>
|
||||
<view v-else class="list">
|
||||
<view v-for="t in tools" :key="t.tool_key" class="row">
|
||||
<view class="row__top">
|
||||
<text class="row__name">
|
||||
{{ t.name }}
|
||||
</text>
|
||||
<text class="row__left" :class="{ 'row__left--off': !t.can_use }">
|
||||
{{ t.can_use ? `剩 ${t.total_left} 次` : '已用完' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="row__pools">
|
||||
<view class="rpool">
|
||||
<text class="rpool__label">
|
||||
免费
|
||||
</text>
|
||||
<text class="rpool__val">
|
||||
{{ t.free_left }} / {{ t.free_total }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="rpool">
|
||||
<text class="rpool__label">
|
||||
会员
|
||||
</text>
|
||||
<text class="rpool__val">
|
||||
{{ t.is_member ? `${t.member_left} / ${t.member_total}` : '未开通' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="rpool">
|
||||
<text class="rpool__label">
|
||||
次数包
|
||||
</text>
|
||||
<text class="rpool__val">
|
||||
{{ t.paid_left > 0 ? `${t.paid_left} 次` : '无' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="row__bottom">
|
||||
<text class="row__note">
|
||||
{{ sourceText(t) }}
|
||||
</text>
|
||||
<view class="row__buy press" hover-class="press-active" @tap="goPack(t.tool_key)">
|
||||
<text>购买次数包</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="t.paid_left > 0 && t.paid_expire_at" class="row__expire">
|
||||
次数包有效期至 {{ t.paid_expire_at }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="foot" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 0 32rpx 96rpx;
|
||||
background: linear-gradient(160deg, #2b1a10 0%, #4a2c16 55%, #2b1a10 100%);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8rpx 0 20rpx;
|
||||
}
|
||||
.nav__back {
|
||||
flex-shrink: 0;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.nav__link {
|
||||
flex-shrink: 0;
|
||||
height: 60rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 23rpx;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
|
||||
.hero__card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 14rpx;
|
||||
padding: 28rpx 26rpx 24rpx;
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.42);
|
||||
}
|
||||
.hero__glow {
|
||||
position: absolute;
|
||||
top: -120rpx;
|
||||
right: -80rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(245, 197, 107, 0.26) 0%, rgba(245, 197, 107, 0) 70%);
|
||||
}
|
||||
.hero__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.hero__label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 233, 184, 0.6);
|
||||
}
|
||||
.hero__val {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.hero__num {
|
||||
font-size: 60rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
background: linear-gradient(180deg, #fff6e0 0%, #f5c56b 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero__unit {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 233, 184, 0.7);
|
||||
}
|
||||
.hero__cycle {
|
||||
text-align: right;
|
||||
}
|
||||
.hero__cycle-title {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.hero__cycle-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 233, 184, 0.5);
|
||||
}
|
||||
.hero__pools {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.hpool {
|
||||
flex: 1;
|
||||
padding: 14rpx 10rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(245, 197, 107, 0.1);
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.hpool__val {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #ffe9b8;
|
||||
}
|
||||
.hpool__label {
|
||||
font-size: 19rpx;
|
||||
color: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
.hero__state {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 21rpx;
|
||||
color: rgba(255, 233, 184, 0.55);
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
margin-top: -64rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid rgba(245, 197, 107, 0.34);
|
||||
border-radius: 32rpx;
|
||||
padding: 26rpx 26rpx 22rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
|
||||
}
|
||||
.sec__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.sec__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.sec__hint {
|
||||
font-size: 20rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.hint {
|
||||
text-align: center;
|
||||
padding: 50rpx 0;
|
||||
font-size: 25rpx;
|
||||
color: #9a7b65;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.row {
|
||||
padding: 22rpx 22rpx 20rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #fff9f2;
|
||||
border: 2rpx solid #ffe3d3;
|
||||
}
|
||||
.row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.row__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.row__left {
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #e8531a;
|
||||
}
|
||||
.row__left--off {
|
||||
color: #b3907c;
|
||||
}
|
||||
.row__pools {
|
||||
display: flex;
|
||||
gap: 10rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.rpool {
|
||||
flex: 1;
|
||||
padding: 12rpx 10rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #ffe9dd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.rpool__label {
|
||||
font-size: 18rpx;
|
||||
color: #b3907c;
|
||||
}
|
||||
.rpool__val {
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2a1e;
|
||||
}
|
||||
.row__bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.row__note {
|
||||
flex: 1;
|
||||
font-size: 21rpx;
|
||||
line-height: 1.45;
|
||||
color: #9a7b65;
|
||||
}
|
||||
.row__buy {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
color: #3d2413;
|
||||
background: linear-gradient(135deg, #ffe9b8 0%, #f5c56b 55%, #e0a63f 100%);
|
||||
box-shadow: 0 8rpx 18rpx rgba(224, 166, 63, 0.28);
|
||||
}
|
||||
.row__expire {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #c39a80;
|
||||
}
|
||||
|
||||
.foot {
|
||||
height: 40rpx;
|
||||
}
|
||||
</style>
|
||||
85
src/stores/quota.ts
Normal file
85
src/stores/quota.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { QuotaMy, ToolQuota } from '@/api/quota'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { apiQuotaMy, apiQuotaTool } from '@/api/quota'
|
||||
|
||||
/**
|
||||
* 额度状态。
|
||||
*
|
||||
* 只缓存「轻量、会被多处复用」的部分:
|
||||
* - mine:我的额度总览(额度页、会员中心摘要用)
|
||||
* - toolMap:单工具额度(工具页额度条、悬浮广告、用尽弹层共用一份,避免多次请求)
|
||||
* 重量级列表(11 个工具)由 /tools/list 一次性带回,不在这里重复请求。
|
||||
*/
|
||||
export const useQuotaStore = defineStore('quota', () => {
|
||||
const mine = ref<QuotaMy | null>(null)
|
||||
const toolMap = ref<Record<string, ToolQuota>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
/** 当前生效的额度重置周期(天) */
|
||||
const periodDays = computed(() => mine.value?.period_days ?? 7)
|
||||
const isMember = computed(() => mine.value?.is_member ?? false)
|
||||
/** 全部工具合计剩余次数 */
|
||||
const totalLeft = computed(() => mine.value?.total_left ?? 0)
|
||||
|
||||
function applyTool(tool: ToolQuota | null | undefined) {
|
||||
if (!tool || !tool.tool_key)
|
||||
return
|
||||
toolMap.value = { ...toolMap.value, [tool.tool_key]: tool }
|
||||
}
|
||||
|
||||
/** 拉取总览(含各工具额度) */
|
||||
async function fetchMine() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await apiQuotaMy()
|
||||
mine.value = data
|
||||
const next = { ...toolMap.value }
|
||||
for (const tool of data.tools)
|
||||
next[tool.tool_key] = tool
|
||||
toolMap.value = next
|
||||
return data
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取单个工具额度 */
|
||||
async function fetchTool(toolKey: string) {
|
||||
const res = await apiQuotaTool(toolKey)
|
||||
applyTool(res.tool)
|
||||
return res.tool
|
||||
}
|
||||
|
||||
/** 取缓存;没有则请求一次 */
|
||||
async function ensureTool(toolKey: string): Promise<ToolQuota | null> {
|
||||
if (toolMap.value[toolKey])
|
||||
return toolMap.value[toolKey]
|
||||
try {
|
||||
return await fetchTool(toolKey)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mine.value = null
|
||||
toolMap.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
mine,
|
||||
toolMap,
|
||||
loading,
|
||||
periodDays,
|
||||
isMember,
|
||||
totalLeft,
|
||||
applyTool,
|
||||
fetchMine,
|
||||
fetchTool,
|
||||
ensureTool,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -121,6 +121,17 @@ export const useUserStore = defineStore('user', () => {
|
||||
user.value = { ...user.value, nickname, avatar_url: avatarUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新拉取等级与用户信息(购买会员 / 兑换成功后调用)。
|
||||
* 与 ensureLogin 不同,这里不做「已登录就短路」,强制刷新以拿到新的模块权限。
|
||||
*/
|
||||
async function refreshLevel(): Promise<void> {
|
||||
const [lv, u] = await Promise.all([apiGetLevel(), apiGetUserInfo().catch(() => null)])
|
||||
level.value = lv
|
||||
if (u)
|
||||
user.value = u
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
uni.removeStorageSync(USER_KEY)
|
||||
@@ -143,6 +154,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
loginAccount,
|
||||
registerAccount,
|
||||
updateProfile,
|
||||
refreshLevel,
|
||||
loginWx,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ExportType, LoadResult, PickedImage } from './types'
|
||||
* 平台差异通过「平台分函数 + 条件编译」隔离,避免同一作用域重复声明导致 type-check 失败。
|
||||
*/
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { consumeQuota, lastConsumedQuota } from '@/utils/quota'
|
||||
|
||||
const MAX_EDGE_SAFE = 8000 // 超大图预检上限
|
||||
|
||||
@@ -287,8 +288,16 @@ function saveResultH5(path: string, fileName: string): void {
|
||||
uni.showToast({ title: '已开始下载', icon: 'none' })
|
||||
}
|
||||
|
||||
/** 保存结果:MP 存相册(含授权引导),H5 触发下载 */
|
||||
/**
|
||||
* 保存结果:MP 存相册(含授权引导),H5 触发下载。
|
||||
*
|
||||
* 这里同时是「产出结果」的统一收口:11 个工具页的导出/保存都走它,
|
||||
* 所以额度扣减挂在这里 —— 用户只是看看没产出就不扣,真产出(保存)才扣,
|
||||
* 而且不需要逐页改造,也不会有人漏接。
|
||||
* 若额度已用完,consumeQuota 会抛出 QuotaExhaustedError,本次保存不执行。
|
||||
*/
|
||||
export async function saveResult(path: string, fileName: string): Promise<void> {
|
||||
await chargeBeforeSave()
|
||||
// #ifdef MP-WEIXIN
|
||||
return saveResultMP(path)
|
||||
// #endif
|
||||
@@ -297,6 +306,17 @@ export async function saveResult(path: string, fileName: string): Promise<void>
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 产出前扣减额度。
|
||||
* 九宫格一次导出 9 张、压缩「保存全部」一次存 N 张,会连续走到这里,
|
||||
* 由 utils/quota.ts 的合并窗口保证同一批只扣一次。
|
||||
*/
|
||||
async function chargeBeforeSave(): Promise<void> {
|
||||
const quota = await consumeQuota()
|
||||
if (quota)
|
||||
lastConsumedQuota.value = quota
|
||||
}
|
||||
|
||||
/** 按扩展名推断导出格式 */
|
||||
export function typeByExt(path: string): ExportType {
|
||||
return /\.png$/i.test(path) ? 'png' : 'jpg'
|
||||
|
||||
@@ -16,6 +16,9 @@ export async function runTask<T>(title: string, fn: () => Promise<T>): Promise<T
|
||||
return out
|
||||
}
|
||||
catch (e) {
|
||||
// 额度用尽:core.ts 已经弹了「次数用完」的引导层,这里不要再用 toast 叠一层
|
||||
if ((e as Error)?.name === 'QuotaExhaustedError')
|
||||
return null
|
||||
const msg = (e as Error)?.message || '处理失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
return null
|
||||
|
||||
79
src/utils/member-pay.ts
Normal file
79
src/utils/member-pay.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { MemberState } from '@/api/member'
|
||||
import { apiMemberOrder, apiMemberOrderCheck } from '@/api/member'
|
||||
import { checkPaySupport, requestVirtualPayment, VirtualPayError } from '@/utils/vpay'
|
||||
|
||||
/**
|
||||
* 会员购买闭环编排(会员中心页与「功能未解锁」引导共用)
|
||||
*
|
||||
* 流程:终端能力校验 → wx.login 取 code → 服务端下单(返回签名后的 payData)
|
||||
* → 拉起 wx.requestVirtualPayment → 轮询自己服务器的订单状态确认发货
|
||||
*
|
||||
* 注意:平台 success 回调可能丢失,绝不能据此发货;发货由服务端「发货推送 + query_order 查单兜底」
|
||||
* 完成,前端查询接口本身也会触发一次兜底查单,所以这里轮询几次即可拿到结果。
|
||||
*/
|
||||
|
||||
export interface BuyMembershipResult {
|
||||
delivered: boolean
|
||||
state?: MemberState
|
||||
}
|
||||
|
||||
/** 轮询次数与间隔:发货推送通常在秒级到达 */
|
||||
const POLL_TIMES = 5
|
||||
const POLL_INTERVAL_MS = 1200
|
||||
/** 前端支付流程交互超时(毫秒),用于防止 promise 悬挂 */
|
||||
export const PAY_TIMEOUT_MS = 8000
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 取 wx.login 的临时凭证(服务端用它换 session_key 生成用户态签名) */
|
||||
function fetchLoginCode(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res.code)
|
||||
resolve(res.code)
|
||||
else
|
||||
reject(new VirtualPayError('获取微信登录凭证失败,请稍后重试'))
|
||||
},
|
||||
fail: err => reject(new VirtualPayError(err.errMsg || '获取微信登录凭证失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买会员套餐。抛出的错误统一为 VirtualPayError,message 可直接展示。
|
||||
* delivered=false 表示支付流程走完但服务端尚未确认发货(少见,可稍后在订单页查看)。
|
||||
*/
|
||||
export async function buyMembership(planKey: string): Promise<BuyMembershipResult> {
|
||||
const support = checkPaySupport()
|
||||
if (!support.ok)
|
||||
throw new VirtualPayError(support.reason || '当前环境不支持购买')
|
||||
|
||||
const code = await fetchLoginCode()
|
||||
const payData = await apiMemberOrder(planKey, code)
|
||||
|
||||
await requestVirtualPayment({
|
||||
signData: payData.signData,
|
||||
paySig: payData.paySig,
|
||||
signature: payData.signature,
|
||||
mode: payData.mode,
|
||||
})
|
||||
|
||||
let last: Awaited<ReturnType<typeof apiMemberOrderCheck>> | null = null
|
||||
for (let i = 0; i < POLL_TIMES; i++) {
|
||||
last = await apiMemberOrderCheck(payData.out_trade_no)
|
||||
if (last.delivered)
|
||||
return { delivered: true, state: last.state }
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
return { delivered: false, state: last?.state }
|
||||
}
|
||||
|
||||
/** 分 → 元(保留两位,去掉无意义的小数位) */
|
||||
export function fenToYuan(fen: number): string {
|
||||
const yuan = fen / 100
|
||||
return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2)
|
||||
}
|
||||
88
src/utils/quota-pay.ts
Normal file
88
src/utils/quota-pay.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { QuotaPayData } from '@/api/quota'
|
||||
import { apiMemberOrderCheck } from '@/api/member'
|
||||
import { apiQuotaOrder } from '@/api/quota'
|
||||
import { checkPaySupport, requestVirtualPayment, VirtualPayError } from '@/utils/vpay'
|
||||
|
||||
/**
|
||||
* 次数包购买闭环(与会员套餐完全同构,共用同一套订单与发货链路)
|
||||
*
|
||||
* 流程:终端能力校验 → wx.login 取 code → 服务端下单(返回签名后的 payData)
|
||||
* → 拉起 wx.requestVirtualPayment → 轮询自己服务器的订单状态确认发货
|
||||
*
|
||||
* 与买会员的唯一区别:订单是「按工具绑定」的,所以 pack_key 之外还要带 tool_key;
|
||||
* 发货由服务端 grantToolQuota 完成(顺延有效期),前端只轮询,绝不自行发放。
|
||||
*/
|
||||
|
||||
export interface BuyQuotaPackResult {
|
||||
delivered: boolean
|
||||
/** 支付流程已走完但服务端尚未确认发货(少见,可稍后在订单页查看) */
|
||||
pending: boolean
|
||||
packKey: string
|
||||
toolKey: string
|
||||
times: number
|
||||
}
|
||||
|
||||
/** 轮询次数与间隔:发货推送通常在秒级到达 */
|
||||
const POLL_TIMES = 5
|
||||
const POLL_INTERVAL_MS = 1200
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 取 wx.login 的临时凭证(服务端用它换 session_key 生成用户态签名) */
|
||||
function fetchLoginCode(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res.code)
|
||||
resolve(res.code)
|
||||
else
|
||||
reject(new VirtualPayError('获取微信登录凭证失败,请稍后重试'))
|
||||
},
|
||||
fail: err => reject(new VirtualPayError(err.errMsg || '获取微信登录凭证失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买次数包。抛出的错误统一为 VirtualPayError,message 可直接展示。
|
||||
*/
|
||||
export async function buyQuotaPack(packKey: string, toolKey: string): Promise<BuyQuotaPackResult> {
|
||||
const support = checkPaySupport()
|
||||
if (!support.ok)
|
||||
throw new VirtualPayError(support.reason || '当前环境不支持购买')
|
||||
|
||||
const code = await fetchLoginCode()
|
||||
const payData: QuotaPayData = await apiQuotaOrder(packKey, toolKey, code)
|
||||
|
||||
await requestVirtualPayment({
|
||||
signData: payData.signData,
|
||||
paySig: payData.paySig,
|
||||
signature: payData.signature,
|
||||
mode: payData.mode,
|
||||
})
|
||||
|
||||
let last: Awaited<ReturnType<typeof apiMemberOrderCheck>> | null = null
|
||||
for (let i = 0; i < POLL_TIMES; i++) {
|
||||
last = await apiMemberOrderCheck(payData.out_trade_no)
|
||||
if (last.delivered) {
|
||||
return {
|
||||
delivered: true,
|
||||
pending: false,
|
||||
packKey: payData.pack_key,
|
||||
toolKey: payData.tool_key,
|
||||
times: payData.times,
|
||||
}
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
delivered: false,
|
||||
pending: true,
|
||||
packKey: payData.pack_key,
|
||||
toolKey: payData.tool_key,
|
||||
times: payData.times,
|
||||
}
|
||||
}
|
||||
170
src/utils/quota.ts
Normal file
170
src/utils/quota.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { ToolQuota } from '@/api/quota'
|
||||
import { ref } from 'vue'
|
||||
import { apiQuotaConsume, apiQuotaTool } from '@/api/quota'
|
||||
import { CODE_QUOTA_EXHAUSTED } from '@/api/request'
|
||||
import { TOOL_REGISTRY } from '@/config/tools'
|
||||
|
||||
/**
|
||||
* 工具额度的前端门面。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 扣减时机 = 「产出结果」。接入点是 utils/image/core.ts 的 saveResult(),
|
||||
* 全部 11 个工具页都走它,所以不需要逐页改造,也不会有人忘记接。
|
||||
* 2. 合并窗口:九宫格一次导出 9 张、压缩「保存全部」一次存 N 张,
|
||||
* 底层会连续调用 saveResult N 次。若按次扣减会对用户明显不公,
|
||||
* 因此同一工具在 COALESCE_WINDOW_MS 内的多次产出合并为 1 次扣减。
|
||||
* 想改成「按结果张数计费」,把 COALESCE_WINDOW_MS 设为 0 即可。
|
||||
* 3. 额度用尽时不静默失败:抛出 QuotaExhaustedError,并同时唤起购买引导弹层。
|
||||
*/
|
||||
|
||||
/** 同一工具多次产出合并为一次扣减的时间窗(毫秒) */
|
||||
const COALESCE_WINDOW_MS = 30_000
|
||||
|
||||
/** 额度用尽:带 code=4002 的错误,便于上层区分「额度不足」与「网络异常」 */
|
||||
export class QuotaExhaustedError extends Error {
|
||||
toolKey: string
|
||||
|
||||
constructor(message: string, toolKey: string) {
|
||||
super(message)
|
||||
this.name = 'QuotaExhaustedError'
|
||||
this.toolKey = toolKey
|
||||
}
|
||||
}
|
||||
|
||||
/** 额度用尽弹层的共享状态(由 VipDock 内的 QuotaGate 渲染) */
|
||||
export const quotaGateState = ref<{ open: boolean, toolKey: string, message: string }>({
|
||||
open: false,
|
||||
toolKey: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 最近一次扣减后的额度快照。
|
||||
* core.ts 里扣减是在工具页内部发生的,页面拿不到结果;
|
||||
* 用这个 ref 广播出去,让额度条 / 悬浮广告 / store 同步刷新。
|
||||
*/
|
||||
export const lastConsumedQuota = ref<ToolQuota | null>(null)
|
||||
|
||||
/** 已挂载的弹层宿主数量(没有宿主时退回系统模态框,保证引导不丢) */
|
||||
let gateHosts = 0
|
||||
|
||||
export function registerGateHost() {
|
||||
gateHosts++
|
||||
return () => {
|
||||
gateHosts--
|
||||
}
|
||||
}
|
||||
|
||||
/** 唤起额度用尽引导 */
|
||||
export function openQuotaGate(toolKey: string, message = '') {
|
||||
const name = TOOL_REGISTRY.find(t => t.key === toolKey)?.name || '该工具'
|
||||
const content = message || `「${name}」的可用次数已用完`
|
||||
if (gateHosts > 0) {
|
||||
quotaGateState.value = { open: true, toolKey, message: content }
|
||||
return
|
||||
}
|
||||
// 兜底:当前页面没挂 VipDock(比如从工作台直接进来的新工具页)
|
||||
uni.showModal({
|
||||
title: '可用次数已用完',
|
||||
content: `${content}\n开通会员可提升额度,也可以单独购买次数包继续使用。`,
|
||||
confirmText: '去开通',
|
||||
cancelText: '知道了',
|
||||
success: (res) => {
|
||||
if (res.confirm)
|
||||
uni.navigateTo({ url: `/pkg-member/quota/index?tool_key=${toolKey}` })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 当前页面路由对应的工具标识(工具页路径与注册表 path 一一对应) */
|
||||
export function currentToolKey(): string {
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1] as { route?: string } | undefined
|
||||
const route = (current && current.route) || ''
|
||||
if (!route)
|
||||
return ''
|
||||
const withSlash = `/${route}`
|
||||
return TOOL_REGISTRY.find(t => t.path === withSlash)?.key || ''
|
||||
}
|
||||
|
||||
/** 工具标识 → 注册表里的展示信息 */
|
||||
export function toolDefOf(toolKey: string) {
|
||||
return TOOL_REGISTRY.find(t => t.key === toolKey)
|
||||
}
|
||||
|
||||
/** 最近一次扣减时间:同一工具在窗口内只扣一次 */
|
||||
const lastConsumedAt: Record<string, number> = {}
|
||||
|
||||
/**
|
||||
* 产出结果时扣减一次额度。
|
||||
*
|
||||
* @param toolKey 不传则从当前页面路由推导(工具页无需关心自己叫什么)
|
||||
* @param force true = 忽略合并窗口,立即扣减
|
||||
* @returns 扣减后的额度快照;命中合并窗口时返回 null(表示本次不再扣减)
|
||||
*/
|
||||
export async function consumeQuota(toolKey?: string, force = false): Promise<ToolQuota | null> {
|
||||
const key = toolKey || currentToolKey()
|
||||
if (!key)
|
||||
return null
|
||||
|
||||
const now = Date.now()
|
||||
if (!force && COALESCE_WINDOW_MS > 0 && lastConsumedAt[key] && now - lastConsumedAt[key] < COALESCE_WINDOW_MS) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiQuotaConsume(key)
|
||||
lastConsumedAt[key] = now
|
||||
return res.tool
|
||||
}
|
||||
catch (e) {
|
||||
const err = e as Error & { code?: number }
|
||||
if (err?.code === CODE_QUOTA_EXHAUSTED) {
|
||||
// 先记时间戳再抛错:让用户点「继续」时不至于连环弹窗
|
||||
lastConsumedAt[key] = now
|
||||
openQuotaGate(key, err.message)
|
||||
throw new QuotaExhaustedError(err.message || '可用次数已用完', key)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询单个工具的额度(进入工具页时用) */
|
||||
export async function fetchToolQuota(toolKey?: string): Promise<ToolQuota | null> {
|
||||
const key = toolKey || currentToolKey()
|
||||
if (!key)
|
||||
return null
|
||||
try {
|
||||
const res = await apiQuotaTool(key)
|
||||
return res.tool
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 额度用尽时的第二 CTA:去会员中心 */
|
||||
export function goMemberCenter() {
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
}
|
||||
|
||||
/** 额度用尽时的第一 CTA:去给「当前工具」买次数包 */
|
||||
export function goBuyPack(toolKey: string) {
|
||||
uni.navigateTo({ url: `/pkg-member/quota/index?tool_key=${toolKey}` })
|
||||
}
|
||||
|
||||
/** 额度来源 → 展示文案 */
|
||||
export function quotaSourceText(q: ToolQuota | null | undefined): string {
|
||||
if (!q)
|
||||
return ''
|
||||
switch (q.source) {
|
||||
case 'free':
|
||||
return `免费额度剩 ${q.free_left} 次`
|
||||
case 'member':
|
||||
return `会员额度剩 ${q.member_left} 次`
|
||||
case 'paid':
|
||||
return `次数包剩 ${q.paid_left} 次`
|
||||
default:
|
||||
return '次数已用完'
|
||||
}
|
||||
}
|
||||
30
src/utils/unlock-guide.ts
Normal file
30
src/utils/unlock-guide.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useToolsStore } from '@/stores/tools'
|
||||
|
||||
/**
|
||||
* 「功能未解锁」的可操作引导。
|
||||
*
|
||||
* 旧文案是「请联系管理员升级」—— 用户没有任何可执行动作,只能离场。
|
||||
* 现在改为展示「开通会员可解锁哪些模块」,并直接给出去开通的入口。
|
||||
* 展示内容由 /tools/list 返回的模块信息动态生成(enabled_tools_count 即该模块的工具数),不硬编码。
|
||||
*/
|
||||
export function showUnlockGuide() {
|
||||
const toolsStore = useToolsStore()
|
||||
const lockedLines = toolsStore.modules
|
||||
.filter(m => m.locked && m.enabled_tools_count > 0)
|
||||
.map(m => `· ${m.name}(${m.enabled_tools_count} 个工具)`)
|
||||
|
||||
const content = lockedLines.length
|
||||
? `开通会员即可解锁:\n${lockedLines.join('\n')}`
|
||||
: '该功能属于更高等级模块,开通会员后即可解锁使用。'
|
||||
|
||||
uni.showModal({
|
||||
title: '该功能需要更高等级',
|
||||
content,
|
||||
confirmText: '去开通',
|
||||
cancelText: '再看看',
|
||||
success: (res) => {
|
||||
if (res.confirm)
|
||||
uni.navigateTo({ url: '/pkg-member/member/index' })
|
||||
},
|
||||
})
|
||||
}
|
||||
168
src/utils/vpay.ts
Normal file
168
src/utils/vpay.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 微信小程序虚拟支付(道具直购)客户端封装
|
||||
*
|
||||
* 官方 API:https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestVirtualPayment.html
|
||||
* 接入指引:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/person
|
||||
*
|
||||
* 说明:
|
||||
* - signData / paySig / signature 全部由服务端生成,前端只负责透传,绝不自行拼装或签名
|
||||
* - 支付成功的 success 回调可能丢失(用户异常退出),因此**不能**作为发货依据,
|
||||
* 发货由服务端「发货推送 + 查单兜底」完成,前端只轮询自己服务器的订单状态
|
||||
* - 这里用运行时能力探测(而非条件编译)来隔离平台差异:H5 / 其他小程序端拿不到
|
||||
* wx 全局对象时直接给出可读提示,同时避免 vue-tsc 看到重复声明
|
||||
*/
|
||||
|
||||
/** 微信侧可用能力的结构化描述(只声明本项目用到的部分) */
|
||||
interface WxLike {
|
||||
requestVirtualPayment?: (options: Record<string, unknown>) => void
|
||||
getSystemInfoSync?: () => { platform?: string, version?: string }
|
||||
}
|
||||
|
||||
export interface VirtualPayParams {
|
||||
signData: string
|
||||
paySig: string
|
||||
signature: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface PaySupport {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** 虚拟支付错误:区分「用户取消」场景,并可携带需要完整展示的排查说明 */
|
||||
export class VirtualPayError extends Error {
|
||||
errCode?: number
|
||||
|
||||
/** 用户主动关闭收银台:调用方应静默处理,不要弹错误提示 */
|
||||
cancelled = false
|
||||
|
||||
/**
|
||||
* 排查说明(比 toast 能容纳的长得多)。
|
||||
* 有值时调用方建议用 showModal 展示,而不是 showToast。
|
||||
*/
|
||||
detail?: string
|
||||
|
||||
constructor(message: string, errCode?: number) {
|
||||
super(message)
|
||||
this.name = 'VirtualPayError'
|
||||
this.errCode = errCode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台错误标识 → 展示文案。
|
||||
*
|
||||
* wx.requestVirtualPayment 的 fail 回调形如
|
||||
* { errCode: -15010, errMsg: 'requestVirtualPayment:fail PRODUCT_ID_NOT_PUBLISH' }
|
||||
* 其中 `:fail ` 之后的首个词是稳定可判定的标识,而 errCode 会随端和版本变化,
|
||||
* 因此这里按标识匹配;未收录的标识回落展示原始 errMsg,保证新错误不被吞掉。
|
||||
*
|
||||
* 已核实:PRODUCT_ID_NOT_PUBLISH 表示「平台按该 productId 找不到已发布的道具」,
|
||||
* 常见于道具未发布,或道具 ID 与配置大小写/名称不一致(微信开放社区同报错案例)。
|
||||
*/
|
||||
const PLATFORM_HINTS: Record<string, { message: string, detail?: string }> = {
|
||||
PRODUCT_ID_NOT_PUBLISH: {
|
||||
message: '该套餐暂时无法购买',
|
||||
detail: '微信侧未找到已发布的对应道具。请确认【虚拟支付 → 道具管理】中的道具已「发布」(发布后约 10 分钟生效),且道具 ID 与套餐配置完全一致(区分大小写)。',
|
||||
},
|
||||
PRODUCT_ID_NOT_EXIST: {
|
||||
message: '该套餐暂时无法购买',
|
||||
detail: '微信侧不存在该道具 ID。请核对【虚拟支付 → 道具管理】中的道具 ID 与套餐配置是否完全一致(区分大小写)。',
|
||||
},
|
||||
}
|
||||
|
||||
/** 取平台错误标识:`:fail ` 之后的首个词,统一大写 */
|
||||
function platformToken(errMsg: string): string {
|
||||
const marker = ':fail'
|
||||
const at = errMsg.indexOf(marker)
|
||||
const tail = (at >= 0 ? errMsg.slice(at + marker.length) : errMsg).trim()
|
||||
return (tail.split(/[\s,;]/)[0] || '').toUpperCase()
|
||||
}
|
||||
|
||||
/** 用户主动取消:各端文案不一(cancel / user cancel / USER_CANCEL),统一按关键字识别 */
|
||||
function isCancelMessage(errMsg: string): boolean {
|
||||
const token = platformToken(errMsg)
|
||||
return token === 'CANCEL' || token.endsWith('_CANCEL') || /\bcancel\b/i.test(errMsg)
|
||||
}
|
||||
|
||||
/** 把平台原始失败信息转成可直接展示的 VirtualPayError */
|
||||
export function toVirtualPayError(err?: { errMsg?: string, errCode?: number }): VirtualPayError {
|
||||
const raw = err?.errMsg || ''
|
||||
const token = platformToken(raw)
|
||||
const hint = PLATFORM_HINTS[token]
|
||||
const result = new VirtualPayError(hint?.message || raw || '支付未完成', err?.errCode)
|
||||
result.cancelled = isCancelMessage(raw)
|
||||
result.detail = hint?.detail
|
||||
// 原始信息保留在控制台,便于联调定位(未收录的标识也靠它暴露)
|
||||
if (raw)
|
||||
console.warn(`[vpay] 平台支付失败: ${raw}`)
|
||||
return result
|
||||
}
|
||||
|
||||
/** iOS 端最低微信客户端版本(官方要求 8.0.68) */
|
||||
const IOS_MIN_VERSION = [8, 0, 68] as const
|
||||
|
||||
function getWx(): WxLike | undefined {
|
||||
// typeof 守卫对未声明的全局变量也是安全的(不会抛 ReferenceError)
|
||||
if (typeof wx === 'undefined')
|
||||
return undefined
|
||||
return wx as unknown as WxLike
|
||||
}
|
||||
|
||||
/** 当前环境是否为微信小程序 */
|
||||
export function isWeixinMiniProgram(): boolean {
|
||||
return !!getWx()?.requestVirtualPayment
|
||||
}
|
||||
|
||||
function compareVersion(cur: number[], base: readonly number[]): number {
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const c = cur[i] || 0
|
||||
const b = base[i] || 0
|
||||
if (c > b)
|
||||
return 1
|
||||
if (c < b)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 支付前校验:终端能力 + iOS 微信版本。不通过时 reason 可直接展示给用户。 */
|
||||
export function checkPaySupport(): PaySupport {
|
||||
const api = getWx()
|
||||
if (!api?.requestVirtualPayment) {
|
||||
return { ok: false, reason: '请在微信小程序内购买(当前环境不支持微信虚拟支付)' }
|
||||
}
|
||||
try {
|
||||
const info = api.getSystemInfoSync?.() || {}
|
||||
if ((info.platform || '').toLowerCase() === 'ios') {
|
||||
const cur = String(info.version || '').split('.').map(n => Number.parseInt(n, 10) || 0)
|
||||
if (compareVersion(cur, IOS_MIN_VERSION) < 0)
|
||||
return { ok: false, reason: 'iOS 端需微信 8.0.68 及以上版本,请更新微信后再购买' }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// 取不到机型/版本信息时放行,交由平台侧最终校验
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** 拉起虚拟支付。resolve 仅代表「平台侧支付流程走完」,不代表已发货。 */
|
||||
export function requestVirtualPayment(params: VirtualPayParams): Promise<void> {
|
||||
const api = getWx()?.requestVirtualPayment
|
||||
if (!api) {
|
||||
return Promise.reject(new VirtualPayError('请在微信小程序内购买(当前环境不支持微信虚拟支付)'))
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
api({
|
||||
signData: params.signData,
|
||||
paySig: params.paySig,
|
||||
signature: params.signature,
|
||||
mode: params.mode,
|
||||
success: () => resolve(),
|
||||
fail: (err: { errMsg?: string, errCode?: number }) => {
|
||||
reject(toVirtualPayError(err))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user