完成consumer端同步
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<view class="followed-shops-page">
|
||||
<view class="header">
|
||||
<text class="header-title">我关注的店铺</text>
|
||||
</view>
|
||||
|
||||
<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" />
|
||||
<view class="shop-info">
|
||||
<text class="shop-name">{{ shop.shop_name }}</text>
|
||||
<text class="shop-desc">{{ shop.description != null ? shop.description : '暂无介绍' }}</text>
|
||||
<view class="shop-meta">
|
||||
<text class="rating shop-meta-text">⭐ {{ shop.rating_avg }}</text>
|
||||
<text class="sales shop-meta-text">销量: {{ shop.total_sales }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="unfollow-btn" @click.stop="unfollow(shop)">已关注</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loading == false" class="empty-state">
|
||||
<text class="empty-text">暂无关注的店铺</text>
|
||||
<button class="go-shop-btn" @click="goHome">去逛逛</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type FollowedShop = {
|
||||
id: string
|
||||
merchant_id: string
|
||||
shop_name: string
|
||||
shop_logo: string | null
|
||||
description: string | null
|
||||
rating_avg: number
|
||||
total_sales: number
|
||||
}
|
||||
|
||||
const shops = ref<Array<FollowedShop>>([])
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
const loadFollowedShops = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
const userId = supabaseService.getCurrentUserId()
|
||||
if (userId == null || userId == '') {
|
||||
uni.navigateTo({ url: '/pages/auth/login' })
|
||||
return
|
||||
}
|
||||
|
||||
const res = await supabaseService.getFollowedShops(userId)
|
||||
|
||||
const list: Array<FollowedShop> = []
|
||||
if (res != null && Array.isArray(res)) {
|
||||
for (let i: number = 0; i < res.length; i++) {
|
||||
const item = res[i] as UTSJSONObject
|
||||
const shopDataRaw = item.get('ml_shops')
|
||||
if (shopDataRaw != null) {
|
||||
const shopData = shopDataRaw as UTSJSONObject
|
||||
const shop: FollowedShop = {
|
||||
id: shopData.getString('id') ?? '',
|
||||
merchant_id: shopData.getString('merchant_id') ?? '',
|
||||
shop_name: shopData.getString('shop_name') ?? '',
|
||||
shop_logo: shopData.getString('shop_logo'),
|
||||
description: shopData.getString('description'),
|
||||
rating_avg: shopData.getNumber('rating_avg') ?? 5.0,
|
||||
total_sales: shopData.getNumber('total_sales') ?? 0
|
||||
} as FollowedShop
|
||||
list.push(shop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shops.value = list
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const doUnfollow = async (shopId: string, userId: string): Promise<void> => {
|
||||
const success = await supabaseService.unfollowShop(shopId, userId)
|
||||
if (success) {
|
||||
uni.showToast({ title: '已取消', icon: 'none' })
|
||||
loadFollowedShops()
|
||||
} else {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const unfollow = async (shop: FollowedShop): Promise<void> => {
|
||||
const userId = supabaseService.getCurrentUserId()
|
||||
if (userId == null || userId == '') return
|
||||
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定取消关注该店铺吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
doUnfollow(shop.id, userId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const goToShop = (shop: FollowedShop): void => {
|
||||
const targetId = shop.merchant_id != '' ? shop.merchant_id : shop.id
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/shop-detail?merchantId=${targetId}`
|
||||
})
|
||||
}
|
||||
|
||||
const goHome = (): void => {
|
||||
uni.switchTab({ url: '/pages/main/index' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFollowedShops()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.followed-shops-page {
|
||||
padding: 15px;
|
||||
background-color: #f5f5f5;
|
||||
flex: 1;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.shop-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shop-item {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.shop-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.shop-logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 4px;
|
||||
background-color: #eee;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.shop-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shop-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.shop-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shop-meta {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
}
|
||||
.shop-meta-text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.unfollow-btn {
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
background-color: #eee;
|
||||
color: #666;
|
||||
border-radius: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 100px;
|
||||
}
|
||||
.empty-text {
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.go-shop-btn {
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding-top: 50px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<view class="my-subs">
|
||||
<view class="header">
|
||||
<text class="title">我的订阅</text>
|
||||
<button class="ghost" @click="goPlanList">订阅更多</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="items.length === 0" class="empty">暂无订阅</view>
|
||||
|
||||
<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>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">周期</text>
|
||||
<text class="value">{{ (s['plan']?.['billing_period'] === 'yearly') ? '年付' : '月付' }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">价格</text>
|
||||
<text class="value">¥{{ s['plan']?.['price'] }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">开始</text>
|
||||
<text class="value">{{ fmt(s['start_date'] as string) }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">下次扣费</text>
|
||||
<text class="value">{{ fmt(s['next_billing_date'] as string) }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<label class="toggle">
|
||||
<switch :checked="!!s['auto_renew']" @change="e => 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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
|
||||
const loading = ref<boolean>(true)
|
||||
const items = ref<Array<UTSJSONObject>>([])
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const loadSubs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const userId = getCurrentUserId()
|
||||
if (userId == null || userId.length === 0) {
|
||||
items.value = []
|
||||
return
|
||||
}
|
||||
// join: ml_user_subscriptions + ml_subscription_plans
|
||||
const res = await supaClient
|
||||
.from('ml_user_subscriptions')
|
||||
.select('*, plan:ml_subscription_plans(*)', {})
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.execute()
|
||||
items.value = Array.isArray(res.data) ? (res.data as Array<UTSJSONObject>) : []
|
||||
} catch (e) {
|
||||
console.error('加载订阅失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
|
||||
try {
|
||||
const id = (s['id'] ?? '') as string
|
||||
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
|
||||
uni.showToast({ title: value ? '已开启自动续费' : '已关闭自动续费', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('更新自动续费失败:', e)
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAtPeriodEnd = async (s: UTSJSONObject) => {
|
||||
try {
|
||||
const id = (s['id'] ?? '') as string
|
||||
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
|
||||
uni.showToast({ title: '已设置到期取消', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.error('设置到期取消失败:', e)
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const goPlanList = () => {
|
||||
uni.navigateTo({ url: '/pages/mall/consumer/subscription/plan-list' })
|
||||
}
|
||||
|
||||
onMounted(loadSubs)
|
||||
// 注意:uni-app x 的 <script setup> 中不支持 onShow,使用 onMounted 代替
|
||||
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-subs { padding: 12px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 700; }
|
||||
.ghost { background: #fff; border: 1px solid #ddd; color: #333; border-radius: 6px; padding: 6px 10px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
.list { display: flex; flex-direction: column; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 12px; }
|
||||
.card:last-child { margin-bottom: 0; }
|
||||
.row { display: flex; padding: 4px 0; }
|
||||
.label { margin-right: 8px; }
|
||||
.between { justify-content: space-between; align-items: center; }
|
||||
.name { font-size: 16px; font-weight: 700; }
|
||||
.status { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #eee; color: #333; }
|
||||
.st-trial { background: #e6f7ff; color: #1677ff; }
|
||||
.st-active { background: #f6ffed; color: #52c41a; }
|
||||
.st-past_due { background: #fff7e6; color: #fa8c16; }
|
||||
.st-canceled, .st-expired { background: #fff1f0; color: #f5222d; }
|
||||
.label { color: #666; width: 80px; }
|
||||
.value { color: #111; flex: 1; }
|
||||
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
|
||||
.toggle { display: flex; align-items: center; }
|
||||
.toggle-text { margin-right: 6px; }
|
||||
.danger { background: #f5222d; color: #fff; border-radius: 6px; padding: 6px 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<view class="plan-detail">
|
||||
<view class="header">
|
||||
<text class="title">订阅方案详情</text>
|
||||
</view>
|
||||
<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>
|
||||
|
||||
<view class="price-row">
|
||||
<text class="price">¥{{ plan['price'] }}</text>
|
||||
<text class="period">/{{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="primary" @click="toCheckout">订阅此方案</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
const planId = ref<string>('')
|
||||
const loading = ref<boolean>(true)
|
||||
const plan = ref<UTSJSONObject | null>(null)
|
||||
|
||||
onLoad((opts: OnLoadOptions) => {
|
||||
planId.value = (opts['id'] ?? '') as string
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const loadPlan = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
if (planId.value.length === 0) return
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('id', planId.value)
|
||||
.single()
|
||||
.execute()
|
||||
if (res != null && res.error == null) {
|
||||
// single() 风格有些客户端会返回对象数组,这里兼容
|
||||
if (Array.isArray(res.data)) {
|
||||
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
|
||||
} else {
|
||||
plan.value = res.data as UTSJSONObject
|
||||
}
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载方案失败:', e)
|
||||
plan.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toCheckout = () => {
|
||||
if (plan.value == null) return
|
||||
const id = (plan.value['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
|
||||
}
|
||||
|
||||
onMounted(loadPlan)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plan-detail { padding: 12px; }
|
||||
.header { margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 700; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.name { font-size: 16px; font-weight: 700; }
|
||||
.desc { color: #666; margin: 6px 0; }
|
||||
.price-row { display: flex; align-items: flex-end; margin: 8px 0; }
|
||||
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; margin-right: 4px; }
|
||||
.period { color: #999; }
|
||||
.features { margin-top: 8px; }
|
||||
.f-title { font-weight: 700; margin-bottom: 4px; }
|
||||
.f-list { display: flex; flex-direction: column; color: #444; }
|
||||
.f-item { margin-bottom: 2px; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<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-header">
|
||||
<text class="plan-name">{{ p['name'] }}</text>
|
||||
<text v-if="p['billing_period'] === 'yearly'" class="badge">年付优惠</text>
|
||||
</view>
|
||||
<text class="plan-desc">{{ p['description'] != null && (p['description'] as string).length > 0 ? p['description'] : '适用于大部分使用场景' }}</text>
|
||||
<view class="price-row">
|
||||
<text class="price">¥{{ p['price'] }}</text>
|
||||
<text class="period">/{{ p['billing_period'] === 'yearly' ? '年' : '月' }}</text>
|
||||
</view>
|
||||
<view class="feature-list">
|
||||
<text class="feature-item" v-for="(v,k) in toFeatureArray(p['features'])" :key="k">• {{ v }}</text>
|
||||
</view>
|
||||
<view class="actions">
|
||||
<button class="primary" @click.stop="toCheckout(p)">立即订阅</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && plans.length === 0" class="empty">
|
||||
<text>暂无可用订阅方案</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading"><text>加载中...</text></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
const loading = ref<boolean>(true)
|
||||
const plans = ref<Array<UTSJSONObject>>([])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true })
|
||||
.execute()
|
||||
if (Array.isArray(res.data)) {
|
||||
plans.value = res.data as Array<UTSJSONObject>
|
||||
} else {
|
||||
plans.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载订阅方案失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goPlanDetail = (p: UTSJSONObject) => {
|
||||
const id = (p['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/plan-detail?id=${id}` })
|
||||
}
|
||||
|
||||
const toCheckout = (p: UTSJSONObject) => {
|
||||
const id = (p['id'] ?? '') as string
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
|
||||
}
|
||||
|
||||
onMounted(loadPlans)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sub-plan-list { padding: 12px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 700; }
|
||||
.plan-container { display: flex; flex-direction: column; }
|
||||
.plan-card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); margin-bottom: 12px; }
|
||||
.plan-card:last-child { margin-bottom: 0; }
|
||||
.plan-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.plan-name { font-size: 16px; font-weight: 700; color: #333; }
|
||||
.badge { font-size: 12px; color: #fff; background: #3cc51f; border-radius: 999px; padding: 2px 8px; }
|
||||
.plan-desc { color: #666; margin: 6px 0; line-height: 1.5; }
|
||||
.price-row { display: flex; align-items: flex-end; margin: 6px 0; }
|
||||
.price { font-size: 22px; color: #ff4d4f; font-weight: 700; margin-right: 4px; }
|
||||
.period { color: #999; }
|
||||
.feature-list { color: #444; display: flex; flex-direction: column; margin: 6px 0; }
|
||||
.feature-item { font-size: 12px; color: #555; margin-bottom: 2px; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<view class="subscribe-checkout">
|
||||
<view class="header">
|
||||
<text class="title">确认订阅</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="plan == null" class="empty">未找到订阅方案</view>
|
||||
<view v-else class="card">
|
||||
<view class="row">
|
||||
<text class="label">方案</text>
|
||||
<text class="value">{{ plan['name'] }}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">价格</text>
|
||||
<text class="value">¥{{ plan['price'] }} / {{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
|
||||
</view>
|
||||
<view class="row" v-if="trialDays > 0">
|
||||
<text class="label">试用期</text>
|
||||
<text class="value">{{ trialDays }} 天</text>
|
||||
</view>
|
||||
|
||||
<view class="section-title">支付方式</view>
|
||||
<view class="pay-methods">
|
||||
<label class="pay-item" @click="selPay(1)">
|
||||
<radio :checked="payMethod === 1"></radio>
|
||||
<text>微信支付</text>
|
||||
</label>
|
||||
<label class="pay-item" @click="selPay(2)">
|
||||
<radio :checked="payMethod === 2"></radio>
|
||||
<text>支付宝</text>
|
||||
</label>
|
||||
<label class="pay-item" @click="selPay(4)">
|
||||
<radio :checked="payMethod === 4"></radio>
|
||||
<text>余额</text>
|
||||
</label>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="primary" :disabled="submitting" @click="confirmSubscribe">确认并支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
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'
|
||||
|
||||
const planId = ref<string>('')
|
||||
const loading = ref<boolean>(true)
|
||||
const plan = ref<UTSJSONObject | null>(null)
|
||||
const payMethod = ref<number>(PAYMENT_METHOD.WECHAT)
|
||||
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 () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await supaClient
|
||||
.from('ml_subscription_plans')
|
||||
.select('*', {})
|
||||
.eq('id', planId.value)
|
||||
.single()
|
||||
.execute()
|
||||
if (res != null && res.error == null) {
|
||||
if (Array.isArray(res.data)) {
|
||||
plan.value = (res.data as Array<UTSJSONObject>)[0] ?? null
|
||||
} else {
|
||||
plan.value = res.data as UTSJSONObject
|
||||
}
|
||||
trialDays.value = (plan.value?.['trial_days'] ?? 0) as number
|
||||
} else {
|
||||
plan.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载方案失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selPay = (v: number) => { payMethod.value = v }
|
||||
|
||||
// 获取当前用户ID(按现有store实现替换)
|
||||
const getCurrentUserId = (): string => {
|
||||
try {
|
||||
const session = supaClient.getSession()
|
||||
return (session != null && session.user != null) ? (session.user.getString('id') ?? '') : ''
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSubscribe = async () => {
|
||||
if (plan.value == null) return
|
||||
const userId = getCurrentUserId()
|
||||
if (userId.length === 0) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 1) 创建订单或支付意图(此处简化为直接创建订阅记录)
|
||||
const now = new Date()
|
||||
const start = now.toISOString()
|
||||
// 简单计算下个扣费日
|
||||
let nextBilling: string | null = null
|
||||
if ((plan.value?.['billing_period'] ?? 'monthly') === '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 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' })
|
||||
}, 600)
|
||||
} else {
|
||||
uni.showToast({ title: ins?.error?.message ?? '订阅失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('订阅失败:', e)
|
||||
uni.showToast({ title: '订阅失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscribe-checkout { padding: 12px; }
|
||||
.header { margin-bottom: 8px; }
|
||||
.title { font-size: 18px; font-weight: 700; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.label { color: #666; }
|
||||
.value { color: #111; font-weight: 700; }
|
||||
.section-title { margin-top: 12px; font-weight: 700; }
|
||||
.pay-methods { display: flex; flex-direction: column; padding: 8px 0; }
|
||||
.pay-item { display: flex; align-items: center; margin-bottom: 8px; }
|
||||
.pay-item:last-child { margin-bottom: 0; }
|
||||
.pay-icon { margin-right: 8px; }
|
||||
.actions { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.primary { background: #3cc51f; color: #fff; border-radius: 6px; padding: 8px 12px; }
|
||||
.loading, .empty { padding: 24px; text-align: center; color: #888; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user