commit
d4f4c549cb
22 files changed
+625
-106
No files matched your search
@@ -172,6 +172,9 @@ var (
|
||||
}
|
||||
return nil
|
||||
})
|
||||
cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error {
|
||||
return task.ExpirePendingOrders(ctx)
|
||||
})
|
||||
cs.StartBackground(ctx)
|
||||
|
||||
// Initialize update manager
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/payment"
|
||||
@@ -31,10 +33,24 @@ func HandlePaymentCallback(r *ghttp.Request) {
|
||||
// 仅查询订单状态决定跳转目标,履约由异步回调完成。
|
||||
func HandlePaymentEpayReturn(r *ghttp.Request) {
|
||||
orderNo := r.GetQuery("out_trade_no").String()
|
||||
paid := payment.QueryOrderPaid(r.Context(), orderNo)
|
||||
if orderNo == "" {
|
||||
r.Response.RedirectTo("/console/wallet?pay=fail")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// 异步 notify 可能晚于浏览器回跳到达:最多轮询约 4 秒等待回调入账。
|
||||
// 仍未到账不一定是失败(回调可能仍在途),引导到"处理中"页由前端继续刷新,
|
||||
// 避免给用户展示误导性的"失败"。
|
||||
paid := payment.QueryOrderPaid(ctx, orderNo)
|
||||
for i := 0; !paid && i < 8; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
paid = payment.QueryOrderPaid(ctx, orderNo)
|
||||
}
|
||||
|
||||
if paid {
|
||||
r.Response.RedirectTo("/console/wallet?pay=success")
|
||||
return
|
||||
}
|
||||
r.Response.RedirectTo("/console/wallet?pay=fail")
|
||||
r.Response.RedirectTo("/console/wallet?pay=processing")
|
||||
}
|
||||
@@ -197,9 +197,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
data := do.SysOptions{Value: value}
|
||||
// Sync metadata from registry so that changes to IsPublic/Category/Description
|
||||
// are reflected in the DB row on the next write (e.g. a setting newly marked
|
||||
// as IsPublic will become visible to GetPublicOptions).
|
||||
if def := GetSettingDef(key); def != nil {
|
||||
data.Category = def.Category
|
||||
data.Description = def.Label
|
||||
data.IsPublic = def.IsPublic
|
||||
}
|
||||
_, err = dao.SysOptions.Ctx(ctx).
|
||||
Where("key", key).
|
||||
Data(do.SysOptions{Value: value}).
|
||||
Data(data).
|
||||
Update()
|
||||
} else {
|
||||
def := GetSettingDef(key)
|
||||
|
||||
@@ -120,9 +120,9 @@ var Registry = []SettingDef{
|
||||
{Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security",
|
||||
Label: "密码最小长度", Validation: "min:6,max:32"},
|
||||
{Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security",
|
||||
Label: "启用 Turnstile 人机验证"},
|
||||
Label: "启用 Turnstile 人机验证", IsPublic: true},
|
||||
{Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security",
|
||||
Label: "Turnstile Site Key", Sensitive: false},
|
||||
Label: "Turnstile Site Key", Sensitive: false, IsPublic: true},
|
||||
{Key: "turnstile_secret_key", Type: SettingTypeString, Default: "", Category: "security",
|
||||
Label: "Turnstile Secret Key", Sensitive: true},
|
||||
{Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security",
|
||||
|
||||
@@ -57,9 +57,42 @@ func LoadChannelConfig(ctx context.Context, channelType string) (jsonStr string,
|
||||
|
||||
// SaveChannelConfig 将渠道配置 JSON 保存到 sys_options。
|
||||
func SaveChannelConfig(ctx context.Context, channelType string, configJSON string) error {
|
||||
// 启用线上支付渠道前,必须先配置全局回调地址,否则网关异步通知无法回传,
|
||||
// 支付成功也无法入账(发起支付时还有 RequireCallbackBaseURL 二次校验)。
|
||||
if err := validateChannelEnable(ctx, channelType, configJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
return lcommon.Config().SetOption(ctx, channelConfigKey(channelType), configJSON)
|
||||
}
|
||||
|
||||
// validateChannelEnable 校验"启用渠道"的前置条件:启用易支付要求回调地址已配置。
|
||||
func validateChannelEnable(ctx context.Context, channelType string, configJSON string) error {
|
||||
if channelType != "epay" {
|
||||
return nil
|
||||
}
|
||||
cfg, err := ParseChannelConfig(channelType, configJSON)
|
||||
if err != nil || cfg == nil {
|
||||
return nil // JSON 合法性由调用方校验;此处只关心启用前置条件
|
||||
}
|
||||
ec, ok := cfg.(*EpayConfig)
|
||||
if !ok || !ec.IsEnabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" {
|
||||
return lcommon.NewBadRequestError("请先在「支付设置」中配置回调基础URL,再启用支付渠道;否则支付成功后无法回调入账")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireCallbackBaseURL 发起支付前的二次防护:回调地址未配置则拒绝发起。
|
||||
// 渠道启用时已拦截一次,这里防止"启用后被清空回调地址"等场景下仍能发起支付。
|
||||
func RequireCallbackBaseURL(ctx context.Context) error {
|
||||
if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" {
|
||||
return lcommon.NewBusinessError(422, "支付回调地址未配置,请联系管理员在「支付设置」中配置回调基础URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAllChannels 返回所有已注册渠道的配置(含未配置的,返回默认值)。
|
||||
func ListAllChannels(ctx context.Context) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, 0, len(channelTypes))
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -43,10 +42,14 @@ func (p *EpayProvider) CreatePayment(ctx context.Context, order *PaymentOrder, c
|
||||
params["sign"] = sign
|
||||
params["sign_type"] = "MD5"
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/submit.php?%s", strings.TrimRight(cfg.PayAddress, "/"), buildQuery(params))
|
||||
// 采用 POST 表单提交:这里只返回 submit.php 的 action 地址,签名后的全部
|
||||
// 参数放入 Params,由前端构造 <form method="POST"> 隐藏域提交。
|
||||
// 相比拼 GET 查询串更安全:sign 与订单参数不进入 URL(不落浏览器历史/服务器
|
||||
// 日志),也规避 URL 长度限制和中文/特殊字符的编码边界问题。
|
||||
actionURL := fmt.Sprintf("%s/submit.php", strings.TrimRight(cfg.PayAddress, "/"))
|
||||
|
||||
return &PaymentResult{
|
||||
PaymentURL: redirectURL,
|
||||
PaymentURL: actionURL,
|
||||
Params: params,
|
||||
IsRedirect: true,
|
||||
}, nil
|
||||
@@ -128,26 +131,6 @@ func epaySign(params map[string]string, key string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// buildQuery 构建 URL 查询字符串。
|
||||
func buildQuery(params map[string]string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var buf strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
buf.WriteString(url.QueryEscape(k))
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(url.QueryEscape(params[k]))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// ReadBody 读取 HTTP 请求体并返回内容(用于 Stripe Webhook 等需要 raw body 的场景)。
|
||||
func ReadBody(r *http.Request) ([]byte, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
@@ -34,14 +34,10 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
|
||||
tenantCode := strings.TrimSpace(strings.ToLower(req.TenantCode))
|
||||
username := strings.TrimSpace(req.Username)
|
||||
|
||||
// Validate username format
|
||||
if err := common.ValidateUsername(username); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
|
||||
}
|
||||
|
||||
// Validate forbidden words in username, tenant name, tenant code
|
||||
tenantName := strings.TrimSpace(req.TenantName)
|
||||
|
||||
// 先校验禁用词:命中禁用词时优先返回更准确的提示,
|
||||
// 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因
|
||||
if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
}
|
||||
@@ -52,6 +48,11 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if err := common.ValidateUsername(username); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
|
||||
}
|
||||
|
||||
// Check register rate limit (IP + global)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil {
|
||||
@@ -70,14 +71,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 人机验证:优先 Turnstile,否则滑块验证
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Turnstile 未启用时,使用滑块验证
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
// 人机验证:Turnstile 启用时只验 Turnstile,否则用滑块验证
|
||||
turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled")
|
||||
if turnstileEnabled {
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,13 +243,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) {
|
||||
account := strings.TrimSpace(req.Account)
|
||||
|
||||
// Check captcha if required
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
// Check captcha: Turnstile takes priority; slider captcha is fallback
|
||||
turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled")
|
||||
if turnstileEnabled {
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get client info early for login history recording
|
||||
|
||||
@@ -187,9 +187,12 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
|
||||
if req.PaymentChannel == "" {
|
||||
return nil, lcommon.NewBusinessError(422, "请选择支付渠道")
|
||||
}
|
||||
if err := payment.RequireCallbackBaseURL(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
orderNo, finalAmount, currency, orderType, description, err := getOrderForPay(ctx, tenantID, orderID)
|
||||
orderNo, finalAmount, currency, orderType, _, err := getOrderForPay(ctx, tenantID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -222,7 +225,7 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.
|
||||
Amount: finalAmount,
|
||||
Currency: currency,
|
||||
OrderType: orderType,
|
||||
Description: description,
|
||||
Description: gatewayProductName(orderType),
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
NotifyURL: notifyURL,
|
||||
ReturnURL: returnURL,
|
||||
@@ -263,6 +266,22 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// gatewayProductName 返回发送给支付网关(易支付)的商品名称。
|
||||
//
|
||||
// 易支付上游(支付宝/微信商户)的风控会扫描 name 参数中的关键词,
|
||||
// 出现"充值/代充/代付/钱包/余额/额度/API/套现"等字样时会直接拦截,
|
||||
// 网关返回"该商品禁止出售"。因此传给网关的 name 必须使用中性、安全的名称。
|
||||
// 数据库 ord_orders.description 仍保留可读文案供租户订单列表展示,
|
||||
// 二者解耦:此处只决定网关看到的商品名(参考 new-api 使用 "TUC{id}" 的做法)。
|
||||
func gatewayProductName(orderType string) string {
|
||||
switch orderType {
|
||||
case "new_plan", "renew", "upgrade":
|
||||
return "会员订阅"
|
||||
default: // recharge 等其他类型
|
||||
return "会员服务"
|
||||
}
|
||||
}
|
||||
|
||||
// getOrderForPay 获取待支付订单信息(供 OrderPay 内部调用)
|
||||
func getOrderForPay(ctx context.Context, tenantID int64, orderID int64) (orderNo string, finalAmount float64, currency string, orderType string, description string, err error) {
|
||||
var order *struct {
|
||||
@@ -326,6 +345,9 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
|
||||
}
|
||||
|
||||
// 2. 从 sys_options 加载渠道配置
|
||||
if err := payment.RequireCallbackBaseURL(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel)
|
||||
if err != nil {
|
||||
return nil, lcommon.NewBusinessError(422, err.Error())
|
||||
@@ -387,7 +409,7 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
|
||||
Amount: finalAmount,
|
||||
Currency: "CNY",
|
||||
OrderType: "recharge",
|
||||
Description: description,
|
||||
Description: gatewayProductName("recharge"),
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
NotifyURL: notifyURL,
|
||||
ReturnURL: returnURL,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- Sync is_public flag from code registry to DB rows.
|
||||
-- Settings like turnstile_enabled/turnstile_site_key were marked IsPublic in the
|
||||
-- registry but the DB rows still had is_public=false, causing GetPublicOptions
|
||||
-- (WHERE is_public=true) to skip them and return stale defaults to the tenant frontend.
|
||||
|
||||
UPDATE sys_options SET is_public = true, updated_at = NOW()
|
||||
WHERE key IN (
|
||||
'site_name',
|
||||
'site_description',
|
||||
'register_enabled',
|
||||
'register_email_verification',
|
||||
'maintenance_message',
|
||||
'maintenance_duration',
|
||||
'api_maintenance_enabled',
|
||||
'oauth_auto_register',
|
||||
'oauth_github_enabled',
|
||||
'oauth_google_enabled',
|
||||
'turnstile_enabled',
|
||||
'turnstile_site_key',
|
||||
'agreement_enabled'
|
||||
) AND is_public = false;
|
||||
|
||||
-- +goose Down
|
||||
-- No-op: reverting is_public to false would break public settings again,
|
||||
-- so we leave the corrected values in place.
|
||||
SELECT 1;
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import TopProgress from '@/components/TopProgress.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TopProgress />
|
||||
<AConfigProvider>
|
||||
<router-view />
|
||||
</AConfigProvider>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useTopProgress } from '@/composables/useTopProgress'
|
||||
|
||||
const { progress, visible, smooth } = useTopProgress()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="top-progress-container" :class="{ 'is-visible': visible }">
|
||||
<div class="top-progress-bar" :class="{ 'no-anim': !smooth }" :style="{ width: progress + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-progress-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.top-progress-container.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.top-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--ta-primary-gradient);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.top-progress-bar.no-anim {
|
||||
transition: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.top-progress-container,
|
||||
.top-progress-bar {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
/**
|
||||
* 顶部加载进度条(模块级单例)
|
||||
*
|
||||
* 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%,
|
||||
* 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。
|
||||
*
|
||||
* 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。
|
||||
*/
|
||||
|
||||
const TICK_MS = 100
|
||||
/** 指数收敛时间常数:越大爬升越慢 */
|
||||
const TIME_CONSTANT = 1500
|
||||
/** 运行中进度上限,永远到不了 100% */
|
||||
const MAX_PROGRESS = 90
|
||||
/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */
|
||||
const HIDE_DELAY_MS = 300
|
||||
/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */
|
||||
const TIMEOUT_MS = 15_000
|
||||
|
||||
const progress = ref(0)
|
||||
const visible = ref(false)
|
||||
/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */
|
||||
const smooth = ref(true)
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function clearTickTimer(): void {
|
||||
if (tickTimer !== null) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHideTimer(): void {
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
// 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」
|
||||
clearHideTimer()
|
||||
|
||||
// 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start)
|
||||
if (visible.value) return
|
||||
|
||||
// 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见
|
||||
smooth.value = false
|
||||
progress.value = 0
|
||||
visible.value = true
|
||||
|
||||
// 下一帧恢复过渡并起步爬升
|
||||
nextTick(() => {
|
||||
smooth.value = true
|
||||
progress.value = 8 // 即时起步反馈,避免前 100ms 空白
|
||||
startTime = Date.now()
|
||||
|
||||
clearTickTimer()
|
||||
tickTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
// 绝对超时兜底
|
||||
if (elapsed > TIMEOUT_MS) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
// 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移
|
||||
const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT))
|
||||
progress.value = Math.min(next, 99)
|
||||
}, TICK_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function done(): void {
|
||||
clearTickTimer()
|
||||
// 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用)
|
||||
if (!visible.value) return
|
||||
|
||||
progress.value = 100
|
||||
clearHideTimer()
|
||||
hideTimer = setTimeout(() => {
|
||||
visible.value = false
|
||||
// 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置
|
||||
hideTimer = null
|
||||
}, HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
export function useTopProgress() {
|
||||
return { progress, visible, smooth, start, done }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import adminRoutes from './admin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import { useTopProgress } from '@/composables/useTopProgress'
|
||||
import axios from 'axios'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -44,8 +45,11 @@ async function checkSetupStatus(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const { siteName, fetchSiteName } = useSiteName()
|
||||
const { start, done } = useTopProgress()
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
start()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
authStore.loadFromStorage()
|
||||
|
||||
@@ -82,4 +86,12 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -199,8 +199,10 @@ const availableModelOptions = computed(() => {
|
||||
})
|
||||
|
||||
const allModelOptions = computed(() => {
|
||||
return modelsList.value
|
||||
.map(m => ({ label: m.model_name ? `${m.model_name} (${m.model_id})` : m.model_id, value: m.model_id }))
|
||||
// 测试模型下拉列表只显示该渠道已配置的能力模型
|
||||
return abilitiesData.value
|
||||
.filter(a => a.enabled)
|
||||
.map(a => ({ label: a.model_name, value: a.model_name }))
|
||||
})
|
||||
|
||||
function openAddAbilityModal() {
|
||||
@@ -343,7 +345,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
function onTabChange(key: string) {
|
||||
if (key === 'abilities' && abilitiesData.value.length === 0) fetchAbilities()
|
||||
if (key === 'test' && modelsList.value.length === 0) fetchModels()
|
||||
if (key === 'test' && abilitiesData.value.length === 0) fetchAbilities()
|
||||
if (key === 'health_trend' && trendData.value.length === 0) fetchHealthTrend()
|
||||
}
|
||||
|
||||
@@ -517,13 +519,11 @@ function formatHeaders(headers: Record<string, string>): string {
|
||||
<ASelect
|
||||
v-model="testModelName"
|
||||
:options="allModelOptions"
|
||||
:loading="modelsLoading"
|
||||
:loading="abilitiesLoading"
|
||||
allow-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
placeholder="输入关键词搜索模型"
|
||||
placeholder="选择渠道支持的模型"
|
||||
:disabled="testLoading"
|
||||
@search="handleModelSearch"
|
||||
@keydown.enter="handleTest"
|
||||
/>
|
||||
</AFormItem>
|
||||
@@ -698,13 +698,12 @@ function formatHeaders(headers: Record<string, string>): string {
|
||||
<ASelect
|
||||
v-model="addAbilityForm.model_name"
|
||||
:options="availableModelOptions"
|
||||
:loading="modelsLoading"
|
||||
:loading="abilitiesLoading"
|
||||
allow-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
placeholder="输入关键词搜索模型"
|
||||
|
||||
placeholder="选择渠道支持的模型"
|
||||
:fallback-option="false"
|
||||
@search="handleModelSearch"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="上游模型名"><AInput v-model="addAbilityForm.upstream_model" placeholder="留空则与平台模型名相同" /></AFormItem>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<TopProgress />
|
||||
<router-view />
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TopProgress from '@/components/common/TopProgress.vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useTopProgress } from '@/composables/useTopProgress'
|
||||
|
||||
const { progress, visible, smooth } = useTopProgress()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="top-progress-container fixed top-0 left-0 z-[9999] h-[3px] w-full pointer-events-none"
|
||||
:class="{ 'is-visible': visible }"
|
||||
>
|
||||
<div
|
||||
class="top-progress-bar h-full bg-gradient-to-r from-primary-500 to-primary-600"
|
||||
:class="{ 'no-anim': !smooth }"
|
||||
:style="{ width: progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-progress-container {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.top-progress-container.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.top-progress-bar {
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.top-progress-bar.no-anim {
|
||||
transition: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.top-progress-container,
|
||||
.top-progress-bar {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
/**
|
||||
* 顶部加载进度条(模块级单例)
|
||||
*
|
||||
* 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%,
|
||||
* 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。
|
||||
*
|
||||
* 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。
|
||||
*/
|
||||
|
||||
const TICK_MS = 100
|
||||
/** 指数收敛时间常数:越大爬升越慢 */
|
||||
const TIME_CONSTANT = 1500
|
||||
/** 运行中进度上限,永远到不了 100% */
|
||||
const MAX_PROGRESS = 90
|
||||
/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */
|
||||
const HIDE_DELAY_MS = 300
|
||||
/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */
|
||||
const TIMEOUT_MS = 15_000
|
||||
|
||||
const progress = ref(0)
|
||||
const visible = ref(false)
|
||||
/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */
|
||||
const smooth = ref(true)
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function clearTickTimer(): void {
|
||||
if (tickTimer !== null) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHideTimer(): void {
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
// 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」
|
||||
clearHideTimer()
|
||||
|
||||
// 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start)
|
||||
if (visible.value) return
|
||||
|
||||
// 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见
|
||||
smooth.value = false
|
||||
progress.value = 0
|
||||
visible.value = true
|
||||
|
||||
// 下一帧恢复过渡并起步爬升
|
||||
nextTick(() => {
|
||||
smooth.value = true
|
||||
progress.value = 8 // 即时起步反馈,避免前 100ms 空白
|
||||
startTime = Date.now()
|
||||
|
||||
clearTickTimer()
|
||||
tickTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
// 绝对超时兜底
|
||||
if (elapsed > TIMEOUT_MS) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
// 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移
|
||||
const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT))
|
||||
progress.value = Math.min(next, 99)
|
||||
}, TICK_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function done(): void {
|
||||
clearTickTimer()
|
||||
// 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用)
|
||||
if (!visible.value) return
|
||||
|
||||
progress.value = 100
|
||||
clearHideTimer()
|
||||
hideTimer = setTimeout(() => {
|
||||
visible.value = false
|
||||
// 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置
|
||||
hideTimer = null
|
||||
}, HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
export function useTopProgress() {
|
||||
return { progress, visible, smooth, start, done }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import tenantRoutes from './tenant'
|
||||
import { useTenantAuthStore } from '@/stores/tenant-auth'
|
||||
import { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useTopProgress } from '@/composables/useTopProgress'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -29,7 +30,11 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
const { start, done } = useTopProgress()
|
||||
|
||||
router.beforeEach((to) => {
|
||||
start()
|
||||
|
||||
const tenantAuthStore = useTenantAuthStore()
|
||||
tenantAuthStore.loadFromStorage()
|
||||
|
||||
@@ -72,4 +77,12 @@ router.beforeEach((to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
done()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -114,14 +114,15 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
|
||||
persist()
|
||||
}
|
||||
|
||||
async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number }): Promise<any> {
|
||||
async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }): Promise<any> {
|
||||
const { data } = await request.post('/tenant/auth/login', {
|
||||
account,
|
||||
password,
|
||||
type,
|
||||
captcha_key: captcha?.captchaKey,
|
||||
captcha_x: captcha?.captchaX,
|
||||
})
|
||||
turnstile_token: captcha?.turnstileToken,
|
||||
}, { _suppressErrorMsg: true } as any)
|
||||
if (data.data?.totp_required) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 是否为 Safari 浏览器。
|
||||
* Safari 下"axios 异步请求完成后再提交 target=_blank 表单"常被静默拦截
|
||||
* (新标签页打不开,用户卡住),故 Safari 上沿用当前页提交,其它浏览器在新标签页打开。
|
||||
*/
|
||||
function isSafariBrowser(): boolean {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 POST 表单方式把支付参数提交到支付网关。
|
||||
*
|
||||
* 易支付 submit.php 的「页面跳转支付」标准做法:后端只返回 submit.php 的 action
|
||||
* 地址 + 签名后的参数,前端构造一个隐藏表单 POST 提交。
|
||||
* 相比把 sign/订单参数拼进 GET URL,更安全(不落浏览器历史与服务器日志),
|
||||
* 也规避 URL 长度限制和中文/特殊字符的编码边界问题。
|
||||
*
|
||||
* @param url 表单 action 地址(submit.php)
|
||||
* @param params 表单隐藏域参数(含 sign / sign_type)
|
||||
*/
|
||||
export function submitPaymentForm(url: string, params: Record<string, string>): void {
|
||||
if (!url) return
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.method = 'POST'
|
||||
// 在新标签页打开支付页,保留当前控制台不被覆盖;支付完成后由 return_url
|
||||
// 在新标签页回到 /console/wallet 展示结果。Safari 例外(见 isSafariBrowser)。
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
}
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
})
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
document.body.removeChild(form)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理后端返回的支付结果:有 form 参数时走 POST 表单提交,否则普通跳转。
|
||||
* 兼容未来返回纯跳转 URL 的支付渠道(如 Stripe Checkout)。
|
||||
*/
|
||||
export function dispatchPayment(result: {
|
||||
payment_url?: string
|
||||
params?: Record<string, string>
|
||||
}): void {
|
||||
const { payment_url, params } = result || {}
|
||||
if (!payment_url) return
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
submitPaymentForm(payment_url, params)
|
||||
} else {
|
||||
window.location.href = payment_url
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,13 @@ const provisionalToken = ref('')
|
||||
const totpCode = ref('')
|
||||
const totpLoading = ref(false)
|
||||
|
||||
// Captcha state — always required
|
||||
// Captcha state
|
||||
const captcha = reactive({ captchaKey: '', captchaX: 0 })
|
||||
const turnstileToken = ref('')
|
||||
const turnstileToken = ref('')
|
||||
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
|
||||
const turnstileRef = ref<InstanceType<typeof Turnstile> | null>(null)
|
||||
|
||||
const useTurnstile = computed(() => settings.value['turnstile_enabled'] === true)
|
||||
|
||||
const emailInput = ref<HTMLInputElement | null>(null)
|
||||
const accountInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -124,15 +127,24 @@ function validateRam(): boolean {
|
||||
|
||||
async function handleAdminLogin() {
|
||||
if (!validateAdmin()) return
|
||||
if (!captcha.captchaKey || !captcha.captchaX) {
|
||||
errorMsg.value = '请先完成滑块验证'
|
||||
return
|
||||
if (useTurnstile.value) {
|
||||
if (!turnstileToken.value) {
|
||||
errorMsg.value = '请先完成人机验证'
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!captcha.captchaKey || !captcha.captchaX) {
|
||||
errorMsg.value = '请先完成滑块验证'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
|
||||
const captchaPayload = useTurnstile.value
|
||||
? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value }
|
||||
: { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
|
||||
const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload)
|
||||
if (res?.totp_required) {
|
||||
provisionalToken.value = res.provisional_token
|
||||
@@ -142,12 +154,16 @@ async function handleAdminLogin() {
|
||||
proceedAfterLogin()
|
||||
} catch (err) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
if (useTurnstile.value) {
|
||||
turnstileRef.value?.reset()
|
||||
errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码'
|
||||
} else if (apiErr?.code === 10058) {
|
||||
errorMsg.value = '滑块验证失败,请重新拖动'
|
||||
captchaRef.value?.resetCaptcha()
|
||||
} else {
|
||||
errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码'
|
||||
captchaRef.value?.resetCaptcha()
|
||||
}
|
||||
captchaRef.value?.resetCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -155,15 +171,24 @@ async function handleAdminLogin() {
|
||||
|
||||
async function handleRamLogin() {
|
||||
if (!validateRam()) return
|
||||
if (!captcha.captchaKey || !captcha.captchaX) {
|
||||
errorMsg.value = '请先完成滑块验证'
|
||||
return
|
||||
if (useTurnstile.value) {
|
||||
if (!turnstileToken.value) {
|
||||
errorMsg.value = '请先完成人机验证'
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!captcha.captchaKey || !captcha.captchaX) {
|
||||
errorMsg.value = '请先完成滑块验证'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
|
||||
const captchaPayload = useTurnstile.value
|
||||
? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value }
|
||||
: { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX }
|
||||
const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload)
|
||||
if (res?.totp_required) {
|
||||
provisionalToken.value = res.provisional_token
|
||||
@@ -173,12 +198,16 @@ async function handleRamLogin() {
|
||||
proceedAfterLogin()
|
||||
} catch (err) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
if (useTurnstile.value) {
|
||||
turnstileRef.value?.reset()
|
||||
errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码'
|
||||
} else if (apiErr?.code === 10058) {
|
||||
errorMsg.value = '滑块验证失败,请重新拖动'
|
||||
captchaRef.value?.resetCaptcha()
|
||||
} else {
|
||||
errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码'
|
||||
captchaRef.value?.resetCaptcha()
|
||||
}
|
||||
captchaRef.value?.resetCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -335,17 +364,8 @@ async function handleOAuthLogin(provider: string) {
|
||||
</div>
|
||||
|
||||
<!-- Captcha -->
|
||||
<SlideCaptcha ref="captchaRef" v-model="captcha" />
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
|
||||
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<Turnstile v-if="useTurnstile" ref="turnstileRef" v-model="turnstileToken" :site-key="settings['turnstile_site_key']" />
|
||||
<SlideCaptcha v-else ref="captchaRef" v-model="captcha" />
|
||||
|
||||
<!-- Remember & Forgot -->
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -370,6 +390,16 @@ async function handleOAuthLogin(provider: string) {
|
||||
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<!-- Error Message -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
|
||||
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-400">
|
||||
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
|
||||
<span class="mx-1">·</span>
|
||||
@@ -432,17 +462,8 @@ async function handleOAuthLogin(provider: string) {
|
||||
</div>
|
||||
|
||||
<!-- Captcha -->
|
||||
<SlideCaptcha ref="captchaRef" v-model="captcha" />
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
|
||||
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<Turnstile v-if="useTurnstile" ref="turnstileRef" v-model="turnstileToken" :site-key="settings['turnstile_site_key']" />
|
||||
<SlideCaptcha v-else ref="captchaRef" v-model="captcha" />
|
||||
|
||||
<!-- Remember & Forgot -->
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -467,6 +488,16 @@ async function handleOAuthLogin(provider: string) {
|
||||
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<!-- Error Message -->
|
||||
<transition name="slide-fade">
|
||||
<div v-if="errorMsg" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon name="exclamationCircle" size="sm" class="mt-0.5 text-red-500 flex-shrink-0" />
|
||||
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-400">
|
||||
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
|
||||
<span class="mx-1">·</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { dispatchPayment } from '@/utils/payment'
|
||||
|
||||
const route = useRoute()
|
||||
const wallet = ref<any>(null)
|
||||
@@ -16,7 +17,7 @@ const selectedPaymentMethod = ref('')
|
||||
const paymentInfo = ref<any>(null)
|
||||
|
||||
// Pay result notification
|
||||
const payResult = ref<'success' | 'fail' | ''>('')
|
||||
const payResult = ref<'success' | 'fail' | 'processing' | ''>('')
|
||||
|
||||
// Frozen items
|
||||
const showFrozenModal = ref(false)
|
||||
@@ -140,7 +141,7 @@ async function handleRecharge() {
|
||||
})
|
||||
const data = res.data?.data
|
||||
if (data?.payment_url) {
|
||||
window.location.href = data.payment_url
|
||||
dispatchPayment(data)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -238,11 +239,19 @@ onMounted(() => {
|
||||
|
||||
// Handle pay result from return URL
|
||||
const pay = route.query.pay as string
|
||||
if (pay === 'success' || pay === 'fail') {
|
||||
if (pay === 'success' || pay === 'fail' || pay === 'processing') {
|
||||
payResult.value = pay
|
||||
setTimeout(() => { payResult.value = '' }, 5000)
|
||||
if (pay === 'success') {
|
||||
setTimeout(() => { payResult.value = '' }, 5000)
|
||||
fetchWallet()
|
||||
} else if (pay === 'processing') {
|
||||
// 异步回调可能仍在途:延迟刷新余额,到账后自动更新;超时后提示联系客服
|
||||
fetchWallet()
|
||||
setTimeout(fetchWallet, 3000)
|
||||
setTimeout(fetchWallet, 8000)
|
||||
setTimeout(() => { payResult.value = '' }, 12000)
|
||||
} else {
|
||||
setTimeout(() => { payResult.value = '' }, 5000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -294,6 +303,18 @@ onBeforeUnmount(() => {
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="payResult === 'processing'" class="pay-banner pay-banner-processing">
|
||||
<div class="pay-banner-icon">
|
||||
<Icon name="infoCircle" size="sm" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold">支付结果确认中</p>
|
||||
<p class="text-xs opacity-80 mt-0.5">正在等待到账,余额会在确认后自动更新</p>
|
||||
</div>
|
||||
<button class="opacity-60 hover:opacity-100 transition-opacity p-1" @click="payResult = ''">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- ============================================ -->
|
||||
@@ -712,6 +733,17 @@ onBeforeUnmount(() => {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: #dc2626;
|
||||
}
|
||||
.pay-banner-processing {
|
||||
background-color: #fffbeb;
|
||||
border: 1px solid rgba(252, 211, 77, 0.6);
|
||||
color: #92400e;
|
||||
}
|
||||
.pay-banner-processing .pay-banner-icon {
|
||||
height: 2rem; width: 2rem; border-radius: 9999px;
|
||||
background-color: #fef3c7;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; color: #d97706;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Balance Hero Card
|
||||
|
||||
Reference in new issue
Block a user