From 515f1ce4050e143dac33a0150bfeaba6f1be01e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 17 Sep 2026 17:39:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 5 + src/api/member.ts | 108 ++++ src/api/quota.ts | 99 +++ src/api/request.ts | 16 +- src/api/user.ts | 3 + src/components/home/PaneLibrary.vue | 7 +- src/components/home/PaneMine.vue | 29 + src/components/home/PaneWorkbench.vue | 13 +- src/components/vip/QuotaBar.vue | 310 +++++++++ src/components/vip/QuotaGate.vue | 319 ++++++++++ src/components/vip/VipCard.vue | 280 ++++++++ src/components/vip/VipDock.vue | 56 ++ src/components/vip/VipFloatAd.vue | 373 +++++++++++ src/pages.json | 39 ++ src/pages/tools/image/annotate.vue | 3 + src/pages/tools/image/avatarfx.vue | 3 + src/pages/tools/image/collage.vue | 3 + src/pages/tools/image/compress.vue | 3 + src/pages/tools/image/convert.vue | 3 + src/pages/tools/image/crop.vue | 3 + src/pages/tools/image/grid9.vue | 3 + src/pages/tools/image/index.vue | 3 +- src/pages/tools/image/matting.vue | 3 + src/pages/tools/image/qrcode.vue | 3 + src/pages/tools/image/resize.vue | 3 + src/pages/tools/image/watermark.vue | 3 + src/pkg-member/member/index.vue | 880 ++++++++++++++++++++++++++ src/pkg-member/member/orders.vue | 326 ++++++++++ src/pkg-member/quota/index.vue | 655 +++++++++++++++++++ src/pkg-member/quota/my.vue | 502 +++++++++++++++ src/stores/quota.ts | 85 +++ src/stores/user.ts | 12 + src/utils/image/core.ts | 22 +- src/utils/image/task.ts | 3 + src/utils/member-pay.ts | 79 +++ src/utils/quota-pay.ts | 88 +++ src/utils/quota.ts | 170 +++++ src/utils/unlock-guide.ts | 30 + src/utils/vpay.ts | 168 +++++ 39 files changed, 4700 insertions(+), 13 deletions(-) create mode 100644 src/api/member.ts create mode 100644 src/api/quota.ts create mode 100644 src/components/vip/QuotaBar.vue create mode 100644 src/components/vip/QuotaGate.vue create mode 100644 src/components/vip/VipCard.vue create mode 100644 src/components/vip/VipDock.vue create mode 100644 src/components/vip/VipFloatAd.vue create mode 100644 src/pkg-member/member/index.vue create mode 100644 src/pkg-member/member/orders.vue create mode 100644 src/pkg-member/quota/index.vue create mode 100644 src/pkg-member/quota/my.vue create mode 100644 src/stores/quota.ts create mode 100644 src/utils/member-pay.ts create mode 100644 src/utils/quota-pay.ts create mode 100644 src/utils/quota.ts create mode 100644 src/utils/unlock-guide.ts create mode 100644 src/utils/vpay.ts diff --git a/components.d.ts b/components.d.ts index 37b874e..ad18355 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/src/api/member.ts b/src/api/member.ts new file mode 100644 index 0000000..48ec586 --- /dev/null +++ b/src/api/member.ts @@ -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('/member/center') +} + +/** + * 创建会员订单。 + * code 来自 wx.login,服务端用它换 session_key 生成用户态签名,并校验支付人与登录人一致。 + */ +export function apiMemberOrder(planKey: string, code: string) { + return post('/member/order', { plan_key: planKey, code }) +} + +/** 查询订单状态(前端支付成功回调后轮询;服务端同时做查单兜底发货) */ +export function apiMemberOrderCheck(outTradeNo: string) { + return post('/member/order/check', { out_trade_no: outTradeNo }) +} + +/** 我的会员订单(含次数包订单,用 order_type 区分) */ +export function apiMemberOrders(limit = 20) { + return get<{ list: MemberOrder[] }>('/member/orders', { limit }) +} diff --git a/src/api/quota.ts b/src/api/quota.ts new file mode 100644 index 0000000..16a056e --- /dev/null +++ b/src/api/quota.ts @@ -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('/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('/quota/order', { pack_key: packKey, tool_key: toolKey, code }) +} diff --git a/src/api/request.ts b/src/api/request.ts index 2d3e1b0..f4e3411 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -9,6 +9,12 @@ export interface ApiBody { 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(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 diff --git a/src/api/user.ts b/src/api/user.ts index 6c84a3c..7f88de5 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -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 { diff --git a/src/components/home/PaneLibrary.vue b/src/components/home/PaneLibrary.vue index 3427b70..e0367f3 100644 --- a/src/components/home/PaneLibrary.vue +++ b/src/components/home/PaneLibrary.vue @@ -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(() => {}) diff --git a/src/components/home/PaneMine.vue b/src/components/home/PaneMine.vue index 6e0e606..914e53f 100644 --- a/src/components/home/PaneMine.vue +++ b/src/components/home/PaneMine.vue @@ -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() { + + + + VIP + + + + 升级会员 + + + 解锁更多模块 + + + @@ -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; diff --git a/src/components/home/PaneWorkbench.vue b/src/components/home/PaneWorkbench.vue index 44139f1..6481561 100644 --- a/src/components/home/PaneWorkbench.vue +++ b/src/components/home/PaneWorkbench.vue @@ -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 + + + + + diff --git a/src/components/vip/QuotaBar.vue b/src/components/vip/QuotaBar.vue new file mode 100644 index 0000000..5686508 --- /dev/null +++ b/src/components/vip/QuotaBar.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/src/components/vip/QuotaGate.vue b/src/components/vip/QuotaGate.vue new file mode 100644 index 0000000..67f91c9 --- /dev/null +++ b/src/components/vip/QuotaGate.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/src/components/vip/VipCard.vue b/src/components/vip/VipCard.vue new file mode 100644 index 0000000..f14d973 --- /dev/null +++ b/src/components/vip/VipCard.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/components/vip/VipDock.vue b/src/components/vip/VipDock.vue new file mode 100644 index 0000000..3bc8a4d --- /dev/null +++ b/src/components/vip/VipDock.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/vip/VipFloatAd.vue b/src/components/vip/VipFloatAd.vue new file mode 100644 index 0000000..9ba949d --- /dev/null +++ b/src/components/vip/VipFloatAd.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/src/pages.json b/src/pages.json index cb446fe..2988649 100644 --- a/src/pages.json +++ b/src/pages.json @@ -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"] + } } } diff --git a/src/pages/tools/image/annotate.vue b/src/pages/tools/image/annotate.vue index 4d69c90..162edff 100644 --- a/src/pages/tools/image/annotate.vue +++ b/src/pages/tools/image/annotate.vue @@ -1,6 +1,7 @@ + + + + diff --git a/src/pkg-member/member/orders.vue b/src/pkg-member/member/orders.vue new file mode 100644 index 0000000..cb3a333 --- /dev/null +++ b/src/pkg-member/member/orders.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/src/pkg-member/quota/index.vue b/src/pkg-member/quota/index.vue new file mode 100644 index 0000000..db2c1c0 --- /dev/null +++ b/src/pkg-member/quota/index.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/src/pkg-member/quota/my.vue b/src/pkg-member/quota/my.vue new file mode 100644 index 0000000..1809676 --- /dev/null +++ b/src/pkg-member/quota/my.vue @@ -0,0 +1,502 @@ + + + + + diff --git a/src/stores/quota.ts b/src/stores/quota.ts new file mode 100644 index 0000000..9107340 --- /dev/null +++ b/src/stores/quota.ts @@ -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(null) + const toolMap = ref>({}) + 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 { + 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, + } +}) diff --git a/src/stores/user.ts b/src/stores/user.ts index 174f22f..0b12158 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -121,6 +121,17 @@ export const useUserStore = defineStore('user', () => { user.value = { ...user.value, nickname, avatar_url: avatarUrl } } + /** + * 重新拉取等级与用户信息(购买会员 / 兑换成功后调用)。 + * 与 ensureLogin 不同,这里不做「已登录就短路」,强制刷新以拿到新的模块权限。 + */ + async function refreshLevel(): Promise { + 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, } diff --git a/src/utils/image/core.ts b/src/utils/image/core.ts index a74d79a..68cf484 100644 --- a/src/utils/image/core.ts +++ b/src/utils/image/core.ts @@ -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 { + await chargeBeforeSave() // #ifdef MP-WEIXIN return saveResultMP(path) // #endif @@ -297,6 +306,17 @@ export async function saveResult(path: string, fileName: string): Promise // #endif } +/** + * 产出前扣减额度。 + * 九宫格一次导出 9 张、压缩「保存全部」一次存 N 张,会连续走到这里, + * 由 utils/quota.ts 的合并窗口保证同一批只扣一次。 + */ +async function chargeBeforeSave(): Promise { + const quota = await consumeQuota() + if (quota) + lastConsumedQuota.value = quota +} + /** 按扩展名推断导出格式 */ export function typeByExt(path: string): ExportType { return /\.png$/i.test(path) ? 'png' : 'jpg' diff --git a/src/utils/image/task.ts b/src/utils/image/task.ts index ceb0529..8ae4805 100644 --- a/src/utils/image/task.ts +++ b/src/utils/image/task.ts @@ -16,6 +16,9 @@ export async function runTask(title: string, fn: () => Promise): Promise setTimeout(resolve, ms)) +} + +/** 取 wx.login 的临时凭证(服务端用它换 session_key 生成用户态签名) */ +function fetchLoginCode(): Promise { + return new Promise((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 { + 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> | 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) +} diff --git a/src/utils/quota-pay.ts b/src/utils/quota-pay.ts new file mode 100644 index 0000000..cfcd4f8 --- /dev/null +++ b/src/utils/quota-pay.ts @@ -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 { + return new Promise((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 { + 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> | 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, + } +} diff --git a/src/utils/quota.ts b/src/utils/quota.ts new file mode 100644 index 0000000..5024d6f --- /dev/null +++ b/src/utils/quota.ts @@ -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(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 = {} + +/** + * 产出结果时扣减一次额度。 + * + * @param toolKey 不传则从当前页面路由推导(工具页无需关心自己叫什么) + * @param force true = 忽略合并窗口,立即扣减 + * @returns 扣减后的额度快照;命中合并窗口时返回 null(表示本次不再扣减) + */ +export async function consumeQuota(toolKey?: string, force = false): Promise { + 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 { + 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 '次数已用完' + } +} diff --git a/src/utils/unlock-guide.ts b/src/utils/unlock-guide.ts new file mode 100644 index 0000000..9e0973b --- /dev/null +++ b/src/utils/unlock-guide.ts @@ -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' }) + }, + }) +} diff --git a/src/utils/vpay.ts b/src/utils/vpay.ts new file mode 100644 index 0000000..14ac023 --- /dev/null +++ b/src/utils/vpay.ts @@ -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) => 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 = { + 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 { + const api = getWx()?.requestVirtualPayment + if (!api) { + return Promise.reject(new VirtualPayError('请在微信小程序内购买(当前环境不支持微信虚拟支付)')) + } + return new Promise((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)) + }, + }) + }) +}