小程序虚拟支付

This commit is contained in:
李琦
2026-09-17 17:39:06 +08:00
parent 83a7ca7a1e
commit 515f1ce405
39 changed files with 4700 additions and 13 deletions

5
components.d.ts vendored
View File

@@ -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
View 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
View 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 })
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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(() => {})

View File

@@ -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;

View File

@@ -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">

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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"]
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 })

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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
View 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
View 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,
}
})

View File

@@ -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,
}

View File

@@ -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'

View File

@@ -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
View 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
View 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
View 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
View 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
View 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))
},
})
})
}