feat(billing):新增本位币设置,初始化时可选择全局货币单位,一旦设置就不能修改

This commit is contained in:
qian committed 2026-08-29 10:22:47 +08:00
1 parent 3bf1b4ca75
commit cd019cf817
70 files changed
+788 -673

No files matched your search

+20 -16
View File
@@ -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`)
+9
View File
@@ -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: "用户协议",
+17
View File
@@ -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)
}
+26 -13
View File
@@ -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,
+1 -1
View File
@@ -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")
+23
View File
@@ -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 {
+18 -129
View File
@@ -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
}
+1 -1
View File
@@ -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× 输入价)
+4 -4
View File
@@ -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),
}
+4 -4
View File
@@ -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),
}
}
+11 -10
View File
@@ -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)")
}
+14
View File
@@ -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
+3 -3
View File
@@ -154,12 +154,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"},
+23 -9
View File
@@ -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 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等;
+3 -4
View File
@@ -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 {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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
}
+49 -2
View File
@@ -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(); })
+13
View File
@@ -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
+22 -18
View File
@@ -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>
+71
View File
@@ -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)
}
@@ -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,
}
}
+2 -3
View File
@@ -85,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 -1
View File
@@ -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}`
}
-1
View File
@@ -396,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 },
],
},
]
+18 -11
View File
@@ -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()
+12
View File
@@ -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"
+6 -5
View File
@@ -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[]>([])
@@ -171,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)}`
}
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
@@ -228,10 +229,10 @@ const columns: TableColumnData[] = [
]
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)
},
+5 -4
View File
@@ -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>
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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()
}
@@ -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="最大成员数">
+7 -5
View File
@@ -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
+15 -13
View File
@@ -13,6 +13,7 @@ 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()
@@ -164,9 +165,10 @@ 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 {
@@ -198,10 +200,10 @@ function formatTime(s: string): string {
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)
}
// 格式化数字(添加千分位)
@@ -904,7 +906,7 @@ const { exporting, exportFile } = useExport({
<div class="receipt">
<div class="receipt-head">
<span class="receipt-brand">用量 · 费用 · 结算</span>
<span class="receipt-meta">{{ detailLog.currency || 'USD' }}</span>
<span class="receipt-meta">{{ displayCurrency }}</span>
</div>
<!-- 小票行由 receiptRows 数据驱动生成 -->
<template v-for="(row, index) in receiptRows" :key="index">
@@ -933,27 +935,27 @@ const { exporting, exportFile } = useExport({
<div class="snapshot-block-body">
<div class="snapshot-row">
<span class="snapshot-label">基础输入价</span>
<span class="snapshot-value">${{ (snapshot.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">${{ (snapshot.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="snapshot.pricing.effective_input_price !== snapshot.pricing.base_input_price" class="snapshot-row">
<span class="snapshot-label">实际输入价</span>
<span class="snapshot-value text-success">${{ (snapshot.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="snapshot.pricing.effective_output_price !== snapshot.pricing.base_output_price" class="snapshot-row">
<span class="snapshot-label">实际输出价</span>
<span class="snapshot-value text-success">${{ (snapshot.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="snapshot.cache_prices && snapshot.cache_prices.cache_creation_price > 0" class="snapshot-row">
<span class="snapshot-label">缓存创建单价</span>
<span class="snapshot-value">${{ (snapshot.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="snapshot.cache_prices && snapshot.cache_prices.cache_read_price > 0" class="snapshot-row">
<span class="snapshot-label">缓存读取单价</span>
<span class="snapshot-value">${{ (snapshot.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>
@@ -985,7 +987,7 @@ const { exporting, exportFile } = useExport({
<div v-if="(tc.tokens || 0) > 0" class="snapshot-row">
<span class="snapshot-label">{{ tokenCostLabels[key] || key }}</span>
<span class="snapshot-value">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; {{ formatBilling(tc.unit_price || 0, 6) }}/1M = <strong>{{ formatBilling(tc.cost || 0, 6) }}</strong>
</span>
</div>
</template>
@@ -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,
+35 -18
View File
@@ -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">
+84
View File
@@ -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))
}
+8 -9
View File
@@ -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 转空格、截断到分钟;空值显示 -- */
+3 -2
View File
@@ -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 {
+3 -1
View File
@@ -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 || '--') },
{
+48 -27
View File
@@ -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>
@@ -5,6 +5,7 @@ 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,
@@ -33,22 +34,23 @@ 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: 'USD / 次', primary: true, wide: true }]
return [{ label: '单次调用', value: formatPrice(model.per_request_price), unit: `${displayCurrency.value} / 次`, primary: true, wide: true }]
}
const tiles = [
{ label: '输入', value: formatPrice(model.input_price), unit: 'USD / 1M tokens', primary: true, wide: false },
{ label: '输出', value: formatPrice(model.output_price), unit: 'USD / 1M tokens', primary: false, wide: false },
{ 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: 'USD / 1M tokens', primary: false, wide: false })
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: 'USD / 1M tokens', primary: false, wide: false })
tiles.push({ label: '缓存创建', value: formatPrice(model.cache_creation_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false })
}
return tiles
})
@@ -1,4 +1,5 @@
import type { MarketplaceModel, TimePriceItem } from '@/api/marketplace'
import { formatBilling } from '@/composables/useCurrency'
// 分类元数据:卡片磁贴渐变、筛选 pills 与各类徽章共用同一份配置
export interface CategoryMeta {
@@ -84,8 +85,8 @@ export function formatTokens(tokens?: number | null): string {
export function formatPrice(price?: number | null): string {
const value = Number(price)
if (!Number.isFinite(value)) return '—'
// 最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0
return `$${Number(value.toFixed(6))}`
// 本位币直显:最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0
return formatBilling(value, 6)
}
export function billingModeLabel(mode?: string | null): string {
+4 -2
View File
@@ -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>
+3 -1
View File
@@ -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"
+1 -1
View File
@@ -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') {
+2 -1
View File
@@ -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: '创建时间',