mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 18:40:04 +00:00
feat(payment): subscription renewal UI, payment button colors, QR branding, refund day deduction
- Add btn-alipay/btn-wxpay CSS classes; confirm button color follows payment method - Subscription confirm header uses platform gradient (Anthropic orange, Gemini blue, etc.) - Subscription confirm page shows plan details (rate, limits, validity) - SubscriptionPlanCard: show "Renew" button when user has active subscription - SubscriptionsView: add renewal button on active subscription cards - QR code display: brand-color border + center logo overlay (Alipay blue, WeChat green) - StripePaymentView: WeChat QR green border + logo, Alipay spinner brand color - Backend: fix subscription refund to deduct days (ExtendSubscription -days or Revoke) - Backend: rollback subscription days on gateway failure
This commit is contained in:
@@ -102,6 +102,15 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
|
||||
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
|
||||
if o.OrderType == payment.OrderTypeSubscription {
|
||||
p.DeductionType = payment.DeductionTypeSubscription
|
||||
if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
|
||||
p.SubDaysToDeduct = *o.SubscriptionDays
|
||||
sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
|
||||
if err == nil && sub != nil {
|
||||
p.SubscriptionID = sub.ID
|
||||
} else if !force {
|
||||
return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
u, err := s.userRepo.GetByID(ctx, o.UserID)
|
||||
@@ -137,6 +146,22 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
|
||||
p.BalanceToDeduct = 0
|
||||
}
|
||||
}
|
||||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||||
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
|
||||
_, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
|
||||
if err != nil {
|
||||
// If deducting would expire the subscription, revoke it entirely
|
||||
slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
|
||||
if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
|
||||
s.restoreStatus(ctx, p)
|
||||
return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
|
||||
p.SubDaysToDeduct = 0
|
||||
}
|
||||
}
|
||||
if err := s.gwRefund(ctx, p); err != nil {
|
||||
return s.handleGwFail(ctx, p, err)
|
||||
}
|
||||
@@ -204,6 +229,13 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
|
||||
return false
|
||||
}
|
||||
}
|
||||
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
|
||||
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
|
||||
slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
|
||||
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ type RefundPlan struct {
|
||||
DeductionType string
|
||||
BalanceToDeduct float64
|
||||
SubDaysToDeduct int
|
||||
SubscriptionID int64
|
||||
}
|
||||
|
||||
type RefundResult struct {
|
||||
|
||||
@@ -70,8 +70,14 @@
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
<!-- Brand logo overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
||||
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
||||
</div>
|
||||
@@ -152,6 +158,18 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
||||
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
||||
|
||||
const qrBorderClass = computed(() => {
|
||||
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
|
||||
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
|
||||
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
|
||||
})
|
||||
|
||||
const qrLogoBgClass = computed(() => {
|
||||
if (isAlipay.value) return 'bg-[#00AEEF]'
|
||||
if (isWxpay.value) return 'bg-[#2BB741]'
|
||||
return 'bg-gray-400'
|
||||
})
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
@@ -179,33 +197,10 @@ function reopenPopup() {
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
const logoSrc = isAlipay.value ? alipayIcon : isWxpay.value ? wxpayIcon : null
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220, margin: 2,
|
||||
errorCorrectionLevel: logoSrc ? 'H' : 'M',
|
||||
errorCorrectionLevel: 'H',
|
||||
})
|
||||
if (!logoSrc) return
|
||||
const canvas = qrCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const img = new Image()
|
||||
img.src = logoSrc
|
||||
img.onload = () => {
|
||||
const logoSize = 40
|
||||
const x = (canvas.width - logoSize) / 2
|
||||
const y = (canvas.height - logoSize) / 2
|
||||
const pad = 4
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
const r = 5
|
||||
ctx.moveTo(x - pad + r, y - pad)
|
||||
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
|
||||
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
|
||||
ctx.fill()
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ t('payment.subscribeNow') }}
|
||||
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +98,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import {
|
||||
platformAccentBarClass,
|
||||
platformBadgeLightClass,
|
||||
@@ -109,11 +110,14 @@ import {
|
||||
platformLabel,
|
||||
} from '@/utils/platformColors'
|
||||
|
||||
const props = defineProps<{ plan: SubscriptionPlan }>()
|
||||
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
|
||||
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const platform = computed(() => props.plan.group_platform || '')
|
||||
const isRenewal = computed(() =>
|
||||
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
|
||||
)
|
||||
|
||||
// Derived color classes from central config
|
||||
const accentClass = computed(() => platformAccentBarClass(platform.value))
|
||||
|
||||
@@ -5172,6 +5172,7 @@ export default {
|
||||
qrFailed: 'Failed to get WeChat Pay QR code',
|
||||
},
|
||||
subscribeNow: 'Subscribe Now',
|
||||
renewNow: 'Renew',
|
||||
planFeatures: 'Features',
|
||||
planCard: {
|
||||
rate: 'Rate',
|
||||
|
||||
@@ -5369,6 +5369,7 @@ export default {
|
||||
qrFailed: '未能获取微信支付二维码',
|
||||
},
|
||||
subscribeNow: '立即开通',
|
||||
renewNow: '续费',
|
||||
planFeatures: '功能特性',
|
||||
planCard: {
|
||||
rate: '倍率',
|
||||
|
||||
@@ -148,6 +148,20 @@
|
||||
@apply dark:hover:bg-[#635bff];
|
||||
}
|
||||
|
||||
.btn-alipay {
|
||||
@apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
|
||||
@apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
|
||||
@apply active:bg-[#008cbe];
|
||||
@apply dark:shadow-[#00AEEF]/20;
|
||||
}
|
||||
|
||||
.btn-wxpay {
|
||||
@apply bg-[#2BB741] text-white shadow-md shadow-[#2BB741]/25;
|
||||
@apply hover:bg-[#24a038] hover:shadow-lg hover:shadow-[#2BB741]/30;
|
||||
@apply active:bg-[#1d8a2f];
|
||||
@apply dark:shadow-[#2BB741]/20;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply rounded-lg px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,32 @@ const DISCOUNT: Record<Platform, string> = {
|
||||
}
|
||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
|
||||
// ── Header gradient (subscription confirm) ─────────────────────────
|
||||
const GRADIENT: Record<Platform, string> = {
|
||||
anthropic: 'from-orange-500 to-orange-600',
|
||||
openai: 'from-emerald-500 to-emerald-600',
|
||||
antigravity: 'from-purple-500 to-purple-600',
|
||||
gemini: 'from-blue-500 to-blue-600',
|
||||
}
|
||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||
|
||||
// ── Header text (light text on gradient bg) ────────────────────────
|
||||
const GRADIENT_TEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-100',
|
||||
openai: 'text-emerald-100',
|
||||
antigravity: 'text-purple-100',
|
||||
gemini: 'text-blue-100',
|
||||
}
|
||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||
|
||||
const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
||||
anthropic: 'text-orange-200',
|
||||
openai: 'text-emerald-200',
|
||||
antigravity: 'text-purple-200',
|
||||
gemini: 'text-blue-200',
|
||||
}
|
||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
function isPlatform(p: string): p is Platform {
|
||||
@@ -116,6 +142,18 @@ export function platformDiscountClass(p: string): string {
|
||||
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientTextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformGradientSubtextClass(p: string): string {
|
||||
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
|
||||
}
|
||||
|
||||
export function platformLabel(p: string): string {
|
||||
switch (p) {
|
||||
case 'anthropic': return 'Anthropic'
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-full py-3 text-base font-medium" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
@@ -100,13 +100,21 @@
|
||||
<!-- Subscription confirm (inline, replaces plan list) -->
|
||||
<template v-if="selectedPlan">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-5 text-center">
|
||||
<p class="text-sm font-medium text-primary-100">{{ selectedPlan.name }}</p>
|
||||
<div :class="['bg-gradient-to-br px-6 py-5 text-center', planGradientClass]">
|
||||
<p :class="['text-sm font-medium', planGradientTextClass]">{{ selectedPlan.name }}</p>
|
||||
<div class="mt-1 flex items-baseline justify-center gap-1.5">
|
||||
<span class="text-3xl font-bold text-white">${{ selectedPlan.price }}</span>
|
||||
<span v-if="selectedPlan.original_price" class="text-sm text-primary-200 line-through">${{ selectedPlan.original_price }}</span>
|
||||
<span v-if="selectedPlan.original_price" :class="['text-sm line-through', planGradientSubtextClass]">${{ selectedPlan.original_price }}</span>
|
||||
</div>
|
||||
<p v-if="selectedPlan.description" :class="['mt-2 text-sm', planGradientTextClass]">{{ selectedPlan.description }}</p>
|
||||
<div class="mt-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-white/80">
|
||||
<span>{{ t('payment.planCard.rate') }}: ×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
|
||||
<span v-if="selectedPlan.daily_limit_usd != null">{{ t('payment.planCard.dailyLimit') }}: ${{ selectedPlan.daily_limit_usd }}</span>
|
||||
<span v-if="selectedPlan.weekly_limit_usd != null">{{ t('payment.planCard.weeklyLimit') }}: ${{ selectedPlan.weekly_limit_usd }}</span>
|
||||
<span v-if="selectedPlan.monthly_limit_usd != null">{{ t('payment.planCard.monthlyLimit') }}: ${{ selectedPlan.monthly_limit_usd }}</span>
|
||||
<span v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
|
||||
<span>{{ planValiditySuffix }}</span>
|
||||
</div>
|
||||
<p v-if="selectedPlan.description" class="mt-2 text-sm text-primary-100">{{ selectedPlan.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
@@ -132,7 +140,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-full py-3 text-base font-medium" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
@@ -151,7 +159,7 @@
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
|
||||
</div>
|
||||
<div v-else :class="planGridClass">
|
||||
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" @select="selectPlan" />
|
||||
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
|
||||
</div>
|
||||
<!-- Active subscriptions (compact, below plan list) -->
|
||||
<div v-if="activeSubscriptions.length > 0">
|
||||
@@ -203,6 +211,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
@@ -214,7 +223,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import { platformAccentBarClass, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
|
||||
import { platformAccentBarClass, platformBadgeLightClass, platformLabel, platformGradientClass, platformGradientTextClass, platformGradientSubtextClass } from '@/utils/platformColors'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
|
||||
@@ -222,6 +231,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
@@ -405,6 +415,29 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method])
|
||||
if (available) selectedMethod.value = available
|
||||
})
|
||||
|
||||
// Payment button class: follows selected payment method color
|
||||
const paymentButtonClass = computed(() => {
|
||||
const m = selectedMethod.value
|
||||
if (!m) return 'btn-primary'
|
||||
if (m.includes('alipay')) return 'btn-alipay'
|
||||
if (m.includes('wxpay')) return 'btn-wxpay'
|
||||
if (m === 'stripe') return 'btn-stripe'
|
||||
return 'btn-primary'
|
||||
})
|
||||
|
||||
// Subscription confirm header: platform gradient
|
||||
const planGradientClass = computed(() => platformGradientClass(selectedPlan.value?.group_platform || ''))
|
||||
const planGradientTextClass = computed(() => platformGradientTextClass(selectedPlan.value?.group_platform || ''))
|
||||
const planGradientSubtextClass = computed(() => platformGradientSubtextClass(selectedPlan.value?.group_platform || ''))
|
||||
|
||||
const planValiditySuffix = computed(() => {
|
||||
if (!selectedPlan.value) return ''
|
||||
const u = selectedPlan.value.validity_unit || 'day'
|
||||
if (u === 'month') return t('payment.perMonth')
|
||||
if (u === 'year') return t('payment.perYear')
|
||||
return `${selectedPlan.value.validity_days}${t('payment.days')}`
|
||||
})
|
||||
|
||||
function selectPlan(plan: SubscriptionPlan) {
|
||||
selectedPlan.value = plan
|
||||
errorMessage.value = ''
|
||||
@@ -497,6 +530,10 @@ onMounted(async () => {
|
||||
if (checkout.value.balance_disabled) {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
// Handle renewal navigation: ?tab=subscription&group=123
|
||||
if (route.query.tab === 'subscription') {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { loading.value = false }
|
||||
// Fetch active subscriptions (uses cache, non-blocking)
|
||||
|
||||
@@ -26,8 +26,13 @@
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56" />
|
||||
<div class="relative rounded-lg border-2 border-[#2BB741] bg-green-50 p-4 dark:border-[#2BB741]/70 dark:bg-green-950/20">
|
||||
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56 rounded" />
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span class="rounded-full bg-[#2BB741] p-2 shadow ring-2 ring-white">
|
||||
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.636 4.35c-2.084 0-3.993.672-5.363 1.844-1.188.982-2.004 2.308-2.004 3.862 0 1.207.546 2.355 1.483 3.285.114.113.238.213.358.321l-.105.42c-.021.084-.042.17-.042.253 0 .168.126.258.282.258.065 0 .126-.025.18-.058l1.27-.765a.69.69 0 0 1 .58-.086c.96.282 1.99.437 3.043.437 2.633 0 5.03-.972 6.4-2.5.782-.87 1.258-1.901 1.258-3.006 0-3.328-3.325-6.006-7.34-6.006zm-3.21 3.09c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957zm4.739 0c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957z"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
|
||||
</div>
|
||||
@@ -41,7 +46,7 @@
|
||||
<template v-else-if="redirecting">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,18 +49,27 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
subscription.status === 'active'
|
||||
? 'badge-success'
|
||||
: subscription.status === 'expired'
|
||||
? 'badge-warning'
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
subscription.status === 'active'
|
||||
? 'badge-success'
|
||||
: subscription.status === 'expired'
|
||||
? 'badge-warning'
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`userSubscriptions.status.${subscription.status}`) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="subscription.status === 'active'"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
|
||||
>
|
||||
{{ t('payment.renewNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Progress -->
|
||||
@@ -237,6 +246,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
@@ -245,6 +255,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const subscriptions = ref<UserSubscription[]>([])
|
||||
|
||||
Reference in New Issue
Block a user