Merge pull request #1810 from IanShaw027/fix/profile-auth-bindings-i18n
fix(payment,profile,admin): 修复支付二维码流程、绑定提示与后台配置说明
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
// Alipay product codes.
|
// Alipay product codes.
|
||||||
const (
|
const (
|
||||||
|
alipayProductCodePreCreate = "FACE_TO_FACE_PAYMENT"
|
||||||
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
||||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,9 @@ var (
|
|||||||
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
alipayTradeWapPay = func(client *alipay.Client, param alipay.TradeWapPay) (*url.URL, error) {
|
||||||
return client.TradeWapPay(param)
|
return client.TradeWapPay(param)
|
||||||
}
|
}
|
||||||
|
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||||
|
return client.TradePreCreate(ctx, param)
|
||||||
|
}
|
||||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||||
return client.TradePagePay(param)
|
return client.TradePagePay(param)
|
||||||
}
|
}
|
||||||
@@ -99,13 +103,13 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string {
|
|||||||
return map[string]string{"app_id": appID}
|
return map[string]string{"app_id": appID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePayment creates an Alipay payment using redirect-only flow:
|
// CreatePayment creates an Alipay payment using the following routing:
|
||||||
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
|
// - Mobile (H5): alipay.trade.wap.pay — browser redirect into Alipay.
|
||||||
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
|
// - Desktop: prefer alipay.trade.precreate to get a scan payload directly.
|
||||||
// new window; Alipay's own page then shows login/QR. We intentionally do
|
// - Desktop fallback: if precreate is unavailable for the merchant, fall back
|
||||||
// NOT encode the URL into a QR on the client (it isn't a scannable payload
|
// to alipay.trade.page.pay and expose both pay_url and qr_code so the
|
||||||
// and would produce an invalid scan result).
|
// frontend can render a QR while still allowing direct page open.
|
||||||
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||||
client, err := a.getClient()
|
client, err := a.getClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,7 +127,7 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
|
|||||||
if req.IsMobile {
|
if req.IsMobile {
|
||||||
return a.createWapTrade(client, req, notifyURL, returnURL)
|
return a.createWapTrade(client, req, notifyURL, returnURL)
|
||||||
}
|
}
|
||||||
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
return a.createDesktopTrade(ctx, client, req, notifyURL, returnURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
@@ -145,6 +149,48 @@ func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePayment
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Alipay) createDesktopTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
|
resp, precreateErr := a.createPrecreateTrade(ctx, client, req, notifyURL)
|
||||||
|
if precreateErr == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, pagePayErr := a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||||
|
if pagePayErr == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("alipay desktop payment failed: precreate=%v; pagepay=%w", precreateErr, pagePayErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Alipay) createPrecreateTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
|
param := alipay.TradePreCreate{}
|
||||||
|
param.OutTradeNo = req.OrderID
|
||||||
|
param.TotalAmount = req.Amount
|
||||||
|
param.Subject = req.Subject
|
||||||
|
param.ProductCode = alipayProductCodePreCreate
|
||||||
|
param.NotifyURL = notifyURL
|
||||||
|
|
||||||
|
rsp, err := alipayTradePreCreate(ctx, client, param)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
|
||||||
|
}
|
||||||
|
if rsp == nil {
|
||||||
|
return nil, fmt.Errorf("alipay TradePreCreate: empty response")
|
||||||
|
}
|
||||||
|
if rsp.IsFailure() {
|
||||||
|
return nil, fmt.Errorf("alipay TradePreCreate failed: %s", rsp.Error.Error())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rsp.QRCode) == "" {
|
||||||
|
return nil, fmt.Errorf("alipay TradePreCreate: empty qr_code")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &payment.CreatePaymentResponse{
|
||||||
|
TradeNo: req.OrderID,
|
||||||
|
QRCode: rsp.QRCode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
param := alipay.TradePagePay{}
|
param := alipay.TradePagePay{}
|
||||||
param.OutTradeNo = req.OrderID
|
param.OutTradeNo = req.OrderID
|
||||||
@@ -161,6 +207,7 @@ func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePay
|
|||||||
return &payment.CreatePaymentResponse{
|
return &payment.CreatePaymentResponse{
|
||||||
TradeNo: req.OrderID,
|
TradeNo: req.OrderID,
|
||||||
PayURL: payURL.String(),
|
PayURL: payURL.String(),
|
||||||
|
QRCode: payURL.String(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +239,15 @@ func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query
|
|||||||
|
|
||||||
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
|
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("alipay parse amount %q: %w", result.TotalAmount, err)
|
amount, err = parseAlipayAmount(
|
||||||
|
result.TotalAmount,
|
||||||
|
result.ReceiptAmount,
|
||||||
|
result.BuyerPayAmount,
|
||||||
|
result.InvoiceAmount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alipay parse amount: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &payment.QueryOrderResponse{
|
return &payment.QueryOrderResponse{
|
||||||
@@ -228,7 +283,14 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s
|
|||||||
|
|
||||||
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
|
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err)
|
amount, err = parseAlipayAmount(
|
||||||
|
notification.TotalAmount,
|
||||||
|
notification.ReceiptAmount,
|
||||||
|
notification.BuyerPayAmount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alipay parse notification amount: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := a.MerchantIdentityMetadata()
|
metadata := a.MerchantIdentityMetadata()
|
||||||
@@ -306,6 +368,20 @@ func isTradeNotExist(err error) bool {
|
|||||||
return strings.Contains(err.Error(), alipayErrTradeNotExist)
|
return strings.Contains(err.Error(), alipayErrTradeNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseAlipayAmount(values ...string) (float64, error) {
|
||||||
|
for _, raw := range values {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err == nil {
|
||||||
|
return amount, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no valid amount field")
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure interface compliance.
|
// Ensure interface compliance.
|
||||||
var (
|
var (
|
||||||
_ payment.Provider = (*Alipay)(nil)
|
_ payment.Provider = (*Alipay)(nil)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -136,15 +137,22 @@ func TestNewAlipay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
||||||
|
origPreCreate := alipayTradePreCreate
|
||||||
origPagePay := alipayTradePagePay
|
origPagePay := alipayTradePagePay
|
||||||
origWapPay := alipayTradeWapPay
|
origWapPay := alipayTradeWapPay
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
alipayTradePreCreate = origPreCreate
|
||||||
alipayTradePagePay = origPagePay
|
alipayTradePagePay = origPagePay
|
||||||
alipayTradeWapPay = origWapPay
|
alipayTradeWapPay = origWapPay
|
||||||
})
|
})
|
||||||
|
|
||||||
|
preCreateCalls := 0
|
||||||
pagePayCalls := 0
|
pagePayCalls := 0
|
||||||
wapPayCalls := 0
|
wapPayCalls := 0
|
||||||
|
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||||
|
preCreateCalls++
|
||||||
|
return nil, errors.New("merchant does not have FACE_TO_FACE_PAYMENT")
|
||||||
|
}
|
||||||
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||||
pagePayCalls++
|
pagePayCalls++
|
||||||
if param.OutTradeNo != "sub2_100" {
|
if param.OutTradeNo != "sub2_100" {
|
||||||
@@ -161,7 +169,7 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provider := &Alipay{}
|
provider := &Alipay{}
|
||||||
resp, err := provider.createPagePayTrade(&alipay.Client{}, payment.CreatePaymentRequest{
|
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||||
OrderID: "sub2_100",
|
OrderID: "sub2_100",
|
||||||
Amount: "88.00",
|
Amount: "88.00",
|
||||||
Subject: "Balance recharge",
|
Subject: "Balance recharge",
|
||||||
@@ -169,6 +177,9 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
if preCreateCalls != 1 {
|
||||||
|
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||||
|
}
|
||||||
if pagePayCalls != 1 {
|
if pagePayCalls != 1 {
|
||||||
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
t.Fatalf("page pay calls = %d, want 1", pagePayCalls)
|
||||||
}
|
}
|
||||||
@@ -178,6 +189,9 @@ func TestCreateTradeUsesPagePayForDesktop(t *testing.T) {
|
|||||||
if resp.PayURL == "" {
|
if resp.PayURL == "" {
|
||||||
t.Fatal("expected pay_url for desktop page pay")
|
t.Fatal("expected pay_url for desktop page pay")
|
||||||
}
|
}
|
||||||
|
if resp.QRCode != resp.PayURL {
|
||||||
|
t.Fatalf("qr_code = %q, want same as pay_url %q", resp.QRCode, resp.PayURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
|
func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
|
||||||
@@ -213,6 +227,54 @@ func TestCreateTradeUsesWapPayForMobile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTradeUsesPrecreateForDesktopWhenAvailable(t *testing.T) {
|
||||||
|
origPreCreate := alipayTradePreCreate
|
||||||
|
origPagePay := alipayTradePagePay
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alipayTradePreCreate = origPreCreate
|
||||||
|
alipayTradePagePay = origPagePay
|
||||||
|
})
|
||||||
|
|
||||||
|
preCreateCalls := 0
|
||||||
|
pagePayCalls := 0
|
||||||
|
alipayTradePreCreate = func(ctx context.Context, client *alipay.Client, param alipay.TradePreCreate) (*alipay.TradePreCreateRsp, error) {
|
||||||
|
preCreateCalls++
|
||||||
|
if param.ProductCode != alipayProductCodePreCreate {
|
||||||
|
t.Fatalf("product_code = %q, want %q", param.ProductCode, alipayProductCodePreCreate)
|
||||||
|
}
|
||||||
|
return &alipay.TradePreCreateRsp{
|
||||||
|
Error: alipay.Error{Code: alipay.CodeSuccess},
|
||||||
|
QRCode: "https://qr.alipay.example.com/precreate-token",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
|
||||||
|
pagePayCalls++
|
||||||
|
return url.Parse("https://openapi.alipay.com/gateway.do?page-pay")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &Alipay{}
|
||||||
|
resp, err := provider.createDesktopTrade(context.Background(), &alipay.Client{}, payment.CreatePaymentRequest{
|
||||||
|
OrderID: "sub2_102",
|
||||||
|
Amount: "66.00",
|
||||||
|
Subject: "Balance recharge",
|
||||||
|
}, "https://merchant.example.com/api/v1/payment/webhook/alipay", "https://merchant.example.com/payment/result")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if preCreateCalls != 1 {
|
||||||
|
t.Fatalf("precreate calls = %d, want 1", preCreateCalls)
|
||||||
|
}
|
||||||
|
if pagePayCalls != 0 {
|
||||||
|
t.Fatalf("page pay calls = %d, want 0", pagePayCalls)
|
||||||
|
}
|
||||||
|
if resp.QRCode != "https://qr.alipay.example.com/precreate-token" {
|
||||||
|
t.Fatalf("qr_code = %q", resp.QRCode)
|
||||||
|
}
|
||||||
|
if resp.PayURL != "" {
|
||||||
|
t.Fatalf("pay_url = %q, want empty for precreate", resp.PayURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlipayMerchantIdentityMetadata(t *testing.T) {
|
func TestAlipayMerchantIdentityMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -227,3 +289,19 @@ func TestAlipayMerchantIdentityMetadata(t *testing.T) {
|
|||||||
t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
|
t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAlipayAmount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
amount, err := parseAlipayAmount("", "88.00", "77.00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if amount != 88 {
|
||||||
|
t.Fatalf("amount = %v, want 88", amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseAlipayAmount("", "not-a-number"); err == nil {
|
||||||
|
t.Fatal("expected error when no valid amount field exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ Compatible with any payment service that implements the EasyPay protocol.
|
|||||||
|
|
||||||
### Alipay (Direct)
|
### Alipay (Direct)
|
||||||
|
|
||||||
Direct integration with Alipay Open Platform. Desktop flows return a QR code for in-page display, while mobile flows return an Alipay WAP/app redirect URL.
|
Direct integration with Alipay Open Platform. Mobile flows return an Alipay WAP/app redirect URL. Desktop flows prefer Face-to-Face Precreate QR payloads; if the merchant has not enabled that product, the provider falls back to Computer Website Pay and also returns the cashier URL so the frontend can render a QR code or open the hosted checkout page directly.
|
||||||
|
|
||||||
| Parameter | Description | Required |
|
| Parameter | Description | Required |
|
||||||
|-----------|-------------|----------|
|
|-----------|-------------|----------|
|
||||||
@@ -229,7 +229,7 @@ User selects amount and payment method
|
|||||||
▼
|
▼
|
||||||
User completes payment
|
User completes payment
|
||||||
├─ EasyPay → QR code / H5 redirect
|
├─ EasyPay → QR code / H5 redirect
|
||||||
├─ Alipay → Desktop QR / mobile Alipay redirect
|
├─ Alipay → Desktop QR payload (Face-to-Face preferred, Website Pay fallback) / mobile Alipay redirect
|
||||||
├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI
|
├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI
|
||||||
└─ Stripe → Payment Element (card/Alipay/WeChat/etc.)
|
└─ Stripe → Payment Element (card/Alipay/WeChat/etc.)
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支
|
|||||||
|
|
||||||
### 支付宝官方
|
### 支付宝官方
|
||||||
|
|
||||||
直接对接支付宝开放平台。桌面端返回二维码供页面内展示和扫码,移动端返回支付宝手机网站支付跳转链接。
|
直接对接支付宝开放平台。移动端走支付宝手机网站支付跳转;桌面端优先使用当面付返回扫码串,若商户未开通当面付则回退到电脑网站支付,并将收银台链接同时返回给前端用于渲染二维码或直接打开支付页。
|
||||||
|
|
||||||
| 参数 | 说明 | 必填 |
|
| 参数 | 说明 | 必填 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -229,7 +229,7 @@ Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支
|
|||||||
▼
|
▼
|
||||||
用户完成支付
|
用户完成支付
|
||||||
├─ EasyPay → 扫码 / H5 跳转
|
├─ EasyPay → 扫码 / H5 跳转
|
||||||
├─ 支付宝官方 → 桌面二维码 / 移动端支付宝跳转
|
├─ 支付宝官方 → 桌面扫码单(当面付优先,电脑网站支付回退)/ 移动端支付宝跳转
|
||||||
├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI
|
├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI
|
||||||
└─ Stripe → Payment Element(银行卡/支付宝/微信等)
|
└─ Stripe → Payment Element(银行卡/支付宝/微信等)
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -1,23 +1,69 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, useTemplateRef, nextTick } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef, nextTick } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
content?: string
|
content?: string
|
||||||
}>()
|
trigger?: 'hover' | 'click'
|
||||||
|
widthClass?: string
|
||||||
|
}>(), {
|
||||||
|
trigger: 'hover',
|
||||||
|
widthClass: 'w-64',
|
||||||
|
})
|
||||||
|
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
const triggerRef = useTemplateRef<HTMLElement>('trigger')
|
const triggerRef = useTemplateRef<HTMLElement>('trigger')
|
||||||
|
const tooltipRef = useTemplateRef<HTMLElement>('tooltip')
|
||||||
const tooltipStyle = ref({ top: '0px', left: '0px' })
|
const tooltipStyle = ref({ top: '0px', left: '0px' })
|
||||||
|
|
||||||
function onEnter() {
|
function openTooltip() {
|
||||||
show.value = true
|
show.value = true
|
||||||
nextTick(updatePosition)
|
nextTick(updatePosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLeave() {
|
function closeTooltip() {
|
||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onEnter() {
|
||||||
|
if (props.trigger !== 'hover') return
|
||||||
|
openTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
if (props.trigger !== 'hover') return
|
||||||
|
closeTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(event: MouseEvent) {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
event.stopPropagation()
|
||||||
|
if (show.value) {
|
||||||
|
closeTooltip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick(event: MouseEvent) {
|
||||||
|
if (props.trigger !== 'click' || !show.value) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!target) return
|
||||||
|
if (triggerRef.value?.contains(target) || tooltipRef.value?.contains(target)) return
|
||||||
|
closeTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentKeydown(event: KeyboardEvent) {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewportChange() {
|
||||||
|
if (!show.value) return
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
function updatePosition() {
|
function updatePosition() {
|
||||||
const el = triggerRef.value
|
const el = triggerRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -27,6 +73,20 @@ function updatePosition() {
|
|||||||
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
|
left: `${rect.left + rect.width / 2 + window.scrollX}px`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocumentClick, true)
|
||||||
|
document.addEventListener('keydown', onDocumentKeydown)
|
||||||
|
window.addEventListener('resize', onViewportChange)
|
||||||
|
window.addEventListener('scroll', onViewportChange, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick, true)
|
||||||
|
document.removeEventListener('keydown', onDocumentKeydown)
|
||||||
|
window.removeEventListener('resize', onViewportChange)
|
||||||
|
window.removeEventListener('scroll', onViewportChange, true)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -35,6 +95,7 @@ function updatePosition() {
|
|||||||
class="group relative ml-1 inline-flex items-center align-middle"
|
class="group relative ml-1 inline-flex items-center align-middle"
|
||||||
@mouseenter="onEnter"
|
@mouseenter="onEnter"
|
||||||
@mouseleave="onLeave"
|
@mouseleave="onLeave"
|
||||||
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<!-- Trigger Icon -->
|
<!-- Trigger Icon -->
|
||||||
<slot name="trigger">
|
<slot name="trigger">
|
||||||
@@ -56,10 +117,26 @@ function updatePosition() {
|
|||||||
<!-- Teleport to body to escape modal overflow clipping -->
|
<!-- Teleport to body to escape modal overflow clipping -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
|
ref="tooltip"
|
||||||
v-show="show"
|
v-show="show"
|
||||||
class="fixed z-[99999] w-64 -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800"
|
role="tooltip"
|
||||||
|
:class="[
|
||||||
|
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800',
|
||||||
|
props.widthClass,
|
||||||
|
]"
|
||||||
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
|
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="props.trigger === 'click'"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-1.5 top-1.5 rounded p-1 text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
aria-label="Close"
|
||||||
|
@click.stop="closeTooltip"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<slot>{{ content }}</slot>
|
<slot>{{ content }}</slot>
|
||||||
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
80
frontend/src/components/common/__tests__/HelpTooltip.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||||
|
|
||||||
|
function getTooltipElement(): HTMLDivElement {
|
||||||
|
const tooltip = document.body.querySelector('[role="tooltip"]')
|
||||||
|
if (!(tooltip instanceof HTMLDivElement)) {
|
||||||
|
throw new Error('tooltip element not found')
|
||||||
|
}
|
||||||
|
return tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelpTooltip', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the existing hover interaction by default', async () => {
|
||||||
|
const wrapper = mount(HelpTooltip, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
content: 'hover details',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trigger = wrapper.get('.group')
|
||||||
|
const tooltip = getTooltipElement()
|
||||||
|
|
||||||
|
expect(tooltip.style.display).toBe('none')
|
||||||
|
|
||||||
|
await trigger.trigger('mouseenter')
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).not.toBe('none')
|
||||||
|
|
||||||
|
await trigger.trigger('mouseleave')
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).toBe('none')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports click-to-toggle details and closes on outside click', async () => {
|
||||||
|
const wrapper = mount(HelpTooltip, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
content: 'click details',
|
||||||
|
trigger: 'click',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trigger = wrapper.get('.group')
|
||||||
|
const tooltip = getTooltipElement()
|
||||||
|
|
||||||
|
expect(tooltip.style.display).toBe('none')
|
||||||
|
|
||||||
|
await trigger.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).not.toBe('none')
|
||||||
|
expect(tooltip.textContent).toContain('click details')
|
||||||
|
|
||||||
|
const closeButton = tooltip.querySelector('button[aria-label="Close"]')
|
||||||
|
if (!(closeButton instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('close button not found')
|
||||||
|
}
|
||||||
|
closeButton.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).toBe('none')
|
||||||
|
|
||||||
|
await trigger.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).not.toBe('none')
|
||||||
|
|
||||||
|
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(tooltip.style.display).toBe('none')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -73,9 +73,42 @@
|
|||||||
|
|
||||||
<!-- Config fields -->
|
<!-- Config fields -->
|
||||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{{ t('admin.settings.payment.providerConfig') }}
|
{{ t('admin.settings.payment.providerConfig') }}
|
||||||
</h4>
|
</h4>
|
||||||
|
<HelpTooltip v-if="paymentGuide" trigger="click" width-class="w-80">
|
||||||
|
<template #trigger>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 text-[11px] font-semibold text-gray-400 transition-colors hover:border-primary-500 hover:text-primary-600 dark:border-dark-500 dark:text-gray-500 dark:hover:border-primary-400 dark:hover:text-primary-400"
|
||||||
|
:aria-label="t('admin.settings.payment.paymentGuideTrigger')"
|
||||||
|
:title="t('admin.settings.payment.paymentGuideTrigger')"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="font-medium text-white">{{ paymentGuide.summary }}</p>
|
||||||
|
<div
|
||||||
|
v-for="item in paymentGuide.items"
|
||||||
|
:key="item.title"
|
||||||
|
class="space-y-1.5 border-t border-white/10 pt-2 first:border-t-0 first:pt-0"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-white">{{ item.title }}</p>
|
||||||
|
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideOpenLabel') }}</span>{{ item.open }}</p>
|
||||||
|
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideCallLabel') }}</span>{{ item.call }}</p>
|
||||||
|
<p><span class="text-gray-300">{{ t('admin.settings.payment.guideFallbackLabel') }}</span>{{ item.fallback }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="paymentGuide.note" class="border-t border-white/10 pt-2 text-[11px] text-gray-300">
|
||||||
|
{{ paymentGuide.note }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</HelpTooltip>
|
||||||
|
</div>
|
||||||
|
<p v-if="paymentGuide" class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ paymentGuide.summary }}
|
||||||
|
</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="field in resolvedFields" :key="field.key">
|
<div v-for="field in resolvedFields" :key="field.key">
|
||||||
<label class="input-label">
|
<label class="input-label">
|
||||||
@@ -220,6 +253,7 @@
|
|||||||
import { reactive, computed, ref } from 'vue'
|
import { reactive, computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import type { SelectOption } from '@/components/common/Select.vue'
|
import type { SelectOption } from '@/components/common/Select.vue'
|
||||||
import ToggleSwitch from './ToggleSwitch.vue'
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
@@ -263,6 +297,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface PaymentGuideItem {
|
||||||
|
title: string
|
||||||
|
open: string
|
||||||
|
call: string
|
||||||
|
fallback: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentGuide {
|
||||||
|
summary: string
|
||||||
|
items: PaymentGuideItem[]
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
// --- Form state ---
|
// --- Form state ---
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -314,6 +361,63 @@ const resolvedFields = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paymentGuide = computed<PaymentGuide | null>(() => {
|
||||||
|
if (form.provider_key === 'alipay') {
|
||||||
|
return {
|
||||||
|
summary: t('admin.settings.payment.alipayGuideSummary'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.alipayGuideFaceToFaceTitle'),
|
||||||
|
open: t('admin.settings.payment.alipayGuideFaceToFaceOpen'),
|
||||||
|
call: t('admin.settings.payment.alipayGuideFaceToFaceCall'),
|
||||||
|
fallback: t('admin.settings.payment.alipayGuideFaceToFaceFallback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.alipayGuidePagePayTitle'),
|
||||||
|
open: t('admin.settings.payment.alipayGuidePagePayOpen'),
|
||||||
|
call: t('admin.settings.payment.alipayGuidePagePayCall'),
|
||||||
|
fallback: t('admin.settings.payment.alipayGuidePagePayFallback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.alipayGuideWapTitle'),
|
||||||
|
open: t('admin.settings.payment.alipayGuideWapOpen'),
|
||||||
|
call: t('admin.settings.payment.alipayGuideWapCall'),
|
||||||
|
fallback: t('admin.settings.payment.alipayGuideWapFallback'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.provider_key === 'wxpay') {
|
||||||
|
return {
|
||||||
|
summary: t('admin.settings.payment.wxpayGuideSummary'),
|
||||||
|
note: t('admin.settings.payment.wxpayGuideNote'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.wxpayGuideNativeTitle'),
|
||||||
|
open: t('admin.settings.payment.wxpayGuideNativeOpen'),
|
||||||
|
call: t('admin.settings.payment.wxpayGuideNativeCall'),
|
||||||
|
fallback: t('admin.settings.payment.wxpayGuideNativeFallback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.wxpayGuideJsapiTitle'),
|
||||||
|
open: t('admin.settings.payment.wxpayGuideJsapiOpen'),
|
||||||
|
call: t('admin.settings.payment.wxpayGuideJsapiCall'),
|
||||||
|
fallback: t('admin.settings.payment.wxpayGuideJsapiFallback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.settings.payment.wxpayGuideH5Title'),
|
||||||
|
open: t('admin.settings.payment.wxpayGuideH5Open'),
|
||||||
|
call: t('admin.settings.payment.wxpayGuideH5Call'),
|
||||||
|
fallback: t('admin.settings.payment.wxpayGuideH5Fallback'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
const limitableTypes = computed(() => {
|
const limitableTypes = computed(() => {
|
||||||
// Stripe: single "stripe" entry (one set of shared limits)
|
// Stripe: single "stripe" entry (one set of shared limits)
|
||||||
if (form.provider_key === 'stripe') {
|
if (form.provider_key === 'stripe') {
|
||||||
|
|||||||
@@ -84,6 +84,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
||||||
|
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
|
||||||
|
{{ t('payment.qr.openPayWindow') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 text-center">
|
<div class="card p-4 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
'admin.settings.payment.providerConfig': 'Credentials',
|
||||||
|
'admin.settings.payment.paymentGuideTrigger': 'View payment guide',
|
||||||
|
'admin.settings.payment.alipayGuideSummary': 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
|
||||||
|
'admin.settings.payment.wxpayGuideSummary': 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => messages[key] ?? key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mountDialog() {
|
||||||
|
return mount(PaymentProviderDialog, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
saving: false,
|
||||||
|
editing: null,
|
||||||
|
allKeyOptions: [
|
||||||
|
{ value: 'alipay', label: 'Alipay' },
|
||||||
|
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||||
|
{ value: 'stripe', label: 'Stripe' },
|
||||||
|
],
|
||||||
|
enabledKeyOptions: [
|
||||||
|
{ value: 'alipay', label: 'Alipay' },
|
||||||
|
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||||
|
],
|
||||||
|
allPaymentTypes: [
|
||||||
|
{ value: 'alipay', label: 'Alipay' },
|
||||||
|
{ value: 'wxpay', label: 'WeChat Pay' },
|
||||||
|
],
|
||||||
|
redirectLabel: 'Redirect',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
BaseDialog: {
|
||||||
|
template: '<div><slot /><slot name="footer" /></div>',
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
props: ['modelValue', 'options', 'disabled'],
|
||||||
|
template: '<div />',
|
||||||
|
},
|
||||||
|
ToggleSwitch: {
|
||||||
|
template: '<div />',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PaymentProviderDialog payment guide', () => {
|
||||||
|
it('shows no payment guide for providers without a flow guide', () => {
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.alipayGuideSummary'])
|
||||||
|
expect(wrapper.text()).not.toContain(messages['admin.settings.payment.wxpayGuideSummary'])
|
||||||
|
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['alipay', 'admin.settings.payment.alipayGuideSummary'],
|
||||||
|
['wxpay', 'admin.settings.payment.wxpayGuideSummary'],
|
||||||
|
])('shows the payment guide summary for %s', async (providerKey, summaryKey) => {
|
||||||
|
const wrapper = mountDialog()
|
||||||
|
|
||||||
|
;(wrapper.vm as unknown as { reset: (key: string) => void }).reset(providerKey)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(messages[summaryKey])
|
||||||
|
expect(wrapper.find('button[title="View payment guide"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -96,4 +96,36 @@ describe('PaymentStatusPanel', () => {
|
|||||||
expect(wrapper.text()).toContain('payment.result.success')
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows reopen button in QR mode when payUrl is also available', async () => {
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockReturnValue({ closed: false } as Window)
|
||||||
|
|
||||||
|
const wrapper = mount(PaymentStatusPanel, {
|
||||||
|
props: {
|
||||||
|
orderId: 42,
|
||||||
|
qrCode: 'https://pay.example.com/qr/42',
|
||||||
|
payUrl: 'https://pay.example.com/session/42',
|
||||||
|
expiresAt: '2099-01-01T12:30:00Z',
|
||||||
|
paymentType: 'alipay',
|
||||||
|
orderType: 'balance',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Icon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('payment.qr.openPayWindow')
|
||||||
|
|
||||||
|
await wrapper.get('button.btn.btn-secondary.text-sm').trigger('click')
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://pay.example.com/session/42',
|
||||||
|
'paymentPopup',
|
||||||
|
expect.any(String),
|
||||||
|
)
|
||||||
|
|
||||||
|
openSpy.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -190,12 +190,14 @@ describe('buildCreateOrderPayload', () => {
|
|||||||
paymentType: 'alipay_direct',
|
paymentType: 'alipay_direct',
|
||||||
orderType: 'balance',
|
orderType: 'balance',
|
||||||
origin: 'https://app.example.com/',
|
origin: 'https://app.example.com/',
|
||||||
|
isMobile: true,
|
||||||
isWechatBrowser: false,
|
isWechatBrowser: false,
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
amount: 88,
|
amount: 88,
|
||||||
payment_type: 'alipay',
|
payment_type: 'alipay',
|
||||||
order_type: 'balance',
|
order_type: 'balance',
|
||||||
return_url: 'https://app.example.com/payment/result',
|
return_url: 'https://app.example.com/payment/result',
|
||||||
|
is_mobile: true,
|
||||||
payment_source: 'hosted_redirect',
|
payment_source: 'hosted_redirect',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -207,6 +209,7 @@ describe('buildCreateOrderPayload', () => {
|
|||||||
orderType: 'subscription',
|
orderType: 'subscription',
|
||||||
planId: 7,
|
planId: 7,
|
||||||
origin: 'https://app.example.com',
|
origin: 'https://app.example.com',
|
||||||
|
isMobile: false,
|
||||||
isWechatBrowser: true,
|
isWechatBrowser: true,
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
amount: 128,
|
amount: 128,
|
||||||
@@ -214,6 +217,7 @@ describe('buildCreateOrderPayload', () => {
|
|||||||
order_type: 'subscription',
|
order_type: 'subscription',
|
||||||
plan_id: 7,
|
plan_id: 7,
|
||||||
return_url: 'https://app.example.com/payment/result',
|
return_url: 'https://app.example.com/payment/result',
|
||||||
|
is_mobile: false,
|
||||||
payment_source: 'wechat_in_app_resume',
|
payment_source: 'wechat_in_app_resume',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ describe('PROVIDER_CONFIG_FIELDS.wxpay', () => {
|
|||||||
expect(findField('certSerial')?.optional).toBeFalsy()
|
expect(findField('certSerial')?.optional).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes optional mp and H5 metadata fields for WeChat-specific flows', () => {
|
it('only keeps the simplified visible credential set in the admin form', () => {
|
||||||
expect(findField('mpAppId')?.optional).toBe(true)
|
expect(findField('mpAppId')).toBeUndefined()
|
||||||
expect(findField('h5AppName')?.optional).toBe(true)
|
expect(findField('h5AppName')).toBeUndefined()
|
||||||
expect(findField('h5AppUrl')?.optional).toBe(true)
|
expect(findField('h5AppUrl')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface BuildCreateOrderPayloadInput {
|
|||||||
orderType: OrderType
|
orderType: OrderType
|
||||||
planId?: number
|
planId?: number
|
||||||
origin?: string
|
origin?: string
|
||||||
|
isMobile: boolean
|
||||||
isWechatBrowser: boolean
|
isWechatBrowser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): Cr
|
|||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
payment_type: visibleMethod,
|
payment_type: visibleMethod,
|
||||||
order_type: input.orderType,
|
order_type: input.orderType,
|
||||||
|
is_mobile: input.isMobile,
|
||||||
payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser
|
payment_source: visibleMethod === 'wxpay' && input.isWechatBrowser
|
||||||
? 'wechat_in_app_resume'
|
? 'wechat_in_app_resume'
|
||||||
: 'hosted_redirect',
|
: 'hosted_redirect',
|
||||||
|
|||||||
@@ -96,15 +96,12 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
|||||||
],
|
],
|
||||||
wxpay: [
|
wxpay: [
|
||||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||||
{ key: 'mpAppId', label: '', sensitive: false, optional: true },
|
|
||||||
{ key: 'mchId', label: '', sensitive: false },
|
{ key: 'mchId', label: '', sensitive: false },
|
||||||
{ key: 'privateKey', label: '', sensitive: true },
|
{ key: 'privateKey', label: '', sensitive: true },
|
||||||
{ key: 'apiV3Key', label: '', sensitive: true },
|
{ key: 'apiV3Key', label: '', sensitive: true },
|
||||||
{ key: 'certSerial', label: '', sensitive: false },
|
{ key: 'certSerial', label: '', sensitive: false },
|
||||||
{ key: 'publicKey', label: '', sensitive: true },
|
{ key: 'publicKey', label: '', sensitive: true },
|
||||||
{ key: 'publicKeyId', label: '', sensitive: false },
|
{ key: 'publicKeyId', label: '', sensitive: false },
|
||||||
{ key: 'h5AppName', label: '', sensitive: false, optional: true },
|
|
||||||
{ key: 'h5AppUrl', label: '', sensitive: false, optional: true },
|
|
||||||
],
|
],
|
||||||
stripe: [
|
stripe: [
|
||||||
{ key: 'secretKey', label: '', sensitive: true },
|
{ key: 'secretKey', label: '', sensitive: true },
|
||||||
|
|||||||
@@ -4741,6 +4741,8 @@ export default {
|
|||||||
field_certSerial: 'Certificate Serial',
|
field_certSerial: 'Certificate Serial',
|
||||||
field_h5AppName: 'H5 App Name',
|
field_h5AppName: 'H5 App Name',
|
||||||
field_h5AppUrl: 'H5 App URL',
|
field_h5AppUrl: 'H5 App URL',
|
||||||
|
wxpayConfigHint: 'WeChat Pay usually only needs App ID. Fill MP App ID, H5 App Name, and H5 App URL only when your Official Account or H5 flow specifically requires them.',
|
||||||
|
wxpayAdvancedOptions: 'WeChat Pay Advanced Options',
|
||||||
field_secretKey: 'Secret Key',
|
field_secretKey: 'Secret Key',
|
||||||
field_publishableKey: 'Publishable Key',
|
field_publishableKey: 'Publishable Key',
|
||||||
field_webhookSecret: 'Webhook Secret',
|
field_webhookSecret: 'Webhook Secret',
|
||||||
@@ -4771,6 +4773,37 @@ export default {
|
|||||||
providerKey: 'Provider Type',
|
providerKey: 'Provider Type',
|
||||||
selectProviderKey: 'Select Provider Type',
|
selectProviderKey: 'Select Provider Type',
|
||||||
providerConfig: 'Credentials',
|
providerConfig: 'Credentials',
|
||||||
|
paymentGuideTrigger: 'View payment guide',
|
||||||
|
guideOpenLabel: 'Enable: ',
|
||||||
|
guideCallLabel: 'Call: ',
|
||||||
|
guideFallbackLabel: 'Fallback: ',
|
||||||
|
alipayGuideSummary: 'Desktop prefers QR precreate and falls back to cashier; mobile prefers WAP checkout.',
|
||||||
|
alipayGuideFaceToFaceTitle: 'Face-to-face / QR Payment',
|
||||||
|
alipayGuideFaceToFaceOpen: 'Enable face-to-face or QR payment capability.',
|
||||||
|
alipayGuideFaceToFaceCall: 'Desktop orders call alipay.trade.precreate first and render the QR code directly.',
|
||||||
|
alipayGuideFaceToFaceFallback: 'If unavailable or failed, the flow falls back to website checkout automatically.',
|
||||||
|
alipayGuidePagePayTitle: 'Website Payment',
|
||||||
|
alipayGuidePagePayOpen: 'Enable website payment.',
|
||||||
|
alipayGuidePagePayCall: 'When face-to-face is unavailable on desktop, the flow calls alipay.trade.page.pay and still renders the returned link as a QR code.',
|
||||||
|
alipayGuidePagePayFallback: 'The cashier link stays available so users can reopen the checkout page manually.',
|
||||||
|
alipayGuideWapTitle: 'WAP Payment',
|
||||||
|
alipayGuideWapOpen: 'Enable mobile website payment.',
|
||||||
|
alipayGuideWapCall: 'Mobile orders call alipay.trade.wap.pay first and jump to Alipay checkout.',
|
||||||
|
alipayGuideWapFallback: 'If mobile payment is unavailable or fails, the frontend switches to QR payment and shows a notice.',
|
||||||
|
wxpayGuideSummary: 'Desktop prefers Native QR; mobile routes to JSAPI or H5 based on browser context.',
|
||||||
|
wxpayGuideNote: 'The current form defaults to one shared App ID, which fits the common single-subject web, mobile, and Official Account setup.',
|
||||||
|
wxpayGuideNativeTitle: 'Native / QR Payment',
|
||||||
|
wxpayGuideNativeOpen: 'Enable Native or QR payment capability.',
|
||||||
|
wxpayGuideNativeCall: 'Desktop orders use Native by default and the frontend renders the QR payload.',
|
||||||
|
wxpayGuideNativeFallback: 'Mobile flows also fall back here when JSAPI or H5 cannot be used.',
|
||||||
|
wxpayGuideJsapiTitle: 'JSAPI / Official Account',
|
||||||
|
wxpayGuideJsapiOpen: 'Enable Official Account payment and ensure the browser is inside WeChat with an available OpenID.',
|
||||||
|
wxpayGuideJsapiCall: 'Inside WeChat, the app calls JSAPI after authorization and launches WeChat Pay directly.',
|
||||||
|
wxpayGuideJsapiFallback: 'If configuration is missing, the bridge is unavailable, or launch fails, the flow falls back to QR payment.',
|
||||||
|
wxpayGuideH5Title: 'H5 Payment',
|
||||||
|
wxpayGuideH5Open: 'Enable H5 payment.',
|
||||||
|
wxpayGuideH5Call: 'On mobile browsers outside WeChat, the app calls H5 payment when a client IP is available.',
|
||||||
|
wxpayGuideH5Fallback: 'If H5 is unavailable or order creation fails, the flow falls back to QR payment.',
|
||||||
noProviders: 'No provider instances configured',
|
noProviders: 'No provider instances configured',
|
||||||
supportedTypes: 'Supported Payment Types',
|
supportedTypes: 'Supported Payment Types',
|
||||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||||
@@ -5627,6 +5660,7 @@ export default {
|
|||||||
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
|
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
|
||||||
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
|
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
|
||||||
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
|
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
|
||||||
|
mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.',
|
||||||
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
|
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
|
||||||
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
|
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
|
||||||
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
||||||
|
|||||||
@@ -4905,6 +4905,8 @@ export default {
|
|||||||
field_certSerial: '证书序列号',
|
field_certSerial: '证书序列号',
|
||||||
field_h5AppName: 'H5 应用名称',
|
field_h5AppName: 'H5 应用名称',
|
||||||
field_h5AppUrl: 'H5 应用地址',
|
field_h5AppUrl: 'H5 应用地址',
|
||||||
|
wxpayConfigHint: '微信支付通常只需要填写 App ID。公众号 App ID、H5 应用名称、H5 应用地址仅在公众号支付或 H5 场景有特殊要求时再填写。',
|
||||||
|
wxpayAdvancedOptions: '微信支付高级可选项',
|
||||||
field_secretKey: '密钥',
|
field_secretKey: '密钥',
|
||||||
field_publishableKey: '公开密钥',
|
field_publishableKey: '公开密钥',
|
||||||
field_webhookSecret: 'Webhook 密钥',
|
field_webhookSecret: 'Webhook 密钥',
|
||||||
@@ -4935,6 +4937,37 @@ export default {
|
|||||||
providerKey: '服务商类型',
|
providerKey: '服务商类型',
|
||||||
selectProviderKey: '选择服务商类型',
|
selectProviderKey: '选择服务商类型',
|
||||||
providerConfig: '凭证配置',
|
providerConfig: '凭证配置',
|
||||||
|
paymentGuideTrigger: '查看支付方式说明',
|
||||||
|
guideOpenLabel: '开通:',
|
||||||
|
guideCallLabel: '调用:',
|
||||||
|
guideFallbackLabel: '降级:',
|
||||||
|
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。',
|
||||||
|
alipayGuideFaceToFaceTitle: '当面付 / 扫码支付',
|
||||||
|
alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。',
|
||||||
|
alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。',
|
||||||
|
alipayGuideFaceToFaceFallback: '接口不可用或返回失败时,自动降级到电脑网站支付。',
|
||||||
|
alipayGuidePagePayTitle: '电脑网站支付',
|
||||||
|
alipayGuidePagePayOpen: '需开通电脑网站支付。',
|
||||||
|
alipayGuidePagePayCall: '桌面端当面付不可用时调用 alipay.trade.page.pay,并继续把返回链接渲染成二维码。',
|
||||||
|
alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。',
|
||||||
|
alipayGuideWapTitle: '手机网站支付',
|
||||||
|
alipayGuideWapOpen: '需开通手机网站支付。',
|
||||||
|
alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。',
|
||||||
|
alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。',
|
||||||
|
wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。',
|
||||||
|
wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。',
|
||||||
|
wxpayGuideNativeTitle: 'Native / 扫码支付',
|
||||||
|
wxpayGuideNativeOpen: '需开通 Native 或扫码支付能力。',
|
||||||
|
wxpayGuideNativeCall: '桌面端默认调用 Native,下发二维码内容给前台渲染。',
|
||||||
|
wxpayGuideNativeFallback: '移动端无法走 JSAPI 或 H5 时,也会自动回退到这里。',
|
||||||
|
wxpayGuideJsapiTitle: 'JSAPI / 公众号支付',
|
||||||
|
wxpayGuideJsapiOpen: '需开通公众号支付,并保证当前浏览器在微信内且能拿到 OpenID。',
|
||||||
|
wxpayGuideJsapiCall: '微信内浏览器完成授权后调用 JSAPI,直接拉起微信支付。',
|
||||||
|
wxpayGuideJsapiFallback: '未配置、Bridge 不可用或拉起失败时,自动改走扫码支付。',
|
||||||
|
wxpayGuideH5Title: 'H5 支付',
|
||||||
|
wxpayGuideH5Open: '需开通 H5 支付。',
|
||||||
|
wxpayGuideH5Call: '移动端非微信浏览器且有客户端 IP 时调用 H5 支付,跳转微信收银台。',
|
||||||
|
wxpayGuideH5Fallback: '未开通 H5 或下单失败时,自动改走扫码支付。',
|
||||||
noProviders: '暂无服务商实例',
|
noProviders: '暂无服务商实例',
|
||||||
supportedTypes: '支持的支付方式',
|
supportedTypes: '支持的支付方式',
|
||||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||||
@@ -5815,6 +5848,7 @@ export default {
|
|||||||
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
|
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
|
||||||
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
|
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
|
||||||
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
|
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
|
||||||
|
mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。',
|
||||||
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
|
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
|
||||||
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
|
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
|
||||||
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
||||||
|
|||||||
@@ -4166,6 +4166,8 @@
|
|||||||
}}</label>
|
}}</label>
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
v-model="form.payment_help_image_url"
|
v-model="form.payment_help_image_url"
|
||||||
|
:upload-label="t('admin.settings.site.uploadImage')"
|
||||||
|
:remove-label="t('admin.settings.site.remove')"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('admin.settings.payment.helpImagePlaceholder')
|
t('admin.settings.payment.helpImagePlaceholder')
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ vi.mock("vue-i18n", async () => {
|
|||||||
"admin.settings.payment.findProvider": "查看支持的支付方式",
|
"admin.settings.payment.findProvider": "查看支持的支付方式",
|
||||||
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
|
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
|
||||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||||
|
"admin.settings.site.uploadImage": "上传图片",
|
||||||
|
"admin.settings.site.remove": "移除",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
@@ -240,6 +242,37 @@ const SelectStub = defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ImageUploadStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
uploadLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
removeLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () =>
|
||||||
|
h("div", {
|
||||||
|
class: "image-upload-stub",
|
||||||
|
"data-model-value": props.modelValue,
|
||||||
|
"data-upload-label": props.uploadLabel,
|
||||||
|
"data-remove-label": props.removeLabel,
|
||||||
|
"data-placeholder": props.placeholder,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const baseSettingsResponse = {
|
const baseSettingsResponse = {
|
||||||
registration_enabled: true,
|
registration_enabled: true,
|
||||||
email_verify_enabled: false,
|
email_verify_enabled: false,
|
||||||
@@ -375,7 +408,7 @@ function mountView() {
|
|||||||
GroupBadge: true,
|
GroupBadge: true,
|
||||||
GroupOptionItem: true,
|
GroupOptionItem: true,
|
||||||
ProxySelector: true,
|
ProxySelector: true,
|
||||||
ImageUpload: true,
|
ImageUpload: ImageUploadStub,
|
||||||
BackupSettings: true,
|
BackupSettings: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -582,7 +615,7 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
GroupBadge: true,
|
GroupBadge: true,
|
||||||
GroupOptionItem: true,
|
GroupOptionItem: true,
|
||||||
ProxySelector: true,
|
ProxySelector: true,
|
||||||
ImageUpload: true,
|
ImageUpload: ImageUploadStub,
|
||||||
BackupSettings: true,
|
BackupSettings: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -608,6 +641,24 @@ describe("admin SettingsView payment visible method controls", () => {
|
|||||||
);
|
);
|
||||||
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
|
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes translated upload and remove labels to the payment help image uploader", async () => {
|
||||||
|
const wrapper = mountView();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
await openPaymentTab(wrapper);
|
||||||
|
|
||||||
|
const imageUploads = wrapper.findAll(".image-upload-stub");
|
||||||
|
expect(imageUploads.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const paymentHelpImageUpload = imageUploads.find(
|
||||||
|
(node) => node.attributes("data-placeholder") === "admin.settings.payment.helpImagePlaceholder",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(paymentHelpImageUpload).toBeDefined();
|
||||||
|
expect(paymentHelpImageUpload?.attributes("data-upload-label")).toBe("上传图片");
|
||||||
|
expect(paymentHelpImageUpload?.attributes("data-remove-label")).toBe("移除");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("admin SettingsView wechat connect controls", () => {
|
describe("admin SettingsView wechat connect controls", () => {
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ interface CreateOrderOptions {
|
|||||||
wechatResumeToken?: string
|
wechatResumeToken?: string
|
||||||
paymentType?: string
|
paymentType?: string
|
||||||
isResume?: boolean
|
isResume?: boolean
|
||||||
|
mobileQrFallbackAttempted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeixinJSBridgeLike {
|
interface WeixinJSBridgeLike {
|
||||||
@@ -666,14 +667,15 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
errorHintMessage.value = ''
|
errorHintMessage.value = ''
|
||||||
try {
|
|
||||||
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
|
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
|
||||||
|
try {
|
||||||
const payload = buildCreateOrderPayload({
|
const payload = buildCreateOrderPayload({
|
||||||
amount: orderAmount,
|
amount: orderAmount,
|
||||||
paymentType: requestType,
|
paymentType: requestType,
|
||||||
orderType,
|
orderType,
|
||||||
planId,
|
planId,
|
||||||
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
||||||
|
isMobile: isMobileDevice(),
|
||||||
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
||||||
})
|
})
|
||||||
if (options.openid) {
|
if (options.openid) {
|
||||||
@@ -747,8 +749,20 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
appStore.showInfo(t('payment.qr.cancelled'))
|
appStore.showInfo(t('payment.qr.cancelled'))
|
||||||
resetPayment()
|
resetPayment()
|
||||||
} else if (errMsg && !errMsg.includes('ok')) {
|
} else if (errMsg && !errMsg.includes('ok')) {
|
||||||
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
|
||||||
resetPayment()
|
resetPayment()
|
||||||
|
const fallbackApplied = await attemptMobileQrFallback(
|
||||||
|
{ reason: 'WECHAT_JSAPI_FAILED', message: errMsg },
|
||||||
|
{
|
||||||
|
orderAmount,
|
||||||
|
orderType,
|
||||||
|
planId,
|
||||||
|
paymentType: visibleMethod,
|
||||||
|
attempted: options.mobileQrFallbackAttempted === true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!fallbackApplied) {
|
||||||
|
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const resultState = { ...decision.paymentState }
|
const resultState = { ...decision.paymentState }
|
||||||
resetPayment()
|
resetPayment()
|
||||||
@@ -756,8 +770,17 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
resetPayment()
|
resetPayment()
|
||||||
|
const fallbackApplied = await attemptMobileQrFallback(err, {
|
||||||
|
orderAmount,
|
||||||
|
orderType,
|
||||||
|
planId,
|
||||||
|
paymentType: visibleMethod,
|
||||||
|
attempted: options.mobileQrFallbackAttempted === true,
|
||||||
|
})
|
||||||
|
if (!fallbackApplied) {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
|
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
|
||||||
@@ -776,6 +799,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||||
errorHintMessage.value = ''
|
errorHintMessage.value = ''
|
||||||
|
} else if (await attemptMobileQrFallback(err, {
|
||||||
|
orderAmount,
|
||||||
|
orderType,
|
||||||
|
planId,
|
||||||
|
paymentType: requestType,
|
||||||
|
attempted: options.mobileQrFallbackAttempted === true,
|
||||||
|
})) {
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
const handled = applyScenarioError(
|
const handled = applyScenarioError(
|
||||||
err,
|
err,
|
||||||
@@ -795,6 +826,101 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MobileQrFallbackContext {
|
||||||
|
orderAmount: number
|
||||||
|
orderType: OrderType
|
||||||
|
planId?: number
|
||||||
|
paymentType: string
|
||||||
|
attempted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackToDesktopQr(err: unknown, paymentMethod: string, attempted: boolean): boolean {
|
||||||
|
if (attempted || !isMobileDevice()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMethod = normalizeVisibleMethod(paymentMethod) || paymentMethod
|
||||||
|
const reason = typeof err === 'object' && err && 'reason' in err && typeof err.reason === 'string'
|
||||||
|
? err.reason
|
||||||
|
: ''
|
||||||
|
const message = err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string'
|
||||||
|
? err.message
|
||||||
|
: '')
|
||||||
|
const normalizedMessage = message.toLowerCase()
|
||||||
|
|
||||||
|
if (normalizedMethod === 'wxpay') {
|
||||||
|
return reason === 'WECHAT_H5_NOT_AUTHORIZED'
|
||||||
|
|| reason === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED'
|
||||||
|
|| reason === 'WECHAT_JSAPI_FAILED'
|
||||||
|
|| reason === 'PAYMENT_GATEWAY_ERROR'
|
||||||
|
|| reason === 'UNHANDLED_PAYMENT_SCENARIO'
|
||||||
|
|| normalizedMessage.includes('weixinjsbridge is unavailable')
|
||||||
|
|| normalizedMessage.includes('wechat_jsapi_unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMethod === 'alipay') {
|
||||||
|
return reason === 'PAYMENT_GATEWAY_ERROR' || reason === 'UNHANDLED_PAYMENT_SCENARIO'
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptMobileQrFallback(err: unknown, context: MobileQrFallbackContext): Promise<boolean> {
|
||||||
|
if (!shouldFallbackToDesktopQr(err, context.paymentType, context.attempted)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const visibleMethod = normalizeVisibleMethod(context.paymentType) || context.paymentType
|
||||||
|
const payload = buildCreateOrderPayload({
|
||||||
|
amount: context.orderAmount,
|
||||||
|
paymentType: visibleMethod,
|
||||||
|
orderType: context.orderType,
|
||||||
|
planId: context.planId,
|
||||||
|
origin: typeof window !== 'undefined' ? window.location.origin : '',
|
||||||
|
isMobile: false,
|
||||||
|
isWechatBrowser: false,
|
||||||
|
})
|
||||||
|
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
||||||
|
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
||||||
|
const stripeRouteUrl = result.client_secret
|
||||||
|
? router.resolve({
|
||||||
|
path: '/payment/stripe',
|
||||||
|
query: {
|
||||||
|
order_id: String(result.order_id),
|
||||||
|
client_secret: result.client_secret,
|
||||||
|
method: stripeMethod,
|
||||||
|
resume_token: result.resume_token || undefined,
|
||||||
|
},
|
||||||
|
}).href
|
||||||
|
: ''
|
||||||
|
const decision = decidePaymentLaunch(result, {
|
||||||
|
visibleMethod,
|
||||||
|
orderType: context.orderType,
|
||||||
|
isMobile: false,
|
||||||
|
isWechatBrowser: false,
|
||||||
|
stripePopupUrl: stripeRouteUrl,
|
||||||
|
stripeRouteUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (decision.kind !== 'qr_waiting' || !decision.paymentState.qrCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage.value = ''
|
||||||
|
errorHintMessage.value = ''
|
||||||
|
paymentState.value = decision.paymentState
|
||||||
|
paymentPhase.value = 'paying'
|
||||||
|
persistRecoverySnapshot(decision.recovery)
|
||||||
|
appStore.showWarning(t('payment.errors.mobilePaymentFallbackToQr'))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
|
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
|
||||||
const descriptor = describePaymentScenarioError(err, {
|
const descriptor = describePaymentScenarioError(err, {
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const refreshUser = vi.hoisted(() => vi.fn())
|
|||||||
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||||
const showError = vi.hoisted(() => vi.fn())
|
const showError = vi.hoisted(() => vi.fn())
|
||||||
const showInfo = vi.hoisted(() => vi.fn())
|
const showInfo = vi.hoisted(() => vi.fn())
|
||||||
|
const showWarning = vi.hoisted(() => vi.fn())
|
||||||
const getCheckoutInfo = vi.hoisted(() => vi.fn())
|
const getCheckoutInfo = vi.hoisted(() => vi.fn())
|
||||||
const bridgeInvoke = vi.hoisted(() => vi.fn())
|
const bridgeInvoke = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ vi.mock('@/stores', () => ({
|
|||||||
useAppStore: () => ({
|
useAppStore: () => ({
|
||||||
showError,
|
showError,
|
||||||
showInfo,
|
showInfo,
|
||||||
|
showWarning,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ describe('PaymentView WeChat JSAPI flow', () => {
|
|||||||
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
|
fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined)
|
||||||
showError.mockReset()
|
showError.mockReset()
|
||||||
showInfo.mockReset()
|
showInfo.mockReset()
|
||||||
|
showWarning.mockReset()
|
||||||
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
|
getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture())
|
||||||
bridgeInvoke.mockReset()
|
bridgeInvoke.mockReset()
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
@@ -364,13 +367,24 @@ describe('PaymentView WeChat JSAPI flow', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows explicit H5 authorization guidance instead of failing silently', async () => {
|
it('falls back to QR flow when mobile WeChat payment is unavailable', async () => {
|
||||||
routeState.query = {
|
routeState.query = {
|
||||||
wechat_resume: '1',
|
wechat_resume: '1',
|
||||||
wechat_resume_token: 'resume-token-h5',
|
wechat_resume_token: 'resume-token-h5',
|
||||||
payment_type: 'wxpay_direct',
|
payment_type: 'wxpay_direct',
|
||||||
}
|
}
|
||||||
createOrder.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
|
createOrder
|
||||||
|
.mockRejectedValueOnce({ reason: 'WECHAT_H5_NOT_AUTHORIZED' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
order_id: 778,
|
||||||
|
amount: 88,
|
||||||
|
pay_amount: 88,
|
||||||
|
fee_rate: 0,
|
||||||
|
expires_at: '2099-01-01T00:10:00.000Z',
|
||||||
|
payment_type: 'wxpay',
|
||||||
|
qr_code: 'weixin://wxpay/bizpayurl?pr=fallback-native',
|
||||||
|
out_trade_no: 'sub2_qr_778',
|
||||||
|
})
|
||||||
|
|
||||||
shallowMount(PaymentView, {
|
shallowMount(PaymentView, {
|
||||||
global: {
|
global: {
|
||||||
@@ -383,8 +397,18 @@ describe('PaymentView WeChat JSAPI flow', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(showError).toHaveBeenCalledWith(
|
expect(createOrder).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
'payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint',
|
payment_type: 'wxpay',
|
||||||
)
|
is_mobile: true,
|
||||||
|
wechat_resume_token: 'resume-token-h5',
|
||||||
|
}))
|
||||||
|
expect(createOrder).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
payment_type: 'wxpay',
|
||||||
|
is_mobile: false,
|
||||||
|
payment_source: 'hosted_redirect',
|
||||||
|
}))
|
||||||
|
expect(showWarning).toHaveBeenCalledWith('payment.errors.mobilePaymentFallbackToQr')
|
||||||
|
expect(showError).not.toHaveBeenCalled()
|
||||||
|
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toContain('weixin://wxpay/bizpayurl?pr=fallback-native')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user