From cd1bbebb771a213ca09b3915b70827b93bff2642 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 10 Apr 2026 10:42:06 +0800 Subject: [PATCH] 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 --- backend/internal/service/payment_refund.go | 32 +++++++++++ backend/internal/service/payment_service.go | 1 + .../components/payment/PaymentStatusPanel.vue | 45 +++++++--------- .../payment/SubscriptionPlanCard.vue | 8 ++- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/style.css | 14 +++++ frontend/src/utils/platformColors.ts | 38 +++++++++++++ frontend/src/views/user/PaymentView.vue | 53 ++++++++++++++++--- frontend/src/views/user/StripePaymentView.vue | 11 ++-- frontend/src/views/user/SubscriptionsView.vue | 35 +++++++----- 11 files changed, 189 insertions(+), 50 deletions(-) diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 68f9c697ee..fd2822cc41 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -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 } diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index e96e3b7430..7679eb8d54 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -109,6 +109,7 @@ type RefundPlan struct { DeductionType string BalanceToDeduct float64 SubDaysToDeduct int + SubscriptionID int64 } type RefundResult struct { diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index f4f1122844..087b3dcf5e 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -70,8 +70,14 @@

{{ scanTitle }}

-
+
+ +
+ + + +

{{ scanHint }}

@@ -152,6 +158,18 @@ let countdownTimer: ReturnType | 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() { diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index e2540dcbd7..fbaa274476 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -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') }}
@@ -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)) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5da91dc484..31db41ba2d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5172,6 +5172,7 @@ export default { qrFailed: 'Failed to get WeChat Pay QR code', }, subscribeNow: 'Subscribe Now', + renewNow: 'Renew', planFeatures: 'Features', planCard: { rate: 'Rate', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c8d4ed1946..8f071afbdc 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5369,6 +5369,7 @@ export default { qrFailed: '未能获取微信支付二维码', }, subscribeNow: '立即开通', + renewNow: '续费', planFeatures: '功能特性', planCard: { rate: '倍率', diff --git a/frontend/src/style.css b/frontend/src/style.css index f9b64f781c..4ab18cf5c7 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/utils/platformColors.ts b/frontend/src/utils/platformColors.ts index 79c6482bd2..2db64d427b 100644 --- a/frontend/src/utils/platformColors.ts +++ b/frontend/src/utils/platformColors.ts @@ -78,6 +78,32 @@ const DISCOUNT: Record = { } 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 = { + 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 = { + 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 = { + 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' diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index e2dbffd0d8..360877cf03 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -83,7 +83,7 @@ -