完成consumer端同步

This commit is contained in:
2026-05-14 15:28:09 +08:00
parent 612fb3d360
commit 0ffbc53902
197 changed files with 92657 additions and 7564 deletions

View File

@@ -6,7 +6,7 @@
<view class="shop-list" v-if="shops.length > 0">
<view class="shop-item" v-for="shop in shops" :key="shop.id" @click="goToShop(shop)">
<image :src="shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png'" class="shop-logo" mode="aspectFill" />
<image :src="shop.shop_logo != null ? shop.shop_logo : '/static/images/default.png'" class="shop-logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name">{{ shop.shop_name }}</text>
<text class="shop-desc">{{ shop.description != null ? shop.description : '暂无介绍' }}</text>

View File

@@ -11,31 +11,31 @@
<view v-else class="list">
<view class="card" v-for="s in items" :key="s['id']">
<view class="row between">
<text class="name">{{ s['plan']?.['name'] != null ? s['plan']?.['name'] : '订阅' }}</text>
<text class="status" :class="'st-' + (s['status'] != null ? s['status'] : 'active')">{{ statusText(s['status'] as string) }}</text>
<text class="name">{{ getPlanName(s) }}</text>
<text class="status" :class="'st-' + getSubscriptionStatus(s)">{{ statusText(getSubscriptionStatus(s)) }}</text>
</view>
<view class="row">
<text class="label">周期</text>
<text class="value">{{ (s['plan']?.['billing_period'] === 'yearly') ? '年付' : '月付' }}</text>
<text class="value">{{ getBillingPeriodText(s) }}</text>
</view>
<view class="row">
<text class="label">价格</text>
<text class="value">¥{{ s['plan']?.['price'] }}</text>
<text class="value">¥{{ getPlanPrice(s) }}</text>
</view>
<view class="row">
<text class="label">开始</text>
<text class="value">{{ fmt(s['start_date'] as string) }}</text>
<text class="value">{{ fmt(s.getString('start_date')) }}</text>
</view>
<view class="row">
<text class="label">下次扣费</text>
<text class="value">{{ fmt(s['next_billing_date'] as string) }}</text>
<text class="value">{{ fmt(s.getString('next_billing_date')) }}</text>
</view>
<view class="actions">
<label class="toggle">
<switch :checked="!!s['auto_renew']" @change="e => toggleAutoRenew(s, e.detail.value as boolean)" />
<switch :checked="getAutoRenew(s)" @change="(e: UniSwitchChangeEvent) => toggleAutoRenew(s, e.detail.value as boolean)" />
<text class="toggle-text">自动续费</text>
</label>
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="(s['status'] as string) !== 'active'">到期取消</button>
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="getSubscriptionStatus(s) !== 'active'">到期取消</button>
</view>
</view>
</view>
@@ -50,17 +50,52 @@ import { getCurrentUserId } from '@/utils/store.uts'
const loading = ref<boolean>(true)
const items = ref<Array<UTSJSONObject>>([])
function getPlanObj(s: UTSJSONObject): UTSJSONObject | null {
const planRaw = s.get('plan')
if (planRaw == null) return null
if (planRaw instanceof UTSJSONObject) return planRaw as UTSJSONObject
return JSON.parse(JSON.stringify(planRaw)) as UTSJSONObject
}
function getPlanName(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
return planObj != null ? (planObj.getString('name') ?? '订阅') : '订阅'
}
function getSubscriptionStatus(s: UTSJSONObject): string {
return s.getString('status') ?? 'active'
}
function getBillingPeriodText(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
const period = planObj != null ? (planObj.getString('billing_period') ?? 'monthly') : 'monthly'
return period === 'yearly' ? '年付' : '月付'
}
function getPlanPrice(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
const price = planObj != null ? (planObj.getNumber('price') ?? 0) : 0
return price.toString()
}
function getAutoRenew(s: UTSJSONObject): boolean {
return s.getBoolean('auto_renew') ?? false
}
const fmt = (s: string | null): string => {
if (s == null || s.length === 0) return '-'
const d = new Date(s)
if (isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')}`
if (Number.isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,"0")}-${d.getDate().toString().padStart(2,"0")}`
}
const statusText = (st: string): string => {
const map: UTSJSONObject = { trial: '试用', active: '生效', past_due: '逾期', canceled: '已取消', expired: '已过期' } as UTSJSONObject
const val = map[st] as string | null
return val != null ? val : st
if (st === 'trial') return '试用'
if (st === 'active') return '生效'
if (st === 'past_due') return '逾期'
if (st === 'canceled') return '已取消'
if (st === 'expired') return '已过期'
return st
}
const loadSubs = async () => {
@@ -89,14 +124,14 @@ const loadSubs = async () => {
const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
try {
const id = (s['id'] ?? '') as string
const id = s.getString('id') ?? ''
const res = await supaClient
.from('ml_user_subscriptions')
.update({ auto_renew: value })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['auto_renew'] = value
s.set('auto_renew', value)
uni.showToast({ title: value ? '已开启自动续费' : '已关闭自动续费', icon: 'success' })
} catch (e) {
console.error('更新自动续费失败:', e)
@@ -106,15 +141,15 @@ const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
const cancelAtPeriodEnd = async (s: UTSJSONObject) => {
try {
const id = (s['id'] ?? '') as string
const id = s.getString('id') ?? ''
const res = await supaClient
.from('ml_user_subscriptions')
.update({ cancel_at_period_end: true })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['cancel_at_period_end'] = true
s['status'] = 'active' // 保持到期前仍为active
s.set('cancel_at_period_end', true)
s.set('status', 'active')
uni.showToast({ title: '已设置到期取消', icon: 'success' })
} catch (e) {
console.error('设置到期取消失败:', e)
@@ -126,7 +161,9 @@ const goPlanList = () => {
uni.navigateTo({ url: '/pages/mall/consumer/subscription/plan-list' })
}
onMounted(loadSubs)
onMounted(() => {
loadSubs()
})
// 注意uni-app x 的 <script setup> 中不支持 onShow使用 onMounted 代替
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="plan-detail">
<view class="header">
<text class="title">订阅方案详情</text>
@@ -6,18 +6,18 @@
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="plan == null" class="empty">未找到该方案</view>
<view v-else class="card">
<text class="name">{{ plan['name'] }}</text>
<text class="desc">{{ plan['description'] != null && (plan['description'] as string).length > 0 ? plan['description'] : '—' }}</text>
<text class="name">{{ getPlanName() }}</text>
<text class="desc">{{ getPlanDescription() }}</text>
<view class="price-row">
<text class="price">¥{{ plan['price'] }}</text>
<text class="period">/{{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
<text class="price">¥{{ getPlanPrice() }}</text>
<text class="period">/{{ getBillingPeriodText() }}</text>
</view>
<view class="features">
<text class="f-title">包含功能</text>
<view class="f-list">
<text class="f-item" v-for="(v,k) in toFeatureArray(plan['features'])" :key="k">• {{ v }}</text>
<text class="f-item" v-for="(v,k) in toFeatureArray(getPlanFeaturesSource())" :key="k">• {{ v }}</text>
</view>
</view>
@@ -37,22 +37,61 @@ const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
onLoad((opts: OnLoadOptions) => {
planId.value = (opts['id'] ?? '') as string
function getPlanName(): string {
return plan.value != null ? (plan.value.getString('name') ?? '') : ''
}
function getPlanDescription(): string {
const desc = plan.value != null ? (plan.value.getString('description') ?? '') : ''
return desc !== '' ? desc : '—'
}
function getPlanPrice(): string {
const price = plan.value != null ? (plan.value.getNumber('price') ?? 0) : 0
return price.toString()
}
function getBillingPeriodText(): string {
const period = plan.value != null ? (plan.value.getString('billing_period') ?? 'monthly') : 'monthly'
return period === 'yearly' ? '年' : '月'
}
function getPlanFeatures(): any {
if (plan.value == null) return ''
const features = plan.value.get('features')
return features != null ? features : ''
}
function getPlanFeaturesSource(): any {
const features = getPlanFeatures()
return features != null ? features : ''
}
onLoad((opts) => {
if (opts == null) return
const optObj = opts as UTSJSONObject
planId.value = optObj.getString('id') ?? ''
})
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
const raw = JSON.stringify(features)
if (raw == null || raw === '') return arr
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
arr.push(JSON.stringify(parsed[i]).replace(/[\[\]\{\}"]/g, ''))
}
return arr
}
if (parsed instanceof UTSJSONObject) {
arr.push(JSON.stringify(parsed))
return arr
}
} catch (e) {
arr.push(raw)
}
return arr
}
@@ -87,11 +126,13 @@ const loadPlan = async () => {
const toCheckout = () => {
if (plan.value == null) return
const id = (plan.value['id'] ?? '') as string
const id = plan.value.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlan)
onMounted(() => {
loadPlan()
})
</script>
<style scoped>

View File

@@ -1,22 +1,22 @@
<template>
<template>
<view class="sub-plan-list">
<view class="header">
<text class="title">软件订阅</text>
</view>
<view class="plan-container" v-if="!loading && plans.length > 0">
<view class="plan-card" v-for="p in plans" :key="p['id']" @click="goPlanDetail(p)">
<view class="plan-card" v-for="p in plans" :key="getPlanId(p)" @click="goPlanDetail(p)">
<view class="plan-header">
<text class="plan-name">{{ p['name'] }}</text>
<text v-if="p['billing_period'] === 'yearly'" class="badge">年付优惠</text>
<text class="plan-name">{{ getPlanName(p) }}</text>
<text v-if="getBillingPeriod(p) === 'yearly'" class="badge">年付优惠</text>
</view>
<text class="plan-desc">{{ p['description'] != null && (p['description'] as string).length > 0 ? p['description'] : '适用于大部分使用场景' }}</text>
<text class="plan-desc">{{ getPlanDescription(p) }}</text>
<view class="price-row">
<text class="price">¥{{ p['price'] }}</text>
<text class="period">/{{ p['billing_period'] === 'yearly' ? '年' : '月' }}</text>
<text class="price">¥{{ getPlanPrice(p) }}</text>
<text class="period">/{{ getBillingPeriod(p) === 'yearly' ? '年' : '月' }}</text>
</view>
<view class="feature-list">
<text class="feature-item" v-for="(v,k) in toFeatureArray(p['features'])" :key="k">• {{ v }}</text>
<text class="feature-item" v-for="(v,k) in toFeatureArray(getPlanFeatures(p))" :key="k">• {{ v }}</text>
</view>
<view class="actions">
<button class="primary" @click.stop="toCheckout(p)">立即订阅</button>
@@ -39,18 +39,60 @@ import supaClient from '@/components/supadb/aksupainstance.uts'
const loading = ref<boolean>(true)
const plans = ref<Array<UTSJSONObject>>([])
function normalizePlanObject(p: any): UTSJSONObject {
if (p instanceof UTSJSONObject) {
return p as UTSJSONObject
}
const raw = JSON.stringify(p)
if (raw == null || raw === '') {
return new UTSJSONObject()
}
return JSON.parse(raw) as UTSJSONObject
}
function getPlanId(p: any): string {
return normalizePlanObject(p).getString('id') ?? ''
}
function getPlanName(p: any): string {
return normalizePlanObject(p).getString('name') ?? ''
}
function getBillingPeriod(p: any): string {
return normalizePlanObject(p).getString('billing_period') ?? 'monthly'
}
function getPlanDescription(p: any): string {
const desc = normalizePlanObject(p).getString('description') ?? ''
return desc !== '' ? desc : '适用于大部分使用场景'
}
function getPlanPrice(p: any): string {
const price = normalizePlanObject(p).getNumber('price') ?? 0
return price.toString()
}
function getPlanFeatures(p: any): any {
const features = normalizePlanObject(p).get('features')
return features != null ? features : ''
}
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
const raw = JSON.stringify(features)
if (raw == null || raw === '') return arr
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
arr.push(JSON.stringify(parsed[i]).replace(/[\[\]\{\}"]/g, ''))
}
return arr
}
arr.push(raw)
} catch (e) {
arr.push(raw)
}
return arr
}
@@ -77,17 +119,21 @@ const loadPlans = async () => {
}
}
const goPlanDetail = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
const goPlanDetail = (p: any) => {
const planObj = normalizePlanObject(p)
const id = planObj.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/plan-detail?id=${id}` })
}
const toCheckout = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
const toCheckout = (p: any) => {
const planObj = normalizePlanObject(p)
const id = planObj.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlans)
onMounted(() => {
loadPlans()
})
</script>
<style scoped>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="subscribe-checkout">
<view class="header">
<text class="title">确认订阅</text>
@@ -47,21 +47,16 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supaClient from '@/components/supadb/aksupainstance.uts'
import { PAYMENT_METHOD } from '@/types/mall-types.uts'
import { goToLogin } from '@/utils/utils.uts'
const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
const payMethod = ref<number>(PAYMENT_METHOD.WECHAT)
const payMethod = ref<number>(1)
const trialDays = ref<number>(0)
const submitting = ref<boolean>(false)
onLoad(async (opts: OnLoadOptions) => {
planId.value = (opts['planId'] ?? '') as string
await loadPlan()
})
const loadPlan = async () => {
async function loadPlan(): Promise<void> {
try {
loading.value = true
const res = await supaClient
@@ -76,7 +71,7 @@ const loadPlan = async () => {
} else {
plan.value = res.data as UTSJSONObject
}
trialDays.value = (plan.value?.['trial_days'] ?? 0) as number
trialDays.value = plan.value != null ? (plan.value.getNumber('trial_days') ?? 0) : 0
} else {
plan.value = null
}
@@ -87,6 +82,13 @@ const loadPlan = async () => {
}
}
onLoad((opts) => {
if (opts == null) return
const optObj = opts as UTSJSONObject
planId.value = optObj.getString('planId') ?? ''
loadPlan()
})
const selPay = (v: number) => { payMethod.value = v }
// 获取当前用户ID按现有store实现替换
@@ -103,7 +105,7 @@ const confirmSubscribe = async () => {
if (plan.value == null) return
const userId = getCurrentUserId()
if (userId.length === 0) {
uni.showToast({ title: '请先登录', icon: 'none' })
goToLogin(`/pages/mall/consumer/subscription/subscribe-checkout?planId=${planId.value}`)
return
}
@@ -114,30 +116,33 @@ const confirmSubscribe = async () => {
const start = now.toISOString()
// 简单计算下个扣费日
let nextBilling: string | null = null
if ((plan.value?.['billing_period'] ?? 'monthly') === 'yearly') {
const billingPeriod = plan.value.getString('billing_period') ?? 'monthly'
if (billingPeriod === 'yearly') {
nextBilling = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()).toISOString()
} else {
nextBilling = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()
}
const body = {
user_id: userId,
plan_id: plan.value['id'],
status: 'active',
start_date: start,
end_date: null,
next_billing_date: nextBilling,
auto_renew: true,
metadata: { pay_method: payMethod.value }
}
const metadata = new UTSJSONObject()
metadata.set('pay_method', payMethod.value)
const body = new UTSJSONObject()
body.set('user_id', userId)
body.set('plan_id', plan.value.getString('id') ?? '')
body.set('status', 'active')
body.set('start_date', start)
body.set('end_date', null)
body.set('next_billing_date', nextBilling)
body.set('auto_renew', true)
body.set('metadata', metadata)
const ins = await supaClient
.from('ml_user_subscriptions')
.insert(body)
.single?.()
.execute()
if (ins != null && ins.error == null) {
uni.showToast({ title: '订阅成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/main/profile' })
uni.switchTab({ url: '/pages/main/profile' })
}, 600)
} else {
uni.showToast({ title: ins?.error?.message ?? '订阅失败', icon: 'none' })