pay: submit.php 直出收银台页(new-api 易支付对接)
- submit.php 验签建单后不再 302,直接渲染自包含收银台 HTML, 支付宝二维码由服务端 skip2/go-qrcode 生成 PNG data URL 内嵌 - 新增公开轮询端点 /cashier-status(页面经 /api 前缀访问), 与 PaySvc.CashierStatus 共用 cashierStatusReply - Cashier.vue 收银台页同步改版:取景框二维码、倒计时进度条, 去掉跳上游收银台的外链 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -99,6 +99,9 @@ func main() {
|
|||||||
f.BindRoute("POST", "/mapi.php", service.PayMapiHandler(st, up)),
|
f.BindRoute("POST", "/mapi.php", service.PayMapiHandler(st, up)),
|
||||||
f.BindRoute("GET", "/api.php", service.PayApiHandler(st)),
|
f.BindRoute("GET", "/api.php", service.PayApiHandler(st)),
|
||||||
f.BindRoute("POST", "/api.php", service.PayApiHandler(st)),
|
f.BindRoute("POST", "/api.php", service.PayApiHandler(st)),
|
||||||
|
// submit.php 直出收银台页的状态轮询(经代理走 /api 前缀,直连后端也兼容)
|
||||||
|
f.BindRoute("GET", "/cashier-status", service.PayCashierStatusHandler(st)),
|
||||||
|
f.BindRoute("GET", "/api/cashier-status", service.PayCashierStatusHandler(st)),
|
||||||
f.BindRoute("GET", "/healthz", func(c *fun.RouteCtx) error {
|
f.BindRoute("GET", "/healthz", func(c *fun.RouteCtx) error {
|
||||||
c.RequestCtx.WriteString("ok")
|
c.RequestCtx.WriteString("ok")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/mark3labs/mcp-go v1.1.1
|
github.com/mark3labs/mcp-go v1.1.1
|
||||||
github.com/pkg/sftp v1.13.11
|
github.com/pkg/sftp v1.13.11
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/valyala/fasthttp v1.73.0
|
github.com/valyala/fasthttp v1.73.0
|
||||||
github.com/xuri/excelize/v2 v2.11.0
|
github.com/xuri/excelize/v2 v2.11.0
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
|
|||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
|
||||||
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// cashier_page.go submit.php 直出的自包含收银台页(不 302、不依赖前端 SPA),
|
||||||
|
// 二维码由服务端生成 PNG data URL;状态经 /cashier-status 轮询。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cyi-cc/fun"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
|
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||||
|
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cashierTmpl 收银台页面模板(新粗野主义风格,与前端 Cashier.vue 一致)。
|
||||||
|
var cashierTmpl = template.Must(template.New("cashier").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="referrer" content="never">
|
||||||
|
<title>收银台 · ¥{{.Money}}</title>
|
||||||
|
<style>
|
||||||
|
:root{--ink:#1c1b1a;--cream:#f7f4ec;--cream2:#e5e0d5;--ink3:#8a8577;--blue:#1677ff;--green:#18a058}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--cream);padding:20px;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink)}
|
||||||
|
.box{width:380px;max-width:100%;background:#fff;border:3px solid var(--ink);border-radius:20px;padding:28px;text-align:center;box-shadow:6px 6px 0 var(--ink)}
|
||||||
|
.head{border-bottom:2px dashed var(--cream2);padding-bottom:14px;margin-bottom:16px}
|
||||||
|
.logo{font-size:20px;font-weight:900}
|
||||||
|
.orderno{font-size:11px;color:var(--ink3);font-family:Consolas,monospace;margin-top:4px;word-break:break-all}
|
||||||
|
.subject{font-size:14px;font-weight:700}
|
||||||
|
.amount{font-size:40px;font-weight:900;font-family:Consolas,monospace;margin:8px 0 18px}
|
||||||
|
.amount .cny{font-size:22px;vertical-align:6px;margin-right:2px}
|
||||||
|
.qr-area{display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||||
|
.qr-frame{position:relative;padding:14px;background:#fff;border:3px solid var(--ink);border-radius:16px;box-shadow:5px 5px 0 var(--ink)}
|
||||||
|
.qr-frame img{display:block;width:240px;height:240px;border-radius:8px}
|
||||||
|
.qr-empty{width:240px;height:240px;display:flex;align-items:center;justify-content:center;color:var(--ink3);font-size:13px}
|
||||||
|
.corner{position:absolute;width:18px;height:18px;border:3px solid var(--blue)}
|
||||||
|
.tl{top:-6px;left:-6px;border-right:0;border-bottom:0;border-radius:8px 0 0 0}
|
||||||
|
.tr{top:-6px;right:-6px;border-left:0;border-bottom:0;border-radius:0 8px 0 0}
|
||||||
|
.bl{bottom:-6px;left:-6px;border-right:0;border-top:0;border-radius:0 0 0 8px}
|
||||||
|
.br{bottom:-6px;right:-6px;border-left:0;border-top:0;border-radius:0 0 8px 0}
|
||||||
|
.tip{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}
|
||||||
|
.badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:var(--blue);color:#fff;border-radius:6px;font-size:14px;font-weight:900;border:2px solid var(--ink)}
|
||||||
|
.cd{font-size:12px;color:var(--ink3);display:flex;align-items:center;gap:6px;width:100%}
|
||||||
|
.cd .bar{flex:1;height:8px;background:var(--cream2);border:2px solid var(--ink);border-radius:6px;overflow:hidden}
|
||||||
|
.cd .bar i{display:block;height:100%;background:var(--blue);transition:width 1s linear}
|
||||||
|
.state{display:flex;flex-direction:column;align-items:center;gap:12px;padding:30px 0;font-size:15px;font-weight:700}
|
||||||
|
.ok{color:var(--green);font-size:18px}
|
||||||
|
.hint{font-size:12px;color:var(--ink3);font-weight:400}
|
||||||
|
svg{display:block}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<div class="head">
|
||||||
|
<div class="logo">池易支付</div>
|
||||||
|
<div class="orderno">订单号 {{.TradeNo}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="subject">{{or .Subject "商品订单"}}</div>
|
||||||
|
<div class="amount"><span class="cny">¥</span>{{.Money}}</div>
|
||||||
|
|
||||||
|
<div id="wait" class="qr-area"{{if ne .Status 0}} style="display:none"{{end}}>
|
||||||
|
<div class="qr-frame">
|
||||||
|
{{if .QRDataURL}}<img src="{{.QRDataURL}}" alt="支付宝扫码">{{else}}<div class="qr-empty">二维码生成失败,请刷新重试</div>{{end}}
|
||||||
|
<span class="corner tl"></span><span class="corner tr"></span><span class="corner bl"></span><span class="corner br"></span>
|
||||||
|
</div>
|
||||||
|
<div class="tip"><span class="badge">支</span>打开<b>支付宝</b>扫一扫完成付款</div>
|
||||||
|
<div class="cd">⏰ 支付剩余 <b id="cdtext">--:--</b><span class="bar"><i id="cdbar"></i></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="paid" class="state"{{if ne .Status 1}} style="display:none"{{end}}>
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="11" fill="#18a058"/><path d="M7 12.5l3.2 3.2L17 9" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<div class="ok">支付成功</div>
|
||||||
|
<div class="hint" id="redir" style="display:none">正在跳回商家页面…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="closed" class="state"{{if ne .Status 3}} style="display:none"{{end}}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="11" fill="#909399"/><path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>
|
||||||
|
<div>订单已关闭</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var TRADE_NO = {{.TradeNo}};
|
||||||
|
var EXPIRED = {{.ExpiredAt}};
|
||||||
|
var STATUS = {{.Status}};
|
||||||
|
|
||||||
|
function setState(s) {
|
||||||
|
document.getElementById('wait').style.display = s === 0 ? 'flex' : 'none';
|
||||||
|
document.getElementById('paid').style.display = s === 1 ? 'flex' : 'none';
|
||||||
|
document.getElementById('closed').style.display = s === 3 ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
var timer = null;
|
||||||
|
function poll() {
|
||||||
|
fetch('/api/cashier-status?trade_no=' + encodeURIComponent(TRADE_NO))
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (d) {
|
||||||
|
var s = typeof d.status === 'number' ? d.status : 0;
|
||||||
|
if (s === 1) {
|
||||||
|
setState(1);
|
||||||
|
if (d.return_url) {
|
||||||
|
document.getElementById('redir').style.display = 'block';
|
||||||
|
setTimeout(function () { location.href = d.return_url }, 1200);
|
||||||
|
}
|
||||||
|
clearInterval(timer);
|
||||||
|
} else if (s === 3) {
|
||||||
|
setState(3);
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
if (STATUS === 0) {
|
||||||
|
timer = setInterval(poll, 2500);
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
var left = Math.max(0, EXPIRED - Math.floor(Date.now() / 1000));
|
||||||
|
var m = String(Math.floor(left / 60)).padStart(2, '0');
|
||||||
|
var s = String(left % 60).padStart(2, '0');
|
||||||
|
var el = document.getElementById('cdtext');
|
||||||
|
if (el) el.textContent = m + ':' + s;
|
||||||
|
var bar = document.getElementById('cdbar');
|
||||||
|
if (bar) bar.style.width = Math.min(100, left / 18) + '%';
|
||||||
|
}
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
tick();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
type cashierPageData struct {
|
||||||
|
TradeNo string
|
||||||
|
Subject string
|
||||||
|
Money string
|
||||||
|
QRDataURL string
|
||||||
|
ExpiredAt int64
|
||||||
|
Status int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCashier 渲染收银台 HTML(二维码内容 → PNG data URL)。
|
||||||
|
func renderCashier(c *fun.RouteCtx, o db.PayOrder) error {
|
||||||
|
d := cashierPageData{
|
||||||
|
TradeNo: o.TradeNo,
|
||||||
|
Subject: o.Subject,
|
||||||
|
Money: fenToYuan(o.Money),
|
||||||
|
ExpiredAt: o.ExpiredAt,
|
||||||
|
Status: o.Status,
|
||||||
|
}
|
||||||
|
if o.Qrcode != "" {
|
||||||
|
if png, err := qrcode.Encode(o.Qrcode, qrcode.Medium, 256); err == nil {
|
||||||
|
d.QRDataURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := cashierTmpl.Execute(&buf, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.RequestCtx.Response.Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.RequestCtx.SetBody(buf.Bytes())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayCashierStatusHandler GET /cashier-status?trade_no=:收银台页轮询
|
||||||
|
// (公开接口,trade_no 不可枚举即凭证;前端经 /api 前缀代理访问)。
|
||||||
|
func PayCashierStatusHandler(st *store.Store) fun.RouteHandler {
|
||||||
|
return func(c *fun.RouteCtx) error {
|
||||||
|
status, returnURL, err := cashierStatusReply(st, strings.TrimSpace(c.Param("trade_no")))
|
||||||
|
if err != nil {
|
||||||
|
return epayJSON(c, map[string]any{"status": -1})
|
||||||
|
}
|
||||||
|
return epayJSON(c, map[string]any{"status": status, "return_url": returnURL})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cashierStatusReply 收银台状态查询:订单态 + 已支付时的签名回跳地址(RPC 与页面轮询共用)。
|
||||||
|
func cashierStatusReply(st *store.Store, tradeNo string) (int64, string, error) {
|
||||||
|
o, err := st.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
lazyCloseExpired(st, &o)
|
||||||
|
returnURL := ""
|
||||||
|
if o.Status == payStatusPaid && o.ReturnUrl != "" {
|
||||||
|
if pid, key, err := payMerchant(st); err == nil {
|
||||||
|
returnURL = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.Status, returnURL, nil
|
||||||
|
}
|
||||||
@@ -402,18 +402,11 @@ func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo str
|
|||||||
|
|
||||||
// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。
|
// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。
|
||||||
func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) {
|
func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) {
|
||||||
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
|
status, returnURL, err := cashierStatusReply(s.Store, strings.TrimSpace(d.TradeNo))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
|
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
|
||||||
}
|
}
|
||||||
res := dto.PayCashierStatusView{Status: o.Status}
|
return dto.PayCashierStatusView{Status: status, ReturnUrl: returnURL}, nil
|
||||||
if o.Status == payStatusPaid && o.ReturnUrl != "" {
|
|
||||||
pid, key, err := payMerchant(s.Store)
|
|
||||||
if err == nil {
|
|
||||||
res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toPayOrderView(r db.PayOrder) dto.PayOrderView {
|
func toPayOrderView(r db.PayOrder) dto.PayOrderView {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// pay_routes.go 易支付兼容协议端点:/submit.php(跳收银台)、/mapi.php(JSON下单)、/api.php(查单)。
|
// pay_routes.go 易支付兼容协议端点:/submit.php(验签建单+直出收银台页)、/mapi.php(JSON下单)、/api.php(查单)。
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,20 +12,18 @@ import (
|
|||||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// paySubmitHandler GET/POST /submit.php:验签建单后 302 到收银台。
|
// paySubmitHandler GET/POST /submit.php:验签建单后直接渲染收银台页(无跳转)。
|
||||||
func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
|
func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
|
||||||
return func(c *fun.RouteCtx) error {
|
return func(c *fun.RouteCtx) error {
|
||||||
order, err := epayCreateOrder(c, st, up)
|
order, err := epayCreateOrder(c, st, up)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return epayFail(c, err)
|
return epayFail(c, err)
|
||||||
}
|
}
|
||||||
// 当码支付:优先跳二维码支付链接(qr.alipay.com),退化到本地收银台
|
o, err := st.Q().GetPayOrderByTradeNo(context.Background(), order.TradeNo)
|
||||||
target := order.Qrcode
|
if err != nil {
|
||||||
if target == "" {
|
return epayFail(c, err)
|
||||||
target = "/cashier/" + order.TradeNo
|
|
||||||
}
|
}
|
||||||
c.RequestCtx.Redirect(target, 302)
|
return renderCashier(c, o)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { NButton, NIcon, NSpin } from 'naive-ui'
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
import { CheckmarkCircleOutline, TimeOutline, CloseCircleOutline } from '@vicons/ionicons5'
|
import { CheckmarkCircleOutline, TimeOutline, CloseCircleOutline } from '@vicons/ionicons5'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { client } from '../lib/api'
|
import { client } from '../lib/api'
|
||||||
@@ -16,8 +16,11 @@ const info = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
|
const redirecting = ref(false)
|
||||||
|
const now = ref(Math.floor(Date.now() / 1000))
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | null = null
|
||||||
function stopPoll() {
|
function stopPoll() {
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,7 @@ async function applyStatus(status: number, returnUrl?: string) {
|
|||||||
if (status === 1) {
|
if (status === 1) {
|
||||||
const d = await client.paySvc.cashierInfo({ tradeNo })
|
const d = await client.paySvc.cashierInfo({ tradeNo })
|
||||||
if (d.data) info.value.cards = d.data.cards
|
if (d.data) info.value.cards = d.data.cards
|
||||||
if (returnUrl) setTimeout(() => { location.href = returnUrl }, 1500)
|
if (returnUrl) { redirecting.value = true; setTimeout(() => { location.href = returnUrl }, 1500) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +50,17 @@ const statusLabel = computed(() => {
|
|||||||
return { 0: '等待支付', 1: '支付成功', 3: '订单已关闭' }[info.value.status] ?? '订单已关闭'
|
return { 0: '等待支付', 1: '支付成功', 3: '订单已关闭' }[info.value.status] ?? '订单已关闭'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const remainSec = computed(() => {
|
||||||
|
if (!info.value || !info.value.expiredAt) return 0
|
||||||
|
return Math.max(0, info.value.expiredAt - now.value)
|
||||||
|
})
|
||||||
|
const remainText = computed(() => {
|
||||||
|
const m = Math.floor(remainSec.value / 60)
|
||||||
|
const s = remainSec.value % 60
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
const remainPct = computed(() => Math.min(100, (remainSec.value / 1800) * 100))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const r = await client.paySvc.cashierInfo({ tradeNo })
|
const r = await client.paySvc.cashierInfo({ tradeNo })
|
||||||
if (r.status !== 0 || !r.data) { notFound.value = true; return }
|
if (r.status !== 0 || !r.data) { notFound.value = true; return }
|
||||||
@@ -72,8 +86,11 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
onUnmounted(() => { abort.abort(); stopPoll() })
|
load()
|
||||||
|
clockTimer = setInterval(() => { now.value = Math.floor(Date.now() / 1000) }, 1000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => { abort.abort(); stopPoll(); if (clockTimer) clearInterval(clockTimer) })
|
||||||
|
|
||||||
function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }
|
function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }
|
||||||
</script>
|
</script>
|
||||||
@@ -94,21 +111,30 @@ function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="cash-subject">{{ info!.subject || '商品订单' }}</div>
|
<div class="cash-subject">{{ info!.subject || '商品订单' }}</div>
|
||||||
<div class="cash-amount">¥{{ fen2yuan(info!.money) }}</div>
|
<div class="cash-amount"><span class="cash-cny">¥</span>{{ fen2yuan(info!.money) }}</div>
|
||||||
|
|
||||||
<div v-if="info!.status === 0" class="cash-qr-area">
|
<div v-if="info!.status === 0" class="cash-qr-area">
|
||||||
<img v-if="qrDataUrl" :src="qrDataUrl" class="cash-qr" alt="支付宝扫码" />
|
<div class="cash-qr-frame">
|
||||||
|
<img v-if="qrDataUrl" :src="qrDataUrl" class="cash-qr" alt="支付宝扫码" />
|
||||||
|
<div v-else class="cash-qr-empty">二维码生成中…</div>
|
||||||
|
<div class="cash-qr-corner tl" /><div class="cash-qr-corner tr" />
|
||||||
|
<div class="cash-qr-corner bl" /><div class="cash-qr-corner br" />
|
||||||
|
</div>
|
||||||
<div class="cash-qr-tip">
|
<div class="cash-qr-tip">
|
||||||
<n-icon><TimeOutline /></n-icon> 请使用 <b>支付宝</b> 扫码支付
|
<span class="cash-alipay-badge">支</span>
|
||||||
|
打开<b>支付宝</b>扫一扫,向「池易」付款
|
||||||
|
</div>
|
||||||
|
<div class="cash-countdown">
|
||||||
|
<n-icon><TimeOutline /></n-icon>
|
||||||
|
支付剩余时间 {{ remainText }}
|
||||||
|
<div class="cash-countdown-bar"><i :style="{ width: remainPct + '%' }" /></div>
|
||||||
</div>
|
</div>
|
||||||
<n-button v-if="info!.payurl" size="small" quaternary tag="a" :href="info!.payurl" target="_blank">
|
|
||||||
在支付宝收银台继续 →
|
|
||||||
</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="info!.status === 1" class="cash-state">
|
<div v-else-if="info!.status === 1" class="cash-state">
|
||||||
<n-icon :size="56" color="#18a058"><CheckmarkCircleOutline /></n-icon>
|
<n-icon :size="56" color="#18a058"><CheckmarkCircleOutline /></n-icon>
|
||||||
<div class="cash-ok">{{ statusLabel }}</div>
|
<div class="cash-ok">{{ statusLabel }}</div>
|
||||||
|
<div v-if="redirecting" class="cash-redirect">正在跳回商家页面…</div>
|
||||||
<pre v-if="info!.cards" class="cash-cards">{{ info!.cards }}</pre>
|
<pre v-if="info!.cards" class="cash-cards">{{ info!.cards }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,12 +161,50 @@ function fen2yuan(fen: number): string { return (fen / 100).toFixed(2) }
|
|||||||
.cash-logo { font-size: 20px; font-weight: 900; }
|
.cash-logo { font-size: 20px; font-weight: 900; }
|
||||||
.cash-sub { font-size: 11px; color: var(--cb-ink-3, #8a8577); font-family: Consolas, monospace; margin-top: 4px; }
|
.cash-sub { font-size: 11px; color: var(--cb-ink-3, #8a8577); font-family: Consolas, monospace; margin-top: 4px; }
|
||||||
.cash-subject { font-size: 14px; font-weight: 700; }
|
.cash-subject { font-size: 14px; font-weight: 700; }
|
||||||
.cash-amount { font-size: 34px; font-weight: 900; font-family: Consolas, monospace; margin: 8px 0 18px; }
|
.cash-amount { font-size: 40px; font-weight: 900; font-family: Consolas, monospace; margin: 8px 0 18px; }
|
||||||
.cash-qr-area { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
.cash-cny { font-size: 22px; vertical-align: 6px; margin-right: 2px; }
|
||||||
.cash-qr { border: 3px solid var(--cb-ink, #1c1b1a); border-radius: 12px; }
|
.cash-qr-area { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||||
.cash-qr-tip { font-size: 13px; color: var(--cb-ink-3, #8a8577); display: flex; align-items: center; gap: 6px; }
|
.cash-qr-frame {
|
||||||
|
position: relative; padding: 14px; background: #fff;
|
||||||
|
border: 3px solid var(--cb-ink, #1c1b1a); border-radius: 16px;
|
||||||
|
box-shadow: 5px 5px 0 var(--cb-ink, #1c1b1a);
|
||||||
|
}
|
||||||
|
.cash-qr { display: block; border-radius: 8px; }
|
||||||
|
.cash-qr-empty {
|
||||||
|
width: 240px; height: 240px; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--cb-ink-3, #8a8577); font-size: 13px;
|
||||||
|
}
|
||||||
|
.cash-qr-corner {
|
||||||
|
position: absolute; width: 18px; height: 18px; border: 3px solid #1677ff;
|
||||||
|
}
|
||||||
|
.cash-qr-corner.tl { top: -6px; left: -6px; border-right: none; border-bottom: none; border-radius: 8px 0 0 0; }
|
||||||
|
.cash-qr-corner.tr { top: -6px; right: -6px; border-left: none; border-bottom: none; border-radius: 0 8px 0 0; }
|
||||||
|
.cash-qr-corner.bl { bottom: -6px; left: -6px; border-right: none; border-top: none; border-radius: 0 0 0 8px; }
|
||||||
|
.cash-qr-corner.br { bottom: -6px; right: -6px; border-left: none; border-top: none; border-radius: 0 0 8px 0; }
|
||||||
|
.cash-qr-tip {
|
||||||
|
font-size: 14px; font-weight: 700; color: var(--cb-ink, #1c1b1a);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.cash-alipay-badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 24px; background: #1677ff; color: #fff;
|
||||||
|
border-radius: 6px; font-size: 14px; font-weight: 900;
|
||||||
|
border: 2px solid var(--cb-ink, #1c1b1a);
|
||||||
|
}
|
||||||
|
.cash-countdown {
|
||||||
|
font-size: 12px; color: var(--cb-ink-3, #8a8577);
|
||||||
|
display: flex; align-items: center; gap: 6px; width: 100%;
|
||||||
|
}
|
||||||
|
.cash-countdown-bar {
|
||||||
|
flex: 1; height: 8px; background: var(--cb-cream-2, #e5e0d5);
|
||||||
|
border: 2px solid var(--cb-ink, #1c1b1a); border-radius: 6px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.cash-countdown-bar i {
|
||||||
|
display: block; height: 100%; background: #1677ff; transition: width 1s linear;
|
||||||
|
}
|
||||||
.cash-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 30px 0; font-size: 15px; font-weight: 700; }
|
.cash-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 30px 0; font-size: 15px; font-weight: 700; }
|
||||||
.cash-ok { color: #18a058; font-size: 18px; }
|
.cash-ok { color: #18a058; font-size: 18px; }
|
||||||
|
.cash-redirect { font-size: 12px; color: var(--cb-ink-3, #8a8577); font-weight: 400; }
|
||||||
.cash-cards {
|
.cash-cards {
|
||||||
text-align: left; font-size: 12px; font-family: Consolas, monospace; max-height: 200px;
|
text-align: left; font-size: 12px; font-family: Consolas, monospace; max-height: 200px;
|
||||||
overflow: auto; background: var(--cb-cream, #f7f4ec); border-radius: 8px; padding: 10px; margin: 0;
|
overflow: auto; background: var(--cb-cream, #f7f4ec); border-radius: 8px; padding: 10px; margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user