refactor(wallet):重构钱包功能,支持查看冻结信息
This commit is contained in:
1 parent
9a058a7bbc
commit
e4ec55ccf2
20 files changed
+819
-437
No files matched your search
@@ -166,4 +166,5 @@ type ITenantV1 interface {
|
||||
TenantWallet(ctx context.Context, req *v1.TenantWalletReq) (res *v1.TenantWalletRes, err error)
|
||||
TenantWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsReq) (res *v1.TenantWalletTransactionsRes, err error)
|
||||
TenantWalletTransactionsExport(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (res *v1.TenantWalletTransactionsExportRes, err error)
|
||||
TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error)
|
||||
}
|
||||
@@ -36,3 +36,22 @@ type TenantWalletTransactionsExportReq struct {
|
||||
}
|
||||
|
||||
type TenantWalletTransactionsExportRes struct{}
|
||||
|
||||
// TenantWalletFrozenItemsReq 冻结明细请求
|
||||
type TenantWalletFrozenItemsReq struct {
|
||||
g.Meta `path:"/wallet/frozen-items" method:"get" mime:"json" tags:"租户控制台-钱包" summary:"冻结明细"`
|
||||
}
|
||||
|
||||
// TenantWalletFrozenItemsRes 冻结明细响应
|
||||
type TenantWalletFrozenItemsRes struct {
|
||||
Items []FrozenItemDetail `json:"items"`
|
||||
}
|
||||
|
||||
// FrozenItemDetail 单个冻结项
|
||||
type FrozenItemDetail struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error) {
|
||||
return service.Tenant().WalletFrozenItems(ctx, req)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/task"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
relay_common "github.com/qianfree/team-api/relay/common"
|
||||
relay_constant "github.com/qianfree/team-api/relay/constant"
|
||||
relay_handler "github.com/qianfree/team-api/relay/handler"
|
||||
)
|
||||
|
||||
@@ -75,6 +76,9 @@ func HandleTaskSubmit(r *ghttp.Request) {
|
||||
rc, taskDataProvider, taskBillingProvider, channelMeta,
|
||||
)
|
||||
|
||||
// 构建转发路径追踪(任务提交阶段只有一次渠道选择)
|
||||
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, body, channelMeta, capture.StatusCode())
|
||||
|
||||
// 异步记录审计日志
|
||||
go recordTaskSubmitAudit(r, rc, capture, body)
|
||||
}
|
||||
@@ -205,6 +209,7 @@ func HandleMjSubmit(r *ghttp.Request) {
|
||||
rc, taskDataProvider, taskBillingProvider, channelMeta,
|
||||
)
|
||||
|
||||
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, body, channelMeta, capture.StatusCode())
|
||||
go recordTaskSubmitAudit(r, rc, capture, body)
|
||||
}
|
||||
|
||||
@@ -325,5 +330,44 @@ func recordTaskSubmitAudit(r *ghttp.Request, rc *relay_handler.TaskRelayContext,
|
||||
ResponseHeaders: capture.ResponseHeaders(),
|
||||
TaskID: rc.TaskID,
|
||||
TaskStatus: "SUBMITTED",
|
||||
ForwardingTrace: rc.ForwardingTrace,
|
||||
})
|
||||
}
|
||||
|
||||
// buildTaskForwardingTrace 从渠道选择结果构建转发路径追踪
|
||||
func buildTaskForwardingTrace(path string, body []byte, channelMeta *relay_common.ChannelMeta, statusCode int) *relay_common.ForwardingTrace {
|
||||
var modelName string
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if json.Unmarshal(body, &req) == nil {
|
||||
modelName = req.Model
|
||||
}
|
||||
|
||||
providerName := ""
|
||||
if pt := relay_constant.ProviderType(channelMeta.ChannelType); pt > 0 {
|
||||
providerName = pt.String()
|
||||
}
|
||||
|
||||
return &relay_common.ForwardingTrace{
|
||||
EntryPath: path,
|
||||
EntryFormat: "openai",
|
||||
RequestedModel: modelName,
|
||||
UpstreamModel: channelMeta.UpstreamModelName,
|
||||
ModelMapped: channelMeta.IsModelMapped,
|
||||
TotalAttempts: 1,
|
||||
Hops: []relay_common.ForwardingHop{
|
||||
{
|
||||
Attempt: 1,
|
||||
ChannelID: channelMeta.ChannelID,
|
||||
ChannelName: channelMeta.ChannelName,
|
||||
ChannelType: channelMeta.ChannelType,
|
||||
Provider: providerName,
|
||||
BaseURL: channelMeta.BaseURL,
|
||||
UpstreamModel: channelMeta.UpstreamModelName,
|
||||
ModelMapped: channelMeta.IsModelMapped,
|
||||
Success: statusCode == 200,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ok, err := PreDeduct(ctx, tenantID, amount, requestID)
|
||||
ok, err := PreDeduct(ctx, tenantID, amount, requestID, modelName)
|
||||
if !ok {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
// 清除钱包缓存(内存 + Redis)
|
||||
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
|
||||
// 5. 创建计费记录(获取定价快照)
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
@@ -146,6 +147,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
|
||||
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
|
||||
// 5. 获取定价信息
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
|
||||
@@ -67,8 +67,8 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
|
||||
}
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64) (float64, error) {
|
||||
ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID)
|
||||
func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) {
|
||||
ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID, modelName)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("pre-deduct task failed: %w", err)
|
||||
}
|
||||
@@ -97,6 +97,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
|
||||
walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
|
||||
InvalidateWalletRedis(ctx, tenantID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID)
|
||||
CleanupPreDeduct(ctx, tenantID, requestID+"_adjust")
|
||||
|
||||
// 2. 获取定价 + 构建 CostBreakdown
|
||||
pricing, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
@@ -176,12 +178,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
result.RateMultiplier = pricing.DiscountRatio
|
||||
}
|
||||
|
||||
// 5. 差额处理
|
||||
if diff > 0.001 {
|
||||
if ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust"); !ok {
|
||||
return result, fmt.Errorf("settle task adjust pre-deduct: %w", err)
|
||||
}
|
||||
} else if diff < -0.001 {
|
||||
// 5. 差额处理:实际 < 预扣时退还差额(实际 > 预扣时已在步骤 1 扣完,无需额外操作)
|
||||
if diff < -0.001 {
|
||||
if err := SettleFailed(ctx, tenantID, requestID+"_adjust", -diff); err != nil {
|
||||
return result, fmt.Errorf("settle task adjust refund: %w", err)
|
||||
}
|
||||
@@ -247,7 +245,7 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
|
||||
|
||||
if diff > 0 {
|
||||
// 需要补扣
|
||||
ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust")
|
||||
ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust", "")
|
||||
if !ok {
|
||||
return preDeductAmount, fmt.Errorf("adjust task billing: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
@@ -123,7 +124,7 @@ func CheckBalance(ctx context.Context, tenantID int64, amount float64) error {
|
||||
|
||||
// PreDeduct 预扣费用(Redis Lua 原子操作)
|
||||
// 冻结指定金额,返回预扣记录 ID 用于后续结算
|
||||
func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string) (bool, error) {
|
||||
func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) (bool, error) {
|
||||
if amount <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -140,6 +141,9 @@ func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID st
|
||||
// ARGV[1] = amount
|
||||
// ARGV[2] = request_id
|
||||
// ARGV[3] = ttl (PreDeductMaxAge)
|
||||
// ARGV[4] = tenant_id
|
||||
// ARGV[5] = model_name
|
||||
// ARGV[6] = created_at (unix timestamp)
|
||||
luaScript := `
|
||||
local wallet_key = KEYS[1]
|
||||
local prededuct_key = KEYS[2]
|
||||
@@ -167,6 +171,9 @@ end
|
||||
redis.call("HINCRBYFLOAT", wallet_key, "frozen_balance", amount)
|
||||
redis.call("HSET", prededuct_key, "amount", amount)
|
||||
redis.call("HSET", prededuct_key, "tenant_id", ARGV[4])
|
||||
redis.call("HSET", prededuct_key, "model_name", ARGV[5])
|
||||
redis.call("HSET", prededuct_key, "created_at", ARGV[6])
|
||||
redis.call("SADD", "prededuct_active:" .. ARGV[4], request_id)
|
||||
redis.call("EXPIRE", prededuct_key, ttl)
|
||||
return 1
|
||||
`
|
||||
@@ -176,7 +183,7 @@ return 1
|
||||
|
||||
result, err := g.Redis().Do(ctx, "EVAL", luaScript, 2,
|
||||
walletRedisKey, predeductRedisKey,
|
||||
amount, requestID, PreDeductMaxAge, tenantID)
|
||||
amount, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix())
|
||||
if err != nil {
|
||||
// Redis 不可用,降级到 DB 直接扣减
|
||||
return preDeductDB(ctx, tenantID, amount, requestID)
|
||||
@@ -258,12 +265,14 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, am
|
||||
|
||||
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
|
||||
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
|
||||
activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID)
|
||||
|
||||
// 先尝试 Redis 解冻
|
||||
_, err := g.Redis().Do(ctx, "DEL", predeductRedisKey)
|
||||
if err == nil {
|
||||
// 解冻
|
||||
g.Redis().Do(ctx, "HINCRBYFLOAT", walletRedisKey, "frozen_balance", -amount)
|
||||
g.Redis().Do(ctx, "SREM", activeSetKey, requestID)
|
||||
go unfreezeSyncDB(tenantID, amount)
|
||||
return
|
||||
}
|
||||
@@ -392,3 +401,93 @@ func InvalidateWalletRedis(ctx context.Context, tenantID int64) {
|
||||
walletRedisKey := fmt.Sprintf("wallet:%d", tenantID)
|
||||
g.Redis().Do(ctx, "DEL", walletRedisKey)
|
||||
}
|
||||
|
||||
// CleanupPreDeduct 清理预扣记录(结算成功后调用)
|
||||
func CleanupPreDeduct(ctx context.Context, tenantID int64, requestID string) {
|
||||
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
|
||||
activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID)
|
||||
g.Redis().Do(ctx, "DEL", predeductRedisKey)
|
||||
g.Redis().Do(ctx, "SREM", activeSetKey, requestID)
|
||||
}
|
||||
|
||||
// FrozenItem 单个冻结项
|
||||
type FrozenItem struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
}
|
||||
|
||||
// GetFrozenItems 获取租户当前所有冻结项
|
||||
func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) {
|
||||
activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID)
|
||||
|
||||
members, err := g.Redis().Do(ctx, "SMEMBERS", activeSetKey)
|
||||
if err != nil || members.IsNil() {
|
||||
return []FrozenItem{}, nil
|
||||
}
|
||||
|
||||
requestIDs := members.Strings()
|
||||
var items []FrozenItem
|
||||
var staleIDs []string
|
||||
|
||||
for _, reqID := range requestIDs {
|
||||
predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, reqID)
|
||||
|
||||
exists, _ := g.Redis().Do(ctx, "EXISTS", predeductKey)
|
||||
if exists.Int64() == 0 {
|
||||
staleIDs = append(staleIDs, reqID)
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := g.Redis().Do(ctx, "HGETALL", predeductKey)
|
||||
if err != nil || data.IsNil() {
|
||||
staleIDs = append(staleIDs, reqID)
|
||||
continue
|
||||
}
|
||||
|
||||
m := data.Map()
|
||||
|
||||
ttl, _ := g.Redis().Do(ctx, "TTL", predeductKey)
|
||||
remaining := ttl.Int64()
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
var amount float64
|
||||
if v, ok := m["amount"]; ok {
|
||||
amount = gconv.Float64(v)
|
||||
}
|
||||
|
||||
var modelName string
|
||||
if v, ok := m["model_name"]; ok {
|
||||
modelName = gconv.String(v)
|
||||
}
|
||||
|
||||
var createdAt int64
|
||||
if v, ok := m["created_at"]; ok {
|
||||
createdAt = gconv.Int64(v)
|
||||
}
|
||||
|
||||
items = append(items, FrozenItem{
|
||||
RequestID: reqID,
|
||||
ModelName: modelName,
|
||||
Amount: amount,
|
||||
CreatedAt: createdAt,
|
||||
Remaining: remaining,
|
||||
})
|
||||
}
|
||||
|
||||
// 清理过期条目(TTL 已过但仍在 set 中的残留)
|
||||
if len(staleIDs) > 0 {
|
||||
args := make([]any, 0, len(staleIDs)+1)
|
||||
args = append(args, activeSetKey)
|
||||
for _, id := range staleIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
g.Redis().Do(ctx, "SREM", args...)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -131,22 +132,41 @@ func creditWallet(ctx context.Context, tenantID int64, amount float64, descripti
|
||||
return gerror.Newf("wallet not found for tenant %d", tenantID)
|
||||
}
|
||||
|
||||
_, err = g.DB().Exec(ctx,
|
||||
"UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?",
|
||||
amount, w.ID)
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?",
|
||||
amount, w.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var balance struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
}
|
||||
err = tx.Model("bil_wallets").Ctx(ctx).
|
||||
Where("id", w.ID).
|
||||
Fields("balance, frozen_balance").
|
||||
Scan(&balance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{
|
||||
TenantId: tenantID,
|
||||
WalletId: w.ID,
|
||||
Type: "recharge",
|
||||
Amount: amount,
|
||||
BalanceAfter: balance.Balance,
|
||||
FrozenAfter: balance.FrozenBalance,
|
||||
Description: description,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录流水
|
||||
dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{
|
||||
TenantId: tenantID,
|
||||
WalletId: w.ID,
|
||||
Type: "recharge",
|
||||
Amount: amount,
|
||||
Description: description,
|
||||
})
|
||||
|
||||
// 清除 Redis 钱包缓存,下次预扣时从 DB 重新同步
|
||||
billing.InvalidateWalletRedis(ctx, tenantID)
|
||||
|
||||
|
||||
@@ -102,9 +102,10 @@ func handleTimedOutTasks(ctx context.Context) {
|
||||
// 退还预扣费用
|
||||
if t.PreDeductAmount > 0 {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil {
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "poll: refund timed-out task %s: %v", t.PublicTaskID, err)
|
||||
}
|
||||
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s timed out", t.PublicTaskID)
|
||||
|
||||
@@ -127,12 +128,13 @@ func handleUnsettledTasks(ctx context.Context) {
|
||||
// 无法恢复,直接退还预扣
|
||||
g.Log().Warningf(ctx, "poll: unsettled task %s has invalid private_data, refunding", t.PublicTaskID)
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil {
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
|
||||
g.Log().Errorf(ctx, "poll: refund unsettled task %s: %v", t.PublicTaskID, err)
|
||||
} else {
|
||||
t.BillingSettled = true
|
||||
DefaultAsyncProvider.UpdateTask(ctx, t)
|
||||
}
|
||||
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ func handleUnsettledTasks(ctx context.Context) {
|
||||
}
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
_, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
|
||||
t.ModelName, t.PublicTaskID, actualCost, t.PreDeductAmount,
|
||||
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount,
|
||||
0, 0, pd.BillingContext.Ratios)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "poll: retry settle task %s: %v", t.PublicTaskID, err)
|
||||
@@ -157,13 +159,14 @@ func handleUnsettledTasks(ctx context.Context) {
|
||||
} else {
|
||||
// 失败任务:退还预扣
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil {
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "poll: retry refund task %s: %v", t.PublicTaskID, err)
|
||||
} else {
|
||||
t.BillingSettled = true
|
||||
DefaultAsyncProvider.UpdateTask(ctx, t)
|
||||
g.Log().Infof(ctx, "poll: retried refund for task %s", t.PublicTaskID)
|
||||
}
|
||||
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,13 +308,14 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
}
|
||||
|
||||
task.ActualCost = actualCost
|
||||
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.PublicTaskID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios)
|
||||
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err)
|
||||
} else {
|
||||
task.BillingSettled = true
|
||||
DefaultAsyncProvider.UpdateTask(ctx, task)
|
||||
}
|
||||
billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust")
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s completed", task.PublicTaskID)
|
||||
|
||||
@@ -325,12 +329,13 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
// 退还预扣费用
|
||||
if task.PreDeductAmount > 0 && !task.BillingSettled {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.PublicTaskID, task.PreDeductAmount); err != nil {
|
||||
if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.RequestID, task.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "poll: refund failed task %s: %v", task.PublicTaskID, err)
|
||||
} else {
|
||||
task.BillingSettled = true
|
||||
DefaultAsyncProvider.UpdateTask(ctx, task)
|
||||
}
|
||||
billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust")
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s failed: %s", task.PublicTaskID, task.FailReason)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"strconv"
|
||||
@@ -537,3 +538,30 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WalletFrozenItems 获取冻结明细
|
||||
func (s *sTenant) WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (*v1.TenantWalletFrozenItemsRes, error) {
|
||||
role := ctxUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
tenantID := ctxTenantID(ctx)
|
||||
|
||||
items, err := billing.GetFrozenItems(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
details := make([]v1.FrozenItemDetail, 0, len(items))
|
||||
for _, item := range items {
|
||||
details = append(details, v1.FrozenItemDetail{
|
||||
RequestID: item.RequestID,
|
||||
ModelName: item.ModelName,
|
||||
Amount: item.Amount,
|
||||
CreatedAt: item.CreatedAt,
|
||||
Remaining: item.Remaining,
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.TenantWalletFrozenItemsRes{Items: details}, nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -133,18 +134,43 @@ func creditWalletForRedemption(ctx context.Context, tenantID int64, amount float
|
||||
return 0
|
||||
}
|
||||
|
||||
g.DB().Exec(ctx,
|
||||
"UPDATE bil_wallets SET balance = balance + ?, updated_at = ? WHERE id = ?",
|
||||
amount, time.Now(), w.ID)
|
||||
var txID int64
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
_, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?",
|
||||
amount, w.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txID, err := dao.BilTransactions.Ctx(ctx).InsertAndGetId(do.BilTransactions{
|
||||
TenantId: tenantID,
|
||||
WalletId: w.ID,
|
||||
Type: "recharge",
|
||||
Amount: amount,
|
||||
RelatedId: redemptionID,
|
||||
RelatedType: "redemption",
|
||||
Description: fmt.Sprintf("兑换码充值"),
|
||||
var balance struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
}
|
||||
err = tx.Model("bil_wallets").Ctx(ctx).
|
||||
Where("id", w.ID).
|
||||
Fields("balance, frozen_balance").
|
||||
Scan(&balance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := tx.Model("bil_transactions").Ctx(ctx).InsertAndGetId(do.BilTransactions{
|
||||
TenantId: tenantID,
|
||||
WalletId: w.ID,
|
||||
Type: "recharge",
|
||||
Amount: amount,
|
||||
BalanceAfter: balance.Balance,
|
||||
FrozenAfter: balance.FrozenBalance,
|
||||
RelatedId: redemptionID,
|
||||
RelatedType: "redemption",
|
||||
Description: "兑换码充值",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
txID = id
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0
|
||||
|
||||
@@ -65,6 +65,8 @@ type (
|
||||
ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error)
|
||||
// ExportWalletTransactions exports the tenant wallet transactions as CSV or Excel.
|
||||
ExportWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (*v1.TenantWalletTransactionsExportRes, error)
|
||||
// WalletFrozenItems 获取冻结明细
|
||||
WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (*v1.TenantWalletFrozenItemsRes, error)
|
||||
// Dashboard returns the tenant dashboard statistics.
|
||||
Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v1.TenantDashboardRes, error)
|
||||
// TokenTrends returns daily token usage for the past N days.
|
||||
|
||||
@@ -9,7 +9,7 @@ type TaskBillingProvider interface {
|
||||
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (float64, error)
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64) (float64, error)
|
||||
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error)
|
||||
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)
|
||||
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64) (*SettlementResult, error)
|
||||
|
||||
@@ -24,15 +24,16 @@ import (
|
||||
|
||||
// TaskRelayContext 异步任务 relay 上下文
|
||||
type TaskRelayContext struct {
|
||||
TenantID int64
|
||||
UserID int64
|
||||
ApiKeyID int64
|
||||
ProjectID int64
|
||||
TaskID string // 由 HandleTaskSubmit 在创建任务后设置
|
||||
RequestID string
|
||||
Writer http.ResponseWriter
|
||||
Scope string
|
||||
ClientIP string
|
||||
TenantID int64
|
||||
UserID int64
|
||||
ApiKeyID int64
|
||||
ProjectID int64
|
||||
TaskID string // 由 HandleTaskSubmit 在创建任务后设置
|
||||
RequestID string
|
||||
Writer http.ResponseWriter
|
||||
Scope string
|
||||
ClientIP string
|
||||
ForwardingTrace *common.ForwardingTrace
|
||||
}
|
||||
|
||||
// HandleTaskSubmit 异步任务提交管线
|
||||
@@ -116,7 +117,7 @@ func HandleTaskSubmit(
|
||||
}
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost)
|
||||
|
||||
preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost)
|
||||
preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost, modelName)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: pre-deduct failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusPaymentRequired, "insufficient balance", "")
|
||||
|
||||
@@ -134,22 +134,22 @@ const tenantRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: '钱包', icon: 'creditCard', sort: 40, roles: ['owner', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
name: 'TenantPlans',
|
||||
component: () => import('@/views/finance/PlansPage.vue'),
|
||||
meta: { title: '套餐方案', icon: 'gift', sort: 41, roles: ['owner', 'admin'] },
|
||||
path: 'transactions',
|
||||
name: 'TenantTransactions',
|
||||
component: () => import('@/views/finance/TransactionsPage.vue'),
|
||||
meta: { title: '交易记录', icon: 'document', sort: 41, roles: ['owner', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'TenantOrders',
|
||||
component: () => import('@/views/finance/OrdersPage.vue'),
|
||||
meta: { title: '订单记录', icon: 'document', sort: 42, roles: ['owner', 'admin'] },
|
||||
meta: { title: '订单记录', icon: 'receipt', sort: 42, roles: ['owner', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: 'redeem',
|
||||
name: 'TenantRedeem',
|
||||
component: () => import('@/views/finance/RedeemPage.vue'),
|
||||
meta: { title: '兑换码', icon: 'receipt', sort: 43, roles: ['owner', 'admin'] },
|
||||
meta: { title: '兑换码', icon: 'gift', sort: 43, roles: ['owner', 'admin'] },
|
||||
},
|
||||
// 沟通 (50-59)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ const mobileOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
const consoleAnnouncements = ref<any[]>([])
|
||||
let announcementTimer: ReturnType<typeof setInterval> | null = null
|
||||
const walletBalance = ref<string>('')
|
||||
let walletTimer: ReturnType<typeof setInterval> | null = null
|
||||
const { unreadCount, startPolling: startNotificationPolling, stopPolling: stopNotificationPolling, setOnNewNotification } = useNotificationCount()
|
||||
setOnNewNotification((newCount: number) => {
|
||||
if (newCount > 0) {
|
||||
@@ -129,12 +131,27 @@ async function fetchAnnouncements() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWalletBalance() {
|
||||
try {
|
||||
const res = await request.get('/tenant/wallet')
|
||||
const w = res.data?.data
|
||||
if (w) {
|
||||
const bal = w.available_balance ?? w.balance ?? 0
|
||||
walletBalance.value = bal >= 1 ? '$' + bal.toFixed(0) : bal > 0 ? '$' + bal.toFixed(2) : '$0'
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
authStore.loadFromStorage()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
fetchAnnouncements()
|
||||
announcementTimer = setInterval(fetchAnnouncements, 10 * 60 * 1000)
|
||||
startNotificationPolling()
|
||||
fetchWalletBalance()
|
||||
walletTimer = setInterval(fetchWalletBalance, 60000)
|
||||
|
||||
await fetchPublicSettings()
|
||||
if (publicSettings.demo_mode) {
|
||||
@@ -150,6 +167,10 @@ onBeforeUnmount(() => {
|
||||
clearInterval(announcementTimer)
|
||||
announcementTimer = null
|
||||
}
|
||||
if (walletTimer) {
|
||||
clearInterval(walletTimer)
|
||||
walletTimer = null
|
||||
}
|
||||
unmountWatermark()
|
||||
})
|
||||
</script>
|
||||
@@ -274,6 +295,15 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Wallet -->
|
||||
<router-link
|
||||
to="/tenant/wallet"
|
||||
class="relative flex h-9 items-center justify-center rounded-xl px-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
title="钱包"
|
||||
>
|
||||
<Icon name="wallet" size="md" />
|
||||
<span v-if="walletBalance" class="hidden sm:inline text-xs font-medium text-gray-500 ml-1.5">{{ walletBalance }}</span>
|
||||
</router-link>
|
||||
<!-- User Menu -->
|
||||
<div class="relative" data-user-menu>
|
||||
<button
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const plans = ref<any[]>([])
|
||||
const currentPlan = ref<any>(null)
|
||||
const showConfirm = ref(false)
|
||||
const selectedPlan = ref<any>(null)
|
||||
const selectedMonths = ref(1)
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
const monthsOptions = [
|
||||
{ label: '1 个月', value: 1 },
|
||||
{ label: '3 个月', value: 3 },
|
||||
{ label: '6 个月', value: 6 },
|
||||
{ label: '12 个月(优惠)', value: 12 },
|
||||
]
|
||||
|
||||
const currentPlanId = computed(() => currentPlan.value?.plan_id)
|
||||
|
||||
async function fetchPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [plansRes, currentRes] = await Promise.all([
|
||||
request.get('/tenant/plans'),
|
||||
request.get('/tenant/plan/current').catch(() => ({ data: { data: null } })),
|
||||
])
|
||||
const raw = plansRes.data?.data; plans.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
|
||||
currentPlan.value = currentRes.data?.data || null
|
||||
} catch (e) {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function calcPrice(plan: any, months: number) {
|
||||
if (months >= 12) return Number(plan.yearly_price || 0)
|
||||
return Number(plan.monthly_price || 0) * months
|
||||
}
|
||||
|
||||
function openConfirm(plan: any) {
|
||||
selectedPlan.value = plan
|
||||
selectedMonths.value = 1
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
const res = await request.post('/tenant/orders/create', {
|
||||
order_type: 'new_plan',
|
||||
plan_id: selectedPlan.value.id,
|
||||
months: selectedMonths.value,
|
||||
})
|
||||
const order = res.data?.data
|
||||
if (order?.id) {
|
||||
// Mock pay immediately
|
||||
await request.post(`/tenant/orders/${order.id}/pay`)
|
||||
}
|
||||
showConfirm.value = false
|
||||
await fetchPlans()
|
||||
} catch (e) {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentPlan(plan: any) {
|
||||
return currentPlan.value && currentPlan.value.plan_id === plan.id
|
||||
}
|
||||
|
||||
onMounted(fetchPlans)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">套餐方案</h2>
|
||||
<p class="page-description">选择适合您团队的方案,如需更多方案,可联系管理员定制</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="i in 3" :key="i" class="card p-6 animate-pulse">
|
||||
<div class="h-6 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div class="h-8 bg-gray-200 rounded w-3/4 mb-6"></div>
|
||||
<div class="space-y-3">
|
||||
<div class="h-4 bg-gray-200 rounded"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="plans.length === 0" class="empty-state">
|
||||
<Icon name="exclamationCircle" size="xl" class="empty-state-icon text-gray-300" />
|
||||
<p class="empty-state-title">暂无可用套餐</p>
|
||||
<p class="empty-state-description">请联系管理员配置套餐方案</p>
|
||||
</div>
|
||||
|
||||
<!-- Plan Cards -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="card card-hover relative flex flex-col"
|
||||
:class="{ 'ring-2 ring-primary-500 shadow-glow': plan.is_recommended }"
|
||||
>
|
||||
<!-- Recommended Badge -->
|
||||
<div
|
||||
v-if="plan.is_recommended"
|
||||
class="absolute -top-3 left-1/2 -translate-x-1/2 badge badge-primary"
|
||||
>
|
||||
推荐
|
||||
</div>
|
||||
|
||||
<!-- Current Plan Badge -->
|
||||
<div
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="absolute top-4 right-4 badge badge-success"
|
||||
>
|
||||
当前套餐
|
||||
</div>
|
||||
|
||||
<div class="card-body flex-1 flex flex-col">
|
||||
<!-- Plan Name -->
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{{ plan.name }}</h3>
|
||||
<p class="text-xs text-gray-500 mb-4">{{ plan.description || '' }}</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-3xl font-bold text-gray-900">¥{{ Number(plan.monthly_price).toFixed(0) }}</span>
|
||||
<span class="text-sm text-gray-500">/月</span>
|
||||
</div>
|
||||
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-gray-400 mt-1">
|
||||
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-2.5 text-sm text-gray-600 flex-1">
|
||||
<div v-if="plan.monthly_quota_tokens > 0" class="flex items-center gap-2">
|
||||
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
|
||||
<span>{{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
|
||||
<span>不限 Token 用量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="card-footer">
|
||||
<button
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="btn btn-secondary w-full"
|
||||
disabled
|
||||
>
|
||||
当前方案
|
||||
</button>
|
||||
<button
|
||||
v-else-if="Number(plan.monthly_price) === 0"
|
||||
class="btn btn-primary w-full"
|
||||
@click="openConfirm(plan)"
|
||||
>
|
||||
免费开通
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-primary w-full"
|
||||
@click="openConfirm(plan)"
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
|
||||
<div class="modal-content w-full max-w-md">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">确认订阅</h3>
|
||||
<button @click="showConfirm = false" class="btn-ghost btn-icon">
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="selectedPlan" class="space-y-4">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
|
||||
<span class="font-medium text-gray-900">{{ selectedPlan.name }}</span>
|
||||
<span class="badge badge-primary">{{ selectedPlan.identifier }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">订阅时长</label>
|
||||
<div class="grid grid-cols-2 gap-2 mt-1.5">
|
||||
<button
|
||||
v-for="opt in monthsOptions"
|
||||
:key="opt.value"
|
||||
class="rounded-xl px-3 py-2 text-sm font-medium border transition-all"
|
||||
:class="selectedMonths === opt.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
|
||||
@click="selectedMonths = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-xl">
|
||||
<span class="text-sm text-gray-600">应付金额</span>
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="showConfirm = false" class="btn btn-secondary">取消</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="confirmLoading"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
{{ confirmLoading ? '处理中...' : '确认订阅' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
|
||||
const loading = ref(false)
|
||||
const transactions = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
const showExportDropdown = ref(false)
|
||||
const { exporting, exportFile } = useExport({
|
||||
url: '/tenant/wallet/transactions/export',
|
||||
})
|
||||
|
||||
const txTypeLabel: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
pre_deduct: '预扣',
|
||||
settle: '结算',
|
||||
refund: '退款',
|
||||
adjust: '调整',
|
||||
freeze: '冻结',
|
||||
unfreeze: '解冻',
|
||||
}
|
||||
|
||||
const txTypeBadgeClass: Record<string, string> = {
|
||||
recharge: 'badge-success',
|
||||
refund: 'badge-success',
|
||||
consume: 'badge-danger',
|
||||
pre_deduct: 'badge-danger',
|
||||
settle: 'badge-danger',
|
||||
adjust: 'badge-warning',
|
||||
freeze: 'badge-gray',
|
||||
unfreeze: 'badge-gray',
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 0) return '+$' + amount.toFixed(6)
|
||||
return '-$' + Math.abs(amount).toFixed(6)
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/tenant/wallet/transactions', {
|
||||
params: { page: page.value, page_size: pageSize },
|
||||
})
|
||||
const raw = res.data?.data
|
||||
transactions.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
|
||||
total.value = raw?.total || 0
|
||||
} catch {
|
||||
transactions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value * pageSize < total.value) {
|
||||
page.value++
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTransactions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="page-title">交易记录</h1>
|
||||
<p class="page-description">查看所有交易流水明细 · 共 {{ total }} 条记录</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="relative inline-block">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
|
||||
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
|
||||
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
导出
|
||||
</button>
|
||||
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
|
||||
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
|
||||
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="fetchTransactions">
|
||||
<Icon name="refresh" size="sm" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions -->
|
||||
<div class="card">
|
||||
<div v-if="loading" class="p-8 flex justify-center">
|
||||
<div class="spinner h-6 w-6 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="transactions.length > 0" class="table-container border-0 rounded-none">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>金额</th>
|
||||
<th>余额</th>
|
||||
<th>描述</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tx in transactions" :key="tx.id">
|
||||
<td>
|
||||
<span class="badge" :class="txTypeBadgeClass[tx.type] || 'badge-gray'">
|
||||
{{ txTypeLabel[tx.type] || tx.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td :class="tx.amount >= 0 ? 'text-emerald-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ formatAmount(tx.amount) }}
|
||||
</td>
|
||||
<td class="text-gray-700">${{ tx.balance_after?.toFixed(2) ?? '--' }}</td>
|
||||
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
|
||||
<td class="text-gray-500 text-xs">{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<Icon name="creditCard" size="xl" class="empty-state-icon" />
|
||||
<p class="empty-state-title">暂无交易记录</p>
|
||||
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page * pageSize >= total" @click="nextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
|
||||
const loading = ref(false)
|
||||
const wallet = ref<any>(null)
|
||||
const transactions = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
const showExportDropdown = ref(false)
|
||||
const { exporting, exportFile } = useExport({
|
||||
url: '/tenant/wallet/transactions/export',
|
||||
})
|
||||
|
||||
// Recharge
|
||||
const rechargeAmount = ref<number | null>(null)
|
||||
@@ -22,32 +12,28 @@ const customAmount = ref('')
|
||||
const rechargeLoading = ref(false)
|
||||
const presetAmounts = [10, 50, 100, 500]
|
||||
|
||||
const txTypeLabel: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
consume: '消费',
|
||||
pre_deduct: '预扣',
|
||||
settle: '结算',
|
||||
refund: '退款',
|
||||
adjust: '调整',
|
||||
freeze: '冻结',
|
||||
unfreeze: '解冻',
|
||||
}
|
||||
// Plans
|
||||
const plans = ref<any[]>([])
|
||||
const currentPlan = ref<any>(null)
|
||||
const showConfirm = ref(false)
|
||||
const selectedPlan = ref<any>(null)
|
||||
const selectedMonths = ref(1)
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
const txTypeBadgeClass: Record<string, string> = {
|
||||
recharge: 'badge-success',
|
||||
refund: 'badge-success',
|
||||
consume: 'badge-danger',
|
||||
pre_deduct: 'badge-danger',
|
||||
settle: 'badge-danger',
|
||||
adjust: 'badge-warning',
|
||||
freeze: 'badge-gray',
|
||||
unfreeze: 'badge-gray',
|
||||
}
|
||||
// Frozen items
|
||||
const showFrozenModal = ref(false)
|
||||
const frozenItems = ref<any[]>([])
|
||||
const frozenLoading = ref(false)
|
||||
let frozenTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 0) return '+$' + amount.toFixed(2)
|
||||
return '-$' + Math.abs(amount).toFixed(2)
|
||||
}
|
||||
const monthsOptions = [
|
||||
{ label: '1 个月', value: 1 },
|
||||
{ label: '3 个月', value: 3 },
|
||||
{ label: '6 个月', value: 6 },
|
||||
{ label: '12 个月(优惠)', value: 12 },
|
||||
]
|
||||
|
||||
const currentPlanId = computed(() => currentPlan.value?.plan_id)
|
||||
|
||||
async function fetchWallet() {
|
||||
try {
|
||||
@@ -58,35 +44,23 @@ async function fetchWallet() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
async function fetchPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/tenant/wallet/transactions', {
|
||||
params: { page: page.value, page_size: pageSize },
|
||||
})
|
||||
const raw = res.data?.data; transactions.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
|
||||
total.value = raw?.total || 0
|
||||
const [plansRes, currentRes] = await Promise.all([
|
||||
request.get('/tenant/plans'),
|
||||
request.get('/tenant/plan/current').catch(() => ({ data: { data: null } })),
|
||||
])
|
||||
const raw = plansRes.data?.data
|
||||
plans.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
|
||||
currentPlan.value = currentRes.data?.data || null
|
||||
} catch {
|
||||
transactions.value = []
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value * pageSize < total.value) {
|
||||
page.value++
|
||||
fetchTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
function selectPresetAmount(amount: number) {
|
||||
rechargeAmount.value = amount
|
||||
customAmount.value = ''
|
||||
@@ -112,17 +86,97 @@ async function handleRecharge() {
|
||||
rechargeAmount.value = null
|
||||
customAmount.value = ''
|
||||
fetchWallet()
|
||||
fetchTransactions()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function calcPrice(plan: any, months: number) {
|
||||
if (months >= 12) return Number(plan.yearly_price || 0)
|
||||
return Number(plan.monthly_price || 0) * months
|
||||
}
|
||||
|
||||
function openConfirm(plan: any) {
|
||||
selectedPlan.value = plan
|
||||
selectedMonths.value = 1
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
const res = await request.post('/tenant/orders/create', {
|
||||
order_type: 'new_plan',
|
||||
plan_id: selectedPlan.value.id,
|
||||
months: selectedMonths.value,
|
||||
})
|
||||
const order = res.data?.data
|
||||
if (order?.id) {
|
||||
await request.post(`/tenant/orders/${order.id}/pay`)
|
||||
}
|
||||
showConfirm.value = false
|
||||
await fetchPlans()
|
||||
} catch {
|
||||
// interceptor handles error toast
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentPlan(plan: any) {
|
||||
return currentPlan.value && currentPlan.value.plan_id === plan.id
|
||||
}
|
||||
|
||||
// Frozen items
|
||||
async function fetchFrozenItems() {
|
||||
frozenLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/tenant/wallet/frozen-items')
|
||||
frozenItems.value = res.data?.data?.items || []
|
||||
} catch {
|
||||
frozenItems.value = []
|
||||
} finally {
|
||||
frozenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openFrozenModal() {
|
||||
showFrozenModal.value = true
|
||||
fetchFrozenItems()
|
||||
frozenTimer = setInterval(fetchFrozenItems, 10000)
|
||||
}
|
||||
|
||||
function closeFrozenModal() {
|
||||
showFrozenModal.value = false
|
||||
if (frozenTimer) {
|
||||
clearInterval(frozenTimer)
|
||||
frozenTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(unix: number): string {
|
||||
if (!unix) return '-'
|
||||
return new Date(unix * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatRemaining(seconds: number): string {
|
||||
if (seconds <= 0) return '即将到期'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWallet()
|
||||
fetchTransactions()
|
||||
fetchPlans()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (frozenTimer) {
|
||||
clearInterval(frozenTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -131,7 +185,7 @@ onMounted(() => {
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">钱包</h1>
|
||||
<p class="page-description">查看余额和交易记录</p>
|
||||
<p class="page-description">管理余额、充值和套餐方案</p>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Summary -->
|
||||
@@ -141,9 +195,15 @@ onMounted(() => {
|
||||
<p class="text-2xl font-bold text-gray-900">${{ wallet?.available_balance?.toFixed(2) ?? '0.00' }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ wallet?.currency || 'USD' }}</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<div
|
||||
class="card p-5 cursor-pointer hover:-translate-y-0.5 transition-all duration-200 group"
|
||||
@click="openFrozenModal"
|
||||
>
|
||||
<p class="stat-label">冻结余额</p>
|
||||
<p class="text-2xl font-bold text-amber-600">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</p>
|
||||
<p class="text-2xl font-bold text-amber-600 group-hover:text-amber-700 transition-colors">
|
||||
${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}
|
||||
</p>
|
||||
<p v-if="wallet?.frozen_balance > 0" class="text-xs text-amber-500 mt-1">点击查看明细</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="stat-label">总余额</p>
|
||||
@@ -191,82 +251,207 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions -->
|
||||
<!-- Subscription Plans -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900">交易记录</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">共 {{ total }} 条记录</p>
|
||||
<h2 class="font-semibold text-gray-900">套餐方案</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">选择适合您团队的方案</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="i in 3" :key="i" class="p-4 border border-gray-200 rounded-xl animate-pulse">
|
||||
<div class="h-5 bg-gray-200 rounded w-1/2 mb-3"></div>
|
||||
<div class="h-7 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
<!-- Export dropdown -->
|
||||
<div class="relative inline-block">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
|
||||
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
|
||||
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
导出
|
||||
</button>
|
||||
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
|
||||
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
|
||||
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="plans.length === 0" class="py-8 text-center">
|
||||
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
|
||||
<p class="text-gray-500 text-sm">暂无可用套餐,请联系管理员配置</p>
|
||||
</div>
|
||||
|
||||
<!-- Plan Cards -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="relative flex flex-col p-4 border rounded-xl transition-all hover:shadow-sm"
|
||||
:class="plan.is_recommended
|
||||
? 'border-primary-500 bg-primary-50/30 ring-1 ring-primary-500/20'
|
||||
: 'border-gray-200 hover:border-gray-300'"
|
||||
>
|
||||
<!-- Recommended Badge -->
|
||||
<div
|
||||
v-if="plan.is_recommended"
|
||||
class="absolute -top-2.5 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<span class="badge badge-primary text-xs">推荐</span>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan Badge -->
|
||||
<div
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="absolute top-3 right-3"
|
||||
>
|
||||
<span class="badge badge-success text-xs">当前</span>
|
||||
</div>
|
||||
|
||||
<!-- Plan Info -->
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-1">{{ plan.name }}</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">{{ plan.description || '' }}</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-gray-900">¥{{ Number(plan.monthly_price).toFixed(0) }}</span>
|
||||
<span class="text-xs text-gray-500">/月</span>
|
||||
</div>
|
||||
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-gray-400 mt-1">
|
||||
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-1.5 text-sm text-gray-600 flex-1">
|
||||
<div v-if="plan.monthly_quota_tokens > 0" class="flex items-center gap-1.5">
|
||||
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
|
||||
<span class="text-xs">{{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
|
||||
<span class="text-xs">不限 Token 用量</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="mt-4 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="btn btn-secondary w-full btn-sm"
|
||||
disabled
|
||||
>
|
||||
当前方案
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-primary w-full btn-sm"
|
||||
@click="openConfirm(plan)"
|
||||
>
|
||||
{{ Number(plan.monthly_price) === 0 ? '免费开通' : '立即购买' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="fetchTransactions">
|
||||
<Icon name="refresh" size="sm" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 flex justify-center">
|
||||
<div class="spinner h-6 w-6 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="transactions.length > 0" class="table-container border-0 rounded-none">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>金额</th>
|
||||
<th>余额</th>
|
||||
<th>描述</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tx in transactions" :key="tx.id">
|
||||
<td>
|
||||
<span class="badge" :class="txTypeBadgeClass[tx.type] || 'badge-gray'">
|
||||
{{ txTypeLabel[tx.type] || tx.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td :class="tx.amount >= 0 ? 'text-emerald-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ formatAmount(tx.amount) }}
|
||||
</td>
|
||||
<td class="text-gray-700">${{ tx.balance_after?.toFixed(2) ?? '--' }}</td>
|
||||
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
|
||||
<td class="text-gray-500 text-xs">{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<Icon name="creditCard" size="xl" class="empty-state-icon" />
|
||||
<p class="empty-state-title">暂无交易记录</p>
|
||||
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="page * pageSize >= total" @click="nextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
|
||||
<div class="modal-content w-full max-w-md">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">确认订阅</h3>
|
||||
<button @click="showConfirm = false" class="btn-ghost btn-icon">
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="selectedPlan" class="space-y-4">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
|
||||
<span class="font-medium text-gray-900">{{ selectedPlan.name }}</span>
|
||||
<span class="badge badge-primary">{{ selectedPlan.identifier }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">订阅时长</label>
|
||||
<div class="grid grid-cols-2 gap-2 mt-1.5">
|
||||
<button
|
||||
v-for="opt in monthsOptions"
|
||||
:key="opt.value"
|
||||
class="rounded-xl px-3 py-2 text-sm font-medium border transition-all"
|
||||
:class="selectedMonths === opt.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
|
||||
@click="selectedMonths = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-xl">
|
||||
<span class="text-sm text-gray-600">应付金额</span>
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="showConfirm = false" class="btn btn-secondary">取消</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="confirmLoading"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
{{ confirmLoading ? '处理中...' : '确认订阅' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Frozen Items Modal -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showFrozenModal" class="modal-overlay" @click.self="closeFrozenModal">
|
||||
<div class="modal-content w-full max-w-lg">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">冻结明细</h3>
|
||||
<button @click="closeFrozenModal" class="btn-ghost btn-icon">
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Loading -->
|
||||
<div v-if="frozenLoading && frozenItems.length === 0" class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="h-12 bg-gray-100 rounded-xl animate-pulse"></div>
|
||||
</div>
|
||||
<!-- Empty -->
|
||||
<div v-else-if="frozenItems.length === 0" class="py-8 text-center">
|
||||
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
|
||||
<p class="text-gray-500 text-sm">当前没有冻结的资金</p>
|
||||
</div>
|
||||
<!-- Items list -->
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="item in frozenItems" :key="item.request_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ item.model_name || '未知模型' }}</p>
|
||||
<p class="text-xs text-gray-400 truncate mt-0.5">
|
||||
{{ item.request_id.substring(0, 16) }}...
|
||||
<span class="ml-2">{{ formatTime(item.created_at) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right ml-3 flex-shrink-0">
|
||||
<p class="text-sm font-semibold text-amber-600">${{ item.amount?.toFixed(4) }}</p>
|
||||
<p class="text-xs text-gray-400">剩余 {{ formatRemaining(item.remaining) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<p class="text-xs text-gray-400 flex-1">自动刷新中 · 每 10 秒更新</p>
|
||||
<button @click="closeFrozenModal" class="btn btn-secondary btn-sm">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user