Files
image2api/frontend/src/components/PaymentModal.vue
T
chiyiandDevin 4d31219368 feat(ui): 全站统一首页墨黑中性风格,固定亮色并移除光暗切换
- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板
- 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片
- theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮
- 模型页空状态文案对齐「保存渠道即自动同步」

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:17:55 +08:00

175 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import { api } from '../api'
import { refreshMe } from '../auth'
import { payment, closePayment } from '../payment'
import Icon from './Icon.vue'
const qrUrl = ref('')
const remaining = ref(0) // seconds until expiry
const paid = ref(false)
const closeIn = ref(5) // post-paid auto-close countdown
let pollTimer = null, tickTimer = null, closeTimer = null
let serverOffset = 0 // (client epoch − server epoch); makes the countdown clock-skew-proof
const order = computed(() => payment.order || {})
const isJump = computed(() => order.value.pay_info_type === 'jump')
// Dead = the order can no longer be paid: cancelled server-side, or the countdown
// has hit zero (the QR is invalid even before the sweep flips the status).
const dead = computed(() => !paid.value && (order.value.status === 'cancelled' || remaining.value <= 0))
const methodLabel = computed(() => ({ wxpay: '微信支付', alipay: '支付宝' }[order.value.pay_type] || order.value.pay_type || ''))
const statusLabel = computed(() => ({ pending: '待支付', paid: '已支付', cancelled: '已取消' }[order.value.status] || order.value.status))
const mmss = computed(() => {
const s = Math.max(0, remaining.value)
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
})
function fmtTime(unix) {
if (!unix) return '—'
const d = new Date(unix * 1000)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
function clearTimers() {
clearInterval(pollTimer); clearInterval(tickTimer); clearInterval(closeTimer)
pollTimer = tickTimer = closeTimer = null
}
async function renderQR() {
qrUrl.value = ''
const info = order.value.pay_info
if (!info) return
try { qrUrl.value = await QRCode.toDataURL(info, { width: 220, margin: 1 }) } catch { qrUrl.value = '' }
}
async function poll() {
if (!order.value.id || paid.value) return
const r = await api(`/pay/orders/${order.value.id}`)
if (r.ok && r.data) {
payment.order = { ...payment.order, ...r.data }
if (r.data.status === 'paid') onPaid()
else if (r.data.status === 'cancelled') { clearInterval(pollTimer); pollTimer = null }
}
}
function onPaid() {
if (paid.value) return
paid.value = true
clearTimers()
refreshMe() // pull the new credits / 累计充值
closeIn.value = 5
closeTimer = setInterval(() => {
closeIn.value -= 1
if (closeIn.value <= 0) finish()
}, 1000)
}
function finish() {
clearTimers()
// Only run the onPaid callback (e.g. navigate to 设置) when the order was
// actually paid — closing the popup without paying must not navigate.
const cb = paid.value ? payment.onPaid : null
closePayment()
if (cb) cb()
}
function tick() {
const serverNow = Date.now() / 1000 - serverOffset
remaining.value = Math.max(0, Math.floor((order.value.expires_at || 0) - serverNow))
}
watch(() => payment.show, (show) => {
clearTimers()
paid.value = false
if (!show) return
// Anchor the countdown to the server clock so a wrong local clock can't make
// 30 minutes read as 32.
serverOffset = order.value.server_now ? (Date.now() / 1000 - order.value.server_now) : 0
if (order.value.status === 'paid') { onPaid(); return }
if (isJump.value && order.value.pay_info) {
window.open(order.value.pay_info, '_blank') // auto-jump to the cashier
} else {
renderQR()
}
tick(); tickTimer = setInterval(tick, 1000)
poll(); pollTimer = setInterval(poll, 3000)
})
onUnmounted(clearTimers)
</script>
<template>
<transition name="pay-fade">
<div v-if="payment.show" class="fixed inset-0 z-[85] bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="!paid && finish()">
<div class="w-full max-w-sm rounded-2xl bg-[var(--menu-bg)] backdrop-blur-xl text-[color:var(--fg)] border border-[color:var(--glass-border)] shadow-[var(--glass-shadow-lg)] overflow-hidden">
<div class="px-5 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between">
<h2 class="text-base font-semibold">{{ paid ? '支付成功' : dead ? '支付超时' : (isJump ? '支付监控中' : '扫码支付') }}</h2>
<button v-if="!paid" @click="finish" class="text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div>
<!-- paid -->
<div v-if="paid" class="px-6 py-10 text-center">
<div class="w-16 h-16 mx-auto rounded-full text-white grid place-items-center mb-4"
style="background: linear-gradient(135deg, #34d399, #2dd4bf); box-shadow: 0 10px 28px rgb(52 211 153 / 0.4)">
<Icon name="spark" class="w-8 h-8" />
</div>
<p class="text-lg font-semibold text-[color:var(--fg)]">充值成功</p>
<p class="text-sm text-[color:var(--fg-3)] mt-1">已到账 <strong class="text-emerald-500">{{ order.points }}</strong> 积分</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-4">{{ closeIn }} 秒后自动关闭</p>
</div>
<!-- expired / cancelled -->
<div v-else-if="dead" class="px-6 py-10 text-center">
<div class="w-16 h-16 mx-auto rounded-full bg-[var(--surface-2)] text-[color:var(--fg-3)] grid place-items-center mb-4">
<Icon name="close" class="w-8 h-8" />
</div>
<p class="text-lg font-semibold text-[color:var(--fg)]">支付超时</p>
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
<button @click="finish" class="mt-5 rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
</div>
<!-- pending -->
<div v-else class="px-6 py-5">
<!-- qrcode: scan to pay -->
<template v-if="!isJump">
<div class="flex justify-center mb-3">
<div class="w-[228px] h-[228px] rounded-xl border border-[color:var(--glass-border)] shadow-[var(--glass-shadow)] grid place-items-center overflow-hidden bg-white p-3.5">
<img v-if="qrUrl" :src="qrUrl" alt="支付二维码" class="w-full h-full rounded-lg" />
<span v-else class="text-xs text-slate-400">二维码生成中…</span>
</div>
</div>
<p class="text-center text-xs text-[color:var(--fg-3)] mb-4">请使用<strong class="text-[color:var(--fg)]">{{ methodLabel }}</strong>扫码支付</p>
</template>
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
<template v-else>
<div class="flex flex-col items-center justify-center py-7 mb-2">
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#18181b] animate-spin mb-3"></div>
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
</div>
</template>
<dl class="text-sm space-y-2">
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">订单号</dt><dd class="font-mono text-xs text-[color:var(--fg)]">{{ order.id }}</dd></div>
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">状态</dt><dd class="text-amber-500 font-medium">{{ statusLabel }}</dd></div>
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">金额</dt><dd class="font-semibold text-[color:var(--fg)]">¥{{ order.amount }}</dd></div>
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">充值积分</dt><dd class="text-[color:var(--m-orange)] font-semibold">{{ order.points }}</dd></div>
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">下单时间</dt><dd class="text-xs text-[color:var(--fg-2)]">{{ fmtTime(order.created_at) }}</dd></div>
<div class="flex justify-between"><dt class="text-[color:var(--fg-faint)]">支付倒计时</dt><dd class="tabular-nums font-medium" :class="remaining > 0 ? 'text-[color:var(--fg)]' : 'text-rose-500'">{{ remaining > 0 ? mmss : '已超时' }}</dd></div>
</dl>
<p class="text-[11px] text-[color:var(--fg-faint)] mt-4 text-center">支付完成后将自动到账,请勿关闭本窗口</p>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.pay-fade-enter-active, .pay-fade-leave-active { transition: opacity 0.2s ease; }
.pay-fade-enter-from, .pay-fade-leave-to { opacity: 0; }
</style>