feat(billing):新增本位币设置,初始化时可选择全局货币单位,一旦设置就不能修改
This commit is contained in:
1 parent
3bf1b4ca75
commit
cd019cf817
70 files changed
+788
-673
No files matched your search
@@ -282,15 +282,15 @@ cmd(路由注册)
|
||||
|
||||
### 系统货币规则
|
||||
|
||||
平台采用**内部统一 USD + 充值入口 CNY**策略,系统内除充值/支付外的所有金额均为 USD:
|
||||
平台采用**本位币制 + 充值入口 CNY**策略:系统在初始化向导(setup)时选定**本位币**(`billing_currency`,USD 或 CNY,**选定后不可更改**,存量部署默认 USD),内部记账与全站显示统一使用本位币:
|
||||
|
||||
| 业务场景 | 货币 | 说明 |
|
||||
|----------|------|------|
|
||||
| 大模型价格展示和计费 | **USD(美元)** | 模型定价、Token 单价、计费扣费统一使用美元,与上游供应商(OpenAI/Anthropic/Google)报价币种一致 |
|
||||
| 用户钱包余额 | **USD(美元)** | 钱包存储 USD 余额,充值时 CNY 按汇率转换为 USD 入账 |
|
||||
| 计费记录 / 交易流水 | **USD(美元)** | 所有 bil_records、bil_transactions 金额均为 USD |
|
||||
| 大模型价格展示和计费 | **本位币** | 模型定价、Token 单价、计费扣费统一使用本位币(本位币=CNY 的部署按人民币配价) |
|
||||
| 用户钱包余额 | **本位币** | 本位币=USD 时充值 CNY 按汇率转换为 USD 入账;本位币=CNY 时直接入账无换汇 |
|
||||
| 计费记录 / 交易流水 | **本位币** | 所有 bil_records、bil_transactions 金额均为本位币 |
|
||||
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
|
||||
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 |
|
||||
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币(收款渠道物理现实),履约时按本位币入账钱包 |
|
||||
|
||||
#### 三层币种固定规则(强约束,写新代码必须遵守)
|
||||
|
||||
@@ -299,25 +299,29 @@ cmd(路由注册)
|
||||
| 层 | 表前缀 | 币种 | 说明 |
|
||||
|----|--------|------|------|
|
||||
| **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` |
|
||||
| **钱包 / 计费 / 账本层** | `bil_` | **永远 USD** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律美元 |
|
||||
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与 USD 钱包直接比较 |
|
||||
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **USD** | 与 `bil_wallets.cumulative_recharge`(USD)同币种比较 |
|
||||
| **钱包 / 计费 / 账本层** | `bil_` | **本位币** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律本位币(部署级常量,`billing.Currency(ctx)` 读取;初始化选定后不可切换,账本永远单币种) |
|
||||
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与钱包余额直接比较 |
|
||||
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **本位币** | 与 `bil_wallets.cumulative_recharge`(本位币)同币种比较 |
|
||||
|
||||
**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。
|
||||
**唯一换汇点**:CNY → 本位币的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支与线下充值 `OfflineRecharge`,本位币=USD 时调用 `billing.ConvertCNYToUSD`;本位币=CNY 时直接入账,`exchange_rate` 快照存 1)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程本位币不换算。
|
||||
|
||||
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
|
||||
|
||||
#### 汇率规则
|
||||
|
||||
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
|
||||
- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。
|
||||
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
|
||||
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14,已 IsPublic 暴露给前端做展示折算)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
|
||||
- **充值时必须快照汇率**:履约入账时除了写入换算后的本位币金额(`credited_usd` 列,语义为入账本位币金额),还要持久化「原始 CNY 金额 + 当时汇率 + 入账金额」,使历史换算可重建(本位币=CNY 时 `exchange_rate` 存 1)。汇率配置变更不得影响已完成订单的对账。
|
||||
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前本位币余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
|
||||
|
||||
#### 前端展示规则
|
||||
|
||||
- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD)
|
||||
- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY)
|
||||
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。
|
||||
全站显示货币 = 本位币(`/settings/public` 暴露 `billing_currency` + 汇率,两端 `composables/useCurrency.ts` 统一格式化):
|
||||
|
||||
- bil 层数据(钱包 / 交易流水 / 计费 / 用量 / Token 单价 / 阈值 / 兑换码面值)→ 本位币符号直显,**不折算**
|
||||
- ord_/pln_ 层数据(套餐价格 / 充值金额 / 订单金额 / 退款金额 / 优惠码折扣,存 CNY)→ 本位币=CNY 直显 `¥`;本位币=USD 时按汇率折算显示 `$`(**唯一折算场景**)
|
||||
- **编辑表单保持存储币种原值输入**(模型定价按本位币、套餐价格按 CNY),label 标注存储币种;折算仅用于只读展示
|
||||
- 充值页锚定 CNY(订单/履约/折扣口径),本位币=USD 时档位与输入按汇率折算显示、提交时换算回 CNY
|
||||
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是本位币,跟随本位币符号;需要另一币种营收报表时,在**出报表时**按快照汇率折算,账本本身仍存本位币。
|
||||
|
||||
#### 人民币对账 / 导出规则
|
||||
|
||||
@@ -378,7 +382,7 @@ cmd(路由注册)
|
||||
- **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。
|
||||
- **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。)
|
||||
- **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。
|
||||
- **唯一换汇点**:CNY→USD 仅在 `payment.FulfillOrder` recharge 分支调 `billing.ConvertCNYToUSD`(返回 decimal),其余地方不换算(见《系统货币规则》三层币种固定规则)。
|
||||
- **唯一换汇点**:CNY→本位币仅在 `payment.FulfillOrder` recharge 分支与 `OfflineRecharge` 调 `billing.ConvertCNYToUSD`(返回 decimal,本位币=CNY 时直接入账无换算),其余地方不换算(见《系统货币规则》三层币种固定规则)。
|
||||
- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范
|
||||
- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现
|
||||
- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`)
|
||||
|
||||
@@ -404,6 +404,15 @@ const (
|
||||
AgreementCodePrivacy = "privacy" // 隐私政策
|
||||
)
|
||||
|
||||
// 系统配置 key:本位币(系统初始化向导选定后不可更改,bil_ 记账层与全站显示货币)
|
||||
const OptionKeyBillingCurrency = "billing_currency"
|
||||
|
||||
// 本位币取值
|
||||
const (
|
||||
BillingCurrencyUSD = "USD"
|
||||
BillingCurrencyCNY = "CNY"
|
||||
)
|
||||
|
||||
// ValidAgreementCodes 有效协议标识码集合(创建时校验)
|
||||
var ValidAgreementCodes = map[string]string{
|
||||
AgreementCodeTerms: "用户协议",
|
||||
|
||||
@@ -56,12 +56,23 @@ func HandleSetupInitialize(r *ghttp.Request) {
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirmPassword"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
if err := json.Unmarshal(r.GetBody(), &req); err != nil {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "请求格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 本位币:USD/CNY,未选择时默认 USD;初始化写入后不可再更改
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = consts.BillingCurrencyUSD
|
||||
}
|
||||
if currency != consts.BillingCurrencyUSD && currency != consts.BillingCurrencyCNY {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "本位币仅支持 USD 或 CNY")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Username == "" || req.Password == "" {
|
||||
response.ErrorMsg(r, consts.CodeBadRequest, "用户名和密码不能为空")
|
||||
@@ -95,6 +106,12 @@ func HandleSetupInitialize(r *ghttp.Request) {
|
||||
response.Error(r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 本位币随初始化写入(首次落库,此后 SetOption 只读保护拒绝修改)
|
||||
if err := common.Config().SetOption(r.Context(), consts.OptionKeyBillingCurrency, currency); err != nil {
|
||||
response.Error(r, err)
|
||||
return
|
||||
}
|
||||
SetSetupComplete()
|
||||
response.Success(r, nil)
|
||||
}
|
||||
@@ -675,9 +675,10 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
|
||||
}
|
||||
|
||||
// OfflineRecharge 线下充值入账(管理后台)
|
||||
// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。
|
||||
// 场景:用户线下银行转账(人民币 CNY),运营确认到账后入账。
|
||||
// 本位币=USD 时按平台汇率换算入账;本位币=CNY 时直接入账(无换汇)。
|
||||
// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、
|
||||
// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号),
|
||||
// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号),
|
||||
// 供现金对账与开票追溯。
|
||||
func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) {
|
||||
tenantID := req.TenantID
|
||||
@@ -686,12 +687,19 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
return nil, common.NewBadRequestError("入账金额必须大于 0")
|
||||
}
|
||||
|
||||
// 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数),
|
||||
// 汇率取一次用于入账与快照,保证换算可重建。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount)
|
||||
if usdAmount.IsZero() {
|
||||
return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
|
||||
// 本位币=CNY:直接入账;本位币=USD:换汇入账(CNY→USD 只经 billing.ConvertCNYToUSD,
|
||||
// 与充值履约 FulfillOrder 同一函数),汇率取一次用于入账与快照,保证换算可重建。
|
||||
var rate float64
|
||||
var creditAmount decimal.Decimal
|
||||
if billing.IsCNY(ctx) {
|
||||
rate = 1
|
||||
creditAmount = cnyAmount
|
||||
} else {
|
||||
rate = billing.GetExchangeRateCNYToUSD(ctx)
|
||||
creditAmount = billing.ConvertCNYToUSD(ctx, req.Amount)
|
||||
if creditAmount.IsZero() {
|
||||
return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
|
||||
}
|
||||
}
|
||||
|
||||
// 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。
|
||||
@@ -721,9 +729,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
}
|
||||
|
||||
// Redis 加款(资金提交点);流水快照取 Redis 返回值
|
||||
balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, usdAmount)
|
||||
balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, creditAmount)
|
||||
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
|
||||
redisCreditedAmount = usdAmount
|
||||
redisCreditedAmount = creditAmount
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -731,12 +739,17 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
// DB 仅累加累计充值(余额由物化器从 Redis 覆盖)
|
||||
if _, err := g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?",
|
||||
usdAmount, walletID); err != nil {
|
||||
creditAmount, walletID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯
|
||||
desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount)
|
||||
var desc string
|
||||
if billing.IsCNY(ctx) {
|
||||
desc = fmt.Sprintf("线下充值入账 CNY %.2f(本位币直接入账)", cnyAmount.InexactFloat64())
|
||||
} else {
|
||||
desc = fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, creditAmount)
|
||||
}
|
||||
if req.Description != "" {
|
||||
desc = req.Description + ";" + desc
|
||||
}
|
||||
@@ -747,7 +760,7 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
TenantId: tenantID,
|
||||
WalletId: walletID,
|
||||
Type: "recharge",
|
||||
Amount: usdAmount,
|
||||
Amount: creditAmount,
|
||||
BalanceAfter: balanceAfter,
|
||||
FrozenAfter: frozenAfter,
|
||||
Description: desc,
|
||||
|
||||
@@ -174,7 +174,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
|
||||
Balance: 0,
|
||||
FrozenBalance: 0,
|
||||
WarningThreshold: &warningThreshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "create wallet")
|
||||
|
||||
@@ -3,6 +3,7 @@ package billing
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -10,6 +11,28 @@ import (
|
||||
const defaultCNYToUSD = 0.14
|
||||
const defaultUSDToCNY = 7.142857142857143 // 1 / 0.14,确保互为倒数
|
||||
|
||||
// Currency 返回系统本位币(bil_ 记账层的记账币种与全站显示货币)。
|
||||
// 本位币在系统初始化向导选定后不可更改;存量部署与未初始化系统默认 USD。
|
||||
// 配置基础设施不可用(如单测环境无 DB 驱动,g.DB() 直接 panic)时回退 USD,
|
||||
// 与存量部署行为一致,保证纯函数单测可运行。
|
||||
func Currency(ctx context.Context) (cur string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cur = consts.BillingCurrencyUSD
|
||||
}
|
||||
}()
|
||||
c := lcommon.Config().GetString(ctx, consts.OptionKeyBillingCurrency)
|
||||
if c == consts.BillingCurrencyCNY {
|
||||
return consts.BillingCurrencyCNY
|
||||
}
|
||||
return consts.BillingCurrencyUSD
|
||||
}
|
||||
|
||||
// IsCNY 当前本位币是否为人民币(此时充值履约直接入账、无换汇环节)
|
||||
func IsCNY(ctx context.Context) bool {
|
||||
return Currency(ctx) == consts.BillingCurrencyCNY
|
||||
}
|
||||
|
||||
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。
|
||||
// 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
|
||||
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal {
|
||||
|
||||
@@ -1,143 +1,32 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/test/gtest"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
)
|
||||
|
||||
func TestDefaultCNYToUSD(t *testing.T) {
|
||||
if defaultCNYToUSD <= 0 {
|
||||
t.Fatalf("defaultCNYToUSD should be positive, got %f", defaultCNYToUSD)
|
||||
// TestCurrency_FallbackUSD 单测环境无 DB 驱动(Config 读取触发 g.DB() panic),
|
||||
// Currency 必须 recover 兜底返回默认 USD(与存量部署行为一致),不得 panic。
|
||||
// 若在带完整配置/DB 的环境运行,读到已配置本位币则跳过断言。
|
||||
func TestCurrency_FallbackUSD(t *testing.T) {
|
||||
got := Currency(context.Background())
|
||||
if got != consts.BillingCurrencyUSD && got != consts.BillingCurrencyCNY {
|
||||
t.Errorf("Currency() = %q, want USD or CNY", got)
|
||||
}
|
||||
if !billingCurrencyValid(got) {
|
||||
t.Errorf("Currency() = %q, invalid currency", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUSDToCNY(t *testing.T) {
|
||||
if defaultUSDToCNY <= 0 {
|
||||
t.Fatalf("defaultUSDToCNY should be positive, got %f", defaultUSDToCNY)
|
||||
func TestIsCNY_MatchesCurrency(t *testing.T) {
|
||||
c := Currency(context.Background())
|
||||
if want := c == consts.BillingCurrencyCNY; IsCNY(context.Background()) != want {
|
||||
t.Errorf("IsCNY() 与 Currency() 不一致: currency=%q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRates_Reciprocal(t *testing.T) {
|
||||
// 修复后:defaultUSDToCNY 不再是独立配置,而是 defaultCNYToUSD 的倒数
|
||||
// 验证常量定义互为倒数
|
||||
product := defaultCNYToUSD * defaultUSDToCNY
|
||||
if product < 0.9 || product > 1.1 {
|
||||
t.Fatalf("default rate product should be ~1.0, got %f", product)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCNYToUSD_DefaultRate(t *testing.T) {
|
||||
// 无配置时使用默认汇率 0.14
|
||||
tests := []struct {
|
||||
name string
|
||||
cny float64
|
||||
expected float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"100 CNY", 100, 100 * defaultCNYToUSD},
|
||||
{"1 CNY", 1, 1 * defaultCNYToUSD},
|
||||
{"large amount", 10000, 10000 * defaultCNYToUSD},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := tt.cny * defaultCNYToUSD
|
||||
if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
|
||||
t.Errorf("CNY %f → USD: expected %f, got %f", tt.cny, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertUSDToCNY_DefaultRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
usd float64
|
||||
expected float64
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"1 USD", 1, defaultUSDToCNY},
|
||||
{"14 USD", 14, 14 * defaultUSDToCNY},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := tt.usd * defaultUSDToCNY
|
||||
if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
|
||||
t.Errorf("USD %f → CNY: expected %f, got %f", tt.usd, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyConstants_NonZero(t *testing.T) {
|
||||
if defaultCNYToUSD == 0 {
|
||||
t.Error("defaultCNYToUSD must not be zero")
|
||||
}
|
||||
if defaultUSDToCNY == 0 {
|
||||
t.Error("defaultUSDToCNY must not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExchangeRateReciprocal 验证 USD→CNY 汇率是 CNY→USD 的倒数(往返闭合)
|
||||
func TestExchangeRateReciprocal(t *testing.T) {
|
||||
// 测试默认汇率互为倒数
|
||||
reciprocal := 1.0 / defaultCNYToUSD
|
||||
diff := reciprocal - defaultUSDToCNY
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 允许误差 0.01(因为默认常量可能不完全精确)
|
||||
if diff > 0.01 {
|
||||
t.Errorf("默认汇率应互为倒数:1/%.4f = %.4f, 但 defaultUSDToCNY = %.4f, 差值 = %.6f",
|
||||
defaultCNYToUSD, reciprocal, defaultUSDToCNY, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertRoundTrip 验证 CNY→USD→CNY 往返转换闭合(误差在可接受范围内)
|
||||
func TestConvertRoundTrip(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
// 测试多个典型金额
|
||||
testAmounts := []float64{100.0, 50.5, 1.0, 999.99}
|
||||
|
||||
for _, originalCNY := range testAmounts {
|
||||
// CNY → USD(使用默认汇率模拟)
|
||||
usd := decimal.NewFromFloat(originalCNY).Mul(decimal.NewFromFloat(defaultCNYToUSD))
|
||||
|
||||
// USD → CNY(使用倒数)
|
||||
cny := usd.Mul(decimal.NewFromFloat(1.0 / defaultCNYToUSD))
|
||||
|
||||
// 验证往返误差 < 0.01 元(1分钱)
|
||||
diff := cny.Sub(decimal.NewFromFloat(originalCNY)).Abs()
|
||||
t.AssertLT(InexactFloat64(diff), 0.01)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConvertCNYToUSD_Precision 验证 CNY→USD 转换的精度(使用默认汇率)
|
||||
func TestConvertCNYToUSD_Precision(t *testing.T) {
|
||||
// 100 CNY × 0.14 = 14 USD
|
||||
usd := decimal.NewFromFloat(100.0).Mul(decimal.NewFromFloat(defaultCNYToUSD))
|
||||
expected := decimal.NewFromFloat(14.0)
|
||||
|
||||
diff := usd.Sub(expected).Abs()
|
||||
if InexactFloat64(diff) > 0.000001 {
|
||||
t.Errorf("100 CNY 应转换为约 14 USD,实际=%s, 误差=%s", usd.String(), diff.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertUSDToCNY_Precision 验证 USD→CNY 转换的精度(使用倒数)
|
||||
func TestConvertUSDToCNY_Precision(t *testing.T) {
|
||||
// 14 USD × (1/0.14) ≈ 100 CNY
|
||||
usd := decimal.NewFromFloat(14.0)
|
||||
reciprocalRate := 1.0 / defaultCNYToUSD
|
||||
cny := usd.Mul(decimal.NewFromFloat(reciprocalRate))
|
||||
expected := decimal.NewFromFloat(100.0)
|
||||
|
||||
diff := cny.Sub(expected).Abs()
|
||||
if InexactFloat64(diff) > 0.01 {
|
||||
t.Errorf("14 USD 应转换为约 100 CNY,实际=%s, 误差=%s", cny.String(), diff.String())
|
||||
}
|
||||
func billingCurrencyValid(c string) bool {
|
||||
return c == consts.BillingCurrencyUSD || c == consts.BillingCurrencyCNY
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
DiscountRatio: discountRatio,
|
||||
TenantMultiplier: tenantMultiplier,
|
||||
ModelMultiplier: modelMultiplier,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
CacheReadPrice: cacheReadPrice,
|
||||
CacheCreationPrice: cacheCreationPrice,
|
||||
CacheCreation5mPrice: cacheCreationPrice, // 5m TTL = cache_creation_price 基础价(对应官方 1.25× 输入价)
|
||||
|
||||
@@ -166,7 +166,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
BaseCost: preDeductAmount,
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
} else {
|
||||
breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil)
|
||||
@@ -284,7 +284,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
fb := &CostBreakdown{
|
||||
TotalCost: preDeductAmount,
|
||||
BaseCost: preDeductAmount,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
if usage != nil {
|
||||
fb.InputTokens = usage.PromptTokens
|
||||
@@ -459,7 +459,7 @@ func createBillingRecord(ctx context.Context, tenantID, userID, apiKeyID, channe
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
TotalCost: totalCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(now),
|
||||
}
|
||||
@@ -545,7 +545,7 @@ func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiK
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
TotalCost: totalCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(now),
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
|
||||
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
|
||||
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
breakdown := buildTaskCostBreakdown(ctx, pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
|
||||
var billingMode string
|
||||
var discountRatio, effectiveOutputPrice float64
|
||||
@@ -199,7 +199,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
InputPrice: 0,
|
||||
OutputPrice: effectiveOutputPrice,
|
||||
TotalCost: actualCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
Status: "settled",
|
||||
SettledAt: gtime.NewFromTime(time.Now()),
|
||||
BillingMode: billingMode,
|
||||
@@ -311,12 +311,12 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
}
|
||||
|
||||
// buildTaskCostBreakdown 构建任务计费的 CostBreakdown
|
||||
func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
|
||||
func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
|
||||
if pricing == nil {
|
||||
return &CostBreakdown{
|
||||
TotalCost: actualCost,
|
||||
BaseCost: actualCost,
|
||||
Currency: "USD",
|
||||
Currency: Currency(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildTaskCostBreakdown_NilPricing(t *testing.T) {
|
||||
bd := buildTaskCostBreakdown(nil, 0.5, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), nil, 0.5, 1000, 500)
|
||||
if bd == nil {
|
||||
t.Fatal("expected non-nil breakdown")
|
||||
}
|
||||
@@ -25,7 +26,7 @@ func TestBuildTaskCostBreakdown_PerRequestMode(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.10, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.10, 0, 0)
|
||||
assertFloat(t, bd.BaseCost, 0.10, "BaseCost")
|
||||
assertFloat(t, bd.TotalCost, 0.10, "TotalCost")
|
||||
assertFloat(t, bd.PerRequestPrice, 0.10, "PerRequestPrice")
|
||||
@@ -43,7 +44,7 @@ func TestBuildTaskCostBreakdown_TokenMode(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.024, 10000, 5000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 10000, 5000)
|
||||
if bd.OutputTokens != 10000 {
|
||||
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
|
||||
}
|
||||
@@ -61,7 +62,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTenantMultiplier(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.01, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.01, 1000, 500)
|
||||
// tenantMul == 0 → BaseCost = actualCost (no division)
|
||||
assertFloat(t, bd.BaseCost, 0.01, "BaseCost")
|
||||
assertFloat(t, bd.TotalCost, 0.01, "TotalCost")
|
||||
@@ -75,7 +76,7 @@ func TestBuildTaskCostBreakdown_TokenMode_TotalTokens(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.05, 10000, 3000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.05, 10000, 3000)
|
||||
if bd.OutputTokens != 10000 {
|
||||
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func TestBuildTaskCostBreakdown_CarriesPricingFields(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 0.50, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.50, 0, 0)
|
||||
assertFloat(t, bd.DiscountRatio, 0.85, "DiscountRatio")
|
||||
assertFloat(t, bd.TenantMultiplier, 0.85, "TenantMultiplier")
|
||||
if bd.Currency != "USD" {
|
||||
@@ -109,7 +110,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
bd := buildTaskCostBreakdown(pricing, 3.375, 0, 0)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 3.375, 0, 0)
|
||||
if bd.OutputTokens != 0 {
|
||||
t.Errorf("OutputTokens = %d, want 0", bd.OutputTokens)
|
||||
}
|
||||
@@ -130,12 +131,12 @@ func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) {
|
||||
}
|
||||
|
||||
// 0.4 / (0.8 × 0.5) = 1.0
|
||||
bd := buildTaskCostBreakdown(pricing, 0.4, 10000, 5000)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.4, 10000, 5000)
|
||||
assertFloat(t, bd.BaseCost, 1.0, "BaseCost (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.4, "TotalCost")
|
||||
|
||||
// 无 token 用量分支同样按双乘数还原:0.08 / (0.8 × 0.5) = 0.2
|
||||
bd = buildTaskCostBreakdown(pricing, 0.08, 0, 0)
|
||||
bd = buildTaskCostBreakdown(context.Background(), pricing, 0.08, 0, 0)
|
||||
assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token")
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 0.024 / (0.8 × 1.0) = 0.03
|
||||
bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500)
|
||||
bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 1000, 500)
|
||||
assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)")
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"strconv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@@ -220,6 +222,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// 本位币只在首次写入(系统初始化向导)时允许设置,此后只读;
|
||||
// 相同值放行,保证设置页保存整个 payment 分类时不被误拒。
|
||||
if key == consts.OptionKeyBillingCurrency && count > 0 {
|
||||
existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Value()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if old := gconv.String(existing); old != "" && old != value {
|
||||
return gerror.New("本位币在系统初始化后不可更改")
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
data := do.SysOptions{Value: value}
|
||||
// Sync metadata from registry so that changes to IsPublic/Category/Description
|
||||
|
||||
@@ -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"},
|
||||
|
||||
|
||||
@@ -66,22 +66,36 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
|
||||
|
||||
case "recharge":
|
||||
// 折扣语义「付折后价、按原价入账」:用户实付 FinalAmount(折后 CNY),
|
||||
// 钱包按订单原价 Amount 换算 USD 入账,折扣让利真实生效;无折扣时两者相等。
|
||||
// 汇率只取一次,保证入账换算与快照使用同一汇率。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
usdAmount := billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
|
||||
credited, err := creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64()))
|
||||
// 钱包按订单原价 Amount 入账,折扣让利真实生效;无折扣时两者相等。
|
||||
var creditAmount decimal.Decimal
|
||||
var desc string
|
||||
if billing.IsCNY(ctx) {
|
||||
// 本位币=CNY:订单层与记账层同为人民币,按原价直接入账,无换汇环节
|
||||
creditAmount = billing.NewFromFloat(order.Amount)
|
||||
desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → CNY %.2f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
|
||||
} else {
|
||||
// 本位币=USD:按原价换算美元入账;汇率只取一次,保证入账换算与快照使用同一汇率。
|
||||
rate := billing.GetExchangeRateCNYToUSD(ctx)
|
||||
creditAmount = billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
|
||||
desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
|
||||
}
|
||||
credited, err := creditWalletTx(ctx, order.TenantID, creditAmount, orderID, desc)
|
||||
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
|
||||
redisCreditedTenantID = order.TenantID
|
||||
redisCreditedAmount = credited
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "credit wallet failed")
|
||||
}
|
||||
// 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账 USD」,
|
||||
// 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账金额」,
|
||||
// 使历史换算可重建,汇率配置变更不影响已完成订单的现金对账。
|
||||
// 本位币=CNY 时 exchange_rate=1(无换汇)、credited_usd 存本位币金额。
|
||||
snapRate := billing.One
|
||||
if !billing.IsCNY(ctx) {
|
||||
snapRate = billing.NewFromFloat(billing.GetExchangeRateCNYToUSD(ctx))
|
||||
}
|
||||
if _, err = g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE ord_orders SET exchange_rate = ?, credited_usd = ? WHERE id = ?",
|
||||
billing.NewFromFloat(rate), usdAmount, orderID); err != nil {
|
||||
snapRate, creditAmount, orderID); err != nil {
|
||||
return gerror.Wrapf(err, "snapshot exchange rate failed")
|
||||
}
|
||||
_ = billing.CheckAndUpgradeLevel(ctx, order.TenantID)
|
||||
@@ -201,8 +215,8 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int
|
||||
}
|
||||
|
||||
// creditWalletTx 钱包入账(依赖调用方传入携带事务的 ctx,且处于订单行锁保护内)。
|
||||
// amount 为 USD(bil_ 层永远 USD);orderID 写入流水 related_id:退款时靠它精确找回
|
||||
// 履约当时入账的 USD(禁止按当前汇率反算)。
|
||||
// amount 为本位币金额(bil_ 层币种 = 部署级本位币,USD 或 CNY);orderID 写入流水
|
||||
// related_id:退款时靠它精确找回履约当时入账的本位币金额(禁止按当前汇率反算)。
|
||||
//
|
||||
// Redis 权威化架构下的职责拆分:
|
||||
// - Redis 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等;
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// GetGlobalPaymentSettings loads global payment settings from ConfigService.
|
||||
// Currency 固定返回 "CNY":订单/支付层币种由收款渠道决定(epay 收人民币),
|
||||
// 系统计账与显示货币由本位币 billing_currency 决定(初始化选定,不在此配置)。
|
||||
func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, error) {
|
||||
cfg := common.Config()
|
||||
settings := &GlobalPaymentSettings{
|
||||
@@ -26,22 +28,19 @@ func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, erro
|
||||
if n := cfg.GetFloat(ctx, "payment_min_topup"); n > 0 {
|
||||
settings.MinTopUp = n
|
||||
}
|
||||
if val := cfg.GetString(ctx, "payment_currency"); val != "" {
|
||||
settings.Currency = val
|
||||
}
|
||||
settings.CallbackBaseURL = cfg.GetString(ctx, "payment_callback_base_url")
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveGlobalPaymentSettings persists global payment settings via ConfigService.
|
||||
// Currency 字段不落库(订单层币种恒 CNY,不接受配置)。
|
||||
func SaveGlobalPaymentSettings(ctx context.Context, settings *GlobalPaymentSettings) error {
|
||||
cfg := common.Config()
|
||||
items := map[string]string{
|
||||
"payment_amount_options": mustJSON(settings.AmountOptions),
|
||||
"payment_amount_discount": mustJSON(settings.AmountDiscount),
|
||||
"payment_min_topup": mustJSON(settings.MinTopUp),
|
||||
"payment_currency": settings.Currency,
|
||||
"payment_callback_base_url": settings.CallbackBaseURL,
|
||||
}
|
||||
for key, value := range items {
|
||||
|
||||
@@ -528,7 +528,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
record.OutputCost = settleResult.OutputCost
|
||||
record.RefundAmount = settleResult.RefundAmount
|
||||
record.SupplementAmount = settleResult.SupplementAmount
|
||||
record.Currency = "USD"
|
||||
record.Currency = billing.Currency(context.Background())
|
||||
} else {
|
||||
// 失败/超时任务:从定价中获取计费模式
|
||||
billingMode := "per_request"
|
||||
|
||||
@@ -186,7 +186,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
Balance: 0,
|
||||
FrozenBalance: 0,
|
||||
WarningThreshold: &warningThreshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "create wallet")
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
Balance: billing.Zero,
|
||||
FrozenBalance: billing.Zero,
|
||||
WarningThreshold: &threshold,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,7 +52,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
FrozenBalance: 0,
|
||||
AvailableBalance: 0,
|
||||
WarningThreshold: 0,
|
||||
Currency: "USD",
|
||||
Currency: billing.Currency(ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.currency-option.selected {
|
||||
border-color: #2563eb;
|
||||
background-color: rgba(37, 99, 235, 0.04);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -67,6 +73,7 @@
|
||||
.delay-4 { animation-delay: 0.2s; }
|
||||
.delay-5 { animation-delay: 0.25s; }
|
||||
.delay-6 { animation-delay: 0.3s; }
|
||||
.delay-7 { animation-delay: 0.35s; }
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -155,6 +162,31 @@
|
||||
class="input-field w-full px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 outline-none font-mono"
|
||||
placeholder="再次输入密码" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<!-- Base Currency -->
|
||||
<div class="animate-fade-up delay-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
本位币
|
||||
<span class="text-amber-600 font-normal text-xs ml-1">初始化后不可更改</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2.5" id="currencyGroup">
|
||||
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="USD">
|
||||
<input type="radio" name="currency" value="USD" checked class="mt-0.5 accent-[#2563eb]">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-gray-900">USD(美元)</span>
|
||||
<span class="block text-xs text-gray-400 mt-0.5">与上游模型报价对齐</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="CNY">
|
||||
<input type="radio" name="currency" value="CNY" class="mt-0.5 accent-[#2563eb]">
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-gray-900">CNY(人民币)</span>
|
||||
<span class="block text-xs text-gray-400 mt-0.5">面向国内运营</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1.5">决定系统内部记账与全站显示货币,模型定价按本位币配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -168,7 +200,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="pt-1.5 animate-fade-up delay-6">
|
||||
<div class="pt-1.5 animate-fade-up delay-7">
|
||||
<button
|
||||
type="submit" id="submitBtn"
|
||||
class="btn-primary w-full text-white py-2.5 rounded-lg text-sm font-medium">
|
||||
@@ -208,6 +240,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 本位币选择卡片高亮联动
|
||||
var currencyOptions = document.querySelectorAll('.currency-option');
|
||||
function syncCurrencyHighlight() {
|
||||
var selected = document.querySelector('input[name="currency"]:checked');
|
||||
currencyOptions.forEach(function(opt) {
|
||||
opt.classList.toggle('selected', opt.querySelector('input').value === selected.value);
|
||||
});
|
||||
}
|
||||
currencyOptions.forEach(function(opt) {
|
||||
opt.querySelector('input').addEventListener('change', syncCurrencyHighlight);
|
||||
});
|
||||
syncCurrencyHighlight();
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
@@ -216,6 +261,7 @@
|
||||
var displayName = document.getElementById('displayName').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
var confirmPassword = document.getElementById('confirmPassword').value;
|
||||
var currency = document.querySelector('input[name="currency"]:checked').value;
|
||||
|
||||
if (!username) {
|
||||
showError('请输入用户名');
|
||||
@@ -242,7 +288,8 @@
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
password: password,
|
||||
confirmPassword: confirmPassword
|
||||
confirmPassword: confirmPassword,
|
||||
currency: currency
|
||||
})
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
-- 货币显示动态切换:将汇率配置公开(前端展示层折算与充值页折算需要读取)。
|
||||
-- 已落库且 is_public=false 的行不会被 PublicSettingsGet 返回实际值(只会补注册表默认值),
|
||||
-- 因此必须刷存量行;billing_currency 为新增 key,由注册表默认值兜底(USD),无需插入。
|
||||
|
||||
-- +goose StatementBegin
|
||||
UPDATE sys_options SET is_public = true WHERE key = 'payment_exchange_rate_cny_to_usd';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
UPDATE sys_options SET is_public = false WHERE key = 'payment_exchange_rate_cny_to_usd';
|
||||
-- +goose StatementEnd
|
||||
@@ -3,6 +3,10 @@ import { ref, reactive, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -243,18 +247,18 @@ const previewBase = computed(() => {
|
||||
function segmentPreview(seg: TimeSegmentRow): string {
|
||||
const m = (seg.percent || 0) / 100
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `$${(previewBase.value.perRequest * m).toFixed(4)} /次`
|
||||
return `${formatBilling(previewBase.value.perRequest * m, 4)} /次`
|
||||
}
|
||||
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
|
||||
return `${prefix}输入 $${(previewBase.value.input * m).toFixed(4)} · 输出 $${(previewBase.value.output * m).toFixed(4)}`
|
||||
return `${prefix}输入 ${formatBilling(previewBase.value.input * m, 4)} · 输出 ${formatBilling(previewBase.value.output * m, 4)}`
|
||||
}
|
||||
|
||||
function defaultPreviewText(): string {
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `$${previewBase.value.perRequest.toFixed(4)} /次`
|
||||
return `${formatBilling(previewBase.value.perRequest, 4)} /次`
|
||||
}
|
||||
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
|
||||
return `${prefix}输入 $${previewBase.value.input.toFixed(4)} · 输出 $${previewBase.value.output.toFixed(4)}`
|
||||
return `${prefix}输入 ${formatBilling(previewBase.value.input, 4)} · 输出 ${formatBilling(previewBase.value.output, 4)}`
|
||||
}
|
||||
|
||||
function segmentDesc(seg: TimeSegmentRow): string {
|
||||
@@ -500,19 +504,19 @@ watch(() => props.visible, (val) => {
|
||||
<div class="official-prices">
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输入价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.input_price.toFixed(2) }} / 1M</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.input_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输出价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.output_price.toFixed(2) }} / 1M</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.output_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存读取</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? '$' + src.pricing.cache_read_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatBilling(src.pricing.cache_read_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存创建</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? '$' + src.pricing.cache_creation_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatBilling(src.pricing.cache_creation_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
|
||||
@@ -543,7 +547,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="每次调用价格"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 次</template>
|
||||
<template #suffix>{{ currencySymbol }} / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -555,7 +559,7 @@ watch(() => props.visible, (val) => {
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>按量定价</h3>
|
||||
<span class="section-hint">价格单位为 $ / 1M Token</span>
|
||||
<span class="section-hint">价格单位为 {{ currencySymbol }} / 1M Token</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
|
||||
<AFormItem label="输入价格">
|
||||
@@ -566,7 +570,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
@@ -577,7 +581,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存读取">
|
||||
@@ -588,7 +592,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建">
|
||||
@@ -599,7 +603,7 @@ watch(() => props.visible, (val) => {
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -651,7 +655,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
@@ -661,7 +665,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -673,7 +677,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
@@ -683,7 +687,7 @@ watch(() => props.visible, (val) => {
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
/**
|
||||
* 全站货币显示(本位币制),与租户端 useCurrency 镜像:
|
||||
* - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算
|
||||
* - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显
|
||||
* - 本位币由系统初始化向导选定,管理后台只读(保存支付设置后调用 refresh 立即生效)
|
||||
* - 编辑表单保持存储币种原值输入,折算仅用于只读展示
|
||||
*/
|
||||
export type CurrencyCode = 'USD' | 'CNY'
|
||||
|
||||
/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */
|
||||
const FALLBACK_CNY_TO_USD = 0.14
|
||||
|
||||
const { settings, fetchSettings } = usePublicSettings()
|
||||
|
||||
/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */
|
||||
export const displayCurrency = computed<CurrencyCode>(() =>
|
||||
settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD',
|
||||
)
|
||||
|
||||
/** CNY→USD 汇率(支付设置配置,系统设置保存后 refresh 生效) */
|
||||
export const cnyToUsd = computed(() => {
|
||||
const r = Number(settings.value.payment_exchange_rate_cny_to_usd)
|
||||
return r > 0 ? r : FALLBACK_CNY_TO_USD
|
||||
})
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */
|
||||
source?: CurrencyCode
|
||||
/** 显示精度;默认按显示币种取 USD 6 位 / CNY 2 位 */
|
||||
precision?: number
|
||||
/** 正负号前缀(交易流水) */
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化(跟随本位币)。
|
||||
* source='CNY'(订单层)且本位币=USD 时按汇率折算;bil 层数据传 source=displayCurrency 即直显。
|
||||
* 内部读取响应式 computed,render 函数中调用可建立依赖,配置变化自动重渲染。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { source = 'USD', precision, showSign = false } = opts
|
||||
const display = displayCurrency.value
|
||||
let num = Number(value ?? 0)
|
||||
// 唯一折算场景:订单层 CNY 金额在本位币 USD 部署下展示
|
||||
if (source === 'CNY' && display === 'USD') {
|
||||
num = num * cnyToUsd.value
|
||||
}
|
||||
const symbol = display === 'CNY' ? '¥' : '$'
|
||||
const p = precision ?? (display === 'CNY' ? 2 : 6)
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(p))}`
|
||||
}
|
||||
|
||||
/** bil_ 层(钱包/计费/定价等本位币数据)快捷格式化:直显 + 本位币符号 */
|
||||
export function formatBilling(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: displayCurrency.value, precision, showSign })
|
||||
}
|
||||
|
||||
/** ord_/pln_ 层(订单/套餐等 CNY 数据)快捷格式化:本位币=CNY 直显,USD 折算 */
|
||||
export function formatOrder(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: 'CNY', precision, showSign })
|
||||
}
|
||||
|
||||
/** 强制刷新公共配置(系统设置保存后调用,让已打开页面立即按新汇率/本位币重渲染) */
|
||||
export function refreshCurrencySettings(): Promise<void> {
|
||||
return fetchSettings(true)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,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}`
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
IconThunderbolt,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import * as echarts from 'echarts'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -43,10 +44,9 @@ function calcGrowth(current: number, previous: number): { value: number; positiv
|
||||
return { value: Math.round(growth * 10) / 10, positive: growth >= 0 }
|
||||
}
|
||||
|
||||
// 金额格式化走全局本位币(bil 层数据直显 + 本位币符号)
|
||||
function formatMoney(val: any) {
|
||||
const n = Number(val)
|
||||
if (!n && n !== 0) return '$0.00'
|
||||
return '$' + n.toFixed(2)
|
||||
return formatBilling(val, 2)
|
||||
}
|
||||
|
||||
function formatNumber(val: any) {
|
||||
@@ -253,18 +253,19 @@ function renderTrendChart() {
|
||||
const revenues = trends.value.map((r: any) => Number(r.revenue) || 0)
|
||||
const requests = trends.value.map((r: any) => Number(r.requests) || 0)
|
||||
|
||||
// 图例/轴名带币种代码,跟随本位币(由下方 watch 触发重渲染)
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['收入 ($)', '请求数'], bottom: 0 },
|
||||
legend: { data: [`收入 (${displayCurrency.value})`, '请求数'], bottom: 0 },
|
||||
grid: { left: 60, right: 60, top: 15, bottom: 36 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '收入 ($)', axisLabel: { formatter: (v: number) => '$' + v } },
|
||||
{ type: 'value', name: `收入 (${displayCurrency.value})`, axisLabel: { formatter: (v: number) => formatBilling(v) } },
|
||||
{ type: 'value', name: '请求数', splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入 ($)',
|
||||
name: `收入 (${displayCurrency.value})`,
|
||||
type: 'bar',
|
||||
data: revenues,
|
||||
barWidth: 12,
|
||||
@@ -331,7 +332,7 @@ function renderModelChart() {
|
||||
modelChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 130, right: 50, top: 10, bottom: 36 },
|
||||
xAxis: { type: 'value', name: '费用 ($)', nameLocation: 'middle', nameGap: 22 },
|
||||
xAxis: { type: 'value', name: `费用 (${displayCurrency.value})`, nameLocation: 'middle', nameGap: 22 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
@@ -361,7 +362,7 @@ function renderModelHourlyChart() {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (v: number) => '$' + Number(v).toFixed(4),
|
||||
valueFormatter: (v: number) => formatBilling(v, 4),
|
||||
},
|
||||
legend: { data: data.models, bottom: 0, type: 'scroll' },
|
||||
grid: { left: 60, right: 20, top: 15, bottom: 46 },
|
||||
@@ -372,8 +373,8 @@ function renderModelHourlyChart() {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '费用 ($)',
|
||||
axisLabel: { formatter: (v: number) => '$' + v },
|
||||
name: `费用 (${displayCurrency.value})`,
|
||||
axisLabel: { formatter: (v: number) => formatBilling(v) },
|
||||
},
|
||||
series: data.series.map((s: any) => ({
|
||||
name: s.model,
|
||||
@@ -385,6 +386,12 @@ function renderModelHourlyChart() {
|
||||
}, true)
|
||||
}
|
||||
|
||||
// 本位币变化(配置异步加载/后台切换)时重绘全部图表:echarts option 在命令式渲染函数中拼装,
|
||||
// 不在 Vue 渲染树内,需显式 watch 触发重绘;metricCards/表格 render 经 formatBilling 自动建立依赖
|
||||
watch(displayCurrency, () => {
|
||||
renderAllCharts()
|
||||
})
|
||||
|
||||
// === Resize ===
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
trendChart?.resize()
|
||||
|
||||
@@ -15,6 +15,7 @@ const form = reactive({
|
||||
displayName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -51,6 +52,7 @@ async function handleSetup() {
|
||||
displayName: form.displayName.trim(),
|
||||
password: form.password,
|
||||
confirmPassword: form.confirmPassword,
|
||||
currency: form.currency,
|
||||
})
|
||||
Message.success('初始化成功,正在跳转登录页...')
|
||||
markSystemInitialized()
|
||||
@@ -180,6 +182,16 @@ async function handleSetup() {
|
||||
@keydown.enter="handleSetup"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem
|
||||
field="currency"
|
||||
label="本位币"
|
||||
extra="决定系统内部记账与全站显示货币(模型定价按本位币配置),初始化后不可更改"
|
||||
>
|
||||
<ARadioGroup v-model="form.currency">
|
||||
<ARadio value="USD">USD(美元)· 与上游模型报价对齐</ARadio>
|
||||
<ARadio value="CNY">CNY(人民币)· 面向国内运营</ARadio>
|
||||
</ARadioGroup>
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton
|
||||
type="primary"
|
||||
|
||||
@@ -10,6 +10,7 @@ import ModelPricingModal from '@/components/ModelPricingModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { useDateRange } from '@/composables/useDateRange'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
|
||||
|
||||
@@ -122,10 +123,10 @@ const columns: TableColumnData[] = [
|
||||
width: 100,
|
||||
render({ record }) {
|
||||
if (record.billing_settled && record.actual_cost > 0) {
|
||||
return `$${record.actual_cost.toFixed(6)}`
|
||||
return formatBilling(record.actual_cost, 6)
|
||||
}
|
||||
if (record.pre_deduct_amount > 0) {
|
||||
return `$${record.pre_deduct_amount.toFixed(6)} (预扣)`
|
||||
return `${formatBilling(record.pre_deduct_amount, 6)} (预扣)`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
@@ -363,11 +364,11 @@ onMounted(() => {
|
||||
<ADescriptionsItem label="模型">{{ detailData.model_name }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="进度">{{ detailData.progress }}</ADescriptionsItem>
|
||||
<ADescriptionsItem label="预扣金额">
|
||||
<span v-if="detailData.pre_deduct_amount > 0">${{ detailData.pre_deduct_amount.toFixed(4) }}</span>
|
||||
<span v-if="detailData.pre_deduct_amount > 0">{{ formatBilling(detailData.pre_deduct_amount, 4) }}</span>
|
||||
<span v-else>-</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="实际费用">
|
||||
<span v-if="detailData.billing_settled">${{ detailData.actual_cost.toFixed(4) }}</span>
|
||||
<span v-if="detailData.billing_settled">{{ formatBilling(detailData.actual_cost, 4) }}</span>
|
||||
<span v-else>未结算</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="提交时间">{{ detailData.submit_time || '-' }}</ADescriptionsItem>
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref<any[]>([])
|
||||
@@ -45,7 +46,7 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'final_amount', width: 100,
|
||||
render({ record }) { return `¥${Number(record.final_amount).toFixed(2)}` },
|
||||
render({ record }) { return formatOrder(record.final_amount, 2) },
|
||||
},
|
||||
{ title: '支付渠道', dataIndex: 'payment_channel', width: 90 },
|
||||
{
|
||||
@@ -161,7 +162,7 @@ const { exporting, exportFile } = useExport({
|
||||
<AModal v-model:visible="showRefundModal" title="发起退款" :width="450" :mask-closable="false" :on-before-ok="handleRefund" :ok-loading="refundLoading">
|
||||
<AForm :model="refundForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="退款金额">
|
||||
<span class="text-red-500 font-medium">¥{{ refundAmount.toFixed(2) }}</span>
|
||||
<span class="text-red-500 font-medium">{{ formatOrder(refundAmount, 2) }}</span>
|
||||
</AFormItem>
|
||||
<AFormItem label="退款原因" required>
|
||||
<AInput v-model="refundForm.reason" type="textarea" :auto-size="{ minRows: 3, maxRows: 5 }" placeholder="请填写退款原因" />
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ========== 全局支付设置 ==========
|
||||
|
||||
const settingsLoading = ref(false)
|
||||
const settingsSaving = ref(false)
|
||||
|
||||
const settingsForm = reactive({
|
||||
amount_options: '10,20,50,100,200,500',
|
||||
amount_discount: '{}',
|
||||
min_topup: 1,
|
||||
currency: 'CNY',
|
||||
callback_base_url: '',
|
||||
})
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'CNY', label: 'CNY - 人民币' },
|
||||
{ value: 'USD', label: 'USD - 美元' },
|
||||
]
|
||||
|
||||
async function fetchSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/payment-settings')
|
||||
const data = res.data?.data
|
||||
if (!data) return
|
||||
if (data.amount_options) {
|
||||
settingsForm.amount_options = Array.isArray(data.amount_options)
|
||||
? data.amount_options.join(',')
|
||||
: String(data.amount_options)
|
||||
}
|
||||
if (data.amount_discount) {
|
||||
settingsForm.amount_discount = typeof data.amount_discount === 'string'
|
||||
? data.amount_discount
|
||||
: JSON.stringify(data.amount_discount)
|
||||
}
|
||||
if (data.min_topup !== undefined) settingsForm.min_topup = Number(data.min_topup)
|
||||
if (data.currency) settingsForm.currency = data.currency
|
||||
if (data.callback_base_url !== undefined) settingsForm.callback_base_url = data.callback_base_url
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettingsSave() {
|
||||
settingsSaving.value = true
|
||||
try {
|
||||
let amountOptions: number[] = []
|
||||
try {
|
||||
amountOptions = settingsForm.amount_options
|
||||
.split(',')
|
||||
.map((s: string) => Number(s.trim()))
|
||||
.filter((n: number) => !isNaN(n) && n > 0)
|
||||
} catch {
|
||||
Message.error('金额选项格式错误')
|
||||
return
|
||||
}
|
||||
let amountDiscount: Record<string, number> = {}
|
||||
try {
|
||||
amountDiscount = JSON.parse(settingsForm.amount_discount || '{}')
|
||||
} catch {
|
||||
Message.error('折扣映射格式错误')
|
||||
return
|
||||
}
|
||||
await request.put('/admin/payment-settings', {
|
||||
amount_options: amountOptions,
|
||||
amount_discount: amountDiscount,
|
||||
min_topup: settingsForm.min_topup,
|
||||
currency: settingsForm.currency,
|
||||
callback_base_url: settingsForm.callback_base_url,
|
||||
})
|
||||
Message.success('保存成功')
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
settingsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 支付渠道配置 ==========
|
||||
|
||||
const channelsLoading = ref(false)
|
||||
|
||||
const epayForm = reactive({
|
||||
is_enabled: false,
|
||||
pay_address: '',
|
||||
merchant_id: '',
|
||||
merchant_key: '',
|
||||
pay_methods: '',
|
||||
})
|
||||
const epaySaving = ref(false)
|
||||
|
||||
async function fetchChannels() {
|
||||
channelsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/payment-channels')
|
||||
const list = res.data?.data?.list || []
|
||||
const map: Record<string, any> = {}
|
||||
for (const ch of list) {
|
||||
map[ch.channel] = ch.config || {}
|
||||
}
|
||||
|
||||
const epay = map.epay || {}
|
||||
epayForm.is_enabled = !!epay.is_enabled
|
||||
epayForm.pay_address = epay.pay_address || ''
|
||||
epayForm.merchant_id = epay.merchant_id || ''
|
||||
epayForm.merchant_key = epay.merchant_key || ''
|
||||
epayForm.pay_methods = epay.pay_methods
|
||||
? typeof epay.pay_methods === 'string'
|
||||
? epay.pay_methods
|
||||
: JSON.stringify(epay.pay_methods)
|
||||
: ''
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
channelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChannel(channel: string, config: Record<string, any>, loading: { value: boolean }) {
|
||||
loading.value = true
|
||||
try {
|
||||
await request.put(`/admin/payment-channels/${channel}`, {
|
||||
config: JSON.stringify(config),
|
||||
})
|
||||
Message.success('Epay(易支付)配置已保存')
|
||||
} catch {
|
||||
// interceptor handles error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEpaySave() {
|
||||
let payMethods = epayForm.pay_methods
|
||||
try { payMethods = JSON.parse(payMethods) } catch { /* keep as string */ }
|
||||
saveChannel('epay', {
|
||||
is_enabled: epayForm.is_enabled,
|
||||
pay_address: epayForm.pay_address,
|
||||
merchant_id: epayForm.merchant_id,
|
||||
merchant_key: epayForm.merchant_key,
|
||||
pay_methods: payMethods || undefined,
|
||||
}, epaySaving)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchChannels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="支付设置" description="管理支付渠道和全局支付配置" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 全局支付设置 -->
|
||||
<ACard :bordered="false" :loading="settingsLoading" title="全局设置">
|
||||
<AForm :model="settingsForm" :auto-label-width="true">
|
||||
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
|
||||
<AGridItem>
|
||||
<AFormItem label="金额选项" extra="预设充值金额,用逗号分隔">
|
||||
<AInput v-model="settingsForm.amount_options" placeholder="10,20,50,100,200,500" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="最低充值">
|
||||
<AInputNumber v-model="settingsForm.min_topup" :min="0.01" :step="1" :precision="2" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem
|
||||
label="折扣映射"
|
||||
extra='JSON 格式,键为金额,值为折扣率。如 {"100":0.9} 表示充100享9折'
|
||||
>
|
||||
<AInput
|
||||
v-model="settingsForm.amount_discount"
|
||||
type="textarea"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder='{}'
|
||||
/>
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem
|
||||
label="回调基础 URL"
|
||||
extra="支付网关回调地址的前缀,留空则使用请求时的 Host"
|
||||
>
|
||||
<AInput v-model="settingsForm.callback_base_url" placeholder="https://api.example.com" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="默认货币">
|
||||
<ASelect v-model="settingsForm.currency" :options="currencyOptions" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
</AGrid>
|
||||
</AForm>
|
||||
<template #actions>
|
||||
<AButton type="primary" :loading="settingsSaving" @click="handleSettingsSave">保存设置</AButton>
|
||||
</template>
|
||||
</ACard>
|
||||
|
||||
<!-- 支付渠道 -->
|
||||
<ACard :bordered="false" :loading="channelsLoading">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>支付渠道 · Epay(易支付)</span>
|
||||
<ATag :color="epayForm.is_enabled ? 'green' : undefined" size="small">
|
||||
{{ epayForm.is_enabled ? '已启用' : '未启用' }}
|
||||
</ATag>
|
||||
</div>
|
||||
</template>
|
||||
<AForm :model="epayForm" :auto-label-width="true">
|
||||
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
|
||||
<AGridItem>
|
||||
<AFormItem label="启用">
|
||||
<ASwitch v-model="epayForm.is_enabled" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="支付网关地址">
|
||||
<AInput v-model="epayForm.pay_address" placeholder="https://pay.example.com" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="商户ID">
|
||||
<AInput v-model="epayForm.merchant_id" placeholder="1001" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
<AGridItem>
|
||||
<AFormItem label="商户密钥">
|
||||
<AInput v-model="epayForm.merchant_key" placeholder="商户密钥" />
|
||||
</AFormItem>
|
||||
</AGridItem>
|
||||
</AGrid>
|
||||
<AFormItem
|
||||
label="支付方式"
|
||||
extra='JSON 数组。如 [{"name":"支付宝","type":"alipay","color":"#1677ff"},{"name":"微信支付","type":"wxpay","color":"#07c160"}]'
|
||||
>
|
||||
<AInput
|
||||
v-model="epayForm.pay_methods"
|
||||
type="textarea"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder='[{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
|
||||
/>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<template #actions>
|
||||
<AButton type="primary" :loading="epaySaving" @click="handleEpaySave">保存渠道配置</AButton>
|
||||
</template>
|
||||
</ACard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const plans = ref<any[]>([])
|
||||
@@ -24,8 +25,8 @@ const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '标识', dataIndex: 'identifier', width: 100 },
|
||||
{ title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return `¥${Number(record.monthly_price).toFixed(2)}` } },
|
||||
{ title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return `¥${Number(record.yearly_price).toFixed(2)}` } },
|
||||
{ title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return formatOrder(record.monthly_price, 2) } },
|
||||
{ title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return formatOrder(record.yearly_price, 2) } },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) { return h(Tag, { color: record.status === 'active' ? 'green' : undefined, size: 'small' }, () => record.status === 'active' ? '上架' : '下架') },
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatOrder } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const promoCodes = ref<any[]>([])
|
||||
@@ -164,7 +165,7 @@ const { exporting, exportFile } = useExport({
|
||||
<ATable :columns="[
|
||||
{ title: '租户ID', dataIndex: 'tenant_id', width: 80 },
|
||||
{ title: '订单ID', dataIndex: 'order_id', width: 80 },
|
||||
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return '$' + Number(record.discount_amount).toFixed(2) } },
|
||||
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return formatOrder(record.discount_amount, 2) } },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160 },
|
||||
]" :data="usages" :loading="usagesLoading" :pagination="false" row-key="id" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const redemptions = ref<any[]>([])
|
||||
@@ -88,7 +89,7 @@ const usageColumns: TableColumnData[] = [
|
||||
const colors: any = { quota: 'green', plan: 'blue', duration: 'orangered' }
|
||||
return h(Tag, { color: colors[record.type], size: 'small' }, () => labels[record.type] || record.type)
|
||||
}},
|
||||
{ title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? `+${Number(record.value).toFixed(6)}` : '-' } },
|
||||
{ title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? formatBilling(record.value, 6, true) : '-' } },
|
||||
{ title: '组织名称', dataIndex: 'tenant_name', width: 140 },
|
||||
{ title: '兑换人', dataIndex: 'username', width: 120 },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 170, render({ record }) { return record.created_at?.substring(0, 19) || '-' } },
|
||||
|
||||
@@ -6,6 +6,7 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -50,13 +51,13 @@ const columns: TableColumnData[] = [
|
||||
render({ record }) {
|
||||
const amount = record.amount || 0
|
||||
const color = amount > 0 ? 'rgb(var(--green-6))' : amount < 0 ? 'rgb(var(--red-6))' : undefined
|
||||
const prefix = amount > 0 ? '+' : ''
|
||||
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, `${prefix}$${Math.abs(amount).toFixed(6)}`)
|
||||
// bil 层本位币金额:showSign 自动带 +/- 符号(同时修复负数缺失负号的问题)
|
||||
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, formatBilling(amount, 6, true))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 130,
|
||||
render({ record }) { return `$${(record.balance_after || 0).toFixed(6)}` },
|
||||
render({ record }) { return formatBilling(record.balance_after || 0, 6) },
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', width: 180, ellipsis: true, tooltip: true },
|
||||
{ title: '模型', dataIndex: 'model_name', width: 140, ellipsis: true, tooltip: true },
|
||||
|
||||
@@ -8,6 +8,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableEmpty from '@/components/TableEmpty.vue'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -224,7 +225,8 @@ const gradeColor: Record<string, string> = {
|
||||
critical: 'red',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:总成本列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: '模型', dataIndex: 'model_name', ellipsis: true, tooltip: true, width: 220, sortable: { sortDirections: ['ascend', 'descend'] } },
|
||||
{
|
||||
title: '请求数',
|
||||
@@ -299,14 +301,14 @@ const columns: TableColumnData[] = [
|
||||
render: ({ record }: any) => fmtNum(record.total_tokens).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '总成本 (USD)',
|
||||
title: `总成本 (${displayCurrency.value})`,
|
||||
dataIndex: 'total_cost',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
sortable: { sortDirections: ['ascend', 'descend'] },
|
||||
render: ({ record }: any) => `$${fmtNum(record.total_cost).toFixed(6)}`,
|
||||
render: ({ record }: any) => formatBilling(fmtNum(record.total_cost), 6),
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
// 成功率分级(与后端 gradeSuccessRate 同阈值):excellent≥99 / good≥95 / warning≥90 / critical<90
|
||||
function rateGrade(rate: number): string {
|
||||
@@ -520,7 +522,7 @@ function renderChannelMatrix(record: any) {
|
||||
<div class="metric-item">
|
||||
<div class="metric-name">总成本</div>
|
||||
<div class="metric-body">
|
||||
<p>区间内该模型实际计费成本合计(USD),已含输入、输出与缓存各项费用。</p>
|
||||
<p>区间内该模型实际计费成本合计({{ displayCurrency }}),已含输入、输出与缓存各项费用。</p>
|
||||
<p><code>总成本 = Σ total_cost</code></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,8 +598,8 @@ function renderChannelMatrix(record: any) {
|
||||
<div class="metric-card__value">{{ stats.overallRate.toFixed(2) }}%</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--orange">
|
||||
<div class="metric-card__label">总成本 (USD)</div>
|
||||
<div class="metric-card__value">${{ stats.totalCost.toFixed(6) }}</div>
|
||||
<div class="metric-card__label">总成本 ({{ displayCurrency }})</div>
|
||||
<div class="metric-card__value">{{ formatBilling(stats.totalCost, 6) }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--green">
|
||||
<div class="metric-card__label">请求缓存命中率</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TooltipComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// 桑基图无需 GridComponent(无笛卡尔坐标轴)
|
||||
use([CanvasRenderer, SankeyChart, TooltipComponent])
|
||||
@@ -45,7 +46,8 @@ function defaultRange(days: number): [string, string] {
|
||||
}
|
||||
|
||||
function formatValue(v: number): string {
|
||||
if (metric.value === 'cost') return `$${Number(v).toFixed(6)}`
|
||||
// 成本指标按 bil 层本位币展示(tooltip formatter 在悬浮时才调用,天然读取最新本位币)
|
||||
if (metric.value === 'cost') return formatBilling(v, 6)
|
||||
return Number(v).toLocaleString()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,8 @@ function updateCnyToUsd(usdToCny: number | undefined) {
|
||||
<div class="section">
|
||||
<div class="section-title">基础设置</div>
|
||||
<div class="section-grid">
|
||||
<AFormItem label="货币单位">
|
||||
<ASelect v-model="values['payment_currency']">
|
||||
<AOption value="CNY" label="CNY (人民币)" />
|
||||
<AOption value="USD" label="USD (美元)" />
|
||||
</ASelect>
|
||||
<AFormItem label="本位币" help="系统记账与全站显示货币,初始化时选定,不可更改">
|
||||
<AInput :model-value="values['billing_currency'] || 'USD'" disabled style="width: 100%" />
|
||||
</AFormItem>
|
||||
<AFormItem label="最低充值金额">
|
||||
<AInputNumber
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, reactive, provide, inject, type InjectionKey } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import { refreshCurrencySettings } from '@/composables/useCurrency'
|
||||
|
||||
const settingsFormKey: InjectionKey<Record<string, any>> = Symbol('settingsFormValues')
|
||||
|
||||
@@ -35,6 +36,8 @@ export function useSettings(category: () => string) {
|
||||
try {
|
||||
await request.put(`/admin/settings/${category()}`, { settings: formValues })
|
||||
Message.success('保存成功')
|
||||
// 汇率等公共配置变更后强制刷新,已打开页面立即按新值重渲染货币显示
|
||||
refreshCurrencySettings()
|
||||
await refresh()
|
||||
} catch {
|
||||
// error toast already shown by interceptor
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import {
|
||||
Tag, Button, Space, Message, Modal,
|
||||
} from '@arco-design/web-vue'
|
||||
@@ -8,16 +8,18 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const levels = ref<any[]>([])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:阈值列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: '等级号', dataIndex: 'level', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{
|
||||
title: '累计充值阈值(USD)', dataIndex: 'cumulative_recharge_threshold', width: 180,
|
||||
render({ record }) { return `$${Number(record.cumulative_recharge_threshold).toFixed(2)}` },
|
||||
title: `累计充值阈值(${displayCurrency.value})`, dataIndex: 'cumulative_recharge_threshold', width: 180,
|
||||
render({ record }) { return formatBilling(record.cumulative_recharge_threshold, 2) },
|
||||
},
|
||||
{
|
||||
title: '最大成员数', dataIndex: 'max_members', width: 100,
|
||||
@@ -45,7 +47,7 @@ const columns: TableColumnData[] = [
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
async function fetchLevels() {
|
||||
loading.value = true
|
||||
@@ -166,7 +168,7 @@ onMounted(fetchLevels)
|
||||
<AFormItem label="等级名称" required>
|
||||
<AInput v-model="formData.name" placeholder="如:LV2 白银用户" />
|
||||
</AFormItem>
|
||||
<AFormItem label="累计充值阈值(USD)" required>
|
||||
<AFormItem :label="`累计充值阈值(${displayCurrency})`" required>
|
||||
<AInputNumber v-model="formData.cumulative_recharge_threshold" :min="0" :precision="2" class="w-full" placeholder="达到此金额自动升级" />
|
||||
</AFormItem>
|
||||
<AFormItem label="最大成员数">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Tag, Button, Space, Message, InputNumber,
|
||||
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -118,7 +119,8 @@ async function handleCreateSubmit(done: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
// 列定义为 computed:钱包余额列头币种跟随本位币(配置异步加载后自动更新)
|
||||
const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '租户名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '代码', dataIndex: 'code', width: 120 },
|
||||
@@ -171,12 +173,12 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '钱包余额(美元)',
|
||||
title: `钱包余额(${displayCurrency.value})`,
|
||||
dataIndex: 'wallet_balance',
|
||||
width: 120,
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.wallet_balance || '0')
|
||||
return `\$${val.toFixed(2)}`
|
||||
return formatBilling(val, 2)
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
@@ -192,7 +194,7 @@ const columns: TableColumnData[] = [
|
||||
])
|
||||
},
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
|
||||
@@ -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 × ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
|
||||
{{ (tc.tokens || 0).toLocaleString() }} tokens × {{ formatBilling(tc.unit_price || 0, 6) }}/1M = <strong>{{ formatBilling(tc.cost || 0, 6) }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -105,7 +106,7 @@ onMounted(fetchLevelOptions)
|
||||
<ATag v-if="detail.max_members != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="钱包余额">
|
||||
<span class="money">${{ parseFloat(detail.wallet_balance || '0').toFixed(2) }}</span>
|
||||
<span class="money">{{ formatBilling(detail.wallet_balance, 2) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="并发上限">
|
||||
{{ getEffectiveMaxConcurrency() ?? '不限' }}
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency } from '@/composables/useCurrency'
|
||||
|
||||
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -488,22 +492,22 @@ defineExpose({ openPreviewModal })
|
||||
<div class="grid grid-cols-2 gap-x-3">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_read_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="editModelForm.custom_cache_creation_price" :min="0" :precision="4" placeholder="默认" class="w-full">
|
||||
<template #suffix>$ / 1M</template>
|
||||
<template #suffix>{{ currencySymbol }} / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
@@ -514,7 +518,7 @@ defineExpose({ openPreviewModal })
|
||||
<ADivider margin="8px">按次定价</ADivider>
|
||||
<AFormItem label="按次单价">
|
||||
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="每次调用价格" class="w-full">
|
||||
<template #suffix>$ / 次</template>
|
||||
<template #suffix>{{ currencySymbol }} / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</template>
|
||||
@@ -540,24 +544,24 @@ defineExpose({ openPreviewModal })
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="输入价格">
|
||||
<AInputNumber v-model="tier.input_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格">
|
||||
<AInputNumber v-model="tier.output_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 mt-2">
|
||||
<AFormItem label="缓存读取价格">
|
||||
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格">
|
||||
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="4" class="w-full">
|
||||
<template #suffix>$/1M</template>
|
||||
<template #suffix>{{ currencySymbol }}/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -63,13 +64,13 @@ const txColumns: TableColumnData[] = [
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.amount) || 0
|
||||
const color = val >= 0 ? 'rgb(var(--green-6))' : 'rgb(var(--red-6))'
|
||||
const prefix = val >= 0 ? '+' : ''
|
||||
return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(6)}`)
|
||||
// bil 层本位币金额:showSign 自动带 +/- 符号
|
||||
return h('span', { style: { fontWeight: 600, color } }, formatBilling(val, 6, true))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 140,
|
||||
render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(6)}` },
|
||||
render({ record }) { return formatBilling(parseFloat(record.balance_after) || 0, 6) },
|
||||
},
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, h } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const props = defineProps<{
|
||||
tenantId: string
|
||||
@@ -18,6 +19,21 @@ const emit = defineEmits<{
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// 本位币符号:bil 层表单(调整余额/预警阈值)输入前缀跟随本位币,输入值仍为存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
|
||||
// 线下充值入账弹窗文案:收款恒为人民币(银行转账),到账按本位币分支说明
|
||||
const offlineRechargeIntro = computed(() =>
|
||||
displayCurrency.value === 'CNY'
|
||||
? '用于线下银行转账入账:输入人民币金额直接入账钱包(本位币为人民币,无需换算),并累计到累计充值(影响租户等级)。'
|
||||
: '用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。',
|
||||
)
|
||||
const offlineRechargeNote = computed(() =>
|
||||
displayCurrency.value === 'CNY'
|
||||
? '到账金额 = 输入的人民币金额(本位币直接入账)'
|
||||
: '到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数',
|
||||
)
|
||||
|
||||
// === 钱包信息 ===
|
||||
const walletInfo = ref<any>(null)
|
||||
const walletLoading = ref(false)
|
||||
@@ -63,7 +79,7 @@ const frozenColumns: TableColumnData[] = [
|
||||
{
|
||||
title: '冻结金额', dataIndex: 'amount', width: 130,
|
||||
render({ record }) {
|
||||
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, `$${(parseFloat(record.amount) || 0).toFixed(6)}`)
|
||||
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, formatBilling(parseFloat(record.amount) || 0, 6))
|
||||
},
|
||||
},
|
||||
{ title: '冻结时间', dataIndex: 'created_at', width: 280 },
|
||||
@@ -102,7 +118,7 @@ async function handleRelease(done: (closed: boolean) => void) {
|
||||
reason: releaseForm.reason.trim(),
|
||||
})
|
||||
const amt = parseFloat(res.data?.data?.released_amount || 0)
|
||||
Message.success(`已释放冻结 $${amt.toFixed(6)}`)
|
||||
Message.success(`已释放冻结 ${formatBilling(amt, 6)}`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
} catch {
|
||||
@@ -134,7 +150,7 @@ async function handleReleaseAll(done: (closed: boolean) => void) {
|
||||
const data = res.data?.data || {}
|
||||
const amt = parseFloat(data.released_amount || 0)
|
||||
const skipped = parseInt(data.skipped_count || 0, 10)
|
||||
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 $${amt.toFixed(6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
|
||||
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 ${formatBilling(amt, 6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
|
||||
if (skipped) {
|
||||
Message.warning(`跳过原因:${(data.skipped_reasons || []).join(';')}`)
|
||||
}
|
||||
@@ -193,7 +209,8 @@ async function handleOfflineRecharge(done: (closed: boolean) => void) {
|
||||
description: offlineRechargeForm.description,
|
||||
})
|
||||
const data = res.data?.data || {}
|
||||
Message.success(`入账成功,到账 $${parseFloat(data.credited_usd || 0).toFixed(2)}(汇率 ${data.rate})`)
|
||||
// credited_usd 为 bil 层本位币金额,按本位币符号展示(汇率信息保留)
|
||||
Message.success(`入账成功,到账 ${formatBilling(data.credited_usd, 2)}(汇率 ${data.rate})`)
|
||||
done(true)
|
||||
await refreshAll()
|
||||
emit('refresh-detail')
|
||||
@@ -253,16 +270,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
</template>
|
||||
<ADescriptions :column="isMobile ? 1 : 3" bordered size="medium">
|
||||
<ADescriptionsItem label="可用余额">
|
||||
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
<span class="money">{{ formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="总余额">
|
||||
${{ parseFloat(walletInfo.balance).toFixed(6) }}
|
||||
{{ formatBilling(walletInfo.balance, 6) }}
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="冻结余额">
|
||||
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }}</span>
|
||||
<span style="color: rgb(var(--orange-6))">{{ formatBilling(walletInfo.frozen_balance, 6) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="预警阈值">
|
||||
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }}
|
||||
{{ walletInfo.warning_threshold > 0 ? formatBilling(walletInfo.warning_threshold, 6) : '关闭' }}
|
||||
</ADescriptionsItem>
|
||||
</ADescriptions>
|
||||
</ACard>
|
||||
@@ -338,16 +355,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<!-- 调整余额弹窗 -->
|
||||
<AModal v-model:visible="showRechargeModal" title="调整余额" :width="440" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
|
||||
手动调整该租户的{{ displayCurrency === 'USD' ? '美元' : '人民币' }}钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
|
||||
<br />
|
||||
总余额 <span class="money">${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }}</span>
|
||||
| 可用余额 <span class="money">${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }}</span>
|
||||
总余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance, 6) : formatBilling(0, 6) }}</span>
|
||||
| 可用余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) : formatBilling(0, 6) }}</span>
|
||||
<span style="color: var(--ta-text-tertiary)">(扣减上限)</span>
|
||||
</div>
|
||||
<AForm :model="rechargeForm" layout="vertical">
|
||||
<AFormItem label="调整金额(USD)" required>
|
||||
<AFormItem :label="`调整金额(${displayCurrency})`" required>
|
||||
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=增加余额,负数=扣减余额" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
<template #prefix>{{ currencySymbol }}</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="调整说明">
|
||||
@@ -362,7 +379,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<!-- 线下充值入账弹窗 -->
|
||||
<AModal v-model:visible="showOfflineRechargeModal" title="线下充值入账" :width="440" :on-before-ok="handleOfflineRecharge" :ok-loading="offlineRechargeLoading">
|
||||
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
|
||||
用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。
|
||||
{{ offlineRechargeIntro }}
|
||||
</div>
|
||||
<AForm :model="offlineRechargeForm" layout="vertical">
|
||||
<AFormItem label="入账金额(人民币)" required>
|
||||
@@ -378,7 +395,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
|
||||
到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数
|
||||
{{ offlineRechargeNote }}
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
@@ -390,7 +407,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<AForm :model="thresholdForm" layout="vertical">
|
||||
<AFormItem label="预警阈值">
|
||||
<AInputNumber v-model="thresholdForm.threshold" :precision="2" :min="0" placeholder="设为 0 表示不预警" class="w-full">
|
||||
<template #prefix>$</template>
|
||||
<template #prefix>{{ currencySymbol }}</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
@@ -424,7 +441,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
|
||||
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
|
||||
请求ID:<span style="font-family: monospace">{{ releaseTarget.request_id }}</span>
|
||||
<br />
|
||||
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">${{ (parseFloat(releaseTarget.amount) || 0).toFixed(6) }}</span>
|
||||
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">{{ formatBilling(releaseTarget.amount, 6) }}</span>
|
||||
| 已冻结 {{ formatFrozenAge(releaseTarget.age_seconds) }}
|
||||
</div>
|
||||
<AForm :model="releaseForm" layout="vertical">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
} from 'chart.js'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
@@ -28,10 +29,10 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const colors = ['#14b8a6', '#06b6d4', '#6366f1', '#f59e0b', '#ec4899', '#3b82f6', '#10b981', '#ef4444']
|
||||
|
||||
// 金额格式化统一走本位币;chartOptions 为 computed,tooltip 回调随 displayCurrency 变化重渲染
|
||||
function formatCost(value: number): string {
|
||||
if (value === 0) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
return `$${value.toFixed(4)}`
|
||||
if (value === 0 || value >= 1) return formatBilling(value, 2)
|
||||
return formatBilling(value, 4)
|
||||
}
|
||||
|
||||
const totalCost = computed(() => props.data.reduce((sum, item) => sum + Number(item.total_cost || 0), 0))
|
||||
|
||||
@@ -6,6 +6,7 @@ import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
export interface ApiKeyData {
|
||||
id: number
|
||||
@@ -374,7 +375,7 @@ const isFormMode = computed(() => {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="总额度 (USD)" class="!mb-0">
|
||||
<n-form-item :label="`总额度 (${displayCurrency})`" class="!mb-0">
|
||||
<n-input-number
|
||||
v-model:value="form.total_quota"
|
||||
:min="0"
|
||||
@@ -385,7 +386,7 @@ const isFormMode = computed(() => {
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 ${{ apiKey.used_quota?.toFixed(4) }}</p>
|
||||
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 {{ formatBilling(apiKey.used_quota, 4) }}</p>
|
||||
|
||||
<n-form-item label="IP 白名单">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { computed } from 'vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
/**
|
||||
* 全站货币显示(本位币制):
|
||||
* - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算
|
||||
* - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显
|
||||
* - 本位币由系统初始化向导选定,前端只读(/settings/public 暴露)
|
||||
* - 编辑表单保持存储币种原值输入,折算仅用于只读展示
|
||||
*/
|
||||
export type CurrencyCode = 'USD' | 'CNY'
|
||||
|
||||
/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */
|
||||
const FALLBACK_CNY_TO_USD = 0.14
|
||||
|
||||
const { settings } = usePublicSettings()
|
||||
|
||||
/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */
|
||||
export const displayCurrency = computed<CurrencyCode>(() =>
|
||||
settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD',
|
||||
)
|
||||
|
||||
/** CNY→USD 汇率(管理后台支付设置配置) */
|
||||
export const cnyToUsd = computed(() => {
|
||||
const r = Number(settings.value.payment_exchange_rate_cny_to_usd)
|
||||
return r > 0 ? r : FALLBACK_CNY_TO_USD
|
||||
})
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */
|
||||
source?: CurrencyCode
|
||||
/** 显示精度;默认按显示币种取 USD 6 位 / CNY 2 位 */
|
||||
precision?: number
|
||||
/** 正负号前缀(交易流水) */
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化(跟随本位币)。
|
||||
* source='CNY'(订单层)且本位币=USD 时按汇率折算;bil 层数据传 source=displayCurrency 即直显。
|
||||
* 内部读取响应式 computed,render 函数中调用可建立依赖,配置变化自动重渲染。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { source = 'USD', precision, showSign = false } = opts
|
||||
const display = displayCurrency.value
|
||||
let num = Number(value ?? 0)
|
||||
// 唯一折算场景:订单层 CNY 金额在本位币 USD 部署下展示
|
||||
if (source === 'CNY' && display === 'USD') {
|
||||
num = num * cnyToUsd.value
|
||||
}
|
||||
const symbol = display === 'CNY' ? '¥' : '$'
|
||||
const p = precision ?? (display === 'CNY' ? 2 : 6)
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(p))}`
|
||||
}
|
||||
|
||||
/** bil_ 层(钱包/计费/定价等本位币数据)快捷格式化:直显 + 本位币符号 */
|
||||
export function formatBilling(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: displayCurrency.value, precision, showSign })
|
||||
}
|
||||
|
||||
/** ord_/pln_ 层(订单/套餐等 CNY 数据)快捷格式化:本位币=CNY 直显,USD 折算 */
|
||||
export function formatOrder(value: unknown, precision?: number, showSign = false): string {
|
||||
return formatMoney(value, { source: 'CNY', precision, showSign })
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值页专用:本位币 USD 部署下,用户输入的显示货币(USD)金额换算为 CNY 下单金额。
|
||||
* 本位币 CNY 时原样返回(充值锚定 CNY,零折算)。
|
||||
*/
|
||||
export function displayToCny(value: number): number {
|
||||
if (displayCurrency.value === 'CNY') return value
|
||||
return Number((value / cnyToUsd.value).toFixed(2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值页专用:CNY 下单金额换算为显示货币金额(档位折算展示,¥100 × 0.14 = $14)。
|
||||
* 本位币 CNY 时原样返回。
|
||||
*/
|
||||
export function cnyToDisplay(value: number): number {
|
||||
if (displayCurrency.value === 'CNY') return value
|
||||
return Number((value * cnyToUsd.value).toFixed(2))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { formatMoney as formatMoneyWithCurrency } from '@/composables/useCurrency'
|
||||
|
||||
/**
|
||||
* NDataTable 迁移公共渲染工具。
|
||||
@@ -34,24 +35,22 @@ export function renderBadge(
|
||||
}
|
||||
|
||||
export interface FormatMoneyOptions {
|
||||
/** 数据存储层币种(按字段所属层传入:bil 层 USD/CNY 由本位币定,订单层 CNY),非显示币种 */
|
||||
currency?: 'USD' | 'CNY'
|
||||
precision?: number
|
||||
showSign?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一金额格式化。默认 USD、6 位精度(与 NUMERIC(20,10) 对齐)。
|
||||
* currency='CNY' 时用 ¥ 前缀 + 2 位精度(订单/充值层)。
|
||||
* showSign 用 +/− 前缀(交易流水)。
|
||||
* 统一金额格式化(委托 useCurrency,跟随系统本位币显示)。
|
||||
* currency 参数语义 = 数据存储层币种:bil 层数据传本位币对应值(USD 部署默认 USD)、
|
||||
* 订单/充值层传 'CNY'(本位币 USD 时自动按汇率折算显示)。
|
||||
* 精度默认按显示币种取 USD 6 位 / CNY 2 位;showSign 用 +/− 前缀(交易流水)。
|
||||
* 自动去掉末尾的 0(如 $1.500000 → $1.5)。
|
||||
*/
|
||||
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
|
||||
const { currency = 'USD', precision = 6, showSign = false } = opts
|
||||
const num = Number(value ?? 0)
|
||||
const symbol = currency === 'CNY' ? '¥' : '$'
|
||||
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
|
||||
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
|
||||
return `${sign}${symbol}${Number(Math.abs(num).toFixed(precision))}`
|
||||
const { currency = 'USD', precision, showSign = false } = opts
|
||||
return formatMoneyWithCurrency(value, { source: currency, precision, showSign })
|
||||
}
|
||||
|
||||
/** 统一日期格式化:ISO T 转空格、截断到分钟;空值显示 -- */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNotificationCount } from '@/composables/useNotificationCount'
|
||||
import { useAnnouncementRead } from '@/composables/useAnnouncementRead'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useWatermark } from '@/composables/useWatermark'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import { toast } from '@/utils/toast'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue'
|
||||
@@ -173,7 +174,7 @@ async function fetchWalletBalance() {
|
||||
const w = res.data?.data
|
||||
if (w) {
|
||||
const bal = w.available_balance ?? w.balance ?? 0
|
||||
walletBalance.value = bal >= 100 ? '$' + bal.toFixed(0) : '$' + bal.toFixed(2)
|
||||
walletBalance.value = bal >= 100 ? formatBilling(bal, 0) : formatBilling(bal, 2)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
@@ -447,7 +448,7 @@ onBeforeUnmount(() => {
|
||||
title="钱包"
|
||||
>
|
||||
<Icon name="currencyDollar" size="sm" />
|
||||
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || '$0' }}</span>
|
||||
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || formatBilling(0, 2) }}</span>
|
||||
</router-link>
|
||||
<!-- Member Quota Capsule -->
|
||||
<router-link
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
|
||||
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
|
||||
@@ -110,12 +111,12 @@ function formatNumber(value: unknown): string {
|
||||
return number.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(value: unknown): string {
|
||||
const number = Number(value) || 0
|
||||
if (number === 0) return '$0.00'
|
||||
if (number >= 1) return `$${number.toFixed(2)}`
|
||||
if (number >= 0.01) return `$${number.toFixed(4)}`
|
||||
return `$${number.toFixed(6)}`
|
||||
if (number === 0 || number >= 1) return formatBilling(number, 2)
|
||||
if (number >= 0.01) return formatBilling(number, 4)
|
||||
return formatBilling(number, 6)
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
|
||||
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
|
||||
@@ -106,12 +107,12 @@ function formatNumber(value: unknown): string {
|
||||
return number.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(value: unknown): string {
|
||||
const number = Number(value) || 0
|
||||
if (number === 0) return '$0.00'
|
||||
if (number >= 1) return `$${number.toFixed(2)}`
|
||||
if (number >= 0.01) return `$${number.toFixed(4)}`
|
||||
return `$${number.toFixed(6)}`
|
||||
if (number === 0 || number >= 1) return formatBilling(number, 2)
|
||||
if (number >= 0.01) return formatBilling(number, 4)
|
||||
return formatBilling(number, 6)
|
||||
}
|
||||
|
||||
function formatMs(value: unknown): string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -69,9 +70,10 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
title: '面值',
|
||||
key: 'value',
|
||||
width: 130,
|
||||
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
|
||||
render: (row) =>
|
||||
row.type === 'quota'
|
||||
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
|
||||
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
|
||||
: h('span', { class: 'font-mono' }, '-'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const loading = ref(false)
|
||||
const transactions = ref<any[]>([])
|
||||
@@ -87,9 +88,9 @@ const txTypeBadgeClass: Record<string, string> = {
|
||||
unfreeze: 'badge-gray',
|
||||
}
|
||||
|
||||
// 流水金额:bil 层数据直显本位币符号,showSign 为正数补 +、负数自带 -
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 0) return '+$' + amount.toFixed(6)
|
||||
return '-$' + Math.abs(amount).toFixed(6)
|
||||
return formatBilling(amount, 6, true)
|
||||
}
|
||||
|
||||
async function fetchTransactions() {
|
||||
@@ -150,7 +151,7 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
title: '余额',
|
||||
key: 'balance_after',
|
||||
width: 120,
|
||||
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? `$${row.balance_after.toFixed(6)}` : '--'),
|
||||
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? formatBilling(row.balance_after, 6) : '--'),
|
||||
},
|
||||
{ title: '用户', key: 'username', width: 140, render: (row) => h('span', { class: 'text-gray-700 text-sm' }, row.username || '--') },
|
||||
{
|
||||
|
||||
@@ -8,14 +8,18 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { dispatchPayment } from '@/utils/payment'
|
||||
import { formatBilling, formatOrder, displayCurrency, displayToCny, cnyToDisplay } from '@/composables/useCurrency'
|
||||
|
||||
const route = useRoute()
|
||||
const wallet = ref<any>(null)
|
||||
const walletLoading = ref(true)
|
||||
|
||||
// Recharge
|
||||
// rechargeAmount 统一存"用户输入/选择的显示货币金额"(本位币);提交与折扣匹配前折回 CNY
|
||||
const rechargeAmount = ref<number | null>(null)
|
||||
const customAmount = ref<number | null>(null)
|
||||
// 当前选中的预设档位(CNY 原面额),自定义输入时清空,仅用于档位选中态展示
|
||||
const selectedPresetCny = ref<number | null>(null)
|
||||
const rechargeLoading = ref(false)
|
||||
const selectedChannel = ref('')
|
||||
const selectedPaymentMethod = ref('')
|
||||
@@ -75,6 +79,12 @@ const payMethods = computed(() => {
|
||||
|
||||
const minimumAmount = computed(() => Number(paymentInfo.value?.min_topup) || 1)
|
||||
|
||||
// 充值页显示口径换算:minimumAmount 是 CNY 门槛,输入框的最小值需换算为显示货币
|
||||
const displayMinAmount = computed(() => cnyToDisplay(minimumAmount.value))
|
||||
|
||||
// 提交口径:显示货币金额折回 CNY 订单金额(与 handleRecharge 提交值一致,折扣/门槛校验同口径)
|
||||
const rechargeCnyAmount = computed(() => (rechargeAmount.value != null ? displayToCny(rechargeAmount.value) : 0))
|
||||
|
||||
const thresholdActive = computed(() => Number(wallet.value?.warning_threshold) > 0)
|
||||
|
||||
const thresholdValidationError = computed(() => {
|
||||
@@ -87,21 +97,24 @@ const thresholdValidationError = computed(() => {
|
||||
const selectedDiscount = computed(() => {
|
||||
// 折扣档位仅整数金额精确命中(与后端 RechargeCreate 口径一致):
|
||||
// 100.99 不享受 100 档折扣,避免前端展示与实际计价不符
|
||||
if (!rechargeAmount.value || !Number.isInteger(rechargeAmount.value)) return 1
|
||||
return Number(paymentInfo.value?.amount_discount?.[rechargeAmount.value]) || 1
|
||||
// 折扣锚定 CNY 整数面额:预设档位(CNY 锚定)可命中,自定义输入折算后非整数不命中(预期行为)
|
||||
const cny = rechargeCnyAmount.value
|
||||
if (!cny || !Number.isInteger(cny)) return 1
|
||||
return Number(paymentInfo.value?.amount_discount?.[cny]) || 1
|
||||
})
|
||||
|
||||
// 实付金额 = 显示货币金额 × 折扣(显示口径,展示时直接用 formatBilling)
|
||||
const finalPayAmount = computed(() => (rechargeAmount.value || 0) * selectedDiscount.value)
|
||||
|
||||
const rechargeValidationMessage = computed(() => {
|
||||
if (!rechargeAmount.value) return ''
|
||||
if (rechargeAmount.value < minimumAmount.value) return `最低充值金额为 ¥${minimumAmount.value.toFixed(2)}`
|
||||
if (rechargeCnyAmount.value < minimumAmount.value) return `最低充值金额为 ${formatOrder(minimumAmount.value, 2)}`
|
||||
return ''
|
||||
})
|
||||
|
||||
const rechargeReady = computed(() =>
|
||||
!!rechargeAmount.value
|
||||
&& rechargeAmount.value >= minimumAmount.value
|
||||
&& rechargeCnyAmount.value >= minimumAmount.value
|
||||
&& !!selectedChannel.value
|
||||
&& !!selectedPaymentMethod.value,
|
||||
)
|
||||
@@ -135,11 +148,15 @@ async function fetchPaymentInfo() {
|
||||
}
|
||||
|
||||
function selectPresetAmount(amount: number) {
|
||||
rechargeAmount.value = amount
|
||||
// amount 为 CNY 原面额:选中态与折扣匹配锚定 CNY,rechargeAmount 存换算后的显示货币金额
|
||||
selectedPresetCny.value = amount
|
||||
rechargeAmount.value = cnyToDisplay(amount)
|
||||
customAmount.value = null
|
||||
}
|
||||
|
||||
function onCustomInput() {
|
||||
// 自定义输入按显示货币理解(本位币 USD 时即 USD 金额),提交时统一折回 CNY
|
||||
selectedPresetCny.value = null
|
||||
const val = customAmount.value ?? 0
|
||||
rechargeAmount.value = val <= 0 ? null : val
|
||||
}
|
||||
@@ -160,7 +177,8 @@ async function handleRecharge() {
|
||||
rechargeLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/tenant/recharge/create', {
|
||||
amount: rechargeAmount.value,
|
||||
// 订单层恒 CNY:用户输入的显示货币金额折算为 CNY 下单金额(本位币 CNY 时原样)
|
||||
amount: displayToCny(rechargeAmount.value),
|
||||
payment_channel: selectedChannel.value,
|
||||
payment_method: selectedPaymentMethod.value,
|
||||
})
|
||||
@@ -263,9 +281,10 @@ const redeemHistoryColumns = computed<DataTableColumns<any>>(() => [
|
||||
title: '面值',
|
||||
key: 'value',
|
||||
width: 130,
|
||||
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
|
||||
render: (row) =>
|
||||
row.type === 'quota'
|
||||
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
|
||||
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
|
||||
: h('span', { class: 'font-mono' }, '-'),
|
||||
},
|
||||
{
|
||||
@@ -414,14 +433,13 @@ onBeforeUnmount(() => {
|
||||
<p class="mt-0.5 text-xs text-slate-400">当前可用于模型调用</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="currency-badge">{{ wallet?.currency || 'USD' }}</span>
|
||||
<span class="currency-badge">{{ displayCurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="text-xs font-medium text-slate-400">余额</p>
|
||||
<div class="mt-1 flex items-baseline gap-1.5">
|
||||
<span class="text-xl font-semibold text-slate-400">$</span>
|
||||
<strong class="balance-value">{{ wallet?.balance?.toFixed(2) ?? '0.00' }}</strong>
|
||||
<strong class="balance-value">{{ formatBilling(wallet?.balance, 2) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -431,7 +449,7 @@ onBeforeUnmount(() => {
|
||||
<span class="text-sm text-slate-500">冻结金额</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<strong class="text-sm font-semibold tabular-nums text-slate-700">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</strong>
|
||||
<strong class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.frozen_balance, 2) }}</strong>
|
||||
<Icon name="chevronRight" size="xs" class="text-slate-300" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -442,7 +460,7 @@ onBeforeUnmount(() => {
|
||||
<span class="text-sm text-slate-500">余额预警</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">${{ Number(wallet?.warning_threshold).toFixed(2) }}</strong>
|
||||
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.warning_threshold, 2) }}</strong>
|
||||
<span v-else class="text-sm text-slate-400">已关闭</span>
|
||||
<Icon name="chevronRight" size="xs" class="text-slate-300" />
|
||||
</div>
|
||||
@@ -484,25 +502,26 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-semibold text-slate-600">充值金额</label>
|
||||
<span class="text-[11px] text-slate-400">最低 ¥{{ minimumAmount.toFixed(2) }}</span>
|
||||
<span class="text-[11px] text-slate-400">最低 {{ formatOrder(minimumAmount, 2) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="amount in presetAmounts"
|
||||
:key="amount"
|
||||
class="amount-pill"
|
||||
:class="{ 'amount-pill-active': rechargeAmount === amount && !customAmount }"
|
||||
:class="{ 'amount-pill-active': selectedPresetCny === amount && !customAmount }"
|
||||
@click="selectPresetAmount(amount)"
|
||||
>
|
||||
<span><small>¥</small>{{ amount }}</span>
|
||||
<!-- amount 为 CNY 原面额:本位币 USD 时按汇率折算展示 -->
|
||||
<span><small>{{ displayCurrency === 'CNY' ? '¥' : '$' }}</small>{{ cnyToDisplay(amount) }}</span>
|
||||
<em v-if="discountText(amount)">{{ discountText(amount) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative mt-3">
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">¥</span>
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
|
||||
<n-input-number
|
||||
v-model:value="customAmount"
|
||||
:min="minimumAmount"
|
||||
:min="displayMinAmount"
|
||||
:step="0.01"
|
||||
:status="rechargeValidationMessage ? 'error' : undefined"
|
||||
placeholder="输入其他金额"
|
||||
@@ -545,10 +564,10 @@ onBeforeUnmount(() => {
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] text-slate-400">本次实付</p>
|
||||
<div class="mt-0.5 flex items-baseline gap-2">
|
||||
<strong class="text-xl font-bold tabular-nums text-slate-800">¥{{ finalPayAmount.toFixed(2) }}</strong>
|
||||
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">¥{{ rechargeAmount?.toFixed(2) }}</span>
|
||||
<strong class="text-xl font-bold tabular-nums text-slate-800">{{ formatBilling(finalPayAmount, 2) }}</strong>
|
||||
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">{{ formatBilling(rechargeAmount, 2) }}</span>
|
||||
</div>
|
||||
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 ¥{{ rechargeAmount?.toFixed(2) }} 全额折算</p>
|
||||
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 {{ formatBilling(rechargeAmount, 2) }} 全额折算</p>
|
||||
</div>
|
||||
<button class="btn btn-primary min-w-32" :disabled="!rechargeReady || rechargeLoading" @click="handleRecharge">
|
||||
<Icon v-if="rechargeLoading" name="refresh" size="sm" class="animate-spin" />
|
||||
@@ -559,7 +578,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="recharge-note mt-3">
|
||||
<Icon name="infoCircle" size="xs" class="mt-0.5 flex-shrink-0" />
|
||||
<p>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
|
||||
<p v-if="displayCurrency === 'CNY'">支付金额为人民币,直接入账钱包。</p>
|
||||
<p v-else>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -598,7 +618,7 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right ml-3 flex-shrink-0">
|
||||
<p class="text-sm font-semibold text-amber-600">${{ item.amount?.toFixed(4) }}</p>
|
||||
<p class="text-sm font-semibold text-amber-600">{{ formatBilling(item.amount, 4) }}</p>
|
||||
<p class="text-xs text-gray-400">剩余 {{ formatRemaining(item.remaining) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -718,9 +738,9 @@ onBeforeUnmount(() => {
|
||||
<p>当可用余额低于预警线时,向组织 owner / admin 发送通知。设为 0 可关闭预警。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate-600">预警阈值(USD)</label>
|
||||
<label class="text-xs font-semibold text-slate-600">预警阈值({{ displayCurrency }})</label>
|
||||
<div class="relative mt-3">
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">$</span>
|
||||
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
|
||||
<n-input-number
|
||||
v-model:value="thresholdInput"
|
||||
:min="0"
|
||||
@@ -735,10 +755,11 @@ onBeforeUnmount(() => {
|
||||
<p v-else class="mt-1.5 text-xs text-slate-400">设为 0 表示关闭预警</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- 快捷按钮展示跟随本位币,thresholdInput 表单值保持存储币种原值不变 -->
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 0 }" @click="thresholdInput = 0">关闭</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">$1</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">$5</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">$10</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">{{ formatBilling(1) }}</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">{{ formatBilling(5) }}</button>
|
||||
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">{{ formatBilling(10) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -143,10 +144,11 @@ function formatLimit(value: number | null | undefined, suffix = ''): string {
|
||||
return `${value}${suffix}`
|
||||
}
|
||||
|
||||
// Key 额度展示(bil 层,本位币直显)
|
||||
function formatQuota(key: ApiKey): string {
|
||||
const used = key.used_quota || 0
|
||||
if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限`
|
||||
return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}`
|
||||
if (!key.total_quota || key.total_quota <= 0) return `${formatBilling(used, 2)} / 不限`
|
||||
return `${formatBilling(used, 2)} / ${formatBilling(key.total_quota, 2)}`
|
||||
}
|
||||
|
||||
async function disableKey(keyId: number) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, onMounted, computed, h } from 'vue'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
|
||||
@@ -87,9 +88,10 @@ function isBest(item: ComparisonItem, field: keyof ComparisonItem): boolean {
|
||||
return val === Math.min(...values)
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(bil 层数据直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染)
|
||||
function formatCost(v: number): string {
|
||||
if (v < 0.01 && v > 0) return v.toFixed(6)
|
||||
return v.toFixed(4)
|
||||
if (v < 0.01 && v > 0) return formatBilling(v, 6)
|
||||
return formatBilling(v, 4)
|
||||
}
|
||||
|
||||
function formatPct(v: number): string {
|
||||
@@ -191,7 +193,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
h(
|
||||
'span',
|
||||
{ class: isBest(row, 'total_cost') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' },
|
||||
row.requests > 0 ? '$' + formatCost(row.total_cost) : '-'
|
||||
row.requests > 0 ? formatCost(row.total_cost) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -202,7 +204,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
h(
|
||||
'span',
|
||||
{ class: isBest(row, 'avg_cost_per_request') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' },
|
||||
row.requests > 0 ? '$' + formatCost(row.avg_cost_per_request) : '-'
|
||||
row.requests > 0 ? formatCost(row.avg_cost_per_request) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -305,7 +307,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
<Icon name="creditCard" size="lg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-right">
|
||||
<div class="stat-value">${{ formatCost(summary.total_cost) }}</div>
|
||||
<div class="stat-value">{{ formatCost(summary.total_cost) }}</div>
|
||||
<div class="stat-label">总费用</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +364,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
|
||||
height: Math.max(2, (getTrendCost(day, model) / maxTrendCost) * 120) + 'px',
|
||||
backgroundColor: modelColor(mi),
|
||||
}"
|
||||
:title="`${model}: $${getTrendCost(day, model).toFixed(4)}`"
|
||||
:title="`${model}: ${formatBilling(getTrendCost(day, model), 4)}`"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NInput } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -170,9 +171,10 @@ function formatTokens(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
// 价格格式化统一走本位币(bil 层定价直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染)
|
||||
function formatPrice(n: number | null): string {
|
||||
if (n == null || n === 0) return '-'
|
||||
return '$' + n.toFixed(2)
|
||||
return formatBilling(n, 2)
|
||||
}
|
||||
|
||||
function formatTokenRange(min: number, max: number | null): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import BaseSelect from '../../components/common/BaseSelect.vue'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import DateTimeRangePicker from '@/components/common/DateTimeRangePicker.vue'
|
||||
|
||||
// 日期辅助(native,避免引入 dayjs 依赖)
|
||||
@@ -110,9 +111,9 @@ const platformLabel: Record<string, string> = {
|
||||
ali: '阿里',
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: number | undefined): string {
|
||||
if (!n) return '$0.000000'
|
||||
return '$' + n.toFixed(6)
|
||||
return formatBilling(n, 6)
|
||||
}
|
||||
|
||||
function formatTime(s: string | undefined): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import request from '@/utils/request'
|
||||
import { tableScrollX } from '@/utils/renderUtils'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
// timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss
|
||||
function tsToStr(ts: number): string {
|
||||
@@ -186,10 +187,11 @@ function openDetail(log: any) {
|
||||
detailModal.value = true
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: any): string {
|
||||
const v = Number(n)
|
||||
if (n == null || isNaN(v)) return '$0.000000'
|
||||
return '$' + v.toFixed(6)
|
||||
if (n == null || isNaN(v)) return formatBilling(0, 6)
|
||||
return formatBilling(v, 6)
|
||||
}
|
||||
|
||||
function formatMs(n: any): string {
|
||||
|
||||
@@ -11,9 +11,11 @@ export interface TokenUsage {
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
/**
|
||||
* calculateCost 根据模型定价和 token usage 估算费用
|
||||
* @returns 格式化的费用字符串,如 "$0.001234";无法计算时返回 null
|
||||
* @returns 格式化的费用字符串(跟随本位币),如 "$0.001234";无法计算时返回 null
|
||||
*/
|
||||
export function calculateCost(
|
||||
model: ModelPricing | undefined,
|
||||
@@ -27,7 +29,7 @@ export function calculateCost(
|
||||
// 按次计费
|
||||
if (model.billing_mode === 'per_request' && model.per_request_price) {
|
||||
const price = Number(model.per_request_price)
|
||||
if (price > 0) return `$${price.toFixed(6)}`
|
||||
if (price > 0) return formatBilling(price, 6)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -40,5 +42,5 @@ export function calculateCost(
|
||||
const cost = (promptTokens * inputPrice + completionTokens * outputPrice) / 1_000_000
|
||||
if (cost <= 0) return null
|
||||
|
||||
return `$${cost.toFixed(6)}`
|
||||
return formatBilling(cost, 6)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const authStore = useTenantAuthStore()
|
||||
|
||||
@@ -210,9 +211,10 @@ function formatDiscount(multiplier: number) {
|
||||
return `${discount === 0 ? '无折扣' : discount + '% 折扣'}`
|
||||
}
|
||||
|
||||
// 升级门槛属 bil 层(本位币),直显 + 本位币符号
|
||||
function formatThreshold(val: number) {
|
||||
if (val === 0) return '—'
|
||||
return '$' + val.toFixed(2)
|
||||
return formatBilling(val, 2)
|
||||
}
|
||||
|
||||
function getNextLevelThreshold() {
|
||||
@@ -468,7 +470,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Icon name="currencyDollar" size="sm" class="text-gray-400" />
|
||||
<span>累计充值 <strong class="text-gray-900">${{ levelBenefits.cumulative_recharge.toFixed(2) }}</strong></span>
|
||||
<span>累计充值 <strong class="text-gray-900">{{ formatBilling(levelBenefits.cumulative_recharge, 2) }}</strong></span>
|
||||
</div>
|
||||
<div v-if="getNextLevelThreshold() !== null" class="flex-1 w-full sm:w-auto">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
@@ -482,7 +484,7 @@ onMounted(() => {
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
再充值 ${{ (getNextLevelThreshold()! - levelBenefits.cumulative_recharge).toFixed(2) }} 即可升级
|
||||
再充值 {{ formatBilling(getNextLevelThreshold()! - levelBenefits.cumulative_recharge, 2) }} 即可升级
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-xs text-primary-600 font-medium">
|
||||
|
||||
@@ -9,6 +9,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import BaseSelect from '../../components/common/BaseSelect.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -163,8 +164,8 @@ const statCards = computed(() => {
|
||||
icon: 'creditCard',
|
||||
color: '#8b5cf6',
|
||||
soft: '#ede9fe',
|
||||
value: u ? `$${u.month_total_cost.toFixed(2)}` : '--',
|
||||
detail: u ? 'USD' : '',
|
||||
value: u ? formatBilling(u.month_total_cost, 2) : '--',
|
||||
detail: u ? displayCurrency.value : '',
|
||||
},
|
||||
{
|
||||
label: 'API Key 数量',
|
||||
@@ -606,7 +607,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-sm text-gray-500">本月消费</span>
|
||||
<span class="text-sm font-semibold text-primary-600">${{ usage.month_total_cost.toFixed(4) }}</span>
|
||||
<span class="text-sm font-semibold text-primary-600">{{ formatBilling(usage.month_total_cost, 4) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-sm text-gray-500">API Key 数量</span>
|
||||
@@ -648,7 +649,7 @@ onMounted(() => {
|
||||
<template v-if="quota.quota_type === 'periodic'"> · {{ periodLabel[quota.period] }}</template>
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
${{ quota.quota_used.toFixed(6) }} / ${{ quota.quota_limit.toFixed(6) }}
|
||||
{{ formatBilling(quota.quota_used, 6) }} / {{ formatBilling(quota.quota_limit, 6) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
@@ -813,7 +814,7 @@ onMounted(() => {
|
||||
|
||||
<!-- Quota Limit -->
|
||||
<div v-if="quotaForm.quota_type !== 'none'">
|
||||
<label class="input-label">额度上限 (USD)</label>
|
||||
<label class="input-label">额度上限 ({{ displayCurrency }})</label>
|
||||
<n-input
|
||||
:value="String(quotaForm.quota_limit)"
|
||||
type="number"
|
||||
|
||||
@@ -58,7 +58,7 @@ interface Member {
|
||||
// 可用模型数
|
||||
model_count: number
|
||||
model_unlimited: boolean
|
||||
// 本月消费(USD)
|
||||
// 本月消费(本位币)
|
||||
month_cost: number
|
||||
// 最后更新时间
|
||||
updated_at: string
|
||||
|
||||
@@ -12,6 +12,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -284,10 +285,11 @@ async function copyKey(keyId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Key 额度展示(bil 层,本位币直显)
|
||||
function formatKeyQuota(key: ApiKey): string {
|
||||
const used = key.used_quota || 0
|
||||
if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限`
|
||||
return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}`
|
||||
if (!key.total_quota || key.total_quota <= 0) return `${formatBilling(used, 2)} / 不限`
|
||||
return `${formatBilling(used, 2)} / ${formatBilling(key.total_quota, 2)}`
|
||||
}
|
||||
|
||||
async function deleteKey(keyId: number) {
|
||||
@@ -329,9 +331,9 @@ async function fetchUsageLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
function formatCost(n: number): string {
|
||||
if (!n) return '$0.00'
|
||||
return '$' + n.toFixed(4)
|
||||
return formatBilling(n, 4)
|
||||
}
|
||||
|
||||
function switchTab(tab: 'overview' | 'keys' | 'usage') {
|
||||
|
||||
@@ -9,6 +9,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
|
||||
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
|
||||
import request from '@/utils/request'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -127,7 +128,7 @@ const columns = computed<DataTableColumns<any>>(() => [
|
||||
key: 'budget',
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h('span', { class: 'font-mono' }, row.budget ? `$${Number(row.budget).toFixed(2)}` : '不限'),
|
||||
h('span', { class: 'font-mono' }, row.budget ? formatBilling(row.budget, 2) : '不限'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
|
||||
Reference in new issue
Block a user