Commit 212f048e by ccran

feat: 渠道管理批量添加模型;模型Excel导入;模型定价Excel导入;

parent 53771922
package controller
import (
"bytes"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
)
const maxModelImportFileSize = 10 << 20
type modelImportRowResult struct {
Row int `json:"row"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type modelImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Rows []modelImportRowResult `json:"rows"`
}
// ImportModelsMeta imports model metadata from the first worksheet of an xlsx file.
func ImportModelsMeta(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
common.ApiErrorMsg(c, "请上传 Excel 文件,字段名为 file")
return
}
if fileHeader.Size > maxModelImportFileSize {
common.ApiErrorMsg(c, "Excel 文件不能超过 10MB")
return
}
file, err := fileHeader.Open()
if err != nil {
common.ApiError(c, err)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
common.ApiError(c, err)
return
}
rows, err := readFirstExcelSheet(data)
if err != nil {
common.ApiErrorMsg(c, "读取 Excel 失败: "+err.Error())
return
}
if len(rows) < 2 {
common.ApiErrorMsg(c, "Excel 至少需要包含表头和一行数据")
return
}
headers := make(map[string]int)
for i, header := range rows[0] {
normalized := normalizeModelImportHeader(header)
if normalized != "" {
headers[normalized] = i
}
}
if _, ok := headers["model_name"]; !ok {
common.ApiErrorMsg(c, "Excel 表头缺少必填列 model_name")
return
}
overwrite := strings.EqualFold(c.Query("overwrite"), "true")
result := importModelRows(rows[1:], headers, overwrite)
if result.Created > 0 || result.Updated > 0 {
model.RefreshPricing()
}
common.ApiSuccess(c, result)
}
func importModelRows(rows [][]string, headers map[string]int, overwrite bool) modelImportResult {
result := modelImportResult{
Rows: make([]modelImportRowResult, 0, len(rows)),
}
vendorIDByName := make(map[string]int)
for i, row := range rows {
rowNumber := i + 2
if isEmptyXLSXRow(row) {
continue
}
m, err := buildModelFromImportRow(row, headers, vendorIDByName)
rowResult := modelImportRowResult{Row: rowNumber, ModelName: m.ModelName}
if err != nil {
rowResult.Status = "failed"
rowResult.Message = err.Error()
result.Failed++
result.Rows = append(result.Rows, rowResult)
continue
}
var existing model.Model
err = model.DB.Where("model_name = ?", m.ModelName).First(&existing).Error
if err == nil {
if !overwrite {
rowResult.Status = "skipped"
rowResult.Message = "模型名称已存在"
result.Skipped++
result.Rows = append(result.Rows, rowResult)
continue
}
m.Id = existing.Id
if err := m.Update(); err != nil {
rowResult.Status = "failed"
rowResult.Message = err.Error()
result.Failed++
} else {
rowResult.Status = "updated"
result.Updated++
}
result.Rows = append(result.Rows, rowResult)
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
rowResult.Status = "failed"
rowResult.Message = err.Error()
result.Failed++
result.Rows = append(result.Rows, rowResult)
continue
}
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
rowResult.Status = "failed"
rowResult.Message = err.Error()
result.Failed++
result.Rows = append(result.Rows, rowResult)
continue
} else if dup {
rowResult.Status = "skipped"
rowResult.Message = "模型名称已存在"
result.Skipped++
result.Rows = append(result.Rows, rowResult)
continue
}
if err := m.Insert(); err != nil {
rowResult.Status = "failed"
rowResult.Message = err.Error()
result.Failed++
} else {
rowResult.Status = "created"
result.Created++
}
result.Rows = append(result.Rows, rowResult)
}
return result
}
func buildModelFromImportRow(row []string, headers map[string]int, vendorIDByName map[string]int) (model.Model, error) {
m := model.Model{
Status: 1,
SyncOfficial: 1,
NameRule: model.NameRuleExact,
}
m.ModelName = getImportCell(row, headers, "model_name")
if m.ModelName == "" {
return m, fmt.Errorf("模型名称不能为空")
}
m.Description = getImportCell(row, headers, "description")
m.Icon = getImportCell(row, headers, "icon")
m.Tags = getImportCell(row, headers, "tags")
m.Endpoints = getImportCell(row, headers, "endpoints")
if vendorName := getImportCell(row, headers, "vendor_name"); vendorName != "" {
vendorID, err := getImportVendorID(vendorName, vendorIDByName)
if err != nil {
return m, err
}
m.VendorID = vendorID
}
if raw := getImportCell(row, headers, "status"); raw != "" {
status, err := parseImportBoolInt(raw)
if err != nil {
return m, fmt.Errorf("status 格式错误")
}
m.Status = status
}
if raw := getImportCell(row, headers, "sync_official"); raw != "" {
syncOfficial, err := parseImportBoolInt(raw)
if err != nil {
return m, fmt.Errorf("sync_official 格式错误")
}
m.SyncOfficial = syncOfficial
}
if raw := getImportCell(row, headers, "name_rule"); raw != "" {
nameRule, err := parseModelNameRule(raw)
if err != nil {
return m, err
}
m.NameRule = nameRule
}
return m, nil
}
func getImportVendorID(vendorName string, vendorIDByName map[string]int) (int, error) {
if vendorID, ok := vendorIDByName[vendorName]; ok {
return vendorID, nil
}
var vendor model.Vendor
if err := model.DB.Select("id").Where("name = ?", vendorName).First(&vendor).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fmt.Errorf("供应商不存在:%s", vendorName)
}
return 0, err
}
vendorIDByName[vendorName] = vendor.Id
return vendor.Id, nil
}
func readFirstExcelSheet(data []byte) ([][]string, error) {
workbook, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer workbook.Close()
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("未找到工作表")
}
return workbook.GetRows(sheets[0], excelize.Options{RawCellValue: true})
}
func normalizeModelImportHeader(header string) string {
key := strings.ToLower(strings.TrimSpace(header))
key = strings.ReplaceAll(key, " ", "")
key = strings.ReplaceAll(key, "-", "_")
switch key {
case "model_name", "modelname", "模型名称", "模型名", "名称":
return "model_name"
case "description", "desc", "描述", "说明":
return "description"
case "icon", "图标":
return "icon"
case "tags", "tag", "标签":
return "tags"
case "vendor_name", "vendorname", "vendor", "供应商名称", "供应商":
return "vendor_name"
case "endpoints", "endpoint", "端点":
return "endpoints"
case "status", "状态", "启用":
return "status"
case "sync_official", "syncofficial", "官方同步", "同步官方":
return "sync_official"
case "name_rule", "namerule", "匹配规则", "名称匹配规则":
return "name_rule"
default:
return ""
}
}
func getImportCell(row []string, headers map[string]int, key string) string {
index, ok := headers[key]
if !ok || index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func isEmptyXLSXRow(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func parseImportInt(raw string) (int, error) {
value := strings.TrimSpace(raw)
if value == "" {
return 0, nil
}
if intValue, err := strconv.Atoi(value); err == nil {
return intValue, nil
}
floatValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
return int(floatValue), nil
}
func parseImportBoolInt(raw string) (int, error) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "true", "yes", "y", "on", "启用", "是", "1":
return 1, nil
case "false", "no", "n", "off", "禁用", "否", "0":
return 0, nil
default:
return parseImportInt(value)
}
}
func parseModelNameRule(raw string) (int, error) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "", "0", "exact", "精确", "精确名称匹配":
return model.NameRuleExact, nil
case "1", "prefix", "前缀", "前缀名称匹配":
return model.NameRulePrefix, nil
case "2", "contains", "contain", "包含", "包含名称匹配":
return model.NameRuleContains, nil
case "3", "suffix", "后缀", "后缀名称匹配":
return model.NameRuleSuffix, nil
default:
return 0, fmt.Errorf("name_rule 格式错误")
}
}
......@@ -47,6 +47,7 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/waffo-com/waffo-go v1.3.1
github.com/xuri/excelize/v2 v2.10.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
......@@ -73,10 +74,16 @@ require (
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/tour v0.1.0 // indirect
)
require github.com/Azure/go-ntlmssp v0.1.1
......
......@@ -608,8 +608,6 @@ github.com/Azure/azure-sdk-for-go v56.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
......@@ -626,6 +624,8 @@ github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcP
github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
......@@ -1815,6 +1815,10 @@ github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqn
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
......@@ -1953,6 +1957,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
......@@ -2010,6 +2016,12 @@ github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
......@@ -2669,6 +2681,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tour v0.1.0 h1:OWzbINRoGf1wwBhKdFDpYwM88NM0d1SL/Nj6PagS6YE=
golang.org/x/tour v0.1.0/go.mod h1:DUZC6G8mR1AXgXy73r8qt/G5RsefKIlSj6jBMc8b9Wc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
......
......@@ -190,6 +190,7 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/import_model_pricing", controller.ImportModelPricing)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
optionRoute.GET("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog)
optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair)
......@@ -375,6 +376,7 @@ func SetApiRouter(router *gin.Engine) {
{
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
modelsRoute.POST("/import", controller.ImportModelsMeta)
modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta)
......
......@@ -132,6 +132,10 @@ func GetCacheRatioMap() map[string]float64 {
return cacheRatioMap.ReadAll()
}
func GetCreateCacheRatioMap() map[string]float64 {
return createCacheRatioMap.ReadAll()
}
// CacheRatio2JSONString converts the cache ratio map to a JSON string
func CacheRatio2JSONString() string {
return cacheRatioMap.MarshalJSONString()
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -10,6 +10,7 @@ const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
const semiUiDateFnsDir = path.resolve(semiUiDir, 'node_modules/date-fns')
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
......@@ -47,6 +48,7 @@ export default defineConfig(({ envMode }) => {
semiUiDir,
'dist/css/semi.css',
),
'date-fns': semiUiDateFnsDir,
},
},
html: {
......@@ -56,6 +58,7 @@ export default defineConfig(({ envMode }) => {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
port:5173
},
output: {
minify: isProd,
......
......@@ -518,7 +518,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
/>
}
onClick={toggleCollapsed}
icononly={collapsed}
icononly={collapsed.toString()}
style={
collapsed
? { width: 36, height: 24, padding: 0 }
......
......@@ -1937,7 +1937,14 @@ const EditChannelModal = (props) => {
const addCustomModels = () => {
if (customModel.trim() === '') return;
const modelArray = customModel.split(',').map((model) => model.trim());
const modelArray = Array.from(
new Set(
customModel
.split(/[\s,,;;]+/)
.map((model) => model.trim())
.filter(Boolean),
),
);
let localModels = [...inputs.models];
let localModelOptions = [...modelOptions];
......@@ -3537,13 +3544,16 @@ const EditChannelModal = (props) => {
/>
{/* Custom Model Name - Core Config */}
<Form.Input
<Form.TextArea
field='custom_model'
label={t('自定义模型名称')}
placeholder={t('输入自定义模型名称')}
onChange={(value) => setCustomModel(value.trim())}
label={t('批量填入模型')}
placeholder={t(
'粘贴模型列表,支持逗号、空格或换行分隔',
)}
autosize={{ minRows: 2, maxRows: 5 }}
onChange={(value) => setCustomModel(value)}
value={customModel}
suffix={
extraText={
<Button
size='small'
type='primary'
......
......@@ -21,7 +21,16 @@ import React, { useState } from 'react';
import MissingModelsModal from './modals/MissingModelsModal';
import PrefillGroupManagement from './modals/PrefillGroupManagement';
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Checkbox,
Modal,
Popover,
RadioGroup,
Radio,
Upload,
} from '@douyinfe/semi-ui';
import { showSuccess, showError, copy } from '../../../helpers';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
import SelectionNotification from './components/SelectionNotification';
......@@ -39,6 +48,8 @@ const ModelsActions = ({
syncUpstream,
previewUpstreamDiff,
applyUpstreamOverwrite,
importing,
importModels,
compactMode,
setCompactMode,
t,
......@@ -53,6 +64,10 @@ const ModelsActions = ({
const [conflicts, setConflicts] = useState([]);
const [showSyncModal, setShowSyncModal] = useState(false);
const [syncLocale, setSyncLocale] = useState('zh');
const [showImportModal, setShowImportModal] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importFileList, setImportFileList] = useState([]);
const [importOverwrite, setImportOverwrite] = useState(false);
const handleSyncUpstream = async (locale) => {
// 先预览
......@@ -102,6 +117,26 @@ const ModelsActions = ({
setShowAddPrefill(true);
};
const resetImportState = () => {
setImportFile(null);
setImportFileList([]);
setImportOverwrite(false);
};
const handleImportFileChange = ({ fileList }) => {
const selectedFileList = fileList.slice(-1);
setImportFileList(selectedFileList);
setImportFile(selectedFileList[0]?.fileInstance || null);
};
const handleImport = async () => {
const success = await importModels?.(importFile, importOverwrite);
if (success) {
setShowImportModal(false);
resetImportState();
}
};
return (
<>
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
......@@ -167,6 +202,16 @@ const ModelsActions = ({
type='secondary'
className='flex-1 md:flex-initial'
size='small'
loading={importing}
onClick={() => setShowImportModal(true)}
>
{t('导入模型')}
</Button>
<Button
type='secondary'
className='flex-1 md:flex-initial'
size='small'
onClick={() => setShowGroupManagement(true)}
>
{t('预填组管理')}
......@@ -202,6 +247,59 @@ const ModelsActions = ({
</div>
</Modal>
<Modal
title={t('导入模型')}
visible={showImportModal}
onCancel={() => {
if (importing) return;
setShowImportModal(false);
resetImportState();
}}
onOk={handleImport}
okText={t('导入')}
cancelText={t('取消')}
confirmLoading={importing}
okButtonProps={{ disabled: !importFile }}
>
<div className='space-y-3'>
<Banner
type='info'
description={t(
'第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_name、endpoints、status、sync_official、name_rule。',
)}
/>
<Upload
action=''
accept='.xlsx,.xls'
disabled={importing}
fileList={importFileList}
limit={1}
uploadTrigger='custom'
draggable={true}
dragMainText={t('点击上传文件或拖拽文件到这里')}
dragSubText={t('仅支持 xls、xlsx')}
beforeUpload={() => false}
onChange={handleImportFileChange}
onRemove={() => {
setImportFile(null);
setImportFileList([]);
}}
></Upload>
{importFile ? (
<div className='text-sm text-[var(--semi-color-text-2)]'>
{t('已选择文件:{{name}}', { name: importFile.name })}
</div>
) : null}
<Checkbox
checked={importOverwrite}
disabled={importing}
onChange={(event) => setImportOverwrite(event.target.checked)}
>
{t('覆盖已存在的模型')}
</Checkbox>
</div>
</Modal>
<SyncWizardModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
......
......@@ -173,6 +173,8 @@ const ModelsPage = () => {
previewing={modelsData.previewing}
previewUpstreamDiff={modelsData.previewUpstreamDiff}
applyUpstreamOverwrite={modelsData.applyUpstreamOverwrite}
importing={modelsData.importing}
importModels={modelsData.importModels}
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
......
......@@ -97,6 +97,7 @@ export const useModelsData = () => {
const [editingVendor, setEditingVendor] = useState({ id: undefined });
const [syncing, setSyncing] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [importing, setImporting] = useState(false);
const vendorMap = useMemo(() => {
const map = {};
......@@ -250,6 +251,48 @@ export const useModelsData = () => {
}
};
const importModels = async (file, overwrite = false) => {
if (!file) {
showError(t('请选择要导入的 Excel 文件'));
return false;
}
setImporting(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await API.post(
`/api/models/import${overwrite ? '?overwrite=true' : ''}`,
formData,
);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('模型导入失败'));
return false;
}
showSuccess(
t(
'模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个',
{
created: data?.created || 0,
updated: data?.updated || 0,
skipped: data?.skipped || 0,
failed: data?.failed || 0,
},
),
);
await loadVendors();
await refresh();
return true;
} catch (error) {
showError(error.response?.data?.message || t('模型导入失败'));
return false;
} finally {
setImporting(false);
}
};
// Search models with keyword and vendor
const searchModels = async () => {
const { searchKeyword = '', searchVendor = '' } = getFormValues();
......@@ -490,8 +533,10 @@ export const useModelsData = () => {
// Upstream sync
syncing,
previewing,
importing,
syncUpstream,
previewUpstreamDiff,
applyUpstreamOverwrite,
importModels,
};
};
......@@ -3826,6 +3826,21 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
"、": ", "
"、": ", ",
"Excel 导入": "Excel import",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "Importing overwrites the pricing mode for matching models in Excel: per-token billing clears per-request pricing, and per-request billing clears ratio settings.",
"导入模型": "Import Models",
"导入模型定价": "Import model pricing",
"已选择文件:{{name}}": "Selected file: {{name}}",
"批量填入模型": "Batch fill models",
"模型定价导入失败": "Failed to import model pricing",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "Model pricing import complete: updated {{updated}}, per-token {{perToken}}, per-request {{perRequest}}, failed {{failed}}",
"模型导入失败": "Model import failed",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "Model import completed: {{created}} created, {{updated}} updated, {{skipped}} skipped, {{failed}} failed",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "The first row must be the header. model_name is required. Optional columns: description, icon, tags, vendor_id, endpoints, status, sync_official, name_rule.",
"粘贴模型列表,支持逗号、空格或换行分隔": "Paste a model list, separated by commas, spaces, or line breaks",
"覆盖已存在的模型": "Overwrite existing models",
"请选择要导入的 Excel 文件": "Please select the Excel file to import",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "The first row must be headers, and model_name is required. For per-request billing, fill fixed_price; if fixed_price is empty, input_price is used for per-token billing, with optional completion_price."
}
}
......@@ -3680,6 +3680,21 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
"、": ", "
"、": ", ",
"Excel 导入": "Import Excel",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "L’importation remplace le mode de tarification des modèles correspondants dans Excel : la facturation à l’usage supprime le prix par requête, et la facturation par requête supprime les ratios.",
"导入模型": "Importer des modèles",
"导入模型定价": "Importer les tarifs des modèles",
"已选择文件:{{name}}": "Fichier sélectionné : {{name}}",
"批量填入模型": "Remplir les modèles en lot",
"模型定价导入失败": "Échec de l’importation des tarifs des modèles",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "Importation des tarifs terminée : {{updated}} mis à jour, {{perToken}} à l’usage, {{perRequest}} par requête, {{failed}} échecs",
"模型导入失败": "Échec de l’import des modèles",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "Import des modèles terminé : {{created}} créés, {{updated}} mis à jour, {{skipped}} ignorés, {{failed}} en échec",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "La première ligne doit contenir les en-têtes. model_name est obligatoire. Colonnes facultatives : description, icon, tags, vendor_id, endpoints, status, sync_official, name_rule.",
"粘贴模型列表,支持逗号、空格或换行分隔": "Collez une liste de modèles, séparés par des virgules, des espaces ou des sauts de ligne",
"覆盖已存在的模型": "Remplacer les modèles existants",
"请选择要导入的 Excel 文件": "Veuillez sélectionner le fichier Excel à importer",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "La première ligne doit contenir les en-têtes, et model_name est obligatoire. Pour la facturation par requête, renseignez fixed_price ; si fixed_price est vide, input_price sera utilisé pour la facturation à l’usage, avec completion_price facultatif."
}
}
......@@ -3649,6 +3649,21 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
"、": ", "
"、": ", ",
"Excel 导入": "Excel インポート",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "インポートすると、Excel 内の該当モデルの料金方式が上書きされます。従量課金はリクエスト単位価格を削除し、リクエスト単位課金は倍率設定を削除します。",
"导入模型": "モデルをインポート",
"导入模型定价": "モデル料金をインポート",
"已选择文件:{{name}}": "選択済みファイル:{{name}}",
"批量填入模型": "モデルを一括入力",
"模型定价导入失败": "モデル料金のインポートに失敗しました",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "モデル料金のインポートが完了しました:更新 {{updated}} 件、従量 {{perToken}} 件、リクエスト単位 {{perRequest}} 件、失敗 {{failed}} 件",
"模型导入失败": "モデルのインポートに失敗しました",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "モデルのインポートが完了しました:新規 {{created}} 件、更新 {{updated}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "1 行目はヘッダーである必要があります。model_name は必須列です。任意列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。",
"粘贴模型列表,支持逗号、空格或换行分隔": "モデル一覧を貼り付けます。カンマ、スペース、改行で区切れます",
"覆盖已存在的模型": "既存のモデルを上書き",
"请选择要导入的 Excel 文件": "インポートする Excel ファイルを選択してください",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "1 行目はヘッダーで、model_name は必須です。リクエスト単位課金では fixed_price を入力してください。fixed_price が空の場合、input_price を従量課金に使用し、completion_price は任意です。"
}
}
......@@ -3700,6 +3700,21 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
"、": ", "
"、": ", ",
"Excel 导入": "Импорт Excel",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "Импорт перезапишет способ тарификации моделей из Excel: тарификация по токенам очистит цену за запрос, а тарификация за запрос очистит настройки коэффициентов.",
"导入模型": "Импорт моделей",
"导入模型定价": "Импорт цен моделей",
"已选择文件:{{name}}": "Выбран файл: {{name}}",
"批量填入模型": "Массовое заполнение моделей",
"模型定价导入失败": "Не удалось импортировать цены моделей",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "Импорт цен завершён: обновлено {{updated}}, по токенам {{perToken}}, за запрос {{perRequest}}, ошибок {{failed}}",
"模型导入失败": "Не удалось импортировать модели",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "Импорт моделей завершен: создано {{created}}, обновлено {{updated}}, пропущено {{skipped}}, ошибок {{failed}}",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "Первая строка должна быть заголовком. model_name обязателен. Необязательные столбцы: description, icon, tags, vendor_id, endpoints, status, sync_official, name_rule.",
"粘贴模型列表,支持逗号、空格或换行分隔": "Вставьте список моделей, разделенный запятыми, пробелами или переносами строк",
"覆盖已存在的模型": "Перезаписать существующие модели",
"请选择要导入的 Excel 文件": "Выберите файл Excel для импорта",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "Первая строка должна быть строкой заголовков, model_name обязателен. Для тарификации за запрос заполните fixed_price; если fixed_price пустой, input_price будет использован для тарификации по токенам, completion_price необязателен."
}
}
......@@ -4214,6 +4214,21 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
"、": ", "
"、": ", ",
"Excel 导入": "Nhập Excel",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "Việc nhập sẽ ghi đè cách định giá của các mô hình tương ứng trong Excel: tính theo token sẽ xóa giá theo lượt, còn tính theo lượt sẽ xóa cấu hình hệ số.",
"导入模型": "Nhập mô hình",
"导入模型定价": "Nhập giá mô hình",
"已选择文件:{{name}}": "Tệp đã chọn: {{name}}",
"批量填入模型": "Điền hàng loạt mô hình",
"模型定价导入失败": "Không thể nhập giá mô hình",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "Nhập giá mô hình hoàn tất: đã cập nhật {{updated}}, theo token {{perToken}}, theo lượt {{perRequest}}, thất bại {{failed}}",
"模型导入失败": "Nhập mô hình thất bại",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "Nhập mô hình hoàn tất: tạo mới {{created}}, cập nhật {{updated}}, bỏ qua {{skipped}}, thất bại {{failed}}",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "Hàng đầu tiên phải là tiêu đề. model_name là cột bắt buộc. Các cột tùy chọn: description, icon, tags, vendor_id, endpoints, status, sync_official, name_rule.",
"粘贴模型列表,支持逗号、空格或换行分隔": "Dán danh sách mô hình, phân tách bằng dấu phẩy, dấu cách hoặc xuống dòng",
"覆盖已存在的模型": "Ghi đè mô hình hiện có",
"请选择要导入的 Excel 文件": "Vui lòng chọn tệp Excel cần nhập",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "Hàng đầu tiên phải là tiêu đề, và model_name là bắt buộc. Với tính phí theo lượt, hãy điền fixed_price; nếu fixed_price để trống, input_price sẽ được dùng để tính phí theo token, completion_price là tùy chọn."
}
}
......@@ -3809,6 +3809,21 @@
"并确认自行承担部署": "并确认自行承担部署",
"运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任",
",": ",",
"、": "、"
"、": "、",
"Excel 导入": "Excel 导入",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。",
"导入模型": "导入模型",
"导入模型定价": "导入模型定价",
"已选择文件:{{name}}": "已选择文件:{{name}}",
"批量填入模型": "批量填入模型",
"模型定价导入失败": "模型定价导入失败",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个",
"模型导入失败": "模型导入失败",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。",
"粘贴模型列表,支持逗号、空格或换行分隔": "粘贴模型列表,支持逗号、空格或换行分隔",
"覆盖已存在的模型": "覆盖已存在的模型",
"请选择要导入的 Excel 文件": "请选择要导入的 Excel 文件",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。"
}
}
......@@ -3673,6 +3673,21 @@
"并确认自行承担部署": "並確認自行承擔部署",
"运营和收费行为产生的法律责任": "營運和收費行為產生的法律責任",
",": ",",
"、": "、"
"、": "、",
"Excel 导入": "Excel 匯入",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "匯入會覆蓋 Excel 中對應模型的定價方式:按量計費會清除該模型按次價格,按次計費會清除該模型倍率設定。",
"导入模型": "導入模型",
"导入模型定价": "匯入模型定價",
"已选择文件:{{name}}": "已選擇檔案:{{name}}",
"批量填入模型": "批次填入模型",
"模型定价导入失败": "模型定價匯入失敗",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "模型定價匯入完成:更新 {{updated}} 個,按量 {{perToken}} 個,按次 {{perRequest}} 個,失敗 {{failed}} 個",
"模型导入失败": "模型導入失敗",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "模型導入完成:新增 {{created}} 個,更新 {{updated}} 個,跳過 {{skipped}} 個,失敗 {{failed}} 個",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "第一行必須是表頭,model_name 為必填欄位。可選欄位:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。",
"粘贴模型列表,支持逗号、空格或换行分隔": "貼上模型列表,支援逗號、空格或換行分隔",
"覆盖已存在的模型": "覆蓋已存在的模型",
"请选择要导入的 Excel 文件": "請選擇要導入的 Excel 檔案",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "第一列必須是表頭,model_name 為必填欄。按次計費請填寫 fixed_price;如果 fixed_price 為空,則使用 input_price 按量計費,可選 completion_price。"
}
}
......@@ -2630,6 +2630,21 @@
"并确认自行承担部署": "并确认自行承担部署",
"运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任",
",": ",",
"、": "、"
"、": "、",
"Excel 导入": "Excel 导入",
"导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。": "导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。",
"导入模型": "导入模型",
"导入模型定价": "导入模型定价",
"已选择文件:{{name}}": "已选择文件:{{name}}",
"批量填入模型": "批量填入模型",
"模型定价导入失败": "模型定价导入失败",
"模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个": "模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个",
"模型导入失败": "模型导入失败",
"模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个": "模型导入完成:新增 {{created}} 个,更新 {{updated}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个",
"第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。": "第一行必须是表头,model_name 为必填列。可选列:description、icon、tags、vendor_id、endpoints、status、sync_official、name_rule。",
"粘贴模型列表,支持逗号、空格或换行分隔": "粘贴模型列表,支持逗号、空格或换行分隔",
"覆盖已存在的模型": "覆盖已存在的模型",
"请选择要导入的 Excel 文件": "请选择要导入的 Excel 文件",
"第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。": "第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。"
}
}
......@@ -18,18 +18,82 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import { Radio, RadioGroup } from '@douyinfe/semi-ui';
import {
Banner,
Button,
Modal,
Radio,
RadioGroup,
Upload,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingEditor from './components/ModelPricingEditor';
import ModelRatioSettings from './ModelRatioSettings';
import { API, showError, showSuccess } from '../../../helpers';
export default function ModelPricingCombined({ options, refresh }) {
const { t } = useTranslation();
const [editMode, setEditMode] = useState('visual');
const [importVisible, setImportVisible] = useState(false);
const [importing, setImporting] = useState(false);
const [importFile, setImportFile] = useState(null);
const [importFileList, setImportFileList] = useState([]);
const resetImportState = () => {
setImportFile(null);
setImportFileList([]);
};
const handleImportFileChange = ({ fileList }) => {
const selectedFileList = fileList.slice(-1);
setImportFileList(selectedFileList);
setImportFile(selectedFileList[0]?.fileInstance || null);
};
const handleImport = async () => {
if (!importFile) {
showError(t('请选择要导入的 Excel 文件'));
return;
}
setImporting(true);
try {
const formData = new FormData();
formData.append('file', importFile);
const res = await API.post('/api/option/import_model_pricing', formData);
const { success, message, data } = res.data || {};
if (!success) {
showError(message || t('模型定价导入失败'));
return;
}
showSuccess(
t(
'模型定价导入完成:更新 {{updated}} 个,按量 {{perToken}} 个,按次 {{perRequest}} 个,失败 {{failed}} 个',
{
updated: data?.updated || 0,
perToken: data?.per_token || 0,
perRequest: data?.per_request || 0,
failed: data?.failed || 0,
},
),
);
await refresh();
setImportVisible(false);
resetImportState();
} catch (error) {
showError(error.response?.data?.message || t('模型定价导入失败'));
} finally {
setImporting(false);
}
};
return (
<div>
<div style={{ marginTop: 12, marginBottom: 16 }}>
<div
className='flex flex-col md:flex-row md:items-center md:justify-between gap-2'
style={{ marginTop: 12, marginBottom: 16 }}
>
<RadioGroup
type='button'
size='small'
......@@ -39,12 +103,70 @@ export default function ModelPricingCombined({ options, refresh }) {
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='manual'>{t('手动编辑')}</Radio>
</RadioGroup>
<Button
size='small'
type='secondary'
onClick={() => setImportVisible(true)}
>
{t('Excel 导入')}
</Button>
</div>
{editMode === 'visual' ? (
<ModelPricingEditor options={options} refresh={refresh} />
) : (
<ModelRatioSettings options={options} refresh={refresh} />
)}
<Modal
title={t('导入模型定价')}
visible={importVisible}
onCancel={() => {
if (importing) return;
setImportVisible(false);
resetImportState();
}}
onOk={handleImport}
okText={t('导入')}
cancelText={t('取消')}
confirmLoading={importing}
okButtonProps={{ disabled: !importFile }}
>
<div className='space-y-3'>
<Banner
type='info'
description={t(
'第一行必须是表头,model_name 为必填列。按次计费填写 fixed_price;如果 fixed_price 为空,则使用 input_price 按量计费,可选 completion_price。',
)}
/>
<Banner
type='warning'
description={t(
'导入会覆盖 Excel 中对应模型的定价方式:按量计费会清除该模型按次价格,按次计费会清除该模型倍率配置。',
)}
/>
<Upload
action=''
accept='.xlsx,.xls'
disabled={importing}
fileList={importFileList}
limit={1}
uploadTrigger='custom'
draggable={true}
dragMainText={t('点击上传文件或拖拽文件到这里')}
dragSubText={t('仅支持 xls、xlsx')}
beforeUpload={() => false}
onChange={handleImportFileChange}
onRemove={() => {
setImportFile(null);
setImportFileList([]);
}}
></Upload>
{importFile ? (
<div className='text-sm text-[var(--semi-color-text-2)]'>
{t('已选择文件:{{name}}', { name: importFile.name })}
</div>
) : null}
</div>
</Modal>
</div>
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment