diff --git a/api/admin/admin.go b/api/admin/admin.go index ca83456..d84822d 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -106,7 +106,6 @@ type IAdminV1 interface { ModelCreate(ctx context.Context, req *v1.ModelCreateReq) (res *v1.ModelCreateRes, err error) ModelUpdate(ctx context.Context, req *v1.ModelUpdateReq) (res *v1.ModelUpdateRes, err error) ModelDelete(ctx context.Context, req *v1.ModelDeleteReq) (res *v1.ModelDeleteRes, err error) - PricingList(ctx context.Context, req *v1.PricingListReq) (res *v1.PricingListRes, err error) PricingGet(ctx context.Context, req *v1.PricingGetReq) (res *v1.PricingGetRes, err error) PricingSet(ctx context.Context, req *v1.PricingSetReq) (res *v1.PricingSetRes, err error) ModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (res *v1.ModelOptionsRes, err error) diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 2b824f0..ef3efdc 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -4,12 +4,13 @@ import "github.com/gogf/gf/v2/frame/g" // ModelListReq 模型列表请求 type ModelListReq struct { - g.Meta `path:"/models" method:"get" mime:"json" tags:"管理后台-模型" summary:"模型列表"` - Page int `json:"page" d:"1" v:"min:1" dc:"页码"` - PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` - Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"` - Status string `json:"status" dc:"状态筛选:active/deprecated/offline"` - Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` + g.Meta `path:"/models" method:"get" mime:"json" tags:"管理后台-模型" summary:"模型列表"` + Page int `json:"page" d:"1" v:"min:1" dc:"页码"` + PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` + Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"` + Status string `json:"status" dc:"状态筛选:active/deprecated/offline"` + Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` + PricingStatus string `json:"pricing_status" dc:"定价状态筛选:priced/unpriced"` } // ModelListRes 模型列表响应 @@ -37,6 +38,11 @@ type ModelItem struct { DeprecatedAt *string `json:"deprecated_at"` SunsetDate *string `json:"sunset_date"` ReplacementModel string `json:"replacement_model"` + // 定价摘要(来自 mdl_pricing,min_tokens=0 的基准行) + PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered" + InputPrice float64 `json:"input_price"` // $/1M tokens + OutputPrice float64 `json:"output_price"` // $/1M tokens + PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式) } // ModelCreateReq 创建模型请求 @@ -91,35 +97,6 @@ type PricingItem struct { CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"` } -// PricingListReq 定价列表请求(模型定价页面专用) -type PricingListReq struct { - g.Meta `path:"/models/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"模型定价列表"` - Page int `json:"page" d:"1" v:"min:1" dc:"页码"` - PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` - Category string `json:"category" dc:"模型分类筛选"` - Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` -} - -// PricingListRes 定价列表响应 -type PricingListRes struct { - List []PricingListItem `json:"list"` - Total int `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` -} - -// PricingListItem 定价列表项(模型基础信息 + 定价摘要) -type PricingListItem struct { - ID int64 `json:"id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - PricingMode string `json:"pricing_mode"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice float64 `json:"per_request_price"` -} - // PricingGetReq 获取模型定价 type PricingGetReq struct { g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"` diff --git a/internal/controller/admin/admin_v1_pricing_list.go b/internal/controller/admin/admin_v1_pricing_list.go deleted file mode 100644 index 5259e2b..0000000 --- a/internal/controller/admin/admin_v1_pricing_list.go +++ /dev/null @@ -1,12 +0,0 @@ -package admin - -import ( - "context" - - v1 "github.com/qianfree/team-api/api/admin/v1" - "github.com/qianfree/team-api/internal/service" -) - -func (c *ControllerV1) PricingList(ctx context.Context, req *v1.PricingListReq) (res *v1.PricingListRes, err error) { - return service.Admin().ListModelPricing(ctx, req) -} diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index d4f40d2..2f18a30 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -58,7 +58,7 @@ func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) return &v1.ModelOptionsRes{List: list}, nil } -// ListModels 获取模型列表 +// ListModels 获取模型列表(含定价摘要) func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.ModelListRes, error) { query := dao.MdlModels.Ctx(ctx) @@ -71,6 +71,12 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode if req.Search != "" { query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") } + // 定价状态筛选 + if req.PricingStatus == "priced" { + query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0)") + } else if req.PricingStatus == "unpriced" { + query = query.Where("NOT EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0)") + } var total int var models []struct { @@ -99,6 +105,33 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode return nil, err } + // 批量查询定价摘要(min_tokens=0 的基准行) + modelIDs := make([]int64, 0, len(models)) + for _, m := range models { + modelIDs = append(modelIDs, m.ID) + } + type pricingRow struct { + ModelId int64 `json:"model_id"` + BillingMode string `json:"billing_mode"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + } + var pricingRows []pricingRow + if len(modelIDs) > 0 { + err = dao.MdlPricing.Ctx(ctx). + Fields("model_id, billing_mode, input_price, output_price, per_request_price"). + Where("model_id IN (?) AND min_tokens = 0", modelIDs). + Scan(&pricingRows) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + } + pricingMap := make(map[int64]pricingRow, len(pricingRows)) + for _, p := range pricingRows { + pricingMap[p.ModelId] = p + } + list := make([]v1.ModelItem, 0, len(models)) for _, m := range models { item := v1.ModelItem{ @@ -124,6 +157,15 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode item.SunsetDate = &s } item.ReplacementModel = m.ReplacementModel + // 填充定价摘要 + if p, ok := pricingMap[m.ID]; ok { + item.PricingMode = p.BillingMode + item.InputPrice = p.InputPrice + item.OutputPrice = p.OutputPrice + if p.PerRequestPrice != nil { + item.PerRequestPrice = *p.PerRequestPrice + } + } list = append(list, item) } @@ -342,87 +384,6 @@ func parseCapabilities(raw string) map[string]bool { return caps } -// ListModelPricing 模型定价列表(模型定价页面专用) -func (s *sAdmin) ListModelPricing(ctx context.Context, req *v1.PricingListReq) (*v1.PricingListRes, error) { - query := dao.MdlModels.Ctx(ctx).Where("status", "active") - - if req.Category != "" { - query = query.Where("category", req.Category) - } - if req.Search != "" { - query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") - } - - var total int - var models []struct { - ID int64 `json:"id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - } - - err := query.Fields("id, model_id, model_name, category"). - OrderAsc("category").OrderAsc("model_id"). - Page(req.Page, req.PageSize). - ScanAndCount(&models, &total, false) - if err != nil { - return nil, err - } - - modelIDs := make([]int64, 0, len(models)) - for _, m := range models { - modelIDs = append(modelIDs, m.ID) - } - - type pricingRow struct { - ModelId int64 `json:"model_id"` - BillingMode string `json:"billing_mode"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - } - var pricingRows []pricingRow - if len(modelIDs) > 0 { - err = dao.MdlPricing.Ctx(ctx). - Fields("model_id, billing_mode, input_price, output_price, per_request_price"). - Where("model_id IN (?) AND min_tokens = 0", modelIDs). - Scan(&pricingRows) - if err = common.IgnoreScanNoRows(err); err != nil { - return nil, err - } - } - pricingMap := make(map[int64]pricingRow, len(pricingRows)) - for _, p := range pricingRows { - pricingMap[p.ModelId] = p - } - - list := make([]v1.PricingListItem, 0, len(models)) - for _, m := range models { - item := v1.PricingListItem{ - ID: m.ID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, - } - if p, ok := pricingMap[m.ID]; ok { - item.PricingMode = p.BillingMode - item.InputPrice = p.InputPrice - item.OutputPrice = p.OutputPrice - if p.PerRequestPrice != nil { - item.PerRequestPrice = *p.PerRequestPrice - } - } - list = append(list, item) - } - - return &v1.PricingListRes{ - List: list, - Total: total, - Page: req.Page, - PageSize: req.PageSize, - }, nil -} - // GetModelPricing 获取模型定价 func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) { var rows []struct { diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 66a2d42..2d617fb 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -40,7 +40,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe } err := dao.MdlModels.Ctx(ctx). - Where("model_id IN(?)", req.ModelIds). + WhereIn("model_id", req.ModelIds). Scan(&models) if err != nil { return nil, err diff --git a/internal/service/admin.go b/internal/service/admin.go index 5a3cac6..c3fe798 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -208,7 +208,7 @@ type ( ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq) (*v1.AdminMemberExportRes, error) // ListModelOptions 获取模型选项列表(不分页,用于下拉选择) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) - // ListModels 获取模型列表 + // ListModels 获取模型列表(含定价摘要) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.ModelListRes, error) // CreateModel 创建模型(自动创建默认 token 定价记录) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.ModelCreateRes, error) @@ -216,8 +216,6 @@ type ( UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) // DeleteModel 删除模型(同时删除定价记录、租户分配记录和分组关联) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error) - // ListModelPricing 模型定价列表(模型定价页面专用) - ListModelPricing(ctx context.Context, req *v1.PricingListReq) (*v1.PricingListRes, error) // GetModelPricing 获取模型定价 GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) // SetModelPricing 设置模型定价(全量替换) diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 8f6af9b..a717589 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -138,12 +138,6 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/ai/TaskLogsPage.vue'), meta: { title: '任务日志' }, }, - { - path: 'model-pricing', - name: 'AdminModelPricing', - component: () => import('@/views/ai/ModelPricingPage.vue'), - meta: { title: '模型定价' }, - }, { path: 'tenants', name: 'AdminTenants', diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 407ae99..c83f0ea 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -155,7 +155,6 @@ const menuGroups = [ { name: 'AdminModelGroups', label: '模型分组', icon: IconStorage }, { name: 'AdminChannels', label: '渠道管理', icon: IconBranch }, { name: 'AdminTaskLogs', label: '任务日志', icon: IconCalendar }, - { name: 'AdminModelPricing', label: '模型定价', icon: IconLayers }, ], }, { diff --git a/web/admin/src/views/ai/ModelPricingPage.vue b/web/admin/src/views/ai/ModelPricingPage.vue deleted file mode 100644 index 9a3d8ed..0000000 --- a/web/admin/src/views/ai/ModelPricingPage.vue +++ /dev/null @@ -1,815 +0,0 @@ - - - - - diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 27b2d62..b63cb57 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -5,6 +5,7 @@ import { } from '@arco-design/web-vue' import type { TableColumnData, FormInstance } from '@arco-design/web-vue' import PageHeader from '@/components/PageHeader.vue' +import ModelPricingDrawer from '@/components/ModelPricingDrawer.vue' import request from '@/utils/request' import { useExport } from '@/composables/useExport' @@ -21,6 +22,18 @@ const pagination = reactive({ const filterCategory = ref(null) const filterStatus = ref(null) const filterSearch = ref('') +const filterPricingStatus = ref(null) + +const pricingStatusOptions = [ + { label: '全部定价', value: '' }, + { label: '已定价', value: 'priced' }, + { label: '未定价', value: 'unpriced' }, +] + +// 定价抽屉 +const pricingDrawerVisible = ref(false) +const pricingDrawerModelId = ref(null) +const pricingDrawerModelIdStr = ref('') const categoryOptions = [ { label: '全部分类', value: '' }, @@ -149,16 +162,26 @@ const columns: TableColumnData[] = [ return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) }, }, + { title: '上下文', dataIndex: 'max_context_tokens', width: 100 }, + { title: '输出上限', dataIndex: 'max_output_tokens', width: 100 }, { - title: '状态', - dataIndex: 'status', - width: 160, + title: '定价', + dataIndex: 'pricing', + width: 180, render({ record }) { + if (!record.pricing_mode) { + return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价') + } + const modeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯' } const tags = [ - h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status), + h(Tag, { color: 'green', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode), ] - if (record.status === 'deprecated' && record.sunset_date) { - tags.push(h(Tag, { color: 'red', size: 'small' }, () => `下线: ${record.sunset_date}`)) + 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'}/次`)) + } 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'}`)) } return h('span', { style: 'display: inline-flex; gap: 4px; align-items: center;' }, tags) }, @@ -176,17 +199,31 @@ const columns: TableColumnData[] = [ return h('span', { style: 'font-size: 12px; color: var(--color-text-3); line-height: 1.4;' }, parts.join('\n')) }, }, - { title: '上下文', dataIndex: 'max_context_tokens', width: 100 }, - { title: '输出上限', dataIndex: 'max_output_tokens', width: 100 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render({ record }) { + const tags = [ + h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status), + ] + if (record.status === 'deprecated' && record.sunset_date) { + tags.push(h(Tag, { color: 'red', size: 'small' }, () => `下线: ${record.sunset_date}`)) + } + return h('span', { style: 'display: inline-flex; gap: 4px; align-items: center;' }, tags) + }, + }, + { title: '更新时间', dataIndex: 'updated_at', width: 170 }, { title: '操作', dataIndex: 'actions', - width: 140, + width: 200, fixed: 'right', render({ record }) { return h(Space, { size: 4 }, () => [ h(Button, { size: 'small', onClick: () => openEdit(record) }, () => '编辑'), + h(Button, { size: 'small', type: 'outline', onClick: () => openPricingDrawer(record) }, () => '定价'), h(Popconfirm, { content: '确定删除该模型?', onOk: () => deleteModel(record), @@ -206,6 +243,7 @@ async function fetchData() { if (filterCategory.value) params.category = filterCategory.value if (filterStatus.value) params.status = filterStatus.value if (filterSearch.value) params.search = filterSearch.value + if (filterPricingStatus.value) params.pricing_status = filterPricingStatus.value const res: any = await request.get('/admin/models', { params }) data.value = res.data?.data?.list || res.data?.list || [] @@ -299,6 +337,17 @@ async function deleteModel(row: any) { } } +function openPricingDrawer(record: any) { + pricingDrawerModelId.value = record.id + pricingDrawerModelIdStr.value = record.model_id + pricingDrawerVisible.value = true +} + +function onPricingSaved() { + pricingDrawerVisible.value = false + fetchData() +} + onMounted(() => { fetchData() }) @@ -563,6 +612,14 @@ function resetImport() { style="width: 120px" @change="handleFilter" /> + + + +