Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
commit
0fd2e737ef
79 files changed
+1904
-3781
No files matched your search
@@ -282,15 +282,15 @@ cmd(路由注册)
|
||||
|
||||
### 系统货币规则
|
||||
|
||||
平台采用**内部统一 USD + 充值入口 CNY**策略,系统内除充值/支付外的所有金额均为 USD:
|
||||
平台采用**本位币制 + 充值入口 CNY**策略:系统在初始化向导(setup)时选定**本位币**(`billing_currency`,USD 或 CNY,**选定后不可更改**,存量部署默认 USD),内部记账与全站显示统一使用本位币:
|
||||
|
||||
| 业务场景 | 货币 | 说明 |
|
||||
|----------|------|------|
|
||||
| 大模型价格展示和计费 | **USD(美元)** | 模型定价、Token 单价、计费扣费统一使用美元,与上游供应商(OpenAI/Anthropic/Google)报价币种一致 |
|
||||
| 用户钱包余额 | **USD(美元)** | 钱包存储 USD 余额,充值时 CNY 按汇率转换为 USD 入账 |
|
||||
| 计费记录 / 交易流水 | **USD(美元)** | 所有 bil_records、bil_transactions 金额均为 USD |
|
||||
| 大模型价格展示和计费 | **本位币** | 模型定价、Token 单价、计费扣费统一使用本位币(本位币=CNY 的部署按人民币配价) |
|
||||
| 用户钱包余额 | **本位币** | 本位币=USD 时充值 CNY 按汇率转换为 USD 入账;本位币=CNY 时直接入账无换汇 |
|
||||
| 计费记录 / 交易流水 | **本位币** | 所有 bil_records、bil_transactions 金额均为本位币 |
|
||||
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
|
||||
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 |
|
||||
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币(收款渠道物理现实),履约时按本位币入账钱包 |
|
||||
|
||||
#### 三层币种固定规则(强约束,写新代码必须遵守)
|
||||
|
||||
@@ -299,25 +299,29 @@ cmd(路由注册)
|
||||
| 层 | 表前缀 | 币种 | 说明 |
|
||||
|----|--------|------|------|
|
||||
| **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` |
|
||||
| **钱包 / 计费 / 账本层** | `bil_` | **永远 USD** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律美元 |
|
||||
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与 USD 钱包直接比较 |
|
||||
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **USD** | 与 `bil_wallets.cumulative_recharge`(USD)同币种比较 |
|
||||
| **钱包 / 计费 / 账本层** | `bil_` | **本位币** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律本位币(部署级常量,`billing.Currency(ctx)` 读取;初始化选定后不可切换,账本永远单币种) |
|
||||
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与钱包余额直接比较 |
|
||||
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **本位币** | 与 `bil_wallets.cumulative_recharge`(本位币)同币种比较 |
|
||||
|
||||
**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。
|
||||
**唯一换汇点**:CNY → 本位币的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支与线下充值 `OfflineRecharge`,本位币=USD 时调用 `billing.ConvertCNYToUSD`;本位币=CNY 时直接入账,`exchange_rate` 快照存 1)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程本位币不换算。
|
||||
|
||||
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
|
||||
|
||||
#### 汇率规则
|
||||
|
||||
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
|
||||
- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。
|
||||
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
|
||||
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14,已 IsPublic 暴露给前端做展示折算)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
|
||||
- **充值时必须快照汇率**:履约入账时除了写入换算后的本位币金额(`credited_usd` 列,语义为入账本位币金额),还要持久化「原始 CNY 金额 + 当时汇率 + 入账金额」,使历史换算可重建(本位币=CNY 时 `exchange_rate` 存 1)。汇率配置变更不得影响已完成订单的对账。
|
||||
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前本位币余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
|
||||
|
||||
#### 前端展示规则
|
||||
|
||||
- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD)
|
||||
- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY)
|
||||
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。
|
||||
全站显示货币 = 本位币(`/settings/public` 暴露 `billing_currency` + 汇率,两端 `composables/useCurrency.ts` 统一格式化):
|
||||
|
||||
- bil 层数据(钱包 / 交易流水 / 计费 / 用量 / Token 单价 / 阈值 / 兑换码面值)→ 本位币符号直显,**不折算**
|
||||
- ord_/pln_ 层数据(套餐价格 / 充值金额 / 订单金额 / 退款金额 / 优惠码折扣,存 CNY)→ 本位币=CNY 直显 `¥`;本位币=USD 时按汇率折算显示 `$`(**唯一折算场景**)
|
||||
- **编辑表单保持存储币种原值输入**(模型定价按本位币、套餐价格按 CNY),label 标注存储币种;折算仅用于只读展示
|
||||
- 充值页锚定 CNY(订单/履约/折扣口径),本位币=USD 时档位与输入按汇率折算显示、提交时换算回 CNY
|
||||
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是本位币,跟随本位币符号;需要另一币种营收报表时,在**出报表时**按快照汇率折算,账本本身仍存本位币。
|
||||
|
||||
#### 人民币对账 / 导出规则
|
||||
|
||||
@@ -378,7 +382,7 @@ cmd(路由注册)
|
||||
- **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。
|
||||
- **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。)
|
||||
- **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。
|
||||
- **唯一换汇点**:CNY→USD 仅在 `payment.FulfillOrder` recharge 分支调 `billing.ConvertCNYToUSD`(返回 decimal),其余地方不换算(见《系统货币规则》三层币种固定规则)。
|
||||
- **唯一换汇点**:CNY→本位币仅在 `payment.FulfillOrder` recharge 分支与 `OfflineRecharge` 调 `billing.ConvertCNYToUSD`(返回 decimal,本位币=CNY 时直接入账无换算),其余地方不换算(见《系统货币规则》三层币种固定规则)。
|
||||
- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范
|
||||
- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现
|
||||
- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`)
|
||||
|
||||
@@ -404,6 +404,15 @@ const (
|
||||
AgreementCodePrivacy = "privacy" // 隐私政策
|
||||
)
|
||||
|
||||
// 系统配置 key:本位币(系统初始化向导选定后不可更改,bil_ 记账层与全站显示货币)
|
||||
const OptionKeyBillingCurrency = "billing_currency"
|
||||
|
||||
// 本位币取值
|
||||
const (
|
||||
BillingCurrencyUSD = "USD"
|
||||
BillingCurrencyCNY = "CNY"
|
||||
)
|
||||
|
||||
// ValidAgreementCodes 有效协议标识码集合(创建时校验)
|
||||
var ValidAgreementCodes = map[string]string{
|
||||
AgreementCodeTerms: "用户协议",
|
||||
|
||||
@@ -56,12 +56,23 @@ func HandleSetupInitialize(r *ghttp.Request) {
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirmPassword"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
if err := json.Unmarshal(r.GetBody(), &req); err != nil {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "请求格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 本位币:USD/CNY,未选择时默认 USD;初始化写入后不可再更改
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = consts.BillingCurrencyUSD
|
||||
}
|
||||
if currency != consts.BillingCurrencyUSD && currency != consts.BillingCurrencyCNY {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "本位币仅支持 USD 或 CNY")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Username == "" || req.Password == "" {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "用户名和密码不能为空")
|
||||
@@ -95,6 +106,12 @@ func HandleSetupInitialize(r *ghttp.Request) {
|
||||
response.Error(r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 本位币随初始化写入(首次落库,此后 SetOption 只读保护拒绝修改)
|
||||
if err := common.Config().SetOption(r.Context(), consts.OptionKeyBillingCurrency, currency); err != nil {
|
||||
response.Error(r, err)
|
||||
return
|
||||
}
|
||||
SetSetupComplete()
|
||||
response.Success(r, nil)
|
||||
}
|
||||
@@ -675,9 +675,10 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
|
||||
}
|
||||
|
||||
// OfflineRecharge 线下充值入账(管理后台)
|
||||
// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。
|
||||
// 场景:用户线下银行转账(人民币 CNY),运营确认到账后入账。
|
||||
// 本位币=USD 时按平台汇率换算入账;本位币=CNY 时直接入账(无换汇)。
|
||||
// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、
|
||||
// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号),
|
||||
// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号),
|
||||
// 供现金对账与开票追溯。
|
||||
func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) {
|
||||
tenantID := req.TenantID
|
||||
@@ -686,12 +687,19 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
return nil, common.NewBadRequestError("入账金额必须大于 0")
|
||||
}
|
||||
|
||||
// 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数),
|
||||
// 汇率取一次用于入账与快照,保证换算可重建。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount)
|
||||
if usdAmount.IsZero() {
|
||||
return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
|
||||
// 本位币=CNY:直接入账;本位币=USD:换汇入账(CNY→USD 只经 billing.ConvertCNYToUSD,
|
||||
// 与充值履约 FulfillOrder 同一函数),汇率取一次用于入账与快照,保证换算可重建。
|
||||
var rate float64
|
||||
var creditAmount decimal.Decimal
|
||||
if billing.IsCNY(ctx) {
|
||||
rate = 1
|
||||
creditAmount = cnyAmount
|
||||
} else {
|
||||
rate = billing.GetExchangeRateCNYToUSD(ctx)
|
||||
creditAmount = billing.ConvertCNYToUSD(ctx, req.Amount)
|
||||
if creditAmount.IsZero() {
|
||||
return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
|
||||
}
|
||||
}
|
||||
|
||||
// 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。
|
||||
@@ -721,9 +729,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
}
|
||||
|
||||
// Redis 加款(资金提交点);流水快照取 Redis 返回值
|
||||
balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, usdAmount)
|
||||
balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, creditAmount)
|
||||
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
|
||||
redisCreditedAmount = usdAmount
|
||||
redisCreditedAmount = creditAmount
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -731,12 +739,17 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
// DB 仅累加累计充值(余额由物化器从 Redis 覆盖)
|
||||
if _, err := g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?",
|
||||
usdAmount, walletID); err != nil {
|
||||
creditAmount, walletID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯
|
||||
desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount)
|
||||
var desc string
|
||||
if billing.IsCNY(ctx) {
|
||||
desc = fmt.Sprintf("线下充值入账 CNY %.2f(本位币直接入账)", cnyAmount.InexactFloat64())
|
||||
} else {
|
||||
desc = fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, creditAmount)
|
||||
}
|
||||
if req.Description != "" {
|
||||
desc = req.Description + ";" + desc
|
||||
}
|
||||
@@ -747,7 +760,7 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
TenantId: tenantID,
|
||||
WalletId: walletID,
|
||||
Type: "recharge",
|
||||
Amount: usdAmount,
|
||||
Amount: creditAmount,
|
||||
BalanceAfter: balanceAfter,
|
||||
FrozenAfter: frozenAfter,
|
||||
Description: desc,
|
||||
|
||||
@@ -174,7 +174,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
|
||||
Balance: 0,
|
||||
FrozenBalance: 0,
|
||||
WarningThreshold: &warningThreshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "create wallet")
|
||||
|
||||
@@ -115,7 +115,9 @@ func (s *sAdmin) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (*
|
||||
|
||||
// Get check result to find download URL
|
||||
cached := update.GetManager().GetCheckResult()
|
||||
if cached == nil {
|
||||
// 缓存里有更新但没匹配到平台安装包时强制重查,而不是直接报「没有可用的更新包」:
|
||||
// 典型场景是发行版刚发布、CI 尚未上传资产窗口期的检查结果被缓存,资产几分钟后就位
|
||||
if cached == nil || update.HasUpdateButNoAsset(cached) {
|
||||
// Force a check first
|
||||
result, err := update.CheckForUpdate(ctx, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package billing
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -10,6 +11,28 @@ import (
|
||||
const defaultCNYToUSD = 0.14
|
||||
const defaultUSDToCNY = 7.142857142857143 // 1 / 0.14,确保互为倒数
|
||||
|
||||
// Currency 返回系统本位币(bil_ 记账层的记账币种与全站显示货币)。
|
||||
// 本位币在系统初始化向导选定后不可更改;存量部署与未初始化系统默认 USD。
|
||||
// 配置基础设施不可用(如单测环境无 DB 驱动,g.DB() 直接 panic)时回退 USD,
|
||||
// 与存量部署行为一致,保证纯函数单测可运行。
|
||||
func Currency(ctx context.Context) (cur string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cur = consts.BillingCurrencyUSD
|
||||
}
|
||||
}()
|
||||
c := lcommon.Config().GetString(ctx, consts.OptionKeyBillingCurrency)
|
||||
if c == consts.BillingCurrencyCNY {
|
||||
return consts.BillingCurrencyCNY
|
||||
}
|
||||
return consts.BillingCurrencyUSD
|
||||
}
|
||||
|
||||
// IsCNY 当前本位币是否为人民币(此时充值履约直接入账、无换汇环节)
|
||||
func IsCNY(ctx context.Context) bool {
|
||||
return Currency(ctx) == consts.BillingCurrencyCNY
|
||||
}
|
||||
|
||||
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。
|
||||
// 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
|
||||
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal {
|
||||
|
||||
@@ -1,143 +1,32 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/test/gtest"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
)
|
||||
|
||||
func TestDefaultCNYToUSD(t *testing.T) {
|
||||
if defaultCNYToUSD <= 0 {
|
||||
t.Fatalf("defaultCNYToUSD should be positive, got %f", defaultCNYToUSD)
|
||||
// TestCurrency_FallbackUSD 单测环境无 DB 驱动(Config 读取触发 g.DB() panic),
|
||||
// Currency 必须 recover 兜底返回默认 USD(与存量部署行为一致),不得 panic。
|
||||
// 若在带完整配置/DB 的环境运行,读到已配置本位币则跳过断言。
|
||||
func TestCurrency_FallbackUSD(t *testing.T) {
|
||||
got := Currency(context.Background())
|
||||
if got != consts.BillingCurrencyUSD && got != consts.BillingCurrencyCNY {
|
||||
t.Errorf("Currency() = %q, want USD or CNY", got)
|
||||
}
|
||||
if !billingCurrencyValid(got) {
|
||||
t.Errorf("Currency() = %q, invalid currency", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUSDToCNY(t *testing.T) {
|
||||
if defaultUSDToCNY <= 0 {
|
||||
t.Fatalf("defaultUSDToCNY should be positive, got %f", defaultUSDToCNY)
|
||||
func TestIsCNY_MatchesCurrency(t *testing.T) {
|
||||
c := Currency(context.Background())
|
||||
if want := c == consts.BillingCurrencyCNY; IsCNY(context.Background()) != want {
|
||||
t.Errorf("IsCNY() 与 Currency() 不一致: currency=%q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRates_Reciprocal(t *testing.T) {
|
||||
// 修复后:defaultUSDToCNY 不再是独立配置,而是 defaultCNYToUSD 的倒数
|
||||
// 验证常量定义互为倒数
|
||||
product := defaultCNYToUSD * defaultUSDToCNY
|
||||
if product < 0.9 || product > 1.1 {
|
||||
t.Fatalf("default rate product should be ~1.0, got %f", product)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCNYToUSD_DefaultRate(t *testing.T) {
|
||||
// 无配置时使用默认汇率 0.14
|
||||
tests := []struct {
|
||||
name string
|
||||
cny float64
|
||||
expected float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"100 CNY", 100, 100 * defaultCNYToUSD},
|
||||
{"1 CNY", 1, 1 * defaultCNYToUSD},
|
||||
{"large amount", 10000, 10000 * defaultCNYToUSD},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := tt.cny * defaultCNYToUSD
|
||||
if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
|
||||
t.Errorf("CNY %f → USD: expected %f, got %f", tt.cny, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertUSDToCNY_DefaultRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
usd float64
|
||||
expected float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"1 USD", 1, defaultUSDToCNY},
|
||||
{"14 USD", 14, 14 * defaultUSDToCNY},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := tt.usd * defaultUSDToCNY
|
||||
if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
|
||||
t.Errorf("USD %f → CNY: expected %f, got %f", tt.usd, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyConstants_NonZero(t *testing.T) {
|
||||
if defaultCNYToUSD == 0 {
|
||||
t.Error("defaultCNYToUSD must not be zero")
|
||||
}
|
||||
if defaultUSDToCNY == 0 {
|
||||
t.Error("defaultUSDToCNY must not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeRateReciprocal 验证 USD→CNY 汇率是 CNY→USD 的倒数(往返闭合)
|
||||
func TestExchangeRateReciprocal(t *testing.T) {
|
||||
// 测试默认汇率互为倒数
|
||||
reciprocal := 1.0 / defaultCNYToUSD
|
||||
diff := reciprocal - defaultUSDToCNY
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 允许误差 0.01(因为默认常量可能不完全精确)
|
||||
if diff > 0.01 {
|
||||
t.Errorf("默认汇率应互为倒数:1/%.4f = %.4f, 但 defaultUSDToCNY = %.4f, 差值 = %.6f",
|
||||
defaultCNYToUSD, reciprocal, defaultUSDToCNY, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertRoundTrip 验证 CNY→USD→CNY 往返转换闭合(误差在可接受范围内)
|
||||
func TestConvertRoundTrip(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
// 测试多个典型金额
|
||||
testAmounts := []float64{100.0, 50.5, 1.0, 999.99}
|
||||
|
||||
for _, originalCNY := range testAmounts {
|
||||
// CNY → USD(使用默认汇率模拟)
|
||||
usd := decimal.NewFromFloat(originalCNY).Mul(decimal.NewFromFloat(defaultCNYToUSD))
|
||||
|
||||
// USD → CNY(使用倒数)
|
||||
cny := usd.Mul(decimal.NewFromFloat(1.0 / defaultCNYToUSD))
|
||||
|
||||
// 验证往返误差 < 0.01 元(1分钱)
|
||||
diff := cny.Sub(decimal.NewFromFloat(originalCNY)).Abs()
|
||||
t.AssertLT(InexactFloat64(diff), 0.01)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConvertCNYToUSD_Precision 验证 CNY→USD 转换的精度(使用默认汇率)
|
||||
func TestConvertCNYToUSD_Precision(t *testing.T) {
|
||||
// 100 CNY × 0.14 = 14 USD
|
||||
usd := decimal.NewFromFloat(100.0).Mul(decimal.NewFromFloat(defaultCNYToUSD))
|
||||
expected := decimal.NewFromFloat(14.0)
|
||||
|
||||
diff := usd.Sub(expected).Abs()
|
||||
if InexactFloat64(diff) > 0.000001 {
|
||||
t.Errorf("100 CNY 应转换为约 14 USD,实际=%s, 误差=%s", usd.String(), diff.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertUSDToCNY_Precision 验证 USD→CNY 转换的精度(使用倒数)
|
||||
func TestConvertUSDToCNY_Precision(t *testing.T) {
|
||||
// 14 USD × (1/0.14) ≈ 100 CNY
|
||||
usd := decimal.NewFromFloat(14.0)
|
||||
reciprocalRate := 1.0 / defaultCNYToUSD
|
||||
cny := usd.Mul(decimal.NewFromFloat(reciprocalRate))
|
||||
expected := decimal.NewFromFloat(100.0)
|
||||
|
||||
diff := cny.Sub(expected).Abs()
|
||||
if InexactFloat64(diff) > 0.01 {
|
||||
t.Errorf("14 USD 应转换为约 100 CNY,实际=%s, 误差=%s", cny.String(), diff.String())
|
||||
}
|
||||
func billingCurrencyValid(c string) bool {
|
||||
return c == consts.BillingCurrencyUSD || c == consts.BillingCurrencyCNY
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
DiscountRatio: discountRatio,
|
||||
TenantMultiplier: tenantMultiplier,
|
||||
ModelMultiplier: modelMultiplier,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
CacheReadPrice: cacheReadPrice,
|
||||
CacheCreationPrice: cacheCreationPrice,
|
||||
CacheCreation5mPrice: cacheCreationPrice, // 5m TTL = cache_creation_price 基础价(对应官方 1.25× 输入价)
|
||||
|
||||
@@ -166,7 +166,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
BaseCost: preDeductAmount,
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
} else {
|
||||
breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil)
|
||||
@@ -284,7 +284,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
fb := &CostBreakdown{
|
||||
TotalCost: preDeductAmount,
|
||||
BaseCost: preDeductAmount,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
if usage != nil {
|
||||
fb.InputTokens = usage.PromptTokens
|
||||
@@ -459,7 +459,7 @@ func createBillingRecord(ctx context.Context, tenantID, userID, apiKeyID, channe
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
TotalCost: totalCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(now),
|
||||
}
|
||||
@@ -545,7 +545,7 @@ func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiK
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
TotalCost: totalCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(now),
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
|
||||
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
|
||||
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
breakdown := buildTaskCostBreakdown(ctx, pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
|
||||
var billingMode string
|
||||
var discountRatio, effectiveOutputPrice float64
|
||||
@@ -199,7 +199,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
InputPrice: 0,
|
||||
OutputPrice: effectiveOutputPrice,
|
||||
TotalCost: actualCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(time.Now()),
|
||||
BillingMode: billingMode,
|
||||
@@ -311,12 +311,12 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
}
|
||||
|
||||
// buildTaskCostBreakdown 构建任务计费的 CostBreakdown
|
||||
func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
|
||||
func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
|
||||
if pricing == nil {
|
||||
return &CostBreakdown{
|
||||
TotalCost: actualCost,
|
||||
BaseCost: actualCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildTaskCostBreakdown_NilPricing(t *testing.T) {
|
||||
bd := buildTaskCostBreakdown(nil, 0.5, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), nil, 0.5, 1000, 500)
|
||||
if bd == nil {
|
||||
t.Fatal("expected non-nil breakdown")
|
||||
}
|
||||
@@ -25,7 +26,7 @@ func TestBuildTaskCostBreakdown_PerRequestMode(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.10, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.10, 0, 0)
|
||||
assertFloat(t, bd.BaseCost, 0.10, "BaseCost")
|
||||
assertFloat(t, bd.TotalCost, 0.10, "TotalCost")
|
||||
assertFloat(t, bd.PerRequestPrice, 0.10, "PerRequestPrice")
|
||||
@@ -43,7 +44,7 @@ func TestBuildTaskCostBreakdown_TokenMode(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.024, 10000, 5000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 10000, 5000)
|
||||
if bd.OutputTokens != 10000 {
|
||||
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
|
||||
}
|
||||
@@ -61,7 +62,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTenantMultiplier(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.01, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.01, 1000, 500)
|
||||
// tenantMul == 0 → BaseCost = actualCost (no division)
|
||||
assertFloat(t, bd.BaseCost, 0.01, "BaseCost")
|
||||
assertFloat(t, bd.TotalCost, 0.01, "TotalCost")
|
||||
@@ -75,7 +76,7 @@ func TestBuildTaskCostBreakdown_TokenMode_TotalTokens(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.05, 10000, 3000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.05, 10000, 3000)
|
||||
if bd.OutputTokens != 10000 {
|
||||
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func TestBuildTaskCostBreakdown_CarriesPricingFields(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.50, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.50, 0, 0)
|
||||
assertFloat(t, bd.DiscountRatio, 0.85, "DiscountRatio")
|
||||
assertFloat(t, bd.TenantMultiplier, 0.85, "TenantMultiplier")
|
||||
if bd.Currency != "USD" {
|
||||
@@ -109,7 +110,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 3.375, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 3.375, 0, 0)
|
||||
if bd.OutputTokens != 0 {
|
||||
t.Errorf("OutputTokens = %d, want 0", bd.OutputTokens)
|
||||
}
|
||||
@@ -130,12 +131,12 @@ func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) {
|
||||
}
|
||||
|
||||
// 0.4 / (0.8 × 0.5) = 1.0
|
||||
bd := buildTaskCostBreakdown(pricing, 0.4, 10000, 5000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.4, 10000, 5000)
|
||||
assertFloat(t, bd.BaseCost, 1.0, "BaseCost (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.4, "TotalCost")
|
||||
|
||||
// 无 token 用量分支同样按双乘数还原:0.08 / (0.8 × 0.5) = 0.2
|
||||
bd = buildTaskCostBreakdown(pricing, 0.08, 0, 0)
|
||||
bd = buildTaskCostBreakdown(context.Background(), pricing, 0.08, 0, 0)
|
||||
assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token")
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 0.024 / (0.8 × 1.0) = 0.03
|
||||
bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 1000, 500)
|
||||
assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)")
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"strconv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@@ -220,6 +222,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// 本位币只在首次写入(系统初始化向导)时允许设置,此后只读;
|
||||
// 相同值放行,保证设置页保存整个 payment 分类时不被误拒。
|
||||
if key == consts.OptionKeyBillingCurrency && count > 0 {
|
||||
existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Value()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if old := gconv.String(existing); old != "" && old != value {
|
||||
return gerror.New("本位币在系统初始化后不可更改")
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
data := do.SysOptions{Value: value}
|
||||
// Sync metadata from registry so that changes to IsPublic/Category/Description
|
||||
|
||||
@@ -156,12 +156,12 @@ var Registry = []SettingDef{
|
||||
Label: "充值折扣", Description: "JSON 对象,如 {\"100\":0.9} 表示充100享9折"},
|
||||
{Key: "payment_min_topup", Type: SettingTypeFloat, Default: "1", Category: "payment",
|
||||
Label: "最低充值金额", Validation: "min:0.01"},
|
||||
{Key: "payment_currency", Type: SettingTypeString, Default: "CNY", Category: "payment",
|
||||
Label: "货币单位", Validation: "enum:CNY,USD"},
|
||||
{Key: "billing_currency", Type: SettingTypeString, Default: "USD", Category: "payment",
|
||||
Label: "本位币", Description: "系统内部记账与全站显示的货币(USD/CNY)。仅在系统初始化向导中选定,初始化后不可更改;存量部署默认 USD", Validation: "enum:USD,CNY", IsPublic: true},
|
||||
{Key: "payment_callback_base_url", Type: SettingTypeString, Default: "", Category: "payment",
|
||||
Label: "支付回调基础URL", Description: "为空则使用请求 Host"},
|
||||
{Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment",
|
||||
Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"},
|
||||
Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100", IsPublic: true},
|
||||
{Key: "payment_notice", Type: SettingTypeString, Default: "", Category: "payment",
|
||||
Label: "支付说明", Description: "展示在租户端钱包充值页的说明文案,支持纯文本或 HTML 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用 <br>,富文本请直接粘贴 HTML"},
|
||||
|
||||
|
||||
@@ -64,13 +64,22 @@ type trafficFlowRow struct {
|
||||
// normalizeMonitorDateRange 规范化监控类接口的日期范围,返回 YYYY-MM-DD 的 [start, end](含)。
|
||||
// 缺省近 30 天;end 不超过今天;跨度上限 trafficFlowMaxDays。流量桑基图与模型性能共用。
|
||||
func normalizeMonitorDateRange(startDate, endDate string) (string, string) {
|
||||
return normalizeMonitorDateRangeAt(startDate, endDate, time.Now())
|
||||
}
|
||||
|
||||
// normalizeMonitorDateRangeAt is separated for deterministic tests. Date-only
|
||||
// filters must be compared as calendar dates in the server's location, rather
|
||||
// than against the current instant. Otherwise, on a non-UTC server before its
|
||||
// UTC offset, today's parsed midnight can incorrectly fall after time.Now()
|
||||
// and the range silently falls back to the default 30 days.
|
||||
func normalizeMonitorDateRangeAt(startDate, endDate string, now time.Time) (string, string) {
|
||||
const layout = "2006-01-02"
|
||||
today := time.Now()
|
||||
end, err := time.Parse(layout, endDate)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end, err := time.ParseInLocation(layout, endDate, now.Location())
|
||||
if err != nil || end.After(today) {
|
||||
end = today
|
||||
}
|
||||
start, err := time.Parse(layout, startDate)
|
||||
start, err := time.ParseInLocation(layout, startDate, now.Location())
|
||||
if err != nil || start.After(end) {
|
||||
start = end.AddDate(0, 0, -29)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
func TestNormalizeMonitorDateRangeKeepsTodayInNonUTCTimezone(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
now := time.Date(2026, time.August, 29, 0, 10, 0, 0, location)
|
||||
|
||||
start, end := normalizeMonitorDateRangeAt("2026-08-29", "2026-08-29", now)
|
||||
if start != "2026-08-29" || end != "2026-08-29" {
|
||||
t.Fatalf("same-day range = [%s, %s], want [2026-08-29, 2026-08-29]", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// perfFieldValue 按 JSON 字段名(snake_case)从结构体取值,整数统一归一为 int64、
|
||||
// 浮点统一为 float64,与旧的 []map[string]any 断言口径保持一致。
|
||||
func perfFieldValue(item v1.ModelPerformanceSummary, field string) any {
|
||||
|
||||
@@ -66,22 +66,36 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
|
||||
|
||||
case "recharge":
|
||||
// 折扣语义「付折后价、按原价入账」:用户实付 FinalAmount(折后 CNY),
|
||||
// 钱包按订单原价 Amount 换算 USD 入账,折扣让利真实生效;无折扣时两者相等。
|
||||
// 汇率只取一次,保证入账换算与快照使用同一汇率。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
usdAmount := billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
|
||||
credited, err := creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64()))
|
||||
// 钱包按订单原价 Amount 入账,折扣让利真实生效;无折扣时两者相等。
|
||||
var creditAmount decimal.Decimal
|
||||
var desc string
|
||||
if billing.IsCNY(ctx) {
|
||||
// 本位币=CNY:订单层与记账层同为人民币,按原价直接入账,无换汇环节
|
||||
creditAmount = billing.NewFromFloat(order.Amount)
|
||||
desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → CNY %.2f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
|
||||
} else {
|
||||
// 本位币=USD:按原价换算美元入账;汇率只取一次,保证入账换算与快照使用同一汇率。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
creditAmount = billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
|
||||
desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
|
||||
}
|
||||
credited, err := creditWalletTx(ctx, order.TenantID, creditAmount, orderID, desc)
|
||||
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
|
||||
redisCreditedTenantID = order.TenantID
|
||||
redisCreditedAmount = credited
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "credit wallet failed")
|
||||
}
|
||||
// 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账 USD」,
|
||||
// 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账金额」,
|
||||
// 使历史换算可重建,汇率配置变更不影响已完成订单的现金对账。
|
||||
// 本位币=CNY 时 exchange_rate=1(无换汇)、credited_usd 存本位币金额。
|
||||
snapRate := billing.One
|
||||
if !billing.IsCNY(ctx) {
|
||||
snapRate = billing.NewFromFloat(billing.GetExchangeRateCNYToUSD(ctx))
|
||||
}
|
||||
if _, err = g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE ord_orders SET exchange_rate = ?, credited_usd = ? WHERE id = ?",
|
||||
billing.NewFromFloat(rate), usdAmount, orderID); err != nil {
|
||||
snapRate, creditAmount, orderID); err != nil {
|
||||
return gerror.Wrapf(err, "snapshot exchange rate failed")
|
||||
}
|
||||
_ = billing.CheckAndUpgradeLevel(ctx, order.TenantID)
|
||||
@@ -201,8 +215,8 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int
|
||||
}
|
||||
|
||||
// creditWalletTx 钱包入账(依赖调用方传入携带事务的 ctx,且处于订单行锁保护内)。
|
||||
// amount 为 USD(bil_ 层永远 USD);orderID 写入流水 related_id:退款时靠它精确找回
|
||||
// 履约当时入账的 USD(禁止按当前汇率反算)。
|
||||
// amount 为本位币金额(bil_ 层币种 = 部署级本位币,USD 或 CNY);orderID 写入流水
|
||||
// related_id:退款时靠它精确找回履约当时入账的本位币金额(禁止按当前汇率反算)。
|
||||
//
|
||||
// Redis 权威化架构下的职责拆分:
|
||||
// - Redis 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等;
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// GetGlobalPaymentSettings loads global payment settings from ConfigService.
|
||||
// Currency 固定返回 "CNY":订单/支付层币种由收款渠道决定(epay 收人民币),
|
||||
// 系统计账与显示货币由本位币 billing_currency 决定(初始化选定,不在此配置)。
|
||||
func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, error) {
|
||||
cfg := common.Config()
|
||||
settings := &GlobalPaymentSettings{
|
||||
@@ -26,22 +28,19 @@ func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, erro
|
||||
if n := cfg.GetFloat(ctx, "payment_min_topup"); n > 0 {
|
||||
settings.MinTopUp = n
|
||||
}
|
||||
if val := cfg.GetString(ctx, "payment_currency"); val != "" {
|
||||
settings.Currency = val
|
||||
}
|
||||
settings.CallbackBaseURL = cfg.GetString(ctx, "payment_callback_base_url")
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveGlobalPaymentSettings persists global payment settings via ConfigService.
|
||||
// Currency 字段不落库(订单层币种恒 CNY,不接受配置)。
|
||||
func SaveGlobalPaymentSettings(ctx context.Context, settings *GlobalPaymentSettings) error {
|
||||
cfg := common.Config()
|
||||
items := map[string]string{
|
||||
"payment_amount_options": mustJSON(settings.AmountOptions),
|
||||
"payment_amount_discount": mustJSON(settings.AmountDiscount),
|
||||
"payment_min_topup": mustJSON(settings.MinTopUp),
|
||||
"payment_currency": settings.Currency,
|
||||
"payment_callback_base_url": settings.CallbackBaseURL,
|
||||
}
|
||||
for key, value := range items {
|
||||
|
||||
@@ -528,7 +528,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
record.OutputCost = settleResult.OutputCost
|
||||
record.RefundAmount = settleResult.RefundAmount
|
||||
record.SupplementAmount = settleResult.SupplementAmount
|
||||
record.Currency = "USD"
|
||||
record.Currency = billing.Currency(context.Background())
|
||||
} else {
|
||||
// 失败/超时任务:从定价中获取计费模式
|
||||
billingMode := "per_request"
|
||||
|
||||
@@ -186,7 +186,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
Balance: 0,
|
||||
FrozenBalance: 0,
|
||||
WarningThreshold: &warningThreshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "create wallet")
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
Balance: billing.Zero,
|
||||
FrozenBalance: billing.Zero,
|
||||
WarningThreshold: &threshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,7 +52,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
FrozenBalance: 0,
|
||||
AvailableBalance: 0,
|
||||
WarningThreshold: 0,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -114,8 +114,11 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) {
|
||||
// Store ETag for conditional requests
|
||||
manager.lastETag.Store(etag)
|
||||
|
||||
// Cache result
|
||||
setCheckCache(ctx, result)
|
||||
// Cache result。「有更新但没匹配到平台安装包」的结果不写 Redis:TTL 24h 太长,
|
||||
// 会挡住发布窗口期之后的检查拿到完整资产列表(内存结果照常存,供状态展示)
|
||||
if !HasUpdateButNoAsset(result) {
|
||||
setCheckCache(ctx, result)
|
||||
}
|
||||
manager.checkResult.Store(result)
|
||||
|
||||
return result, nil
|
||||
@@ -185,6 +188,14 @@ func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease,
|
||||
return &release, etag, nil
|
||||
}
|
||||
|
||||
// HasUpdateButNoAsset 判断检查结果是否为「有更新但当前平台没匹配到安装包」。
|
||||
// 典型成因:发行版刚发布、CI 尚未上传资产的窗口期拉到的结果(资产几分钟后才就位)。
|
||||
// 这类结果不可信:不能写缓存,已入库的脏缓存读到后按无效处理,
|
||||
// 执行更新时则强制重查,而不是直接报「没有可用的更新包」。
|
||||
func HasUpdateButNoAsset(r *CheckResult) bool {
|
||||
return r != nil && r.HasUpdate && r.DownloadURL == ""
|
||||
}
|
||||
|
||||
// getCheckCache reads cached check result from Redis.
|
||||
// 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查,
|
||||
// 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。
|
||||
@@ -205,6 +216,11 @@ func getCheckCache(ctx context.Context) (out *CheckResult) {
|
||||
if err := json.Unmarshal(val.Bytes(), &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
// 「有更新但没匹配到平台安装包」的脏缓存(发布窗口期写入,含旧版本遗留数据)按无效处理,
|
||||
// 让调用方 fallthrough 到实时检查拿完整资产列表
|
||||
if HasUpdateButNoAsset(&result) {
|
||||
return nil
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,27 @@ func TestCompareVersions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUpdateButNoAsset(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result *CheckResult
|
||||
want bool
|
||||
}{
|
||||
{"nil 结果", nil, false},
|
||||
{"有更新且有安装包", &CheckResult{HasUpdate: true, DownloadURL: "https://example.com/team-api-0.2.14-linux-amd64.tar.gz"}, false},
|
||||
{"无更新且无安装包", &CheckResult{HasUpdate: false, DownloadURL: ""}, false},
|
||||
{"有更新但缺安装包(发布窗口期脏数据)", &CheckResult{HasUpdate: true, DownloadURL: ""}, true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := HasUpdateButNoAsset(c.result); got != c.want {
|
||||
t.Errorf("HasUpdateButNoAsset(%+v) = %v, want %v", c.result, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSemver(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"0.2.0_39", "0.2.0+39"}, // Makefile git describe 版本:下划线转构建元数据
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.currency-option.selected {
|
||||
border-color: #2563eb;
|
||||
background-color: rgba(37, 99, 235, 0.04);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -67,6 +73,7 @@
|
||||
.delay-4 { animation-delay: 0.2s; }
|
||||
.delay-5 { animation-delay: 0.25s; }
|
||||
.delay-6 { animation-delay: 0.3s; }
|
||||
.delay-7 { animation-delay: 0.35s; }
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -155,6 +162,31 @@
|
||||
class="input-field w-full px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 outline-none font-mono"
|
||||
placeholder="再次输入密码" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<!-- Base Currency -->
|
||||
<div class="animate-fade-up delay-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
本位币
|
||||
<span class="text-amber-600 font-normal text-xs ml-1">初始化后不可更改</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2.5" id="currencyGroup">
|
||||
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="USD">
|
||||
<input type="radio" name="currency" value="USD" checked class="mt-0.5 accent-[#2563eb]">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-gray-900">USD(美元)</span>
|
||||
<span class="block text-xs text-gray-400 mt-0.5">与上游模型报价对齐</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="CNY">
|
||||
<input type="radio" name="currency" value="CNY" class="mt-0.5 accent-[#2563eb]">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-gray-900">CNY(人民币)</span>
|
||||
<span class="block text-xs text-gray-400 mt-0.5">面向国内运营</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1.5">决定系统内部记账与全站显示货币,模型定价按本位币配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -168,7 +200,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="pt-1.5 animate-fade-up delay-6">
|
||||
<div class="pt-1.5 animate-fade-up delay-7">
|
||||
<button
|
||||
type="submit" id="submitBtn"
|
||||
class="btn-primary w-full text-white py-2.5 rounded-lg text-sm font-medium">
|
||||
@@ -208,6 +240,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 本位币选择卡片高亮联动
|
||||
var currencyOptions = document.querySelectorAll('.currency-option');
|
||||
function syncCurrencyHighlight() {
|
||||
var selected = document.querySelector('input[name="currency"]:checked');
|
||||
currencyOptions.forEach(function(opt) {
|
||||
opt.classList.toggle('selected', opt.querySelector('input').value === selected.value);
|
||||
});
|
||||
}
|
||||
currencyOptions.forEach(function(opt) {
|
||||
opt.querySelector('input').addEventListener('change', syncCurrencyHighlight);
|
||||
});
|
||||
syncCurrencyHighlight();
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
@@ -216,6 +261,7 @@
|
||||
var displayName = document.getElementById('displayName').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
var confirmPassword = document.getElementById('confirmPassword').value;
|
||||
var currency = document.querySelector('input[name="currency"]:checked').value;
|
||||
|
||||
if (!username) {
|
||||
showError('请输入用户名');
|
||||
@@ -242,7 +288,8 @@
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
password: password,
|
||||
confirmPassword: confirmPassword
|
||||
confirmPassword: confirmPassword,
|
||||
currency: currency
|
||||
})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
-- 货币显示动态切换:将汇率配置公开(前端展示层折算与充值页折算需要读取)。
|
||||
-- 已落库且 is_public=false 的行不会被 PublicSettingsGet 返回实际值(只会补注册表默认值),
|
||||
-- 因此必须刷存量行;billing_currency 为新增 key,由注册表默认值兜底(USD),无需插入。
|
||||
|
||||
-- +goose StatementBegin
|
||||
UPDATE sys_options SET is_public = true WHERE key = 'payment_exchange_rate_cny_to_usd';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
UPDATE sys_options SET is_public = false WHERE key = 'payment_exchange_rate_cny_to_usd';
|
||||
-- +goose StatementEnd
|
||||
@@ -3,6 +3,10 @@ import { ref, reactive, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -243,18 +247,18 @@ const previewBase = computed(() => {
|
||||
function segmentPreview(seg: TimeSegmentRow): string {
|
||||
const m = (seg.percent || 0) / 100
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `$${(previewBase.value.perRequest * m).toFixed(4)} /次`
|
||||
return `${formatBilling(previewBase.value.perRequest * m, 4)} /次`
|
||||
}
|
||||
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
|
||||
return `${prefix}输入 $${(previewBase.value.input * m).toFixed(4)} · 输出 $${(previewBase.value.output * m).toFixed(4)}`
|
||||
return `${prefix}输入 ${formatBilling(previewBase.value.input * m, 4)} · 输出 ${formatBilling(previewBase.value.output * m, 4)}`
|
||||
}
|
||||
|
||||
function defaultPreviewText(): string {
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `$${previewBase.value.perRequest.toFixed(4)} /次`
|
||||
return `${formatBilling(previewBase.value.perRequest, 4)} /次`
|
||||
}
|
||||
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
|
||||
return `${prefix}输入 $${previewBase.value.input.toFixed(4)} · 输出 $${previewBase.value.output.toFixed(4)}`
|
||||
return `${prefix}输入 ${formatBilling(previewBase.value.input, 4)} · 输出 ${formatBilling(previewBase.value.output, 4)}`
|
||||
}
|
||||
|
||||
function segmentDesc(seg: TimeSegmentRow): string {
|
||||
@@ -500,19 +504,19 @@ watch(() => props.visible, (val) => {
|
||||
<div class="official-prices">
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输入价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.input_price.toFixed(2) }} / 1M</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.input_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输出价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.output_price.toFixed(2) }} / 1M</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.output_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存读取</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? '$' + src.pricing.cache_read_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatBilling(src.pricing.cache_read_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存创建</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? '$' + src.pricing.cache_creation_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatBilling(src.pricing.cache_creation_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
|
||||
@@ -543,7 +547,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="每次调用价格"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 次</template>
|
||||
<template #suffix>{{ currencySymbol }} / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -555,7 +559,7 @@ watch(() => props.visible, (val) => {
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>按量定价</h3>
|
||||
<span class="section-hint">价格单位为 $ / 1M Token</span>
|
||||
<span class="section-hint">价格单位为 {{ currencySymbol }} / 1M Token</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
|
||||
<AFormItem label="输入价格">
|
||||
@@ -566,7 +570,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
@@ -577,7 +581,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存读取">
|
||||
@@ -588,7 +592,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建">
|
||||
@@ -599,7 +603,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -651,7 +655,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
@@ -661,7 +665,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -673,7 +677,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
@@ -683,7 +687,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
/**
|
||||
* 全站货币显示(本位币制),与租户端 useCurrency 镜像:
|
||||
* - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算
|
||||
* - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显
|
||||
* - 本位币由系统初始化向导选定,管理后台只读(保存支付设置后调用 refresh 立即生效)
|
||||
* - 编辑表单保持存储币种原值输入,折算仅用于只读展示
|
||||
*/
|
||||
export type CurrencyCode = 'USD' | 'CNY'
|
||||
|
||||
/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */
|
||||
const FALLBACK_CNY_TO_USD = 0.14
|
||||
|
||||
const { settings, fetchSettings } = usePublicSettings()
|
||||
|
||||
/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */
|
||||
export const displayCurrency = computed<CurrencyCode>(() =>
|
||||
settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD',
|
||||
)
|
||||
|
||||
/** CNY→USD 汇率(支付设置配置,系统设置保存后 refresh 生效) */
|
||||
export const cnyToUsd = computed(() => {
|
||||
const r = Number(settings.value.payment_exchange_rate_cny_to_usd)
|
||||
return r > 0 ? r : FALLBACK_CNY_TO_USD
|
||||
})
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */
|
||||
source?: CurrencyCode
|
||||
/** 显示精度;默认按显示币种取 USD 6 位 / CNY 2 位 */
|
||||
precision?: number
|
||||
/** 正负号前缀(交易流水) */
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化(跟随本位币)。
|
||||
* source='CNY'(订单层)且本位币=USD 时按汇率折算;bil 层数据传 source=displayCurrency 即直显。
|
||||
* 内部读取响应式 computed,render 函数中调用可建立依赖,配置变化自动重渲染。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { source = 'USD', precision, showSign = false } = opts
|
||||
const display = displayCurrency.value
|
||||
let num = Number(value ?? 0)
|
||||
// 唯一折算场景:订单层 CNY 金额在本位币 USD 部署下展示
|
||||
if (source === 'CNY' && display === 'USD') {
|
||||
num = num * cnyToUsd.value
|
||||
}
|
||||
const symbol = display === 'CNY' ? '¥' : '$'
|
||||
const p = precision ?? (display === 'CNY' ? 2 : 6)
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(p))}`
|
||||
}
|
||||
|
||||
/** bil_ 层(钱包/计费/定价等本位币数据)快捷格式化:直显 + 本位币符号 */
|
||||
export function formatBilling(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: displayCurrency.value, precision, showSign })
|
||||
}
|
||||
|
||||
/** ord_/pln_ 层(订单/套餐等 CNY 数据)快捷格式化:本位币=CNY 直显,USD 折算 */
|
||||
export function formatOrder(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: 'CNY', precision, showSign })
|
||||
}
|
||||
|
||||
/** 强制刷新公共配置(系统设置保存后调用,让已打开页面立即按新汇率/本位币重渲染) */
|
||||
export function refreshCurrencySettings(): Promise<void> {
|
||||
return fetchSettings(true)
|
||||
}
|
||||
@@ -10,13 +10,6 @@ function toDateTimeStr(d: Date): string {
|
||||
// 默认查询当天:起始时间为当天 0 点,截止时间留空(后端按「到现在」实时处理)。
|
||||
// 日期选择器里的「现在」仅用于展示;查询时截止仍为默认「现在」则不传 end_date,避免固定截止时间漏掉后续新记录。
|
||||
// 该值在模块加载时固定一次,页面间共享,保证「截止等于默认现在则不下发 end_date」的判据一致。
|
||||
const defaultEnd = toDateTimeStr(new Date())
|
||||
|
||||
function defaultTodayRange(): string[] {
|
||||
const d = new Date()
|
||||
return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd]
|
||||
}
|
||||
|
||||
// 指定偏移天数的当天 0 点 / 23:59:59(offsetDays=0 表示今天)
|
||||
function dateStartStr(offsetDays: number): string {
|
||||
const d = new Date()
|
||||
@@ -31,13 +24,21 @@ function dateEndStr(offsetDays: number): string {
|
||||
|
||||
// 日期时间选择器底部快捷选项:今天 / 昨天 / 近三天 / 近一周。
|
||||
// 截止「现在」的选项复用 defaultEnd,保持「截止不传 → 后端按到现在实时查询」的语义。
|
||||
const quickDateRanges: ShortcutType[] = [
|
||||
{ label: '今天', value: () => defaultTodayRange() },
|
||||
{ label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] },
|
||||
{ label: '近三天', value: () => [dateStartStr(2), defaultEnd] },
|
||||
{ label: '近一周', value: () => [dateStartStr(6), defaultEnd] },
|
||||
]
|
||||
|
||||
export function useDateRange() {
|
||||
// 默认展示截止时间预留 1 小时;查询仍识别此默认值并省略 end_date,
|
||||
// 因而后端会实时查询到当前时刻,用户停留页面后刷新也不会漏掉新记录。
|
||||
const defaultEndDate = new Date()
|
||||
defaultEndDate.setHours(defaultEndDate.getHours() + 1)
|
||||
const defaultEnd = toDateTimeStr(defaultEndDate)
|
||||
const defaultTodayRange = (): string[] => {
|
||||
const d = new Date()
|
||||
return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd]
|
||||
}
|
||||
const quickDateRanges: ShortcutType[] = [
|
||||
{ label: '今天', value: defaultTodayRange },
|
||||
{ label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] },
|
||||
{ label: '近三天', value: () => [dateStartStr(2), defaultEnd] },
|
||||
{ label: '近一周', value: () => [dateStartStr(6), defaultEnd] },
|
||||
]
|
||||
return { defaultEnd, defaultTodayRange, quickDateRanges }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 全局公共配置(/settings/public,无认证)。
|
||||
* 与租户端 usePublicSettings 镜像:模块级单例 + 30s TTL + 并发合并,
|
||||
* 管理后台用于本位币/汇率(货币显示)与站点名等公共配置。
|
||||
*/
|
||||
export interface PublicSettings {
|
||||
site_name: string
|
||||
site_description: string
|
||||
maintenance_mode: boolean
|
||||
maintenance_message: string
|
||||
maintenance_duration: string
|
||||
demo_mode: boolean
|
||||
demo_message: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const settings = ref<PublicSettings>({
|
||||
site_name: '',
|
||||
site_description: '',
|
||||
maintenance_mode: false,
|
||||
maintenance_message: '',
|
||||
maintenance_duration: '',
|
||||
demo_mode: false,
|
||||
demo_message: '',
|
||||
})
|
||||
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
let lastFetchTime = 0
|
||||
const CACHE_TTL = 30_000 // 30秒缓存
|
||||
|
||||
export function usePublicSettings() {
|
||||
async function fetchSettings(force = false): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetchTime < CACHE_TTL && fetchPromise === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetchPromise) {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const { data } = await request.get('/settings/public')
|
||||
if (data?.data?.settings) {
|
||||
Object.assign(settings.value, data.data.settings)
|
||||
}
|
||||
lastFetchTime = Date.now()
|
||||
} catch {
|
||||
// silent — non-critical
|
||||
} finally {
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
return {
|
||||
settings: readonly(settings),
|
||||
fetchSettings,
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,6 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/finance/TransactionsPage.vue'),
|
||||
meta: { title: '交易流水' },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
name: 'AdminPlans',
|
||||
component: () => import('@/views/finance/PlansPage.vue'),
|
||||
meta: { title: '套餐管理' },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'AdminOrders',
|
||||
@@ -91,10 +85,9 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: '订单管理' },
|
||||
},
|
||||
{
|
||||
// 旧"支付设置"独立页已并入系统设置(payment 分类),URL 保留重定向兼容
|
||||
path: 'payment-settings',
|
||||
name: 'AdminPaymentSettings',
|
||||
component: () => import('@/views/finance/PaymentSettingsPage.vue'),
|
||||
meta: { title: '支付设置' },
|
||||
redirect: { name: 'AdminSettings' },
|
||||
},
|
||||
{
|
||||
path: 'redemptions',
|
||||
|
||||
@@ -4,6 +4,7 @@ import adminRoutes from './admin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import request, { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useTopProgress } from '@/composables/useTopProgress'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -45,6 +46,7 @@ async function checkSetupStatus(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const { siteName, fetchSiteName } = useSiteName()
|
||||
const { fetchSettings: fetchPublicSettings } = usePublicSettings()
|
||||
const { start, done } = useTopProgress()
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
@@ -54,7 +56,8 @@ router.beforeEach(async (to) => {
|
||||
authStore.loadFromStorage()
|
||||
|
||||
if (to.meta.title) {
|
||||
await fetchSiteName()
|
||||
// 站点名与公共配置(本位币/汇率,供全站货币显示)并行拉取,30s TTL
|
||||
await Promise.all([fetchSiteName(), fetchPublicSettings()])
|
||||
const name = siteName.value || 'Team-API'
|
||||
document.title = `${to.meta.title as string} — ${name}`
|
||||
}
|
||||
|
||||
@@ -337,7 +337,6 @@ const menuGroups = [
|
||||
label: '财务中心',
|
||||
icon: IconIdcard,
|
||||
items: [
|
||||
{ name: 'AdminPlans', label: '套餐管理', icon: IconIdcard },
|
||||
{ name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock },
|
||||
{ name: 'AdminTransactions', label: '交易流水', icon: IconCommand },
|
||||
{ name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift },
|
||||
@@ -397,7 +396,6 @@ const menuGroups = [
|
||||
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock },
|
||||
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings },
|
||||
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage },
|
||||
{ name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
IconThunderbolt,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import * as echarts from 'echarts'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -43,10 +44,9 @@ function calcGrowth(current: number, previous: number): { value: number; positiv
|
||||
return { value: Math.round(growth * 10) / 10, positive: growth >= 0 }
|
||||
}
|
||||
|
||||
// 金额格式化走全局本位币(bil 层数据直显 + 本位币符号)
|
||||
function formatMoney(val: any) {
|
||||
const n = Number(val)
|
||||
if (!n && n !== 0) return '$0.00'
|
||||
return '$' + n.toFixed(2)
|
||||
return formatBilling(val, 2)
|
||||
}
|
||||
|
||||
function formatNumber(val: any) {
|
||||
@@ -253,18 +253,19 @@ function renderTrendChart() {
|
||||
const revenues = trends.value.map((r: any) => Number(r.revenue) || 0)
|
||||
const requests = trends.value.map((r: any) => Number(r.requests) || 0)
|
||||
|
||||
// 图例/轴名带币种代码,跟随本位币(由下方 watch 触发重渲染)
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['收入 ($)', '请求数'], bottom: 0 },
|
||||
legend: { data: [`收入 (${displayCurrency.value})`, '请求数'], bottom: 0 },
|
||||
grid: { left: 60, right: 60, top: 15, bottom: 36 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '收入 ($)', axisLabel: { formatter: (v: number) => '$' + v } },
|
||||
{ type: 'value', name: `收入 (${displayCurrency.value})`, axisLabel: { formatter: (v: number) => formatBilling(v) } },
|
||||
{ type: 'value', name: '请求数', splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入 ($)',
|
||||
name: `收入 (${displayCurrency.value})`,
|
||||
type: 'bar',
|
||||
data: revenues,
|
||||
barWidth: 12,
|
||||
@@ -331,7 +332,7 @@ function renderModelChart() {
|
||||
modelChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 130, right: 50, top: 10, bottom: 36 },
|
||||
xAxis: { type: 'value', name: '费用 ($)', nameLocation: 'middle', nameGap: 22 },
|
||||
xAxis: { type: 'value', name: `费用 (${displayCurrency.value})`, nameLocation: 'middle', nameGap: 22 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
@@ -361,7 +362,7 @@ function renderModelHourlyChart() {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (v: number) => '$' + Number(v).toFixed(4),
|
||||
valueFormatter: (v: number) => formatBilling(v, 4),
|
||||
},
|
||||
legend: { data: data.models, bottom: 0, type: 'scroll' },
|
||||
grid: { left: 60, right: 20, top: 15, bottom: 46 },
|
||||
@@ -372,8 +373,8 @@ function renderModelHourlyChart() {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '费用 ($)',
|
||||
axisLabel: { formatter: (v: number) => '$' + v },
|
||||
name: `费用 (${displayCurrency.value})`,
|
||||
axisLabel: { formatter: (v: number) => formatBilling(v) },
|
||||
},
|
||||
series: data.series.map((s: any) => ({
|
||||
name: s.model,
|
||||
@@ -385,6 +386,12 @@ function renderModelHourlyChart() {
|
||||
}, true)
|
||||
}
|
||||
|
||||
// 本位币变化(配置异步加载/后台切换)时重绘全部图表:echarts option 在命令式渲染函数中拼装,
|
||||
// 不在 Vue 渲染树内,需显式 watch 触发重绘;metricCards/表格 render 经 formatBilling 自动建立依赖
|
||||
watch(displayCurrency, () => {
|
||||
renderAllCharts()
|
||||
})
|
||||
|
||||
// === Resize ===
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
trendChart?.resize()
|
||||
|
||||
@@ -15,6 +15,7 @@ const form = reactive({
|
||||
displayName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -51,6 +52,7 @@ async function handleSetup() {
|
||||
displayName: form.displayName.trim(),
|
||||
password: form.password,
|
||||
confirmPassword: form.confirmPassword,
|
||||
currency: form.currency,
|
||||
})
|
||||
Message.success('初始化成功,正在跳转登录页...')
|
||||
markSystemInitialized()
|
||||
@@ -180,6 +182,16 @@ async function handleSetup() {
|
||||
@keydown.enter="handleSetup"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem
|
||||
field="currency"
|
||||
label="本位币"
|
||||
extra="决定系统内部记账与全站显示货币(模型定价按本位币配置),初始化后不可更改"
|
||||
>
|
||||
<ARadioGroup v-model="form.currency">
|
||||
<ARadio value="USD">USD(美元)· 与上游模型报价对齐</ARadio>
|
||||
<ARadio value="CNY">CNY(人民币)· 面向国内运营</ARadio>
|
||||
</ARadioGroup>
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton
|
||||
type="primary"
|
||||
|
||||
@@ -10,6 +10,7 @@ import ModelPricingModal from '@/components/ModelPricingModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -152,6 +153,12 @@ const statusTagLabel: Record<string, string> = {
|
||||
offline: '下线',
|
||||
}
|
||||
|
||||
const pricingModeTagColor: Record<string, string> = {
|
||||
token: 'arcoblue',
|
||||
per_request: 'orangered',
|
||||
tiered: 'purple',
|
||||
}
|
||||
|
||||
// ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)=====
|
||||
|
||||
// token 数紧凑格式化:128000 → 128K,1500000 → 1.5M,无值返回 '-'
|
||||
@@ -165,13 +172,13 @@ function formatTokens(n: number | null | undefined): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
// 定价主文本:$输入/$输出、$单价/次、未定价
|
||||
// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显)
|
||||
function pricingMain(row: any): string {
|
||||
if (!row.pricing_mode) return '未定价'
|
||||
if (row.pricing_mode === 'per_request') {
|
||||
return `$${row.per_request_price?.toFixed(4) ?? '0'}/次`
|
||||
return `${formatBilling(row.per_request_price ?? 0, 4)}/次`
|
||||
}
|
||||
return `$${row.input_price?.toFixed(2) ?? '0'}/$${row.output_price?.toFixed(2) ?? '0'}`
|
||||
return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}`
|
||||
}
|
||||
|
||||
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
|
||||
@@ -218,14 +225,14 @@ const columns: TableColumnData[] = [
|
||||
}
|
||||
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
|
||||
const tags = [
|
||||
h(Tag, { color: 'green', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
|
||||
h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
|
||||
]
|
||||
if (record.pricing_mode === 'per_request') {
|
||||
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
|
||||
`$${record.per_request_price?.toFixed(4) ?? '0'}/次`))
|
||||
`${formatBilling(record.per_request_price ?? 0, 4)}/次`))
|
||||
} else {
|
||||
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
|
||||
`$${record.input_price?.toFixed(2) ?? '0'}/$${record.output_price?.toFixed(2) ?? '0'}`))
|
||||
`${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`))
|
||||
}
|
||||
return h('span', { style: 'display: inline-flex; gap: 4px; align-items: center;' }, tags)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { useDateRange } from '@/composables/useDateRange'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
|
||||
|
||||
@@ -122,10 +123,10 @@ const columns: TableColumnData[] = [
|
||||
width: 100,
|
||||
render({ record }) {
|
||||
if (record.billing_settled && record.actual_cost > 0) {
|
||||
return `$${record.actual_cost.toFixed(6)}`
|
||||
return formatBilling(record.actual_cost, 6)
|
||||
}
|
||||
if (record.pre_deduct_amount > 0) {
|
||||
return `$${record.pre_deduct_amount.toFixed(6)} (预扣)`
|
||||
return `${formatBilling(record.pre_deduct_amount, 6)} (预扣)`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
@@ -363,11 +364,11 @@ onMounted(() => {
|
||||
<ADescriptionsItem label="模型">{{ detailData.model_name }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="进度">{{ detailData.progress }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="预扣金额">
|
||||
<span v-if="detailData.pre_deduct_amount > 0">${{ detailData.pre_deduct_amount.toFixed(4) }}</span>
|
||||
<span v-if="detailData.pre_deduct_amount > 0">{{ formatBilling(detailData.pre_deduct_amount, 4) }}</span>
|
||||
<span v-else>-</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="实际费用">
|
||||
<span v-if="detailData.billing_settled">${{ detailData.actual_cost.toFixed(4) }}</span>
|
||||
<span v-if="detailData.billing_settled">{{ formatBilling(detailData.actual_cost, 4) }}</span>
|
||||
<span v-else>未结算</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="提交时间">{{ detailData.submit_time || '-' }}</ADescriptionsItem>
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref<any[]>([])
|
||||
@@ -45,7 +46,7 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'final_amount', width: 100,
|
||||
render({ record }) { return `¥${Number(record.final_amount).toFixed(2)}` },
|
||||
render({ record }) { return formatOrder(record.final_amount, 2) },
|
||||
},
|
||||
{ title: '支付渠道', dataIndex: 'payment_channel', width: 90 },
|
||||
{
|
||||
@@ -161,7 +162,7 @@ const { exporting, exportFile } = useExport({
|
||||
<AModal v-model:visible="showRefundModal" title="发起退款" :width="450" :mask-closable="false" :on-before-ok="handleRefund" :ok-loading="refundLoading">
|
||||
<AForm :model="refundForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="退款金额">
|
||||
<span class="text-red-500 font-medium">¥{{ refundAmount.toFixed(2) }}</span>
|
||||
<span class="text-red-500 font-medium">{{ formatOrder(refundAmount, 2) }}</span>
|
||||
</AFormItem>
|
||||
<AFormItem label="退款原因" required>
|
||||
<AInput v-model="refundForm.reason" type="textarea" :auto-size="{ minRows: 3, maxRows: 5 }" placeholder="请填写退款原因" />
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ========== 全局支付设置 ==========
|
||||
|
||||
const settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
|
||||
const settingsForm = reactive({
|
||||
amount_options: '10,20,50,100,200,500',
|
||||
amount_discount: '{}',
|
||||
min_topup: 1,
|
||||
currency: 'CNY',
|
||||
callback_base_url: '',
|
||||
})
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'CNY', label: 'CNY - 人民币' },
|
||||
{ value: 'USD', label: 'USD - 美元' },
|
||||
]
|
||||
|
||||
async function fetchSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/payment-settings')
|
||||
const data = res.data?.data
|
||||
if (!data) return
|
||||
if (data.amount_options) {
|
||||
settingsForm.amount_options = Array.isArray(data.amount_options)
|
||||
? data.amount_options.join(',')
|
||||
: String(data.amount_options)
|
||||
}
|
||||
if (data.amount_discount) {
|
||||
settingsForm.amount_discount = typeof data.amount_discount === 'string'
|
||||
? data.amount_discount
|
||||
: JSON.stringify(data.amount_discount)
|
||||
}
|
||||
if (data.min_topup !== undefined) settingsForm.min_topup = Number(data.min_topup)
|
||||
if (data.currency) settingsForm.currency = data.currency
|
||||
if (data.callback_base_url !== undefined) settingsForm.callback_base_url = data.callback_base_url
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettingsSave() {
|
||||
settingsSaving.value = true
|
||||
try {
|
||||
let amountOptions: number[] = []
|
||||
try {
|
||||
amountOptions = settingsForm.amount_options
|
||||
.split(',')
|
||||
.map((s: string) => Number(s.trim()))
|
||||
.filter((n: number) => !isNaN(n) && n > 0)
|
||||
} catch {
|
||||
Message.error('金额选项格式错误')
|
||||
return
|
||||
}
|
||||
let amountDiscount: Record<string, number> = {}
|
||||
try {
|
||||
amountDiscount = JSON.parse(settingsForm.amount_discount || '{}')
|
||||
} catch {
|
||||
Message.error('折扣映射格式错误')
|
||||
return
|
||||
}
|
||||
await request.put('/admin/payment-settings', {
|
||||
amount_options: amountOptions,
|
||||
amount_discount: amountDiscount,
|
||||
min_topup: settingsForm.min_topup,
|
||||
currency: settingsForm.currency,
|
||||
callback_base_url: settingsForm.callback_base_url,
|
||||
})
|
||||
Message.success('保存成功')
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
settingsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 支付渠道配置 ==========
|
||||
|
||||
const channelsLoading = ref(false)
|
||||
|
||||
const epayForm = reactive({
|
||||
is_enabled: false,
|
||||
pay_address: '',
|
||||
merchant_id: '',
|
||||
merchant_key: '',
|
||||
pay_methods: '',
|
||||
})
|
||||
const epaySaving = ref(false)
|
||||
|
||||
async function fetchChannels() {
|
||||
channelsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/payment-channels')
|
||||
const list = res.data?.data?.list || []
|
||||
const map: Record<string, any> = {}
|
||||
for (const ch of list) {
|
||||
map[ch.channel] = ch.config || {}
|
||||
}
|
||||
|
||||
const epay = map.epay || {}
|
||||
epayForm.is_enabled = !!epay.is_enabled
|
||||
epayForm.pay_address = epay.pay_address || ''
|
||||
epayForm.merchant_id = epay.merchant_id || ''
|
||||
epayForm.merchant_key = epay.merchant_key || ''
|
||||
epayForm.pay_methods = epay.pay_methods
|
||||
? typeof epay.pay_methods === 'string'
|
||||
? epay.pay_methods
|
||||
: JSON.stringify(epay.pay_methods)
|
||||
: ''
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
channelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChannel(channel: string, config: Record<string, any>, loading: { value: boolean }) {
|
||||
loading.value = true
|
||||
try {
|
||||
await request.put(`/admin/payment-channels/${channel}`, {
|
||||
config: JSON.stringify(config),
|
||||
})
|
||||
Message.success('Epay(易支付)配置已保存')
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEpaySave() {
|
||||
let payMethods = epayForm.pay_methods
|
||||
try { payMethods = JSON.parse(payMethods) } catch { /* keep as string */ }
|
||||
saveChannel('epay', {
|
||||
is_enabled: epayForm.is_enabled,
|
||||
pay_address: epayForm.pay_address,
|
||||
merchant_id: epayForm.merchant_id,
|
||||
merchant_key: epayForm.merchant_key,
|
||||
pay_methods: payMethods || undefined,
|
||||
}, epaySaving)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="支付设置" description="管理支付渠道和全局支付配置" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 全局支付设置 -->
|
||||
<ACard :bordered="false" :loading="settingsLoading" title="全局设置">
|
||||
<AForm :model="settingsForm" :auto-label-width="true">
|
||||
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
|
||||
<AGridItem>
|
||||
<AFormItem label="金额选项" extra="预设充值金额,用逗号分隔">
|
||||
<AInput v-model="settingsForm.amount_options" placeholder="10,20,50,100,200,500" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="最低充值">
|
||||
<AInputNumber v-model="settingsForm.min_topup" :min="0.01" :step="1" :precision="2" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem
|
||||
label="折扣映射"
|
||||
extra='JSON 格式,键为金额,值为折扣率。如 {"100":0.9} 表示充100享9折'
|
||||
>
|
||||
<AInput
|
||||
v-model="settingsForm.amount_discount"
|
||||
type="textarea"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder='{}'
|
||||
/>
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem
|
||||
label="回调基础 URL"
|
||||
extra="支付网关回调地址的前缀,留空则使用请求时的 Host"
|
||||
>
|
||||
<AInput v-model="settingsForm.callback_base_url" placeholder="https://api.example.com" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="默认货币">
|
||||
<ASelect v-model="settingsForm.currency" :options="currencyOptions" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
</AGrid>
|
||||
</AForm>
|
||||
<template #actions>
|
||||
<AButton type="primary" :loading="settingsSaving" @click="handleSettingsSave">保存设置</AButton>
|
||||
</template>
|
||||
</ACard>
|
||||
|
||||
<!-- 支付渠道 -->
|
||||
<ACard :bordered="false" :loading="channelsLoading">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>支付渠道 · Epay(易支付)</span>
|
||||
<ATag :color="epayForm.is_enabled ? 'green' : undefined" size="small">
|
||||
{{ epayForm.is_enabled ? '已启用' : '未启用' }}
|
||||
</ATag>
|
||||
</div>
|
||||
</template>
|
||||
<AForm :model="epayForm" :auto-label-width="true">
|
||||
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
|
||||
<AGridItem>
|
||||
<AFormItem label="启用">
|
||||
<ASwitch v-model="epayForm.is_enabled" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="支付网关地址">
|
||||
<AInput v-model="epayForm.pay_address" placeholder="https://pay.example.com" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="商户ID">
|
||||
<AInput v-model="epayForm.merchant_id" placeholder="1001" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="商户密钥">
|
||||
<AInput v-model="epayForm.merchant_key" placeholder="商户密钥" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
</AGrid>
|
||||
<AFormItem
|
||||
label="支付方式"
|
||||
extra='JSON 数组。如 [{"name":"支付宝","type":"alipay","color":"#1677ff"},{"name":"微信支付","type":"wxpay","color":"#07c160"}]'
|
||||
>
|
||||
<AInput
|
||||
v-model="epayForm.pay_methods"
|
||||
type="textarea"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder='[{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
|
||||
/>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<template #actions>
|
||||
<AButton type="primary" :loading="epaySaving" @click="handleEpaySave">保存渠道配置</AButton>
|
||||
</template>
|
||||
</ACard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const plans = ref<any[]>([])
|
||||
@@ -24,8 +25,8 @@ const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '标识', dataIndex: 'identifier', width: 100 },
|
||||
{ title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return `¥${Number(record.monthly_price).toFixed(2)}` } },
|
||||
{ title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return `¥${Number(record.yearly_price).toFixed(2)}` } },
|
||||
{ title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return formatOrder(record.monthly_price, 2) } },
|
||||
{ title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return formatOrder(record.yearly_price, 2) } },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) { return h(Tag, { color: record.status === 'active' ? 'green' : undefined, size: 'small' }, () => record.status === 'active' ? '上架' : '下架') },
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const promoCodes = ref<any[]>([])
|
||||
@@ -164,7 +165,7 @@ const { exporting, exportFile } = useExport({
|
||||
<ATable :columns="[
|
||||
{ title: '租户ID', dataIndex: 'tenant_id', width: 80 },
|
||||
{ title: '订单ID', dataIndex: 'order_id', width: 80 },
|
||||
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return '$' + Number(record.discount_amount).toFixed(2) } },
|
||||
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return formatOrder(record.discount_amount, 2) } },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160 },
|
||||
]" :data="usages" :loading="usagesLoading" :pagination="false" row-key="id" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const redemptions = ref<any[]>([])
|
||||
@@ -88,7 +89,7 @@ const usageColumns: TableColumnData[] = [
|
||||
const colors: any = { quota: 'green', plan: 'blue', duration: 'orangered' }
|
||||
return h(Tag, { color: colors[record.type], size: 'small' }, () => labels[record.type] || record.type)
|
||||
}},
|
||||
{ title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? `+${Number(record.value).toFixed(6)}` : '-' } },
|
||||
{ title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? formatBilling(record.value, 6, true) : '-' } },
|
||||
{ title: '组织名称', dataIndex: 'tenant_name', width: 140 },
|
||||
{ title: '兑换人', dataIndex: 'username', width: 120 },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 170, render({ record }) { return record.created_at?.substring(0, 19) || '-' } },
|
||||
|
||||
@@ -6,6 +6,7 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -50,13 +51,13 @@ const columns: TableColumnData[] = [
|
||||
render({ record }) {
|
||||
const amount = record.amount || 0
|
||||
const color = amount > 0 ? 'rgb(var(--green-6))' : amount < 0 ? 'rgb(var(--red-6))' : undefined
|
||||
const prefix = amount > 0 ? '+' : ''
|
||||
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, `${prefix}$${Math.abs(amount).toFixed(6)}`)
|
||||
// bil 层本位币金额:showSign 自动带 +/- 符号(同时修复负数缺失负号的问题)
|
||||
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, formatBilling(amount, 6, true))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 130,
|
||||
render({ record }) { return `$${(record.balance_after || 0).toFixed(6)}` },
|
||||
render({ record }) { return formatBilling(record.balance_after || 0, 6) },
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', width: 180, ellipsis: true, tooltip: true },
|
||||
{ title: '模型', dataIndex: 'model_name', width: 140, ellipsis: true, tooltip: true },
|
||||
|
||||
@@ -8,6 +8,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableEmpty from '@/components/TableEmpty.vue'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -224,7 +225,8 @@ const gradeColor: Record<string, string> = {
|
||||
critical: 'red',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:总成本列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: '模型', dataIndex: 'model_name', ellipsis: true, tooltip: true, width: 220, sortable: { sortDirections: ['ascend', 'descend'] } },
|
||||
{
|
||||
title: '请求数',
|
||||
@@ -299,14 +301,14 @@ const columns: TableColumnData[] = [
|
||||
render: ({ record }: any) => fmtNum(record.total_tokens).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '总成本 (USD)',
|
||||
title: `总成本 (${displayCurrency.value})`,
|
||||
dataIndex: 'total_cost',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
sortable: { sortDirections: ['ascend', 'descend'] },
|
||||
render: ({ record }: any) => `$${fmtNum(record.total_cost).toFixed(6)}`,
|
||||
render: ({ record }: any) => formatBilling(fmtNum(record.total_cost), 6),
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
// 成功率分级(与后端 gradeSuccessRate 同阈值):excellent≥99 / good≥95 / warning≥90 / critical<90
|
||||
function rateGrade(rate: number): string {
|
||||
@@ -520,7 +522,7 @@ function renderChannelMatrix(record: any) {
|
||||
<div class="metric-item">
|
||||
<div class="metric-name">总成本</div>
|
||||
<div class="metric-body">
|
||||
<p>区间内该模型实际计费成本合计(USD),已含输入、输出与缓存各项费用。</p>
|
||||
<p>区间内该模型实际计费成本合计({{ displayCurrency }}),已含输入、输出与缓存各项费用。</p>
|
||||
<p><code>总成本 = Σ total_cost</code></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,8 +598,8 @@ function renderChannelMatrix(record: any) {
|
||||
<div class="metric-card__value">{{ stats.overallRate.toFixed(2) }}%</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--orange">
|
||||
<div class="metric-card__label">总成本 (USD)</div>
|
||||
<div class="metric-card__value">${{ stats.totalCost.toFixed(6) }}</div>
|
||||
<div class="metric-card__label">总成本 ({{ displayCurrency }})</div>
|
||||
<div class="metric-card__value">{{ formatBilling(stats.totalCost, 6) }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--green">
|
||||
<div class="metric-card__label">请求缓存命中率</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TooltipComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// 桑基图无需 GridComponent(无笛卡尔坐标轴)
|
||||
use([CanvasRenderer, SankeyChart, TooltipComponent])
|
||||
@@ -45,7 +46,8 @@ function defaultRange(days: number): [string, string] {
|
||||
}
|
||||
|
||||
function formatValue(v: number): string {
|
||||
if (metric.value === 'cost') return `$${Number(v).toFixed(6)}`
|
||||
// 成本指标按 bil 层本位币展示(tooltip formatter 在悬浮时才调用,天然读取最新本位币)
|
||||
if (metric.value === 'cost') return formatBilling(v, 6)
|
||||
return Number(v).toLocaleString()
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ function goToTask(taskId: string) {
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 100 },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 180 },
|
||||
{ title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, render({ record }) {
|
||||
return record.tenant_name || record.tenant_id
|
||||
}},
|
||||
@@ -180,7 +180,6 @@ const columns: TableColumnData[] = [
|
||||
return h(Tag, { color: auditLevelTagColor[record.audit_level] || 'gray', size: 'small' }, () => auditLevelLabel[record.audit_level] || record.audit_level)
|
||||
},
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', width: 180 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 80, fixed: 'right',
|
||||
render({ record }) {
|
||||
|
||||
@@ -26,11 +26,8 @@ function updateCnyToUsd(usdToCny: number | undefined) {
|
||||
<div class="section">
|
||||
<div class="section-title">基础设置</div>
|
||||
<div class="section-grid">
|
||||
<AFormItem label="货币单位">
|
||||
<ASelect v-model="values['payment_currency']">
|
||||
<AOption value="CNY" label="CNY (人民币)" />
|
||||
<AOption value="USD" label="USD (美元)" />
|
||||
</ASelect>
|
||||
<AFormItem label="本位币" help="系统记账与全站显示货币,初始化时选定,不可更改">
|
||||
<AInput :model-value="values['billing_currency'] || 'USD'" disabled style="width: 100%" />
|
||||
</AFormItem>
|
||||
<AFormItem label="最低充值金额">
|
||||
<AInputNumber
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, reactive, provide, inject, type InjectionKey } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import { refreshCurrencySettings } from '@/composables/useCurrency'
|
||||
|
||||
const settingsFormKey: InjectionKey<Record<string, any>> = Symbol('settingsFormValues')
|
||||
|
||||
@@ -35,6 +36,8 @@ export function useSettings(category: () => string) {
|
||||
try {
|
||||
await request.put(`/admin/settings/${category()}`, { settings: formValues })
|
||||
Message.success('保存成功')
|
||||
// 汇率等公共配置变更后强制刷新,已打开页面立即按新值重渲染货币显示
|
||||
refreshCurrencySettings()
|
||||
await refresh()
|
||||
} catch {
|
||||
// error toast already shown by interceptor
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import {
|
||||
Tag, Button, Space, Message, Modal,
|
||||
} from '@arco-design/web-vue'
|
||||
@@ -8,16 +8,18 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const levels = ref<any[]>([])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:阈值列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: '等级号', dataIndex: 'level', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{
|
||||
title: '累计充值阈值(USD)', dataIndex: 'cumulative_recharge_threshold', width: 180,
|
||||
render({ record }) { return `$${Number(record.cumulative_recharge_threshold).toFixed(2)}` },
|
||||
title: `累计充值阈值(${displayCurrency.value})`, dataIndex: 'cumulative_recharge_threshold', width: 180,
|
||||
render({ record }) { return formatBilling(record.cumulative_recharge_threshold, 2) },
|
||||
},
|
||||
{
|
||||
title: '最大成员数', dataIndex: 'max_members', width: 100,
|
||||
@@ -45,7 +47,7 @@ const columns: TableColumnData[] = [
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
async function fetchLevels() {
|
||||
loading.value = true
|
||||
@@ -166,7 +168,7 @@ onMounted(fetchLevels)
|
||||
<AFormItem label="等级名称" required>
|
||||
<AInput v-model="formData.name" placeholder="如:LV2 白银用户" />
|
||||
</AFormItem>
|
||||
<AFormItem label="累计充值阈值(USD)" required>
|
||||
<AFormItem :label="`累计充值阈值(${displayCurrency})`" required>
|
||||
<AInputNumber v-model="formData.cumulative_recharge_threshold" :min="0" :precision="2" class="w-full" placeholder="达到此金额自动升级" />
|
||||
</AFormItem>
|
||||
<AFormItem label="最大成员数">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Tag, Button, Space, Message, InputNumber,
|
||||
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -118,7 +119,8 @@ async function handleCreateSubmit(done: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:钱包余额列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '租户名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '代码', dataIndex: 'code', width: 120 },
|
||||
@@ -171,12 +173,12 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '钱包余额(美元)',
|
||||
title: `钱包余额(${displayCurrency.value})`,
|
||||
dataIndex: 'wallet_balance',
|
||||
width: 120,
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.wallet_balance || '0')
|
||||
return `\$${val.toFixed(2)}`
|
||||
return formatBilling(val, 2)
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
@@ -192,7 +194,7 @@ const columns: TableColumnData[] = [
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Message, Tag, Tooltip, Space, Button,
|
||||
} from '@arco-design/web-vue'
|
||||
import { IconArchive, IconSave } from '@arco-design/web-vue/es/icon'
|
||||
import { IconSave, IconFile, IconBarChart, IconCamera, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ForwardingTracePanel from '@/components/ForwardingTracePanel.vue'
|
||||
import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { useDateRange } from '@/composables/useDateRange'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
|
||||
const isMobileView = useIsMobile()
|
||||
@@ -160,12 +160,15 @@ const billingSourceLabel: Record<string, string> = {
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
// 计费快照默认折叠,需要时展开查看定价/倍率等审计细节
|
||||
const snapshotCollapsed = ref(true)
|
||||
const detailLog = ref<any>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// 费用格式化:bil 层本位币直显,符号跟随 displayCurrency
|
||||
function formatCost(n: number): string {
|
||||
if (n == null || isNaN(n)) return '$0.000000'
|
||||
return '$' + n.toFixed(6)
|
||||
if (n == null || isNaN(n)) return formatBilling(0, 6)
|
||||
return formatBilling(n, 6)
|
||||
}
|
||||
|
||||
function formatMs(n: number): string {
|
||||
@@ -173,15 +176,34 @@ function formatMs(n: number): string {
|
||||
return n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// 耗时分级着色:按三档阈值返回 绿/蓝/橙,超出最大档返回红色(输入毫秒)
|
||||
function durationColor(ms: number, thresholds: [number, number, number]): string {
|
||||
if (ms == null || ms <= 0) return 'inherit'
|
||||
if (ms < thresholds[0]) return '#00b42a' // 绿色:优
|
||||
if (ms < thresholds[1]) return '#165dff' // 蓝色:良
|
||||
if (ms < thresholds[2]) return '#ff7d00' // 橙色:中
|
||||
return '#f53f3f' // 红色:差
|
||||
}
|
||||
|
||||
// 延迟分级:<30s 绿 / <60s 蓝 / <180s 橙 / ≥180s 红
|
||||
function latencyColor(ms: number): string {
|
||||
return durationColor(ms, [30000, 60000, 180000])
|
||||
}
|
||||
|
||||
// TTFT 分级:<3s 绿 / <10s 蓝 / <30s 橙 / ≥30s 红
|
||||
function ttftColor(ms: number): string {
|
||||
return durationColor(ms, [3000, 10000, 30000])
|
||||
}
|
||||
|
||||
function formatTime(s: string): string {
|
||||
if (!s) return '-'
|
||||
return s.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
// 格式化金额(保留2位小数)
|
||||
// 格式化金额(bil 层本位币,保留2位小数)
|
||||
function formatMoney(amount: number): string {
|
||||
if (amount == null || isNaN(amount)) return '$0.00'
|
||||
return '$' + amount.toFixed(2)
|
||||
if (amount == null || isNaN(amount)) return formatBilling(0, 2)
|
||||
return formatBilling(amount, 2)
|
||||
}
|
||||
|
||||
// 格式化数字(添加千分位)
|
||||
@@ -203,7 +225,7 @@ function totalTokens(log: any): number {
|
||||
}
|
||||
|
||||
function parseSnapshot(log: any): any {
|
||||
if (!log.billing_snapshot) return null
|
||||
if (!log?.billing_snapshot) return null
|
||||
try {
|
||||
return typeof log.billing_snapshot === 'string' ? JSON.parse(log.billing_snapshot) : log.billing_snapshot
|
||||
} catch {
|
||||
@@ -211,6 +233,77 @@ function parseSnapshot(log: any): any {
|
||||
}
|
||||
}
|
||||
|
||||
// 计费快照解析结果:computed 只解析一次,避免模板里几十处调用反复 JSON.parse
|
||||
const snapshot = computed(() => parseSnapshot(detailLog.value))
|
||||
|
||||
// 快照 token_costs 的 key → 中文标签
|
||||
const tokenCostLabels: Record<string, string> = {
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
cache_read: '缓存读取',
|
||||
cache_creation: '缓存创建',
|
||||
cache_creation_5m: '缓存创建(5分钟)',
|
||||
cache_creation_1h: '缓存创建(1小时)',
|
||||
}
|
||||
|
||||
// 消费明细小票行:divider=true 渲染分隔线,strong=true 渲染小计行,total=true 渲染合计行
|
||||
interface ReceiptRow {
|
||||
label?: string
|
||||
value?: string
|
||||
sub?: string
|
||||
valueClass?: string
|
||||
strong?: boolean
|
||||
total?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
function tokenRow(label: string, tokens: number | null | undefined): ReceiptRow {
|
||||
return { label, value: (tokens || 0).toLocaleString() }
|
||||
}
|
||||
|
||||
// 小票行数据驱动生成,替代手写重复模板
|
||||
const receiptRows = computed<ReceiptRow[]>(() => {
|
||||
const d = detailLog.value
|
||||
if (!d) return []
|
||||
const rows: ReceiptRow[] = [
|
||||
tokenRow('输入 Token', d.input_tokens),
|
||||
tokenRow('输出 Token', d.output_tokens),
|
||||
]
|
||||
if ((d.cache_read_tokens || 0) > 0) rows.push(tokenRow('缓存读取', d.cache_read_tokens))
|
||||
if ((d.cache_creation_tokens || 0) > 0) rows.push(tokenRow('缓存创建', d.cache_creation_tokens))
|
||||
if ((d.cache_creation_5m_tokens || 0) > 0) rows.push(tokenRow('缓存创建(5分钟)', d.cache_creation_5m_tokens))
|
||||
if ((d.cache_creation_1h_tokens || 0) > 0) rows.push(tokenRow('缓存创建(1小时)', d.cache_creation_1h_tokens))
|
||||
if ((d.reasoning_tokens || 0) > 0) rows.push(tokenRow('推理 Token', d.reasoning_tokens))
|
||||
if ((d.audio_input_tokens || 0) > 0) rows.push(tokenRow('音频输入', d.audio_input_tokens))
|
||||
if ((d.audio_output_tokens || 0) > 0) rows.push(tokenRow('音频输出', d.audio_output_tokens))
|
||||
if ((d.image_output_tokens || 0) > 0) rows.push(tokenRow('图像输出', d.image_output_tokens))
|
||||
if ((d.image_count || 0) > 0) rows.push({ label: '生成图片', value: `${d.image_count} 张`, sub: d.image_size ? `(${d.image_size})` : '' })
|
||||
// Token 合计行
|
||||
rows.push({ label: 'Token 合计', value: totalTokens(d).toLocaleString(), strong: true })
|
||||
rows.push({ divider: true })
|
||||
rows.push({ label: '输入费用', value: formatCost(d.input_cost || 0) })
|
||||
rows.push({ label: '输出费用', value: formatCost(d.output_cost || 0) })
|
||||
if ((d.cache_creation_cost || 0) > 0) rows.push({ label: '缓存创建费用', value: formatCost(d.cache_creation_cost) })
|
||||
if ((d.cache_read_cost || 0) > 0) rows.push({ label: '缓存读取费用', value: formatCost(d.cache_read_cost) })
|
||||
const rm = d.rate_multiplier
|
||||
rows.push({
|
||||
label: '费率倍率',
|
||||
value: rm && rm !== 1 ? `${rm.toFixed(4)}x` : '-',
|
||||
valueClass: rm && rm !== 1 ? (rm < 1 ? 'text-success' : 'text-warning') : undefined,
|
||||
})
|
||||
rows.push({ label: '基础费用', value: formatCost(d.total_cost || 0) })
|
||||
rows.push({ label: '定价来源', value: billingSourceLabel[d.billing_source] || d.billing_source || '-' })
|
||||
if ((d.pre_deduct_amount || 0) > 0 || (d.refund_amount || 0) > 0 || (d.supplement_amount || 0) > 0) {
|
||||
rows.push({ divider: true })
|
||||
rows.push({ label: '预扣金额', value: formatCost(d.pre_deduct_amount) })
|
||||
if ((d.refund_amount || 0) > 0) rows.push({ label: '退回金额', value: formatCost(d.refund_amount), valueClass: 'text-success' })
|
||||
if ((d.supplement_amount || 0) > 0) rows.push({ label: '补扣金额', value: formatCost(d.supplement_amount), valueClass: 'text-warning' })
|
||||
}
|
||||
rows.push({ divider: true })
|
||||
rows.push({ label: '实际费用', value: formatCost(d.actual_cost || 0), total: true })
|
||||
return rows
|
||||
})
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
Message.success('已复制')
|
||||
@@ -224,32 +317,11 @@ function viewAuditLog(requestId: string, taskId?: string) {
|
||||
router.push({ name: 'AdminRequestAuditLogs', query })
|
||||
}
|
||||
|
||||
// 转发路径追踪(从审计日志按 request_id 懒加载,用量日志表不存 trace)
|
||||
const forwardingTrace = ref<any>(null)
|
||||
const forwardingTraceLoading = ref(false)
|
||||
const forwardingTraceFound = ref(false)
|
||||
|
||||
async function fetchForwardingTrace(requestId: string) {
|
||||
forwardingTrace.value = null
|
||||
forwardingTraceFound.value = false
|
||||
if (!requestId) return
|
||||
forwardingTraceLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/audit/forwarding-trace/${encodeURIComponent(requestId)}`)
|
||||
const d = res.data?.data
|
||||
forwardingTraceFound.value = !!d?.found
|
||||
forwardingTrace.value = d?.forwarding_trace || null
|
||||
} catch {
|
||||
/* 静默:trace 为辅助信息,失败不打扰用户 */
|
||||
} finally {
|
||||
forwardingTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(record: any) {
|
||||
detailLog.value = record
|
||||
// 每次打开重置为折叠,避免上一条记录的快照展开状态带入
|
||||
snapshotCollapsed.value = true
|
||||
detailVisible.value = true
|
||||
fetchForwardingTrace(record.request_id)
|
||||
}
|
||||
|
||||
function tooltipRow(label: string, value: string, valueClass = 'dark-tooltip-value') {
|
||||
@@ -260,7 +332,12 @@ function tooltipRow(label: string, value: string, valueClass = 'dark-tooltip-val
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 100 },
|
||||
{
|
||||
title: '时间', dataIndex: 'created_at', width: 160,
|
||||
render({ record }) {
|
||||
return h('span', { style: 'white-space: nowrap' }, formatTime(record.created_at))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true,
|
||||
render({ record }) { return record.tenant_name || record.tenant_id },
|
||||
@@ -338,14 +415,15 @@ const columns: TableColumnData[] = [
|
||||
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '↓'),
|
||||
h('span', { class: 'token-value' }, (record.output_tokens || 0).toLocaleString()),
|
||||
]),
|
||||
h('span', { class: 'token-item', style: 'color: #0fc6c2', 'aria-label': `缓存读取 ${(record.cache_read_tokens || 0).toLocaleString()}` }, [
|
||||
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '⚡'),
|
||||
h('span', { class: 'token-value' }, (record.cache_read_tokens || 0).toLocaleString()),
|
||||
]),
|
||||
h('span', { class: 'token-item', style: 'color: #ff7d00', 'aria-label': `缓存写入 ${(record.cache_creation_tokens || 0).toLocaleString()}` }, [
|
||||
h(IconSave, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', { class: 'token-value' }, (record.cache_creation_tokens || 0).toLocaleString()),
|
||||
]),
|
||||
h('span', { class: 'token-item', style: 'color: #0fc6c2', 'aria-label': `缓存读取 ${(record.cache_read_tokens || 0).toLocaleString()}` }, [
|
||||
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '⚡'),
|
||||
h('span', { class: 'token-value' }, (record.cache_read_tokens || 0).toLocaleString()),
|
||||
]),
|
||||
|
||||
]),
|
||||
]),
|
||||
content: () => h('div', { class: 'dark-tooltip' }, tooltipContent),
|
||||
@@ -391,16 +469,16 @@ const columns: TableColumnData[] = [
|
||||
title: '延迟', dataIndex: 'latency_ms', width: 100,
|
||||
render({ record }) {
|
||||
const items: any[] = [
|
||||
h('div', { class: 'time-text' }, formatMs(record.latency_ms)),
|
||||
h('div', { class: 'time-text', style: { color: latencyColor(record.latency_ms) } }, formatMs(record.latency_ms)),
|
||||
]
|
||||
if (record.first_token_ms > 0) {
|
||||
items.push(h('div', { class: 'sub-text', style: 'font-size: 11px' }, `TTFT ${formatMs(record.first_token_ms)}`))
|
||||
items.push(h('div', { class: 'sub-text', style: { fontSize: '11px', color: ttftColor(record.first_token_ms) } }, `TTFT ${formatMs(record.first_token_ms)}`))
|
||||
}
|
||||
return h('div', { style: 'line-height: 1.4' }, items)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
title: '状态', dataIndex: 'status', width: 130,
|
||||
render({ record }) {
|
||||
const items: any[] = [
|
||||
h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusLabel[record.status] || record.status),
|
||||
@@ -411,12 +489,6 @@ const columns: TableColumnData[] = [
|
||||
return h(Space, { size: 4 }, () => items)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'created_at', width: 160,
|
||||
render({ record }) {
|
||||
return h('span', { class: 'time-text' }, formatTime(record.created_at))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 80, fixed: 'right',
|
||||
render({ record }) {
|
||||
@@ -715,18 +787,47 @@ const { exporting, exportFile } = useExport({
|
||||
<a-modal
|
||||
v-model:visible="detailVisible"
|
||||
title="用量详情"
|
||||
:width="720"
|
||||
:width="isMobileView ? '92%' : 720"
|
||||
:footer="false"
|
||||
:body-style="{ maxHeight: '65vh', overflowY: 'auto' }"
|
||||
unmount-on-close
|
||||
>
|
||||
<template v-if="detailLog">
|
||||
<!-- 关键结果摘要:模型 / 状态 / 费用 / 耗时一眼可读 -->
|
||||
<div class="detail-summary">
|
||||
<div class="detail-summary-main">
|
||||
<span class="summary-model">{{ detailLog.model_name }}<span v-if="hasUpstreamModel(detailLog)" class="summary-upstream"> ↳ {{ detailLog.upstream_model }}</span></span>
|
||||
<a-tag :color="statusTagColor[detailLog.status]" size="small">{{ statusLabel[detailLog.status] || detailLog.status }}</a-tag>
|
||||
<span v-if="detailLog.retry_index > 0" class="retry-badge">重试 {{ detailLog.retry_index }} 次</span>
|
||||
<span class="summary-cost">{{ formatCost(detailLog.actual_cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="detail-summary-sub">
|
||||
<span :style="{ color: latencyColor(detailLog.latency_ms) }">总延迟 {{ formatMs(detailLog.latency_ms) }}</span>
|
||||
<template v-if="detailLog.first_token_ms > 0">
|
||||
<span class="summary-dot">·</span>
|
||||
<span :style="{ color: ttftColor(detailLog.first_token_ms) }">首 Token {{ formatMs(detailLog.first_token_ms) }}</span>
|
||||
</template>
|
||||
<span class="summary-dot">·</span>
|
||||
<span>{{ formatTime(detailLog.created_at) }}</span>
|
||||
<a-tag v-if="detailLog.stream_end_reason" :color="detailLog.stream_end_reason === 'done' ? 'green' : 'gray'" size="small">{{ detailLog.stream_end_reason }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLog.error_message" class="detail-section">
|
||||
<div class="detail-section-title error-title">
|
||||
<span class="detail-icon detail-icon-error"><IconExclamationCircle /></span>
|
||||
错误信息
|
||||
</div>
|
||||
<div class="error-message">{{ detailLog.error_message }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-doc"></span>
|
||||
<span class="detail-icon"><IconFile /></span>
|
||||
基本信息
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-item detail-item-full">
|
||||
<span class="detail-label">请求 ID</span>
|
||||
<span class="detail-value mono-text">
|
||||
{{ detailLog.request_id }}
|
||||
@@ -734,7 +835,7 @@ const { exporting, exportFile } = useExport({
|
||||
<a-link class="copy-btn" @click="viewAuditLog(detailLog.request_id, detailLog.task_id || undefined)">查看审计日志</a-link>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detailLog.task_id" class="detail-item">
|
||||
<div v-if="detailLog.task_id" class="detail-item detail-item-full">
|
||||
<span class="detail-label">关联任务</span>
|
||||
<span class="detail-value mono-text">
|
||||
{{ detailLog.task_id }}
|
||||
@@ -748,9 +849,9 @@ const { exporting, exportFile } = useExport({
|
||||
<span v-if="detailLog.channel_type" class="sub-text">({{ detailLog.channel_type }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div v-if="detailLog.relay_mode" class="detail-item">
|
||||
<span class="detail-label">代理模式</span>
|
||||
<span class="detail-value mono-text">{{ detailLog.relay_mode || '-' }}</span>
|
||||
<span class="detail-value mono-text">{{ detailLog.relay_mode }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">请求类型</span>
|
||||
@@ -769,18 +870,11 @@ const { exporting, exportFile } = useExport({
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">状态</span>
|
||||
<span class="detail-value">
|
||||
<a-tag :color="statusTagColor[detailLog.status]" size="small">
|
||||
{{ statusLabel[detailLog.status] || detailLog.status }}
|
||||
</a-tag>
|
||||
<span v-if="detailLog.retry_index > 0" class="retry-badge">重试 {{ detailLog.retry_index }} 次</span>
|
||||
<span class="detail-label">API Key</span>
|
||||
<span class="detail-value mono-text">
|
||||
{{ detailLog.api_key_name || detailLog.api_key_id || '-' }}<span v-if="detailLog.api_key_name && detailLog.api_key_id" class="sub-text">({{ detailLog.api_key_id }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">API Key ID</span>
|
||||
<span class="detail-value mono-text">{{ detailLog.api_key_id || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">客户端 IP</span>
|
||||
<span class="detail-value mono-text">{{ detailLog.client_ip || '-' }}</span>
|
||||
@@ -799,228 +893,69 @@ const { exporting, exportFile } = useExport({
|
||||
</div>
|
||||
<div v-if="detailLog.user_agent" class="detail-item detail-item-full">
|
||||
<span class="detail-label">User-Agent</span>
|
||||
<span class="detail-value sub-text" :title="detailLog.user_agent">{{ detailLog.user_agent }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">时间</span>
|
||||
<span class="detail-value">{{ formatTime(detailLog.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasUpstreamModel(detailLog)" class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-arrow"></span>
|
||||
模型映射
|
||||
</div>
|
||||
<div class="model-mapping">
|
||||
<a-tag>{{ detailLog.model_name }}</a-tag>
|
||||
<span class="mapping-arrow">→</span>
|
||||
<a-tag color="arcoblue">{{ detailLog.upstream_model }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-chart"></span>
|
||||
Token 使用量
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-in"></span> 输入 Token</span>
|
||||
<span class="detail-value">{{ (detailLog.input_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-out"></span> 输出 Token</span>
|
||||
<span class="detail-value">{{ (detailLog.output_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-cache-read"></span> 缓存读取</span>
|
||||
<span class="detail-value token-cache-read">{{ (detailLog.cache_read_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-cache-create"></span> 缓存创建</span>
|
||||
<span class="detail-value token-cache-create">{{ (detailLog.cache_creation_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-cache-5m"></span> 缓存创建(5分钟)</span>
|
||||
<span class="detail-value token-cache-5m">{{ (detailLog.cache_creation_5m_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-cache-1h"></span> 缓存创建(1小时)</span>
|
||||
<span class="detail-value token-cache-1h">{{ (detailLog.cache_creation_1h_tokens || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.reasoning_tokens > 0" class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-reasoning"></span> 推理 Token</span>
|
||||
<span class="detail-value token-reasoning">{{ detailLog.reasoning_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.audio_input_tokens > 0" class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-audio"></span> 音频输入</span>
|
||||
<span class="detail-value token-audio">{{ detailLog.audio_input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.audio_output_tokens > 0" class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-audio"></span> 音频输出</span>
|
||||
<span class="detail-value token-audio">{{ detailLog.audio_output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.image_output_tokens > 0" class="detail-item">
|
||||
<span class="detail-label"><span class="token-dot token-dot-image"></span> 图像输出</span>
|
||||
<span class="detail-value token-image">{{ detailLog.image_output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.image_count > 0" class="detail-item">
|
||||
<span class="detail-label">生成图片</span>
|
||||
<span class="detail-value">
|
||||
{{ detailLog.image_count }} 张
|
||||
<span v-if="detailLog.image_size" class="sub-text">({{ detailLog.image_size }})</span>
|
||||
</span>
|
||||
<span class="detail-value sub-text detail-ellipsis" :title="detailLog.user_agent">{{ detailLog.user_agent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-credit"></span>
|
||||
费用明细
|
||||
<span class="detail-icon"><IconBarChart /></span>
|
||||
消费明细
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">输入费用</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.input_cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">输出费用</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.output_cost || 0) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.cache_creation_cost > 0" class="detail-item">
|
||||
<span class="detail-label">缓存创建费用</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.cache_creation_cost) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.cache_read_cost > 0" class="detail-item">
|
||||
<span class="detail-label">缓存读取费用</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.cache_read_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-summary">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">费率倍率</span>
|
||||
<span
|
||||
v-if="detailLog.rate_multiplier && detailLog.rate_multiplier !== 1"
|
||||
class="detail-value"
|
||||
:class="detailLog.rate_multiplier < 1 ? 'text-success' : 'text-warning'"
|
||||
>
|
||||
{{ detailLog.rate_multiplier.toFixed(4) }}x
|
||||
</span>
|
||||
<span v-else class="detail-value">-</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">基础费用</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.total_cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">实际费用</span>
|
||||
<span class="detail-value text-success">{{ formatCost(detailLog.actual_cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">定价来源</span>
|
||||
<span class="detail-value">{{ billingSourceLabel[detailLog.billing_source] || detailLog.billing_source || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">货币</span>
|
||||
<span class="detail-value">{{ detailLog.currency || 'USD' }}</span>
|
||||
<div class="receipt">
|
||||
<div class="receipt-head">
|
||||
<span class="receipt-brand">用量 · 费用 · 结算</span>
|
||||
<span class="receipt-meta">{{ displayCurrency }}</span>
|
||||
</div>
|
||||
<!-- 小票行由 receiptRows 数据驱动生成 -->
|
||||
<template v-for="(row, index) in receiptRows" :key="index">
|
||||
<div v-if="row.divider" class="receipt-divider"></div>
|
||||
<div v-else class="receipt-row" :class="[row.strong ? 'receipt-strong' : '', row.total ? 'receipt-total' : '']">
|
||||
<span class="receipt-label">{{ row.label }}</span>
|
||||
<span class="receipt-value" :class="row.valueClass">
|
||||
{{ row.value }}<span v-if="row.sub" class="receipt-sub">{{ row.sub }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLog.pre_deduct_amount > 0 || detailLog.refund_amount > 0 || detailLog.supplement_amount > 0" class="detail-section">
|
||||
<div v-if="detailLog.billing_summary || snapshot" class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-refresh"></span>
|
||||
结算明细
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">预扣金额</span>
|
||||
<span class="detail-value">{{ formatCost(detailLog.pre_deduct_amount) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.refund_amount > 0" class="detail-item">
|
||||
<span class="detail-label">退回金额</span>
|
||||
<span class="detail-value text-success">{{ formatCost(detailLog.refund_amount) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.supplement_amount > 0" class="detail-item">
|
||||
<span class="detail-label">补扣金额</span>
|
||||
<span class="detail-value text-warning">{{ formatCost(detailLog.supplement_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-clock"></span>
|
||||
性能指标
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">总延迟</span>
|
||||
<span class="detail-value">{{ formatMs(detailLog.latency_ms) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">首 Token 延迟</span>
|
||||
<span class="detail-value">{{ formatMs(detailLog.first_token_ms) }}</span>
|
||||
</div>
|
||||
<div v-if="detailLog.stream_end_reason" class="detail-item">
|
||||
<span class="detail-label">流结束原因</span>
|
||||
<span class="detail-value">
|
||||
<a-tag
|
||||
:color="detailLog.stream_end_reason === 'done' ? 'green' : 'gray'"
|
||||
size="medium"
|
||||
>
|
||||
{{ detailLog.stream_end_reason }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLog.billing_summary || parseSnapshot(detailLog)" class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
<span class="detail-icon detail-icon-clipboard"></span>
|
||||
<span class="detail-icon"><IconCamera /></span>
|
||||
计费快照
|
||||
<a-link class="snapshot-toggle" @click="snapshotCollapsed = !snapshotCollapsed">{{ snapshotCollapsed ? '展开' : '收起' }}</a-link>
|
||||
</div>
|
||||
|
||||
<template v-if="parseSnapshot(detailLog)">
|
||||
<div v-if="snapshot && !snapshotCollapsed">
|
||||
<div class="snapshot-grid">
|
||||
<div class="snapshot-block">
|
||||
<div class="snapshot-block-title">定价信息</div>
|
||||
<div class="snapshot-block-body">
|
||||
<div v-if="parseSnapshot(detailLog).pricing.billing_source" class="snapshot-row">
|
||||
<span class="snapshot-label">价格来源</span>
|
||||
<span class="snapshot-value">{{ billingSourceLabel[parseSnapshot(detailLog).pricing.billing_source] || parseSnapshot(detailLog).pricing.billing_source }}</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).pricing.billing_mode" class="snapshot-row">
|
||||
<span class="snapshot-label">计费模式</span>
|
||||
<span class="snapshot-value">{{ billingModeLabel[parseSnapshot(detailLog).pricing.billing_mode] || parseSnapshot(detailLog).pricing.billing_mode }}</span>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">基础输入价</span>
|
||||
<span class="snapshot-value">${{ (parseSnapshot(detailLog).pricing.base_input_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value">{{ formatBilling(snapshot.pricing.base_input_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">基础输出价</span>
|
||||
<span class="snapshot-value">${{ (parseSnapshot(detailLog).pricing.base_output_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value">{{ formatBilling(snapshot.pricing.base_output_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).pricing.effective_input_price !== parseSnapshot(detailLog).pricing.base_input_price" class="snapshot-row">
|
||||
<div v-if="snapshot.pricing.effective_input_price !== snapshot.pricing.base_input_price" class="snapshot-row">
|
||||
<span class="snapshot-label">实际输入价</span>
|
||||
<span class="snapshot-value text-success">${{ (parseSnapshot(detailLog).pricing.effective_input_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value text-success">{{ formatBilling(snapshot.pricing.effective_input_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).pricing.effective_output_price !== parseSnapshot(detailLog).pricing.base_output_price" class="snapshot-row">
|
||||
<div v-if="snapshot.pricing.effective_output_price !== snapshot.pricing.base_output_price" class="snapshot-row">
|
||||
<span class="snapshot-label">实际输出价</span>
|
||||
<span class="snapshot-value text-success">${{ (parseSnapshot(detailLog).pricing.effective_output_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value text-success">{{ formatBilling(snapshot.pricing.effective_output_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).cache_prices && parseSnapshot(detailLog).cache_prices.cache_creation_price > 0" class="snapshot-row">
|
||||
<div v-if="snapshot.cache_prices && snapshot.cache_prices.cache_creation_price > 0" class="snapshot-row">
|
||||
<span class="snapshot-label">缓存创建单价</span>
|
||||
<span class="snapshot-value">${{ (parseSnapshot(detailLog).cache_prices.cache_creation_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value">{{ formatBilling(snapshot.cache_prices.cache_creation_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).cache_prices && parseSnapshot(detailLog).cache_prices.cache_read_price > 0" class="snapshot-row">
|
||||
<div v-if="snapshot.cache_prices && snapshot.cache_prices.cache_read_price > 0" class="snapshot-row">
|
||||
<span class="snapshot-label">缓存读取单价</span>
|
||||
<span class="snapshot-value">${{ (parseSnapshot(detailLog).cache_prices.cache_read_price || 0).toFixed(6) }}/1M</span>
|
||||
<span class="snapshot-value">{{ formatBilling(snapshot.cache_prices.cache_read_price || 0, 6) }}/1M</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1028,107 +963,46 @@ const { exporting, exportFile } = useExport({
|
||||
<div class="snapshot-block">
|
||||
<div class="snapshot-block-title">倍率信息</div>
|
||||
<div class="snapshot-block-body">
|
||||
<div v-if="parseSnapshot(detailLog).multipliers.model_multiplier && parseSnapshot(detailLog).multipliers.model_multiplier !== 1" class="snapshot-row">
|
||||
<div v-if="snapshot.multipliers.model_multiplier && snapshot.multipliers.model_multiplier !== 1" class="snapshot-row">
|
||||
<span class="snapshot-label">模型倍率</span>
|
||||
<span class="snapshot-value">{{ (parseSnapshot(detailLog).multipliers.model_multiplier).toFixed(4) }}x</span>
|
||||
<span class="snapshot-value">{{ (snapshot.multipliers.model_multiplier).toFixed(4) }}x</span>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">租户倍率</span>
|
||||
<span class="snapshot-value" :class="(parseSnapshot(detailLog).multipliers.tenant_multiplier || 1) < 1 ? 'text-success' : ''">
|
||||
{{ (parseSnapshot(detailLog).multipliers.tenant_multiplier || 1).toFixed(4) }}x
|
||||
<span class="snapshot-value" :class="(snapshot.multipliers.tenant_multiplier || 1) < 1 ? 'text-success' : ''">
|
||||
{{ (snapshot.multipliers.tenant_multiplier || 1).toFixed(4) }}x
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).multipliers.discount_ratio && parseSnapshot(detailLog).multipliers.discount_ratio !== 1" class="snapshot-row">
|
||||
<div v-if="snapshot.multipliers.discount_ratio && snapshot.multipliers.discount_ratio !== 1" class="snapshot-row">
|
||||
<span class="snapshot-label">折扣比例</span>
|
||||
<span class="snapshot-value text-success">{{ (parseSnapshot(detailLog).multipliers.discount_ratio).toFixed(4) }}x</span>
|
||||
<span class="snapshot-value text-success">{{ (snapshot.multipliers.discount_ratio).toFixed(4) }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parseSnapshot(detailLog).token_costs" class="snapshot-block snapshot-block-full">
|
||||
<div v-if="snapshot.token_costs" class="snapshot-block snapshot-block-full">
|
||||
<div class="snapshot-block-title">Token 费用计算</div>
|
||||
<div class="snapshot-block-body">
|
||||
<template v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key">
|
||||
<template v-for="(tc, key) in snapshot.token_costs" :key="key">
|
||||
<div v-if="(tc.tokens || 0) > 0" class="snapshot-row">
|
||||
<span class="snapshot-label">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
|
||||
<span class="snapshot-label">{{ tokenCostLabels[key] || key }}</span>
|
||||
<span class="snapshot-value">
|
||||
{{ (tc.tokens || 0).toLocaleString() }} tokens × ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
|
||||
{{ (tc.tokens || 0).toLocaleString() }} tokens × {{ formatBilling(tc.unit_price || 0, 6) }}/1M = <strong>{{ formatBilling(tc.cost || 0, 6) }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parseSnapshot(detailLog).settlement" class="snapshot-block">
|
||||
<div class="snapshot-block-title">结算信息</div>
|
||||
<div class="snapshot-block-body">
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">预扣金额</span>
|
||||
<span class="snapshot-value">{{ formatCost(parseSnapshot(detailLog).settlement.pre_deduct_amount || 0) }}</span>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">实际费用</span>
|
||||
<span class="snapshot-value text-success">{{ formatCost(parseSnapshot(detailLog).settlement.actual_cost || 0) }}</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).settlement.refund_amount > 0" class="snapshot-row">
|
||||
<span class="snapshot-label">退还金额</span>
|
||||
<span class="snapshot-value text-success">{{ formatCost(parseSnapshot(detailLog).settlement.refund_amount) }}</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).settlement.supplement_amount > 0" class="snapshot-row">
|
||||
<span class="snapshot-label">补扣金额</span>
|
||||
<span class="snapshot-value text-warning">{{ formatCost(parseSnapshot(detailLog).settlement.supplement_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parseSnapshot(detailLog).request_meta" class="snapshot-block">
|
||||
<div class="snapshot-block-title">请求元信息</div>
|
||||
<div class="snapshot-block-body">
|
||||
<div v-if="parseSnapshot(detailLog).request_meta.requested_model" class="snapshot-row">
|
||||
<span class="snapshot-label">请求模型</span>
|
||||
<span class="snapshot-value mono-text">{{ parseSnapshot(detailLog).request_meta.requested_model }}</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).request_meta.upstream_model" class="snapshot-row">
|
||||
<span class="snapshot-label">上游模型</span>
|
||||
<span class="snapshot-value mono-text">{{ parseSnapshot(detailLog).request_meta.upstream_model }}</span>
|
||||
</div>
|
||||
<div class="snapshot-row">
|
||||
<span class="snapshot-label">流式请求</span>
|
||||
<span class="snapshot-value">{{ parseSnapshot(detailLog).request_meta.is_stream ? '是' : '否' }}</span>
|
||||
</div>
|
||||
<div v-if="parseSnapshot(detailLog).request_meta.first_token_ms" class="snapshot-row">
|
||||
<span class="snapshot-label">首 Token</span>
|
||||
<span class="snapshot-value">{{ parseSnapshot(detailLog).request_meta.first_token_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLog.billing_summary" style="margin-top: 12px;">
|
||||
<div v-if="detailLog.billing_summary && !snapshotCollapsed" style="margin-top: 12px;">
|
||||
<div class="snapshot-text-title">计算过程</div>
|
||||
<pre class="billing-snapshot">{{ detailLog.billing_summary }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLog.error_message" class="detail-section">
|
||||
<div class="detail-section-title error-title">
|
||||
<span class="detail-icon detail-icon-error"></span>
|
||||
错误信息
|
||||
</div>
|
||||
<div class="error-message">{{ detailLog.error_message }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">
|
||||
转发路径追踪
|
||||
</div>
|
||||
<a-spin :loading="forwardingTraceLoading" class="w-full">
|
||||
<ForwardingTracePanel v-if="forwardingTrace" :trace="forwardingTrace" />
|
||||
<a-empty v-else-if="!forwardingTraceLoading && !forwardingTraceFound" description="该请求未开启审计,无转发追踪数据" />
|
||||
<a-empty v-else-if="!forwardingTraceLoading && forwardingTraceFound" description="审计已记录,但未捕获转发追踪" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
@@ -1167,6 +1041,8 @@ const { exporting, exportFile } = useExport({
|
||||
.token-item > svg,
|
||||
.token-symbol {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.token-value {
|
||||
min-width: 0;
|
||||
@@ -1215,7 +1091,6 @@ const { exporting, exportFile } = useExport({
|
||||
}
|
||||
.cost-value {
|
||||
font-weight: 600;
|
||||
color: #00b42a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dark-tooltip {
|
||||
@@ -1300,9 +1175,6 @@ const { exporting, exportFile } = useExport({
|
||||
.detail-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-section:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -1314,48 +1186,19 @@ const { exporting, exportFile } = useExport({
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.snapshot-toggle {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.detail-icon-doc {
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid #165dff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.detail-icon-arrow {
|
||||
color: #165dff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-icon-chart {
|
||||
color: #165dff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-icon-credit {
|
||||
color: #165dff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-icon-refresh {
|
||||
color: #165dff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-icon-clock {
|
||||
color: #165dff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-icon-clipboard {
|
||||
color: #165dff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-icon-error {
|
||||
color: #f53f3f;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
@@ -1385,25 +1228,135 @@ const { exporting, exportFile } = useExport({
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-summary {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #e5e6eb;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 24px;
|
||||
/* 消费明细小票风格 */
|
||||
.receipt {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9dce0;
|
||||
border-radius: 8px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.model-mapping {
|
||||
.receipt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #d9dce0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.receipt-brand {
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.receipt-meta {
|
||||
color: #86909c;
|
||||
font-size: 11px;
|
||||
}
|
||||
.receipt-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 3.5px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.receipt-label {
|
||||
color: #86909c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.receipt-value {
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.receipt-divider {
|
||||
margin: 6px 0;
|
||||
border-top: 1px dashed #d9dce0;
|
||||
}
|
||||
.receipt-total {
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.receipt-total .receipt-label {
|
||||
color: #1d2129;
|
||||
font-weight: 600;
|
||||
}
|
||||
.receipt-total .receipt-value {
|
||||
font-size: 15px;
|
||||
}
|
||||
/* 小票小计行(Token 合计) */
|
||||
.receipt-strong .receipt-label {
|
||||
color: #1d2129;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 小票值的灰色补充说明(如图片尺寸) */
|
||||
.receipt-sub {
|
||||
margin-left: 4px;
|
||||
color: #86909c;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 关键结果摘要条 */
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.detail-summary-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mapping-arrow {
|
||||
color: #86909c;
|
||||
.summary-model {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.summary-upstream {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
}
|
||||
.summary-cost {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1d2129;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.detail-summary-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
}
|
||||
.summary-dot {
|
||||
color: #c9cdd4;
|
||||
}
|
||||
.detail-summary-main .retry-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 超长值单行省略(配合 title 提示) */
|
||||
.detail-ellipsis {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono-text {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
@@ -1475,10 +1428,8 @@ const { exporting, exportFile } = useExport({
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.error-title {
|
||||
.detail-section-title.error-title {
|
||||
border-left-color: #f53f3f;
|
||||
}
|
||||
.error-title .detail-section-title {
|
||||
color: #f53f3f;
|
||||
}
|
||||
.error-message {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -105,7 +106,7 @@ onMounted(fetchLevelOptions)
|
||||
<ATag v-if="detail.max_members != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="钱包余额">
|
||||
<span class="money">${{ parseFloat(detail.wallet_balance || '0').toFixed(2) }}</span>
|
||||
<span class="money">{{ formatBilling(detail.wallet_balance, 2) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="并发上限">
|
||||
{{ getEffectiveMaxConcurrency() ?? '不限' }}
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency } from '@/composables/useCurrency'
|
||||
|
||||
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -488,22 +492,22 @@ defineExpose({ openPreviewModal })
|
||||
<div class="grid grid-cols-2 gap-x-3">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_read_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_creation_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -514,7 +518,7 @@ defineExpose({ openPreviewModal })
|
||||
<ADivider margin="8px">按次定价</ADivider>
|
||||
<AFormItem label="按次单价">
|
||||
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="每次调用价格" class="w-full">
|
||||
<template #suffix>$ / 次</template>
|
||||
<template #suffix>{{ currencySymbol }} / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</template>
|
||||
@@ -540,24 +544,24 @@ defineExpose({ openPreviewModal })
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="tier.input_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="tier.output_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -63,13 +64,13 @@ const txColumns: TableColumnData[] = [
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.amount) || 0
|
||||
const color = val >= 0 ? 'rgb(var(--green-6))' : 'rgb(var(--red-6))'
|
||||
const prefix = val >= 0 ? '+' : ''
|
||||
return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(6)}`)
|
||||
// bil 层本位币金额:showSign 自动带 +/- 符号
|
||||
return h('span', { style: { fontWeight: 600, color } }, formatBilling(val, 6, true))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 140,
|
||||
render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(6)}` },
|
||||
render({ record }) { return formatBilling(parseFloat(record.balance_after) || 0, 6) },
|
||||
},
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, h } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -18,6 +19,21 @@ const emit = defineEmits<{
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// 本位币符号:bil 层表单(调整余额/预警阈值)输入前缀跟随本位币,输入值仍为存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
// 线下充值入账弹窗文案:收款恒为人民币(银行转账),到账按本位币分支说明
|
||||
const offlineRechargeIntro = computed(() =>
|
||||
displayCurrency.value === 'CNY'
|
||||
? '用于线下银行转账入账:输入人民币金额直接入账钱包(本位币为人民币,无需换算),并累计到累计充值(影响租户等级)。'
|
||||
: '用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。',
|
||||
)
|
||||
const offlineRechargeNote = computed(() =>
|
||||
displayCurrency.value === 'CNY'
|
||||
? '到账金额 = 输入的人民币金额(本位币直接入账)'
|
||||
: '到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数',
|
||||
)
|
||||
|
||||
// === 钱包信息 ===
|
||||
const walletInfo = ref<any>(null)
|
||||
const walletLoading = ref(false)
|
||||
@@ -63,7 +79,7 @@ const frozenColumns: TableColumnData[] = [
|
||||
{
|
||||
title: '冻结金额', dataIndex: 'amount', width: 130,
|
||||
render({ record }) {
|
||||
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, `$${(parseFloat(record.amount) || 0).toFixed(6)}`)
|
||||
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, formatBilling(parseFloat(record.amount) || 0, 6))
|
||||
},
|
||||
},
|
||||
{ title: '冻结时间', dataIndex: 'created_at', width: 280 },
|
||||
@@ -102,7 +118,7 @@ async function handleRelease(done: (closed: boolean) => void) {
|
||||
reason: releaseForm.reason.trim(),
|
||||
})
|
||||
const amt = parseFloat(res.data?.data?.released_amount || 0)
|
||||
Message.success(`已释放冻结 $${amt.toFixed(6)}`)
|
||||
Message.success(`已释放冻结 ${formatBilling(amt, 6)}`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
} catch {
|
||||
@@ -134,7 +150,7 @@ async function handleReleaseAll(done: (closed: boolean) => void) {
|
||||
const data = res.data?.data || {}
|
||||
const amt = parseFloat(data.released_amount || 0)
|
||||
const skipped = parseInt(data.skipped_count || 0, 10)
|
||||
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 $${amt.toFixed(6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
|
||||
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 ${formatBilling(amt, 6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
|
||||
if (skipped) {
|
||||
Message.warning(`跳过原因:${(data.skipped_reasons || []).join(';')}`)
|
||||
}
|
||||
@@ -193,7 +209,8 @@ async function handleOfflineRecharge(done: (closed: boolean) => void) {
|
||||
description: offlineRechargeForm.description,
|
||||
})
|
||||
const data = res.data?.data || {}
|
||||
Message.success(`入账成功,到账 $${parseFloat(data.credited_usd || 0).toFixed(2)}(汇率 ${data.rate})`)
|
||||
// credited_usd 为 bil 层本位币金额,按本位币符号展示(汇率信息保留)
|
||||
Message.success(`入账成功,到账 ${formatBilling(data.credited_usd, 2)}(汇率 ${data.rate})`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
emit('refresh-detail')
|
||||
@@ -253,16 +270,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
</template>
|
||||
<ADescriptions :column="isMobile ? 1 : 3" bordered size="medium">
|
||||
<ADescriptionsItem label="可用余额">
|
||||
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
<span class="money">{{ formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="总余额">
|
||||
${{ parseFloat(walletInfo.balance).toFixed(6) }}
|
||||
{{ formatBilling(walletInfo.balance, 6) }}
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="冻结余额">
|
||||
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
<span style="color: rgb(var(--orange-6))">{{ formatBilling(walletInfo.frozen_balance, 6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="预警阈值">
|
||||
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }}
|
||||
{{ walletInfo.warning_threshold > 0 ? formatBilling(walletInfo.warning_threshold, 6) : '关闭' }}
|
||||
</ADescriptionsItem>
|
||||
</ADescriptions>
|
||||
</ACard>
|
||||
@@ -338,16 +355,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<!-- 调整余额弹窗 -->
|
||||
<AModal v-model:visible="showRechargeModal" title="调整余额" :width="440" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
|
||||
手动调整该租户的{{ displayCurrency === 'USD' ? '美元' : '人民币' }}钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
|
||||
<br />
|
||||
总余额 <span class="money">${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }}</span>
|
||||
| 可用余额 <span class="money">${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }}</span>
|
||||
总余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance, 6) : formatBilling(0, 6) }}</span>
|
||||
| 可用余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) : formatBilling(0, 6) }}</span>
|
||||
<span style="color: var(--ta-text-tertiary)">(扣减上限)</span>
|
||||
</div>
|
||||
<AForm :model="rechargeForm" layout="vertical">
|
||||
<AFormItem label="调整金额(USD)" required>
|
||||
<AFormItem :label="`调整金额(${displayCurrency})`" required>
|
||||
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=增加余额,负数=扣减余额" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
<template #prefix>{{ currencySymbol }}</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="调整说明">
|
||||
@@ -362,7 +379,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<!-- 线下充值入账弹窗 -->
|
||||
<AModal v-model:visible="showOfflineRechargeModal" title="线下充值入账" :width="440" :on-before-ok="handleOfflineRecharge" :ok-loading="offlineRechargeLoading">
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。
|
||||
{{ offlineRechargeIntro }}
|
||||
</div>
|
||||
<AForm :model="offlineRechargeForm" layout="vertical">
|
||||
<AFormItem label="入账金额(人民币)" required>
|
||||
@@ -378,7 +395,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数
|
||||
{{ offlineRechargeNote }}
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
@@ -390,7 +407,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<AForm :model="thresholdForm" layout="vertical">
|
||||
<AFormItem label="预警阈值">
|
||||
<AInputNumber v-model="thresholdForm.threshold" :precision="2" :min="0" placeholder="设为 0 表示不预警" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
<template #prefix>{{ currencySymbol }}</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
@@ -424,7 +441,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
请求ID:<span style="font-family: monospace">{{ releaseTarget.request_id }}</span>
|
||||
<br />
|
||||
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">${{ (parseFloat(releaseTarget.amount) || 0).toFixed(6) }}</span>
|
||||
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">{{ formatBilling(releaseTarget.amount, 6) }}</span>
|
||||
| 已冻结 {{ formatFrozenAge(releaseTarget.age_seconds) }}
|
||||
</div>
|
||||
<AForm :model="releaseForm" layout="vertical">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
} from 'chart.js'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
@@ -28,10 +29,10 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const colors = ['#14b8a6', '#06b6d4', '#6366f1', '#f59e0b', '#ec4899', '#3b82f6', '#10b981', '#ef4444']
|
||||
|
||||
// 金额格式化统一走本位币;chartOptions 为 computed,tooltip 回调随 displayCurrency 变化重渲染
|
||||
function formatCost(value: number): string {
|
||||
if (value === 0) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
return `$${value.toFixed(4)}`
|
||||
if (value === 0 || value >= 1) return formatBilling(value, 2)
|
||||
return formatBilling(value, 4)
|
||||
}
|
||||
|
||||
const totalCost = computed(() => props.data.reduce((sum, item) => sum + Number(item.total_cost || 0), 0))
|
||||
|
||||
@@ -6,6 +6,7 @@ import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
export interface ApiKeyData {
|
||||
id: number
|
||||
@@ -374,7 +375,7 @@ const isFormMode = computed(() => {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="总额度 (USD)" class="!mb-0">
|
||||
<n-form-item :label="`总额度 (${displayCurrency})`" class="!mb-0">
|
||||
<n-input-number
|
||||
v-model:value="form.total_quota"
|
||||
:min="0"
|
||||
@@ -385,7 +386,7 @@ const isFormMode = computed(() => {
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 ${{ apiKey.used_quota?.toFixed(4) }}</p>
|
||||
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 {{ formatBilling(apiKey.used_quota, 4) }}</p>
|
||||
|
||||
<n-form-item label="IP 白名单">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { computed } from 'vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
/**
|
||||
* 全站货币显示(本位币制):
|
||||
* - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算
|
||||
* - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显
|
||||
* - 本位币由系统初始化向导选定,前端只读(/settings/public 暴露)
|
||||
* - 编辑表单保持存储币种原值输入,折算仅用于只读展示
|
||||
*/
|
||||
export type CurrencyCode = 'USD' | 'CNY'
|
||||
|
||||
/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */
|
||||
const FALLBACK_CNY_TO_USD = 0.14
|
||||
|
||||
const { settings } = usePublicSettings()
|
||||
|
||||
/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */
|
||||
export const displayCurrency = computed<CurrencyCode>(() =>
|
||||
settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD',
|
||||
)
|
||||
|
||||
/** CNY→USD 汇率(管理后台支付设置配置) */
|
||||
export const cnyToUsd = computed(() => {
|
||||
const r = Number(settings.value.payment_exchange_rate_cny_to_usd)
|
||||
return r > 0 ? r : FALLBACK_CNY_TO_USD
|
||||
})
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */
|
||||
source?: CurrencyCode
|
||||
/** 显示精度;默认按显示币种取 USD 6 位 / CNY 2 位 */
|
||||
precision?: number
|
||||
/** 正负号前缀(交易流水) */
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化(跟随本位币)。
|
||||
* source='CNY'(订单层)且本位币=USD 时按汇率折算;bil 层数据传 source=displayCurrency 即直显。
|
||||
* 内部读取响应式 computed,render 函数中调用可建立依赖,配置变化自动重渲染。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { source = 'USD', precision, showSign = false } = opts
|
||||
const display = displayCurrency.value
|
||||
let num = Number(value ?? 0)
|
||||
// 唯一折算场景:订单层 CNY 金额在本位币 USD 部署下展示
|
||||
if (source === 'CNY' && display === 'USD') {
|
||||
num = num * cnyToUsd.value
|
||||
}
|
||||
const symbol = display === 'CNY' ? '¥' : '$'
|
||||
const p = precision ?? (display === 'CNY' ? 2 : 6)
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(p))}`
|
||||
}
|
||||
|
||||
/** bil_ 层(钱包/计费/定价等本位币数据)快捷格式化:直显 + 本位币符号 */
|
||||
export function formatBilling(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: displayCurrency.value, precision, showSign })
|
||||
}
|
||||
|
||||
/** ord_/pln_ 层(订单/套餐等 CNY 数据)快捷格式化:本位币=CNY 直显,USD 折算 */
|
||||
export function formatOrder(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: 'CNY', precision, showSign })
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值页专用:本位币 USD 部署下,用户输入的显示货币(USD)金额换算为 CNY 下单金额。
|
||||
* 本位币 CNY 时原样返回(充值锚定 CNY,零折算)。
|
||||
*/
|
||||
export function displayToCny(value: number): number {
|
||||
if (displayCurrency.value === 'CNY') return value
|
||||
return Number((value / cnyToUsd.value).toFixed(2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值页专用:CNY 下单金额换算为显示货币金额(档位折算展示,¥100 × 0.14 = $14)。
|
||||
* 本位币 CNY 时原样返回。
|
||||
*/
|
||||
export function cnyToDisplay(value: number): number {
|
||||
if (displayCurrency.value === 'CNY') return value
|
||||
return Number((value * cnyToUsd.value).toFixed(2))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { formatMoney as formatMoneyWithCurrency } from '@/composables/useCurrency'
|
||||
|
||||
/**
|
||||
* NDataTable 迁移公共渲染工具。
|
||||
@@ -34,24 +35,22 @@ export function renderBadge(
|
||||
}
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种(按字段所属层传入:bil 层 USD/CNY 由本位币定,订单层 CNY),非显示币种 */
|
||||
currency?: 'USD' | 'CNY'
|
||||
precision?: number
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化。默认 USD、6 位精度(与 NUMERIC(20,10) 对齐)。
|
||||
* currency='CNY' 时用 ¥ 前缀 + 2 位精度(订单/充值层)。
|
||||
* showSign 用 +/− 前缀(交易流水)。
|
||||
* 统一金额格式化(委托 useCurrency,跟随系统本位币显示)。
|
||||
* currency 参数语义 = 数据存储层币种:bil 层数据传本位币对应值(USD 部署默认 USD)、
|
||||
* 订单/充值层传 'CNY'(本位币 USD 时自动按汇率折算显示)。
|
||||
* 精度默认按显示币种取 USD 6 位 / CNY 2 位;showSign 用 +/− 前缀(交易流水)。
|
||||
* 自动去掉末尾的 0(如 $1.500000 → $1.5)。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { currency = 'USD', precision = 6, showSign = false } = opts
|
||||
const num = Number(value ?? 0)
|
||||
const symbol = currency === 'CNY' ? '¥' : '$'
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(precision))}`
|
||||
const { currency = 'USD', precision, showSign = false } = opts
|
||||
return formatMoneyWithCurrency(value, { source: currency, precision, showSign })
|
||||
}
|
||||
|
||||
/** 统一日期格式化:ISO T 转空格、截断到分钟;空值显示 -- */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNotificationCount } from '@/composables/useNotificationCount'
|
||||
import { useAnnouncementRead } from '@/composables/useAnnouncementRead'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useWatermark } from '@/composables/useWatermark'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import { toast } from '@/utils/toast'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue'
|
||||
@@ -173,7 +174,7 @@ async function fetchWalletBalance() {
|
||||
const w = res.data?.data
|
||||
if (w) {
|
||||
const bal = w.available_balance ?? w.balance ?? 0
|
||||
walletBalance.value = bal >= 100 ? '$' + bal.toFixed(0) : '$' + bal.toFixed(2)
|
||||
walletBalance.value = bal >= 100 ? formatBilling(bal, 0) : formatBilling(bal, 2)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
@@ -447,7 +448,7 @@ onBeforeUnmount(() => {
|
||||
title="钱包"
|
||||
>
|
||||
<Icon name="currencyDollar" size="sm" />
|
||||
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || '$0' }}</span>
|
||||
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || formatBilling(0, 2) }}</span>
|
||||
</router-link>
|
||||
<!-- Member Quota Capsule -->
|
||||
<router-link
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
|
||||
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
|
||||
@@ -110,12 +111,12 @@ function formatNumber(value: unknown): string {
|
||||
return number.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(value: unknown): string {
|
||||
const number = Number(value) || 0
|
||||
if (number === 0) return '$0.00'
|
||||
if (number >= 1) return `$${number.toFixed(2)}`
|
||||
if (number >= 0.01) return `$${number.toFixed(4)}`
|
||||
return `$${number.toFixed(6)}`
|
||||
if (number === 0 || number >= 1) return formatBilling(number, 2)
|
||||
if (number >= 0.01) return formatBilling(number, 4)
|
||||
return formatBilling(number, 6)
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
|
||||
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
|
||||
@@ -106,12 +107,12 @@ function formatNumber(value: unknown): string {
|
||||
return number.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(value: unknown): string {
|
||||
const number = Number(value) || 0
|
||||
if (number === 0) return '$0.00'
|
||||
if (number >= 1) return `$${number.toFixed(2)}`
|
||||
if (number >= 0.01) return `$${number.toFixed(4)}`
|
||||
return `$${number.toFixed(6)}`
|
||||
if (number === 0 || number >= 1) return formatBilling(number, 2)
|
||||
if (number >= 0.01) return formatBilling(number, 4)
|
||||
return formatBilling(number, 6)
|
||||
}
|
||||
|
||||
function formatMs(value: unknown): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -69,9 +70,10 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
title: '面值',
|
||||
key: 'value',
|
||||
width: 130,
|
||||
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
|
||||
render: (row) =>
|
||||
row.type === 'quota'
|
||||
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
|
||||
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
|
||||
: h('span', { class: 'font-mono' }, '-'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const transactions = ref<any[]>([])
|
||||
@@ -87,9 +88,9 @@ const txTypeBadgeClass: Record<string, string> = {
|
||||
unfreeze: 'badge-gray',
|
||||
}
|
||||
|
||||
// 流水金额:bil 层数据直显本位币符号,showSign 为正数补 +、负数自带 -
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 0) return '+$' + amount.toFixed(6)
|
||||
return '-$' + Math.abs(amount).toFixed(6)
|
||||
return formatBilling(amount, 6, true)
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
@@ -150,7 +151,7 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
title: '余额',
|
||||
key: 'balance_after',
|
||||
width: 120,
|
||||
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? `$${row.balance_after.toFixed(6)}` : '--'),
|
||||
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? formatBilling(row.balance_after, 6) : '--'),
|
||||
},
|
||||
{ title: '用户', key: 'username', width: 140, render: (row) => h('span', { class: 'text-gray-700 text-sm' }, row.username || '--') },
|
||||
{
|
||||
|
||||
@@ -8,14 +8,18 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { dispatchPayment } from '@/utils/payment'
|
||||
import { formatBilling, formatOrder, displayCurrency, displayToCny, cnyToDisplay } from '@/composables/useCurrency'
|
||||
|
||||
const route = useRoute()
|
||||
const wallet = ref<any>(null)
|
||||
const walletLoading = ref(true)
|
||||
|
||||
// Recharge
|
||||
// rechargeAmount 统一存"用户输入/选择的显示货币金额"(本位币);提交与折扣匹配前折回 CNY
|
||||
const rechargeAmount = ref<number | null>(null)
|
||||
const customAmount = ref<number | null>(null)
|
||||
// 当前选中的预设档位(CNY 原面额),自定义输入时清空,仅用于档位选中态展示
|
||||
const selectedPresetCny = ref<number | null>(null)
|
||||
const rechargeLoading = ref(false)
|
||||
const selectedChannel = ref('')
|
||||
const selectedPaymentMethod = ref('')
|
||||
@@ -75,6 +79,12 @@ const payMethods = computed(() => {
|
||||
|
||||
const minimumAmount = computed(() => Number(paymentInfo.value?.min_topup) || 1)
|
||||
|
||||
// 充值页显示口径换算:minimumAmount 是 CNY 门槛,输入框的最小值需换算为显示货币
|
||||
const displayMinAmount = computed(() => cnyToDisplay(minimumAmount.value))
|
||||
|
||||
// 提交口径:显示货币金额折回 CNY 订单金额(与 handleRecharge 提交值一致,折扣/门槛校验同口径)
|
||||
const rechargeCnyAmount = computed(() => (rechargeAmount.value != null ? displayToCny(rechargeAmount.value) : 0))
|
||||
|
||||
const thresholdActive = computed(() => Number(wallet.value?.warning_threshold) > 0)
|
||||
|
||||
const thresholdValidationError = computed(() => {
|
||||
@@ -87,21 +97,24 @@ const thresholdValidationError = computed(() => {
|
||||
const selectedDiscount = computed(() => {
|
||||
// 折扣档位仅整数金额精确命中(与后端 RechargeCreate 口径一致):
|
||||
// 100.99 不享受 100 档折扣,避免前端展示与实际计价不符
|
||||
if (!rechargeAmount.value || !Number.isInteger(rechargeAmount.value)) return 1
|
||||
return Number(paymentInfo.value?.amount_discount?.[rechargeAmount.value]) || 1
|
||||
// 折扣锚定 CNY 整数面额:预设档位(CNY 锚定)可命中,自定义输入折算后非整数不命中(预期行为)
|
||||
const cny = rechargeCnyAmount.value
|
||||
if (!cny || !Number.isInteger(cny)) return 1
|
||||
return Number(paymentInfo.value?.amount_discount?.[cny]) || 1
|
||||
})
|
||||
|
||||
// 实付金额 = 显示货币金额 × 折扣(显示口径,展示时直接用 formatBilling)
|
||||
const finalPayAmount = computed(() => (rechargeAmount.value || 0) * selectedDiscount.value)
|
||||
|
||||
const rechargeValidationMessage = computed(() => {
|
||||
if (!rechargeAmount.value) return ''
|
||||
if (rechargeAmount.value < minimumAmount.value) return `最低充值金额为 ¥${minimumAmount.value.toFixed(2)}`
|
||||
if (rechargeCnyAmount.value < minimumAmount.value) return `最低充值金额为 ${formatOrder(minimumAmount.value, 2)}`
|
||||
return ''
|
||||
})
|
||||
|
||||
const rechargeReady = computed(() =>
|
||||
!!rechargeAmount.value
|
||||
&& rechargeAmount.value >= minimumAmount.value
|
||||
&& rechargeCnyAmount.value >= minimumAmount.value
|
||||
&& !!selectedChannel.value
|
||||
&& !!selectedPaymentMethod.value,
|
||||
)
|
||||
@@ -135,11 +148,15 @@ async function fetchPaymentInfo() {
|
||||
}
|
||||
|
||||
function selectPresetAmount(amount: number) {
|
||||
rechargeAmount.value = amount
|
||||
// amount 为 CNY 原面额:选中态与折扣匹配锚定 CNY,rechargeAmount 存换算后的显示货币金额
|
||||
selectedPresetCny.value = amount
|
||||
rechargeAmount.value = cnyToDisplay(amount)
|
||||
customAmount.value = null
|
||||
}
|
||||
|
||||
function onCustomInput() {
|
||||
// 自定义输入按显示货币理解(本位币 USD 时即 USD 金额),提交时统一折回 CNY
|
||||
selectedPresetCny.value = null
|
||||
const val = customAmount.value ?? 0
|
||||
rechargeAmount.value = val <= 0 ? null : val
|
||||
}
|
||||
@@ -160,7 +177,8 @@ async function handleRecharge() {
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/tenant/recharge/create', {
|
||||
amount: rechargeAmount.value,
|
||||
// 订单层恒 CNY:用户输入的显示货币金额折算为 CNY 下单金额(本位币 CNY 时原样)
|
||||
amount: displayToCny(rechargeAmount.value),
|
||||
payment_channel: selectedChannel.value,
|
||||
payment_method: selectedPaymentMethod.value,
|
||||
})
|
||||
@@ -263,9 +281,10 @@ const redeemHistoryColumns = computed<DataTableColumns<any>>(() => [
|
||||
title: '面值',
|
||||
key: 'value',
|
||||
width: 130,
|
||||
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
|
||||
render: (row) =>
|
||||
row.type === 'quota'
|
||||
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
|
||||
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
|
||||
: h('span', { class: 'font-mono' }, '-'),
|
||||
},
|
||||
{
|
||||
@@ -414,14 +433,13 @@ onBeforeUnmount(() => {
|
||||
<p class="mt-0.5 text-xs text-slate-400">当前可用于模型调用</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="currency-badge">{{ wallet?.currency || 'USD' }}</span>
|
||||
<span class="currency-badge">{{ displayCurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="text-xs font-medium text-slate-400">余额</p>
|
||||
<div class="mt-1 flex items-baseline gap-1.5">
|
||||
<span class="text-xl font-semibold text-slate-400">$</span>
|
||||
<strong class="balance-value">{{ wallet?.balance?.toFixed(2) ?? '0.00' }}</strong>
|
||||
<strong class="balance-value">{{ formatBilling(wallet?.balance, 2) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -431,7 +449,7 @@ onBeforeUnmount(() => {
|
||||
<span class="text-sm text-slate-500">冻结金额</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<strong class="text-sm font-semibold tabular-nums text-slate-700">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</strong>
|
||||
<strong class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.frozen_balance, 2) }}</strong>
|
||||
<Icon name="chevronRight" size="xs" class="text-slate-300" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -442,7 +460,7 @@ onBeforeUnmount(() => {
|
||||
<span class="text-sm text-slate-500">余额预警</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">${{ Number(wallet?.warning_threshold).toFixed(2) }}</strong>
|
||||
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.warning_threshold, 2) }}</strong>
|
||||
<span v-else class="text-sm text-slate-400">已关闭</span>
|
||||
<Icon name="chevronRight" size="xs" class="text-slate-300" />
|
||||
</div>
|
||||
@@ -484,25 +502,26 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-semibold text-slate-600">充值金额</label>
|
||||
<span class="text-[11px] text-slate-400">最低 ¥{{ minimumAmount.toFixed(2) }}</span>
|
||||
<span class="text-[11px] text-slate-400">最低 {{ formatOrder(minimumAmount, 2) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="amount in presetAmounts"
|
||||
:key="amount"
|
||||
class="amount-pill"
|
||||
:class="{ 'amount-pill-active': rechargeAmount === amount && !customAmount }"
|
||||
:class="{ 'amount-pill-active': selectedPresetCny === amount && !customAmount }"
|
||||
@click="selectPresetAmount(amount)"
|
||||
>
|
||||
<span><small>¥</small>{{ amount }}</span>
|
||||
<!-- amount 为 CNY 原面额:本位币 USD 时按汇率折算展示 -->
|
||||
<span><small>{{ displayCurrency === 'CNY' ? '¥' : '$' }}</small>{{ cnyToDisplay(amount) }}</span>
|
||||
<em v-if="discountText(amount)">{{ discountText(amount) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative mt-3">
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">¥</span>
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
|
||||
<n-input-number
|
||||
v-model:value="customAmount"
|
||||
:min="minimumAmount"
|
||||
:min="displayMinAmount"
|
||||
:step="0.01"
|
||||
:status="rechargeValidationMessage ? 'error' : undefined"
|
||||
placeholder="输入其他金额"
|
||||
@@ -545,10 +564,10 @@ onBeforeUnmount(() => {
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] text-slate-400">本次实付</p>
|
||||
<div class="mt-0.5 flex items-baseline gap-2">
|
||||
<strong class="text-xl font-bold tabular-nums text-slate-800">¥{{ finalPayAmount.toFixed(2) }}</strong>
|
||||
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">¥{{ rechargeAmount?.toFixed(2) }}</span>
|
||||
<strong class="text-xl font-bold tabular-nums text-slate-800">{{ formatBilling(finalPayAmount, 2) }}</strong>
|
||||
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">{{ formatBilling(rechargeAmount, 2) }}</span>
|
||||
</div>
|
||||
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 ¥{{ rechargeAmount?.toFixed(2) }} 全额折算</p>
|
||||
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 {{ formatBilling(rechargeAmount, 2) }} 全额折算</p>
|
||||
</div>
|
||||
<button class="btn btn-primary min-w-32" :disabled="!rechargeReady || rechargeLoading" @click="handleRecharge">
|
||||
<Icon v-if="rechargeLoading" name="refresh" size="sm" class="animate-spin" />
|
||||
@@ -559,7 +578,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="recharge-note mt-3">
|
||||
<Icon name="infoCircle" size="xs" class="mt-0.5 flex-shrink-0" />
|
||||
<p>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
|
||||
<p v-if="displayCurrency === 'CNY'">支付金额为人民币,直接入账钱包。</p>
|
||||
<p v-else>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -598,7 +618,7 @@ onBeforeUnmount(() => {
|
||||
</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-sm font-semibold text-amber-600">{{ formatBilling(item.amount, 4) }}</p>
|
||||
<p class="text-xs text-gray-400">剩余 {{ formatRemaining(item.remaining) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -718,9 +738,9 @@ onBeforeUnmount(() => {
|
||||
<p>当可用余额低于预警线时,向组织 owner / admin 发送通知。设为 0 可关闭预警。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate-600">预警阈值(USD)</label>
|
||||
<label class="text-xs font-semibold text-slate-600">预警阈值({{ displayCurrency }})</label>
|
||||
<div class="relative mt-3">
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">$</span>
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
|
||||
<n-input-number
|
||||
v-model:value="thresholdInput"
|
||||
:min="0"
|
||||
@@ -735,10 +755,11 @@ onBeforeUnmount(() => {
|
||||
<p v-else class="mt-1.5 text-xs text-slate-400">设为 0 表示关闭预警</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- 快捷按钮展示跟随本位币,thresholdInput 表单值保持存储币种原值不变 -->
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 0 }" @click="thresholdInput = 0">关闭</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">$1</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">$5</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">$10</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">{{ formatBilling(1) }}</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">{{ formatBilling(5) }}</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">{{ formatBilling(10) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import type { MarketplaceModel } from '@/api/marketplace'
|
||||
import { formatPrice, formatTokens, getCategoryMeta, getCapabilityList } from '../marketplaceMeta'
|
||||
|
||||
const props = defineProps<{
|
||||
model: MarketplaceModel
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [model: MarketplaceModel]
|
||||
copy: [modelId: string]
|
||||
}>()
|
||||
|
||||
const meta = computed(() => getCategoryMeta(props.model.category))
|
||||
const capabilities = computed(() => getCapabilityList(props.model, 4))
|
||||
|
||||
// 价格文案按计费模式派生,避免模板里堆叠条件
|
||||
const priceView = computed(() => {
|
||||
const model = props.model
|
||||
if (model.billing_mode === 'per_request') {
|
||||
return { caption: '按次计费', main: formatPrice(model.per_request_price), secondary: '', unit: '/ 次' }
|
||||
}
|
||||
return {
|
||||
caption: model.billing_mode === 'tiered' ? '输入 / 输出(首档起)' : '输入 / 输出',
|
||||
main: formatPrice(model.input_price),
|
||||
secondary: formatPrice(model.output_price),
|
||||
unit: '/ 1M tokens',
|
||||
}
|
||||
})
|
||||
|
||||
// 类别渐变注入 CSS 变量;入场延迟按序号递增形成 stagger(封顶 11 张)
|
||||
const cardStyle = computed(() => ({
|
||||
'--cat-from': meta.value.from,
|
||||
'--cat-to': meta.value.to,
|
||||
'--cat-glow': meta.value.glow,
|
||||
animationDelay: `${Math.min(props.index, 11) * 45}ms`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="card-in group relative flex cursor-pointer flex-col overflow-hidden rounded-[20px] border border-white/80 bg-white/85 shadow-card backdrop-blur-xl transition-all duration-300 hover:-translate-y-1.5 hover:border-primary-200/70 hover:shadow-card-hover"
|
||||
:style="cardStyle"
|
||||
@click="emit('open', model)"
|
||||
>
|
||||
<!-- 顶部类别渐变线:hover 时从左展开 -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 top-0 z-10 h-[3px] origin-left scale-x-0 bg-gradient-to-r from-[var(--cat-from)] to-[var(--cat-to)] transition-transform duration-300 group-hover:scale-x-100"
|
||||
></span>
|
||||
<!-- 右上角类别光晕:hover 淡入 -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-25"
|
||||
style="background: var(--cat-to)"
|
||||
></span>
|
||||
|
||||
<div class="relative flex-1 pb-4">
|
||||
<!-- 头部:类别磁贴 + 名称 / 模型 ID -->
|
||||
<div class="flex items-start gap-4 p-5 pb-0">
|
||||
<div
|
||||
class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white transition-transform duration-300 group-hover:-rotate-3 group-hover:scale-105"
|
||||
:style="{ background: 'linear-gradient(135deg, var(--cat-from), var(--cat-to))', boxShadow: '0 8px 24px rgba(var(--cat-glow), 0.35)' }"
|
||||
>
|
||||
<Icon :name="meta.icon" size="lg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<h3 class="line-clamp-1 text-base font-semibold text-gray-900">{{ model.model_name || model.model_id }}</h3>
|
||||
<span v-if="model.discount_label" class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
|
||||
{{ model.discount_label }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`复制模型 ID ${model.model_id}`"
|
||||
title="复制模型 ID"
|
||||
class="group/copy mt-1 flex max-w-full items-center gap-1.5 text-left"
|
||||
@click.stop="emit('copy', model.model_id)"
|
||||
>
|
||||
<code class="truncate font-mono text-xs text-gray-400 transition-colors group-hover/copy:text-primary-600">{{ model.model_id }}</code>
|
||||
<Icon name="copy" size="xs" class="shrink-0 text-gray-300 transition-colors group-hover/copy:text-primary-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<p class="line-clamp-2 min-h-[2.5rem] px-5 pt-3 text-sm leading-relaxed text-gray-500">
|
||||
{{ model.description || '暂无模型描述,点击查看技术规格与计费信息。' }}
|
||||
</p>
|
||||
|
||||
<!-- 规格:上下文 / 最大输出 -->
|
||||
<div class="flex gap-2 px-5 pt-3">
|
||||
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
|
||||
<span class="text-[11px] text-gray-400">上下文</span>
|
||||
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_context_tokens) }}</strong>
|
||||
</div>
|
||||
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
|
||||
<span class="text-[11px] text-gray-400">最大输出</span>
|
||||
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_output_tokens) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 能力芯片:最多 3 个 -->
|
||||
<div v-if="capabilities.length" class="flex flex-wrap gap-1.5 px-5 pt-3">
|
||||
<span
|
||||
v-for="item in capabilities"
|
||||
:key="item"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-700"
|
||||
>
|
||||
<Icon name="check" size="xs" />{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底栏:价格 + 详情入口 -->
|
||||
<div class="flex items-end justify-between gap-3 border-t border-gray-100/80 px-5 pb-4 pt-4">
|
||||
<div class="min-w-0">
|
||||
<span class="text-[11px] text-gray-400">{{ priceView.caption }}</span>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<strong class="text-lg font-bold tabular-nums text-gray-900">{{ priceView.main }}</strong>
|
||||
<strong v-if="priceView.secondary" class="text-sm font-semibold tabular-nums text-gray-600">{{ priceView.secondary }}</strong>
|
||||
<span class="whitespace-nowrap text-[11px] text-gray-400">{{ priceView.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-primary-600 transition-all duration-200 hover:bg-primary-50 active:scale-[0.98] group-hover:bg-primary-500 group-hover:text-white group-hover:shadow-glow"
|
||||
@click.stop="emit('open', model)"
|
||||
>
|
||||
详情
|
||||
<Icon name="arrowRight" size="sm" class="transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片入场:backwards 填充保证延迟期间隐藏、结束后不覆盖 hover 位移 */
|
||||
.card-in {
|
||||
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { NDrawer, NDrawerContent } from 'naive-ui'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import type { MarketplaceModel } from '@/api/marketplace'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { displayCurrency } from '@/composables/useCurrency'
|
||||
import {
|
||||
billingModeLabel,
|
||||
formatPrice,
|
||||
formatTokens,
|
||||
getCapabilityList,
|
||||
getCategoryMeta,
|
||||
timePriceText,
|
||||
timeWindow,
|
||||
} from '../marketplaceMeta'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
model: MarketplaceModel | null
|
||||
loading: boolean
|
||||
isLoggedIn: boolean
|
||||
primaryActionRoute: RouteLocationRaw
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
copy: [modelId: string]
|
||||
}>()
|
||||
|
||||
const { isMobile } = useIsMobile()
|
||||
|
||||
const meta = computed(() => getCategoryMeta(props.model?.category))
|
||||
|
||||
// 计费瓦片:按计费模式决定展示哪些价格,有值的缓存价附加在后面
|
||||
// 价格走 formatPrice(本位币),单位文案跟随 displayCurrency(computed 内读取,配置变化自动重渲染)
|
||||
const priceTiles = computed(() => {
|
||||
const model = props.model
|
||||
if (!model) return []
|
||||
if (model.billing_mode === 'per_request') {
|
||||
return [{ label: '单次调用', value: formatPrice(model.per_request_price), unit: `${displayCurrency.value} / 次`, primary: true, wide: true }]
|
||||
}
|
||||
const tiles = [
|
||||
{ label: '输入', value: formatPrice(model.input_price), unit: `${displayCurrency.value} / 1M tokens`, primary: true, wide: false },
|
||||
{ label: '输出', value: formatPrice(model.output_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false },
|
||||
]
|
||||
// 后端未设置缓存价时返回 0 而非 null,0 价瓦片属于展示噪音,直接隐藏
|
||||
if (model.cache_read_price) {
|
||||
tiles.push({ label: '缓存读取', value: formatPrice(model.cache_read_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false })
|
||||
}
|
||||
if (model.cache_creation_price) {
|
||||
tiles.push({ label: '缓存创建', value: formatPrice(model.cache_creation_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false })
|
||||
}
|
||||
return tiles
|
||||
})
|
||||
|
||||
// 抽屉内展示全部能力,不做数量截断
|
||||
const capabilities = computed(() => getCapabilityList(props.model ?? ({} as MarketplaceModel), Number.MAX_SAFE_INTEGER))
|
||||
|
||||
const tags = computed(() => props.model?.tags || [])
|
||||
|
||||
const tileStyle = computed(() => ({
|
||||
background: `linear-gradient(135deg, ${meta.value.from}, ${meta.value.to})`,
|
||||
boxShadow: `0 8px 24px rgba(${meta.value.glow}, 0.35)`,
|
||||
}))
|
||||
|
||||
const categoryBadgeStyle = computed(() => ({
|
||||
color: meta.value.to,
|
||||
backgroundColor: `rgba(${meta.value.glow}, 0.1)`,
|
||||
}))
|
||||
|
||||
const drawerWidth = computed(() => (isMobile.value ? '100%' : 560))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="show"
|
||||
:width="drawerWidth"
|
||||
placement="right"
|
||||
:auto-focus="false"
|
||||
@update:show="(value: boolean) => emit('update:show', value)"
|
||||
>
|
||||
<n-drawer-content v-if="model" closable :body-content-style="{ padding: 0 }">
|
||||
<template #header>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white" :style="tileStyle">
|
||||
<Icon :name="meta.icon" size="lg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="truncate text-lg font-bold text-gray-900">{{ model.model_name || model.model_id }}</h2>
|
||||
<span v-if="model.discount_label" class="badge badge-warning shrink-0">{{ model.discount_label }}</span>
|
||||
</div>
|
||||
<button type="button" class="group mt-0.5 flex max-w-full items-center gap-1.5" title="复制模型 ID" @click="emit('copy', model.model_id)">
|
||||
<code class="truncate font-mono text-xs text-gray-500">{{ model.model_id }}</code>
|
||||
<Icon name="copy" size="xs" class="shrink-0 text-gray-400 transition-colors group-hover:text-primary-600" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium" :style="categoryBadgeStyle">{{ meta.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="animate-fade-in space-y-6 px-5 py-5 sm:px-6">
|
||||
<!-- 详情接口加载中:列表数据先行展示,仅提示补充信息加载 -->
|
||||
<p v-if="loading" class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span class="spinner border-primary-500" />正在加载完整模型信息...
|
||||
</p>
|
||||
|
||||
<!-- 模型简介 -->
|
||||
<section>
|
||||
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<Icon name="document" size="sm" class="text-primary-500" />模型简介
|
||||
</h3>
|
||||
<p class="text-sm leading-relaxed text-gray-600">{{ model.description || '暂无模型描述。' }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 计费信息 -->
|
||||
<section>
|
||||
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<Icon name="creditCard" size="sm" class="text-primary-500" />计费信息
|
||||
<span v-if="model.billing_mode === 'tiered'" class="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
阶梯计费 · 以下为首档起价
|
||||
</span>
|
||||
</h3>
|
||||
<div class="rounded-2xl border border-primary-100/80 bg-gradient-to-br from-primary-50/70 via-white to-white p-4 shadow-glass-sm">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="tile in priceTiles"
|
||||
:key="tile.label"
|
||||
class="rounded-xl bg-white/85 p-3 shadow-sm ring-1 ring-gray-100"
|
||||
:class="tile.wide ? 'col-span-2' : ''"
|
||||
>
|
||||
<span class="text-xs text-gray-400">{{ tile.label }}</span>
|
||||
<div class="mt-0.5 text-xl font-bold tabular-nums" :class="tile.primary ? 'text-primary-700' : 'text-gray-900'">
|
||||
{{ tile.value }}
|
||||
</div>
|
||||
<span class="text-[11px] text-gray-400">{{ tile.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分时段价目 -->
|
||||
<div v-if="model.time_prices?.length" class="mt-4 border-t border-primary-100/70 pt-3">
|
||||
<p class="mb-1 text-xs font-medium text-gray-500">
|
||||
分时段价格
|
||||
<small class="ml-1 font-normal text-gray-400">命中时段按下方价格计费,未命中按默认价</small>
|
||||
</p>
|
||||
<div v-for="(tp, index) in model.time_prices" :key="index" class="flex items-center justify-between gap-3 py-1.5">
|
||||
<span class="min-w-0 text-xs text-gray-600">
|
||||
{{ tp.name }}
|
||||
<small class="block text-[11px] text-gray-400">{{ timeWindow(tp) }}</small>
|
||||
</span>
|
||||
<strong class="shrink-0 text-right text-xs font-semibold tabular-nums text-gray-800">{{ timePriceText(model, tp) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="model.price_change_note" class="mt-3 flex items-start gap-1.5 text-xs text-amber-700">
|
||||
<Icon name="infoCircle" size="xs" class="mt-0.5 shrink-0" />{{ model.price_change_note }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 技术规格 -->
|
||||
<section>
|
||||
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<Icon name="cog" size="sm" class="text-primary-500" />技术规格
|
||||
</h3>
|
||||
<div class="divide-y divide-gray-100 rounded-2xl border border-gray-100 bg-white/70">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="document" size="sm" class="text-gray-400" />最大上下文</span>
|
||||
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_context_tokens) }} tokens</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="expand" size="sm" class="text-gray-400" />最大输出</span>
|
||||
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_output_tokens) }} tokens</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="grid" size="sm" class="text-gray-400" />模型类别</span>
|
||||
<strong class="text-sm font-semibold text-gray-900">{{ meta.label }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="creditCard" size="sm" class="text-gray-400" />计费模式</span>
|
||||
<strong class="text-sm font-semibold text-gray-900">{{ billingModeLabel(model.billing_mode) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 能力支持 -->
|
||||
<section>
|
||||
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<Icon name="checkCircle" size="sm" class="text-primary-500" />能力支持
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="item in capabilities"
|
||||
:key="item"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700"
|
||||
>
|
||||
<Icon name="checkCircle" size="xs" />{{ item }}
|
||||
</span>
|
||||
<span v-if="capabilities.length === 0" class="text-xs text-gray-400">标准 API 接入</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 模型标签 -->
|
||||
<section v-if="tags.length">
|
||||
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<Icon name="link" size="sm" class="text-primary-500" />模型标签
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="tag in tags" :key="tag" class="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-600">{{ tag }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-gray-400">实际可用范围以账户套餐与 API 密钥权限为准</p>
|
||||
<router-link :to="primaryActionRoute" class="btn btn-primary btn-sm shrink-0" @click="emit('update:show', false)">
|
||||
{{ isLoggedIn ? '前往使用' : '免费开始' }}
|
||||
<Icon name="arrowRight" size="sm" />
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { MarketplaceModel, TimePriceItem } from '@/api/marketplace'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// 分类元数据:卡片磁贴渐变、筛选 pills 与各类徽章共用同一份配置
|
||||
export interface CategoryMeta {
|
||||
key: string
|
||||
// null 表示「全部」,请求时不传 category
|
||||
value: string | null
|
||||
// 完整名称:卡片类别徽章 / 抽屉 / 规格行
|
||||
label: string
|
||||
// 两个字:筛选 pills 用
|
||||
shortLabel: string
|
||||
// hover 提示
|
||||
description: string
|
||||
// Icon.vue 内置图标名
|
||||
icon: string
|
||||
// 渐变起止色
|
||||
from: string
|
||||
to: string
|
||||
// 发光阴影 RGB("r, g, b"),供 rgba() 拼接 box-shadow
|
||||
glow: string
|
||||
}
|
||||
|
||||
// 色值与全局 .icon-tile-* 系列保持一致,chat 占据主色 teal
|
||||
export const categoryMetaList: CategoryMeta[] = [
|
||||
{ key: 'all', value: null, label: '全部模型', shortLabel: '全部', description: '浏览完整模型目录', icon: 'grid', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' },
|
||||
{ key: 'chat', value: 'chat', label: '对话与推理', shortLabel: '对话', description: '文本生成、推理与工具调用', icon: 'chat', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' },
|
||||
{ key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' },
|
||||
{ key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' },
|
||||
{ key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' },
|
||||
{ key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' },
|
||||
]
|
||||
|
||||
// 未知分类的渲染兜底:仅用于展示,绝不进入筛选 pills
|
||||
export const fallbackCategoryMeta: CategoryMeta = {
|
||||
key: 'other', value: null, label: '其他模型', shortLabel: '其他', description: '其他模型能力', icon: 'bolt', from: '#d1d5db', to: '#9ca3af', glow: '156, 163, 175',
|
||||
}
|
||||
|
||||
export function getCategoryMeta(category?: string | null): CategoryMeta {
|
||||
if (!category) return categoryMetaList[0]
|
||||
return categoryMetaList.find(item => item.value === category) || fallbackCategoryMeta
|
||||
}
|
||||
|
||||
// 能力中文标签:合并控制台模型页的映射,缺失的 key 回退为可读文本
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
streaming: '流式输出',
|
||||
function_calling: '工具调用',
|
||||
tool_calling: '工具调用',
|
||||
parallel_function_calling: '并行工具调用',
|
||||
tool_choice: '工具选择',
|
||||
vision: '视觉理解',
|
||||
multimodal: '多模态',
|
||||
json_mode: 'JSON 模式',
|
||||
response_schema: '结构化输出',
|
||||
system_messages: '系统消息',
|
||||
reasoning: '深度思考',
|
||||
web_search: '联网搜索',
|
||||
image_generation: '图像生成',
|
||||
prompt_caching: '提示词缓存',
|
||||
audio: '音频能力',
|
||||
audio_input: '音频输入',
|
||||
audio_output: '音频输出',
|
||||
pdf_input: 'PDF 输入',
|
||||
embedding_image: '图像嵌入',
|
||||
}
|
||||
|
||||
// capabilities 是 { key: boolean } 形态,只取为真的项并转成中文标签
|
||||
export function getCapabilityList(model: MarketplaceModel, limit = 4): string[] {
|
||||
if (!model.capabilities || typeof model.capabilities !== 'object') return []
|
||||
return Object.entries(model.capabilities)
|
||||
.filter(([, value]) => value === true || value === 1 || value === 'true')
|
||||
.map(([key]) => capabilityLabels[key] || key.replaceAll('_', ' '))
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
export function formatTokens(tokens?: number | null): string {
|
||||
const value = Number(tokens || 0)
|
||||
if (!value) return '—'
|
||||
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||
if (value >= 1_000) return `${Number((value / 1_000).toFixed(1))}K`
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatPrice(price?: number | null): string {
|
||||
const value = Number(price)
|
||||
if (!Number.isFinite(value)) return '—'
|
||||
// 本位币直显:最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0
|
||||
return formatBilling(value, 6)
|
||||
}
|
||||
|
||||
export function billingModeLabel(mode?: string | null): string {
|
||||
if (mode === 'per_request') return '按次计费'
|
||||
if (mode === 'tiered') return '阶梯计费'
|
||||
return '按量计费'
|
||||
}
|
||||
|
||||
// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ──
|
||||
|
||||
function daysLabel(days?: number[] | null): string {
|
||||
if (!days || days.length === 0) return '每天'
|
||||
const sorted = [...days].sort((a, b) => a - b)
|
||||
if (sorted.join(',') === '1,2,3,4,5') return '工作日'
|
||||
if (sorted.join(',') === '6,7') return '周末'
|
||||
const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
return sorted.map((d) => names[d] || String(d)).join('、')
|
||||
}
|
||||
|
||||
export function timeWindow(tp: TimePriceItem): string {
|
||||
const parts: string[] = [daysLabel(tp.days)]
|
||||
if (tp.start_time && tp.end_time) parts.push(`${tp.start_time}~${tp.end_time}`)
|
||||
else parts.push('全天')
|
||||
if (tp.valid_from) parts.push(tp.valid_to ? `${tp.valid_from}~${tp.valid_to}` : `${tp.valid_from} 起`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export function timePriceText(model: MarketplaceModel, tp: TimePriceItem): string {
|
||||
if (model.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price)} /次`
|
||||
return `输入 ${formatPrice(tp.input_price)} · 输出 ${formatPrice(tp.output_price)}${model.billing_mode === 'tiered' ? ' 起' : ''}`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -143,10 +144,11 @@ function formatLimit(value: number | null | undefined, suffix = ''): string {
|
||||
return `${value}${suffix}`
|
||||
}
|
||||
|
||||
// Key 额度展示(bil 层,本位币直显)
|
||||
function formatQuota(key: ApiKey): string {
|
||||
const used = key.used_quota || 0
|
||||
if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限`
|
||||
return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}`
|
||||
if (!key.total_quota || key.total_quota <= 0) return `${formatBilling(used, 2)} / 不限`
|
||||
return `${formatBilling(used, 2)} / ${formatBilling(key.total_quota, 2)}`
|
||||
}
|
||||
|
||||
async function disableKey(keyId: number) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, onMounted, computed, h } from 'vue'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
|
||||
@@ -87,9 +88,10 @@ function isBest(item: ComparisonItem, field: keyof ComparisonItem): boolean {
|
||||
return val === Math.min(...values)
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(bil 层数据直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染)
|
||||
function formatCost(v: number): string {
|
||||
if (v < 0.01 && v > 0) return v.toFixed(6)
|
||||
return v.toFixed(4)
|
||||
if (v < 0.01 && v > 0) return formatBilling(v, 6)
|
||||
return formatBilling(v, 4)
|
||||
}
|
||||
|
||||
function formatPct(v: number): string {
|
||||
@@ -191,7 +193,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
h(
|
||||
'span',
|
||||
{ class: isBest(row, 'total_cost') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' },
|
||||
row.requests > 0 ? '$' + formatCost(row.total_cost) : '-'
|
||||
row.requests > 0 ? formatCost(row.total_cost) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -202,7 +204,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
h(
|
||||
'span',
|
||||
{ class: isBest(row, 'avg_cost_per_request') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' },
|
||||
row.requests > 0 ? '$' + formatCost(row.avg_cost_per_request) : '-'
|
||||
row.requests > 0 ? formatCost(row.avg_cost_per_request) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -305,7 +307,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
<Icon name="creditCard" size="lg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-right">
|
||||
<div class="stat-value">${{ formatCost(summary.total_cost) }}</div>
|
||||
<div class="stat-value">{{ formatCost(summary.total_cost) }}</div>
|
||||
<div class="stat-label">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +364,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
height: Math.max(2, (getTrendCost(day, model) / maxTrendCost) * 120) + 'px',
|
||||
backgroundColor: modelColor(mi),
|
||||
}"
|
||||
:title="`${model}: $${getTrendCost(day, model).toFixed(4)}`"
|
||||
:title="`${model}: ${formatBilling(getTrendCost(day, model), 4)}`"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NInput } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -170,9 +171,10 @@ function formatTokens(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
// 价格格式化统一走本位币(bil 层定价直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染)
|
||||
function formatPrice(n: number | null): string {
|
||||
if (n == null || n === 0) return '-'
|
||||
return '$' + n.toFixed(2)
|
||||
return formatBilling(n, 2)
|
||||
}
|
||||
|
||||
function formatTokenRange(min: number, max: number | null): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import BaseSelect from '../../components/common/BaseSelect.vue'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import DateTimeRangePicker from '@/components/common/DateTimeRangePicker.vue'
|
||||
|
||||
// 日期辅助(native,避免引入 dayjs 依赖)
|
||||
@@ -110,9 +111,9 @@ const platformLabel: Record<string, string> = {
|
||||
ali: '阿里',
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: number | undefined): string {
|
||||
if (!n) return '$0.000000'
|
||||
return '$' + n.toFixed(6)
|
||||
return formatBilling(n, 6)
|
||||
}
|
||||
|
||||
function formatTime(s: string | undefined): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss
|
||||
function tsToStr(ts: number): string {
|
||||
@@ -186,10 +187,11 @@ function openDetail(log: any) {
|
||||
detailModal.value = true
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: any): string {
|
||||
const v = Number(n)
|
||||
if (n == null || isNaN(v)) return '$0.000000'
|
||||
return '$' + v.toFixed(6)
|
||||
if (n == null || isNaN(v)) return formatBilling(0, 6)
|
||||
return formatBilling(v, 6)
|
||||
}
|
||||
|
||||
function formatMs(n: any): string {
|
||||
|
||||
@@ -11,9 +11,11 @@ export interface TokenUsage {
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
/**
|
||||
* calculateCost 根据模型定价和 token usage 估算费用
|
||||
* @returns 格式化的费用字符串,如 "$0.001234";无法计算时返回 null
|
||||
* @returns 格式化的费用字符串(跟随本位币),如 "$0.001234";无法计算时返回 null
|
||||
*/
|
||||
export function calculateCost(
|
||||
model: ModelPricing | undefined,
|
||||
@@ -27,7 +29,7 @@ export function calculateCost(
|
||||
// 按次计费
|
||||
if (model.billing_mode === 'per_request' && model.per_request_price) {
|
||||
const price = Number(model.per_request_price)
|
||||
if (price > 0) return `$${price.toFixed(6)}`
|
||||
if (price > 0) return formatBilling(price, 6)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -40,5 +42,5 @@ export function calculateCost(
|
||||
const cost = (promptTokens * inputPrice + completionTokens * outputPrice) / 1_000_000
|
||||
if (cost <= 0) return null
|
||||
|
||||
return `$${cost.toFixed(6)}`
|
||||
return formatBilling(cost, 6)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const authStore = useTenantAuthStore()
|
||||
|
||||
@@ -210,9 +211,10 @@ function formatDiscount(multiplier: number) {
|
||||
return `${discount === 0 ? '无折扣' : discount + '% 折扣'}`
|
||||
}
|
||||
|
||||
// 升级门槛属 bil 层(本位币),直显 + 本位币符号
|
||||
function formatThreshold(val: number) {
|
||||
if (val === 0) return '—'
|
||||
return '$' + val.toFixed(2)
|
||||
return formatBilling(val, 2)
|
||||
}
|
||||
|
||||
function getNextLevelThreshold() {
|
||||
@@ -468,7 +470,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Icon name="currencyDollar" size="sm" class="text-gray-400" />
|
||||
<span>累计充值 <strong class="text-gray-900">${{ levelBenefits.cumulative_recharge.toFixed(2) }}</strong></span>
|
||||
<span>累计充值 <strong class="text-gray-900">{{ formatBilling(levelBenefits.cumulative_recharge, 2) }}</strong></span>
|
||||
</div>
|
||||
<div v-if="getNextLevelThreshold() !== null" class="flex-1 w-full sm:w-auto">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
@@ -482,7 +484,7 @@ onMounted(() => {
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
再充值 ${{ (getNextLevelThreshold()! - levelBenefits.cumulative_recharge).toFixed(2) }} 即可升级
|
||||
再充值 {{ formatBilling(getNextLevelThreshold()! - levelBenefits.cumulative_recharge, 2) }} 即可升级
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-xs text-primary-600 font-medium">
|
||||
|
||||
@@ -9,6 +9,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import BaseSelect from '../../components/common/BaseSelect.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -163,8 +164,8 @@ const statCards = computed(() => {
|
||||
icon: 'creditCard',
|
||||
color: '#8b5cf6',
|
||||
soft: '#ede9fe',
|
||||
value: u ? `$${u.month_total_cost.toFixed(2)}` : '--',
|
||||
detail: u ? 'USD' : '',
|
||||
value: u ? formatBilling(u.month_total_cost, 2) : '--',
|
||||
detail: u ? displayCurrency.value : '',
|
||||
},
|
||||
{
|
||||
label: 'API Key 数量',
|
||||
@@ -606,7 +607,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-sm text-gray-500">本月消费</span>
|
||||
<span class="text-sm font-semibold text-primary-600">${{ usage.month_total_cost.toFixed(4) }}</span>
|
||||
<span class="text-sm font-semibold text-primary-600">{{ formatBilling(usage.month_total_cost, 4) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-sm text-gray-500">API Key 数量</span>
|
||||
@@ -648,7 +649,7 @@ onMounted(() => {
|
||||
<template v-if="quota.quota_type === 'periodic'"> · {{ periodLabel[quota.period] }}</template>
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
${{ quota.quota_used.toFixed(6) }} / ${{ quota.quota_limit.toFixed(6) }}
|
||||
{{ formatBilling(quota.quota_used, 6) }} / {{ formatBilling(quota.quota_limit, 6) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
@@ -813,7 +814,7 @@ onMounted(() => {
|
||||
|
||||
<!-- Quota Limit -->
|
||||
<div v-if="quotaForm.quota_type !== 'none'">
|
||||
<label class="input-label">额度上限 (USD)</label>
|
||||
<label class="input-label">额度上限 ({{ displayCurrency }})</label>
|
||||
<n-input
|
||||
:value="String(quotaForm.quota_limit)"
|
||||
type="number"
|
||||
|
||||
@@ -58,7 +58,7 @@ interface Member {
|
||||
// 可用模型数
|
||||
model_count: number
|
||||
model_unlimited: boolean
|
||||
// 本月消费(USD)
|
||||
// 本月消费(本位币)
|
||||
month_cost: number
|
||||
// 最后更新时间
|
||||
updated_at: string
|
||||
|
||||
@@ -12,6 +12,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -284,10 +285,11 @@ async function copyKey(keyId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Key 额度展示(bil 层,本位币直显)
|
||||
function formatKeyQuota(key: ApiKey): string {
|
||||
const used = key.used_quota || 0
|
||||
if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限`
|
||||
return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}`
|
||||
if (!key.total_quota || key.total_quota <= 0) return `${formatBilling(used, 2)} / 不限`
|
||||
return `${formatBilling(used, 2)} / ${formatBilling(key.total_quota, 2)}`
|
||||
}
|
||||
|
||||
async function deleteKey(keyId: number) {
|
||||
@@ -329,9 +331,9 @@ async function fetchUsageLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: number): string {
|
||||
if (!n) return '$0.00'
|
||||
return '$' + n.toFixed(4)
|
||||
return formatBilling(n, 4)
|
||||
}
|
||||
|
||||
function switchTab(tab: 'overview' | 'keys' | 'usage') {
|
||||
|
||||
@@ -9,6 +9,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -127,7 +128,7 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
key: 'budget',
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h('span', { class: 'font-mono' }, row.budget ? `$${Number(row.budget).toFixed(2)}` : '不限'),
|
||||
h('span', { class: 'font-mono' }, row.budget ? formatBilling(row.budget, 2) : '不限'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
|
||||
Reference in new issue
Block a user