From 2bd33ea43009760f5433d323b2c7d242f55466d8 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:04:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B8=A0=E9=81=93=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20-=20=E6=8B=89=E5=8F=96=E6=A8=A1=E5=9E=8B=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8B=BE=E9=80=89=E5=AF=BC=E5=85=A5=E5=B9=B6=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E7=A6=BB=E7=BA=BF=E5=90=AF=E5=81=9C=E6=B8=A0=E9=81=93?= =?UTF-8?q?=20(#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/finance.go | 26 ++++- backend/internal/service/admin.go | 12 +- .../internal/service/admin_channel_test.go | 100 ++++++++++++++++ backend/internal/service/channel_models.go | 105 +++++++++++++++++ .../components/channel-model-manager.tsx | 110 ++++++++++++++++-- web/src/services/api/wallet.ts | 8 +- web/src/styles/admin-ui.css | 41 +++++++ web/test/admin-ui-regressions.test.ts | 22 ++++ 8 files changed, 410 insertions(+), 14 deletions(-) diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 4d185846b..e68a9156b 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -214,8 +214,30 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { if !enforceRateLimit(c, "admin-channel-models-fetch:"+user.ID+":"+c.Param("id"), 10, time.Minute) { return } - // 上游密钥只在 service 内使用,handler 仅返回去重后的模型标识和新增数量。 - result, err := svc.FetchAdminChannelModels(c.Request.Context(), user, c.Param("id")) + // 上游密钥只在 service 内使用;点击拉取时仅返回目录,确认后才写入渠道模型。 + models, err := svc.PreviewAdminChannelModels(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"models": models}) + }) + r.POST("/admin/channels/:id/models/import", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "admin-channel-models-import:"+user.ID+":"+c.Param("id"), 10, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.AdminChannelModelImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ImportAdminChannelModels(c.Request.Context(), user, c.Param("id"), req.Models) if err != nil { failService(c, err) return diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index ddb435ea9..5297fd7e0 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -761,8 +761,16 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan if requestedAllowLocal && !s.DesktopLocalChannelsEnabled() { return channel, BadAuthRequest("当前后端未启用本机渠道") } - if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil { - return channel, err + // 启用/停用或只修改价格、模型等本地配置时,不应要求上游域名当前可解析。 + // 只有 Base URL 或本机渠道开关实际变化时才做出站地址校验。 + connectionChanged := strings.TrimRight(baseURL, "/") != strings.TrimRight(channel.BaseURL, "/") + if req.AllowLocalChannel != nil { + connectionChanged = connectionChanged || *req.AllowLocalChannel != channel.AllowLocalChannel + } + if connectionChanged { + if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil { + return channel, err + } } models := uniqueNonEmpty(req.Models) modelsJSON, _ := json.Marshal(models) diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index 7d411cfff..eeec40aaa 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -52,6 +52,31 @@ func TestMergeChannelRequestSupportsEnabledOnlyPatch(t *testing.T) { } } +func TestUpdateSystemChannelEnabledOnlySkipsOutboundResolution(t *testing.T) { + svc, db := newChannelModelTestService(t) + svc.dataDir = t.TempDir() + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Dead", BaseURL: "https://dead.invalid/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + disabled := false + updated, err := svc.UpdateSystemChannel(admin, channel.ID, ChannelRequest{Enabled: &disabled}) + if err != nil { + t.Fatalf("UpdateSystemChannel() should not resolve the stored URL for an enabled-only patch: %v", err) + } + if updated.Enabled { + t.Fatal("UpdateSystemChannel() did not persist disabled state") + } + var stored model.ModelChannel + if err := db.First(&stored, "id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + if stored.Enabled { + t.Fatal("stored channel is still enabled after update") + } +} + func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) { request := ChannelRequest{Name: "Headers", BaseURL: "https://example.com/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}} channel, err := channelFromRequest(request, model.ModelChannel{}) @@ -153,6 +178,81 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) { } } +func TestImportAdminChannelModelsOnlyImportsSelectedModels(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"},{"id":"model-b"}]}`)) + })) + defer upstream.Close() + + svc, db := newChannelModelTestService(t) + svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true} + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + + preview, err := svc.PreviewAdminChannelModels(context.Background(), admin, channel.ID) + if err != nil { + t.Fatal(err) + } + if len(preview) != 2 { + t.Fatalf("preview models = %#v, want two models", preview) + } + var before int64 + if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&before).Error; err != nil { + t.Fatal(err) + } + if before != 0 { + t.Fatalf("preview created %d channel models", before) + } + + result, err := svc.ImportAdminChannelModels(context.Background(), admin, channel.ID, []string{"model-b"}) + if err != nil { + t.Fatal(err) + } + if result.Added != 1 || len(result.Models) != 1 || result.Models[0] != "model-b" { + t.Fatalf("import result = %#v, want only model-b", result) + } + var imported []model.ChannelModel + if err := db.Where("channel_id = ?", channel.ID).Find(&imported).Error; err != nil { + t.Fatal(err) + } + if len(imported) != 1 || imported[0].ModelKey != "model-b" { + t.Fatalf("imported models = %#v, want only model-b", imported) + } +} + +func TestImportAdminChannelModelsRejectsUnknownSelection(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer upstream.Close() + + svc, db := newChannelModelTestService(t) + svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true} + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + + _, err := svc.ImportAdminChannelModels(context.Background(), admin, channel.ID, []string{"not-in-catalog"}) + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Message != "所选模型不在上游模型目录中:not-in-catalog" { + t.Fatalf("ImportAdminChannelModels() error = %#v", err) + } + var count int64 + if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("unknown selection created %d channel models", count) + } +} + func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) { svc, db := newChannelModelTestService(t) admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 4cc651d4a..f6019bad3 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -59,6 +59,10 @@ type AdminChannelModelFetchResult struct { Added int64 `json:"added"` } +type AdminChannelModelImportRequest struct { + Models []string `json:"models"` +} + type AdminChannelModelTestResult struct { DurationMs int64 `json:"durationMs"` } @@ -184,6 +188,107 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User return &AdminChannelModelFetchResult{Models: models, Added: added}, nil } +// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。 +func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + return s.fetchAdminChannelModelCatalog(ctx, actor, channelID) +} + +// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。 +func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID) + if err != nil { + return nil, err + } + if len(selected) == 0 { + return nil, BadAuthRequest("请至少选择一个要导入的模型") + } + if len(selected) > 500 { + return nil, BadAuthRequest("单次最多导入 500 个模型") + } + available := make(map[string]string, len(models)) + for _, name := range models { + available[channelModelCatalogKey(name)] = name + } + chosen := make([]string, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + for _, rawName := range selected { + name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/") + key := channelModelCatalogKey(name) + if key == "" { + continue + } + canonical, ok := available[key] + if !ok { + return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + chosen = append(chosen, canonical) + } + if len(chosen) == 0 { + return nil, BadAuthRequest("请至少选择一个有效的模型") + } + + existing, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return nil, err + } + known := make(map[string]struct{}, len(existing)) + for _, item := range existing { + known[channelModelCatalogKey(item.ModelKey)] = struct{}{} + } + retired := retiredChannelModelKeys(channel.RetiredModelsJSON) + missing := make([]model.ChannelModel, 0, len(chosen)) + for _, name := range chosen { + key := channelModelCatalogKey(name) + if _, ok := known[key]; ok || retired[key] { + continue + } + modelID, idErr := s.repo.NextPrefixedID("MODEL") + if idErr != nil { + return nil, idErr + } + missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1}) + known[key] = struct{}{} + } + added, err := s.repo.CreateMissingChannelModels(missing) + if err != nil { + return nil, err + } + if added > 0 { + s.invalidateRouteCatalog() + } + return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil +} + +func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) { + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) + if err != nil { + return nil, err + } + models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, AllowLocalChannel: channel.AllowLocalChannel, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers}) + if err != nil { + return nil, err + } + return uniqueNonEmpty(models), nil +} + func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 863a70f03..d3fa8a25f 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd"; +import { App, Button, Checkbox, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; @@ -11,7 +11,7 @@ import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; -import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; +import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; import { defaultPriceTier, legacyPriceTierToForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; import { AdminPageFrame } from "./admin-shell"; @@ -37,6 +37,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(false); const [fetching, setFetching] = useState(false); + const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false); + const [fetchPreviewModels, setFetchPreviewModels] = useState([]); + const [selectedFetchModels, setSelectedFetchModels] = useState([]); + const [importing, setImporting] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [editorOpen, setEditorOpen] = useState(false); @@ -75,6 +79,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: .catch(() => setAvailableProtocols([])); setEditing(null); setEditorOpen(false); + resetFetchPreview(); setKeyword(""); setCapability("all"); setStatus("all"); @@ -84,13 +89,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const fetchModels = async () => { setFetching(true); try { - // 拉取只导入缺失项;新模型仍需管理员定价并手动启用。 const result = await fetchAdminChannelModels(channel.id); - await reload(); - await onChanged(); - if (result.models.length === 0) message.warning("上游没有返回可用模型"); - else if (result.added > 0) message.success(`已拉取 ${result.models.length} 个模型,新增 ${result.added} 个待配置模型`); - else message.info(`已拉取 ${result.models.length} 个模型,没有需要新增的模型`); + if (result.models.length === 0) { + message.warning("上游没有返回可用模型"); + return; + } + setFetchPreviewModels(result.models); + setSelectedFetchModels(result.models); + setFetchPreviewOpen(true); } catch (error) { message.error(error instanceof Error ? error.message : "拉取模型失败"); } finally { @@ -98,6 +104,39 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: } }; + const closeFetchPreview = () => { + if (importing) return; + resetFetchPreview(); + }; + + const resetFetchPreview = () => { + setFetchPreviewOpen(false); + setFetchPreviewModels([]); + setSelectedFetchModels([]); + }; + + const importSelectedModels = async () => { + if (!selectedFetchModels.length) return; + if (!selectedNewFetchModels.length) { + message.info("当前勾选的模型均已存在,没有需要新增的模型"); + resetFetchPreview(); + return; + } + setImporting(true); + try { + const result = await importAdminChannelModels(channel.id, selectedFetchModels); + await reload(); + await onChanged(); + resetFetchPreview(); + if (result.added > 0) message.success(`已导入 ${result.added} 个模型,新增模型仍需配置价格后启用`); + else message.info("所选模型均已存在,没有新增模型"); + } catch (error) { + message.error(error instanceof Error ? error.message : "导入模型失败"); + } finally { + setImporting(false); + } + }; + const startCreate = () => { setEditing(null); form.setFieldsValue({ @@ -293,6 +332,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: return true; }); const pagedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize); + const existingFetchModelKeys = new Set(items.map((item) => normalizeFetchModelKey(item.modelKey))); + const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name))); + const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length; + const fetchModelOptions = fetchPreviewModels.map((name) => { + const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name)); + return { + label: alreadyExists ? ( + + {name} + 已存在 + + ) : ( + {name} + ), + value: name, + }; + }); return ( } /> + + 取消 + , + , + ]} + > +
+

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,请取消本次不需要拉入的模型;已存在的模型不会重复导入。

+
+ setSelectedFetchModels(values as string[])} + /> +
+
+ {selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"} + {selectedExistingFetchCount > 0 ? `,另有 ${selectedExistingFetchCount} 个已存在模型已勾选` : ""} +
+
+
(api.get(`/admin/channels/${encodeURIComponent(channelId)}/models`)); } -// 管理员从上游拉取模型目录;服务端只导入缺失项,价格和启用仍需人工确认。 +// 管理员从上游读取模型目录;确认导入后才会写入渠道模型,价格和启用仍需人工确认。 export function fetchAdminChannelModels(channelId: string) { - return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)); + return request<{ models: string[] }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)); +} + +export function importAdminChannelModels(channelId: string, models: string[]) { + return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })); } export function testAdminChannelModel(channelId: string, input: Pick & { capabilityConfig?: ChannelModel["capabilityConfig"] }) { diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index 73aa659c9..ee939dd67 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -8889,6 +8889,47 @@ } /* 系统渠道模型编辑器:恢复已确认的信息层级、协议参数与价格档布局。 */ +.admin-model-import-modal .channel-model-import-picker .ant-checkbox-wrapper { + display: flex; + min-width: 0; + min-height: 38px; + align-items: center; + margin-inline-end: 0; + border: 1px solid var(--admin-divider); + border-radius: 7px; + background: var(--admin-layer-2); + padding: 7px 10px; + color: var(--admin-text-primary); + transition: background-color 150ms ease, border-color 150ms ease; +} + +.admin-model-import-modal .channel-model-import-picker .ant-checkbox-wrapper:hover { + border-color: color-mix(in srgb, var(--foreground) 22%, var(--admin-card-border)); + background: color-mix(in srgb, var(--foreground) 5%, var(--admin-layer-1)); +} + +.admin-model-import-modal .channel-model-import-picker .ant-checkbox-wrapper.ant-checkbox-wrapper-checked { + border-color: color-mix(in srgb, var(--foreground) 26%, var(--admin-card-border)); + background: color-mix(in srgb, var(--foreground) 7%, var(--admin-layer-1)); +} + +.admin-model-import-modal .channel-model-import-picker .ant-checkbox { + align-self: center; + width: 18px; + height: 18px; + flex: none; + border-radius: 5px; +} + +.admin-model-import-modal .channel-model-import-picker .ant-checkbox-checked { + background: var(--control-check-bg) !important; + border-color: var(--control-check-bg) !important; +} + +.admin-model-import-modal .channel-model-import-picker .ant-checkbox-checked::after { + border-color: var(--control-check-fg) !important; +} + .admin-model-editor-drawer .ant-drawer-header { min-height: 52px; padding-inline: 24px; diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts index d62b2e029..28c551ec8 100644 --- a/web/test/admin-ui-regressions.test.ts +++ b/web/test/admin-ui-regressions.test.ts @@ -52,6 +52,28 @@ test("plugin upload owns native drops and price availability text remains readab expect(toggleCss).not.toContain("text-overflow: ellipsis;"); }); +test("channel model fetch requires explicit selection before import", async () => { + const [componentSource, apiSource, adminCssSource] = await Promise.all([ + Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text(), + Bun.file(new URL("../src/services/api/wallet.ts", import.meta.url)).text(), + Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(), + ]); + const component = compactSource(componentSource); + + expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)'); + expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })'); + expect(component).toContain('title="选择要导入的模型"'); + expect(component).toContain("默认已全选"); + expect(component).toContain("setFetchPreviewOpen(true)"); + expect(component).toContain("setSelectedFetchModels(result.models)"); + expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)"); + expect(component).toContain("disabled={!selectedFetchModels.length}"); + expect(component).not.toContain("disabled: alreadyExists"); + expect(component).not.toContain("const result = await fetchAdminChannelModels(channel.id); await reload();"); + expect(adminCssSource).toContain(".admin-model-import-modal .channel-model-import-picker .ant-checkbox-checked"); + expect(adminCssSource).toContain("border-color: var(--control-check-fg) !important"); +}); + test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => { const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text());