diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index e68a9156b..4918e2445 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -269,6 +269,27 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { r.POST("/admin/channels/:id/models", func(c *gin.Context) { saveChannelModel(c, svc, "") }) + r.POST("/admin/channels/:id/models/batch-delete", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req struct { + ModelIDs []string `json:"modelIds"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + deleted, err := svc.DeleteAdminChannelModels(user, c.Param("id"), req.ModelIDs) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"deleted": deleted}) + }) r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) { saveChannelModel(c, svc, c.Param("modelId")) }) diff --git a/backend/internal/handler/finance_routes_test.go b/backend/internal/handler/finance_routes_test.go new file mode 100644 index 000000000..9cc6fe4ab --- /dev/null +++ b/backend/internal/handler/finance_routes_test.go @@ -0,0 +1,23 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestFinanceRoutesExposeChannelModelBatchDelete(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterFinanceRoutes(router.Group("/api"), &service.Service{}) + + const wanted = "POST /api/admin/channels/:id/models/batch-delete" + for _, route := range router.Routes() { + if route.Method+" "+route.Path == wanted { + return + } + } + t.Fatalf("route %s is not registered", wanted) +} diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 820b39795..eb2de0e6c 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -231,11 +231,39 @@ func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) err } func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { + deleted, err := r.DeleteChannelModels(channelID, []string{id}, modelsJSON, now) + if err != nil { + return err + } + if deleted != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +// DeleteChannelModels atomically removes a selection and refreshes the channel's +// compatibility model list. Any active route or task reference aborts the whole +// transaction so a bulk action cannot leave the administrator with a partial result. +func (r *Repository) DeleteChannelModels(channelID string, ids []string, modelsJSON string, now time.Time) (int64, error) { + ids = uniqueStrings(ids) + if len(ids) == 0 { + return 0, gorm.ErrRecordNotFound + } + var deleted int64 + err := r.db.Transaction(func(tx *gorm.DB) error { + var existing int64 + if err := tx.Model(&model.ChannelModel{}). + Where("channel_id = ? AND id IN ?", channelID, ids). + Count(&existing).Error; err != nil { + return err + } + if existing != int64(len(ids)) { + return gorm.ErrRecordNotFound + } var activeReferences int64 if err := tx.Table("logical_model_routes AS route"). Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id"). - Where("route.channel_model_id = ?", id). + Where("route.channel_model_id IN ?", ids). Count(&activeReferences).Error; err != nil { return err } @@ -243,7 +271,7 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON return ErrChannelModelInUse } if err := tx.Model(&model.Task{}). - Where("channel_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). Count(&activeReferences).Error; err != nil { return err } @@ -251,15 +279,15 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON return ErrChannelModelInUse } result := tx.Model(&model.ChannelModel{}). - Where("id = ? AND channel_id = ?", id, channelID). + Where("id IN ? AND channel_id = ?", ids, channelID). Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now}) if result.Error != nil { return result.Error } - if result.RowsAffected != 1 { + if result.RowsAffected != int64(len(ids)) { return gorm.ErrRecordNotFound } - if err := tx.Where("id = ? AND channel_id = ?", id, channelID).Delete(&model.ChannelModel{}).Error; err != nil { + if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil { return err } channelResult := tx.Model(&model.ModelChannel{}). @@ -271,8 +299,10 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON if channelResult.RowsAffected != 1 { return gorm.ErrRecordNotFound } + deleted = result.RowsAffected return nil }) + return deleted, err } func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) { diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index eeec40aaa..0f0da4b7c 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" "testing" "infinite-canvas/backend/internal/model" @@ -275,6 +276,109 @@ func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) { } } +func TestDeleteAdminChannelModelsDeletesSelectionAtomically(t *testing.T) { + svc, db := newChannelModelTestService(t) + 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: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b","model-c"]`} + items := []model.ChannelModel{ + {ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1}, + {ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1}, + {ID: "model-c", ChannelID: channel.ID, ModelKey: "model-c", DisplayName: "Model C", Enabled: true, PriceVersion: 1}, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + + deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{" model-a ", "model-b", "model-a"}) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("deleted = %d, want 2", deleted) + } + var storedChannel model.ModelChannel + if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + if storedChannel.ModelsJSON != `["model-c"]` { + t.Fatalf("ModelsJSON = %s, want model-c only", storedChannel.ModelsJSON) + } + var active []model.ChannelModel + if err := db.Where("channel_id = ?", channel.ID).Find(&active).Error; err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].ID != "model-c" { + t.Fatalf("active models = %#v, want model-c", active) + } + var removed []model.ChannelModel + if err := db.Unscoped().Where("channel_id = ? AND id IN ?", channel.ID, []string{"model-a", "model-b"}).Find(&removed).Error; err != nil { + t.Fatal(err) + } + if len(removed) != 2 { + t.Fatalf("removed models = %#v, want two", removed) + } + for _, item := range removed { + if item.Enabled || item.PriceVersion != 2 || !item.DeletedAt.Valid { + t.Fatalf("removed model state = %#v", item) + } + } +} + +func TestDeleteAdminChannelModelsRejectsWholeSelectionWhenOneModelIsInUse(t *testing.T) { + svc, db := newChannelModelTestService(t) + 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: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b"]`} + items := []model.ChannelModel{ + {ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1}, + {ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1}, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Task{ID: "task-running", ChannelModelID: "model-b", Status: model.TaskStatusRunning}).Error; err != nil { + t.Fatal(err) + } + + deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{"model-a", "model-b"}) + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Message != "所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型" { + t.Fatalf("DeleteAdminChannelModels() deleted = %d, error = %#v", deleted, err) + } + var active int64 + if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&active).Error; err != nil { + t.Fatal(err) + } + if active != 2 { + t.Fatalf("active models = %d, want 2 after atomic rejection", active) + } + var storedChannel model.ModelChannel + if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + if storedChannel.ModelsJSON != channel.ModelsJSON { + t.Fatalf("ModelsJSON changed after rejected batch: %s", storedChannel.ModelsJSON) + } +} + +func TestNormalizeAdminChannelModelDeleteIDsRequiresBoundedSelection(t *testing.T) { + if _, err := normalizeAdminChannelModelDeleteIDs([]string{"", " "}); err == nil { + t.Fatal("empty selection should be rejected") + } + values := make([]string, 101) + for index := range values { + values[index] = "model-" + strconv.Itoa(index) + } + if _, err := normalizeAdminChannelModelDeleteIDs(values); err == nil { + t.Fatal("selection above 100 models should be rejected") + } +} + func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) { svc, db := newChannelModelTestService(t) svc.dataDir = t.TempDir() @@ -344,7 +448,7 @@ func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}, &model.IDSequence{}); err != nil { t.Fatal(err) } return &Service{repo: repository.New(db)}, db diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index f6019bad3..811965d62 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -35,6 +35,8 @@ type ChannelModelRequest struct { PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"` } +const maxAdminChannelModelBatchDeleteCount = 100 + // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { @@ -826,47 +828,86 @@ func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, chan } func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error { + _, err := s.DeleteAdminChannelModels(actor, channelID, []string{id}) + return err +} + +// DeleteAdminChannelModels validates the complete selection before asking the +// repository to remove it atomically. This deliberately rejects partial success: +// administrators can safely correct an in-use model and retry the same selection. +func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) { if err := s.RequireAdmin(actor); err != nil { - return err + return 0, err } if _, err := s.repo.AdminSystemChannel(channelID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("系统渠道不存在或已删除") + return 0, BadAuthRequest("系统渠道不存在或已删除") } - return err + return 0, err } - if _, err := s.repo.ChannelModelByID(channelID, id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("渠道模型不存在或已删除") - } - return err - } - items, err := s.repo.ChannelModels(channelID, false) + modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids) if err != nil { - return err + return 0, err + } + items, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return 0, err + } + selected := make(map[string]bool, len(modelIDs)) + for _, id := range modelIDs { + selected[id] = true + } + found := 0 + for _, item := range items { + if selected[item.ID] { + found++ + } + } + if found != len(modelIDs) { + return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试") } names := make([]string, 0, len(items)) for _, item := range items { - if item.ID != id { + if item.Enabled && !selected[item.ID] { names = append(names, item.ModelKey) } } encoded, err := json.Marshal(names) if err != nil { - return err + return 0, err } // 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。 - err = s.repo.DeleteChannelModel(channelID, id, string(encoded), time.Now()) + deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, string(encoded), time.Now()) if errors.Is(err, repository.ErrChannelModelInUse) { - return BadAuthRequest("渠道模型仍被前台模型供应线路或进行中任务使用,请先移除线路并等待任务结束") + return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型") } if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("渠道模型不存在或已删除") + return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试") } if err == nil { s.invalidateRouteCatalog() } - return err + return deleted, err +} + +func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + if len(result) == 0 { + return nil, BadAuthRequest("请至少选择一个要删除的渠道模型") + } + if len(result) > maxAdminChannelModelBatchDeleteCount { + return nil, BadAuthRequest("单次最多删除 100 个渠道模型") + } + return result, nil } func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error { diff --git a/web/index.html b/web/index.html index b065229fb..ab775c955 100644 --- a/web/index.html +++ b/web/index.html @@ -19,13 +19,13 @@ position: fixed; left: 50%; top: 50%; - width: 42px; - height: 42px; - margin: -21px 0 0 -21px; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; background: rgba(255, 255, 255, 0.84); - mask: url('/logo.svg') center / contain no-repeat; - -webkit-mask: url('/logo.svg') center / contain no-repeat; - animation: appearance-bootstrap-logo 1.8s ease-in-out infinite; + border-radius: 999px; + box-shadow: 0 0 0 7px rgba(255, 255, 255, 0.08); + animation: appearance-bootstrap-pulse 1.8s ease-in-out infinite; } #root:empty::after { content: ""; @@ -40,7 +40,7 @@ border-radius: 999px; animation: appearance-bootstrap-orbit 2.8s linear infinite; } - @keyframes appearance-bootstrap-logo { 0%, 100% { opacity: .62; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.04); } } + @keyframes appearance-bootstrap-pulse { 0%, 100% { opacity: .62; transform: scale(.82); } 50% { opacity: 1; transform: scale(1.08); } } @keyframes appearance-bootstrap-orbit { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { #root:empty::before, diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 9dcc3cd8f..6934fb276 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -7,15 +7,25 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { ModelIcon } from "@/components/model-picker"; import { modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols"; import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; -import { deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; +import { + deleteAdminChannelModel, + deleteAdminChannelModels, + fetchAdminChannelModels, + importAdminChannelModels, + listAdminChannelModels, + type ChannelModel, + type ChannelModelPriceTier, +} from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; import { ChannelModelEditor } from "./channel-model-editor"; import { AdminPageFrame } from "./admin-shell"; -import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; +import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise }) { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const [items, setItems] = useState([]); + const [selectedModelIds, setSelectedModelIds] = useState([]); + const [deletingSelected, setDeletingSelected] = useState(false); const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(false); const [fetching, setFetching] = useState(false); @@ -48,7 +58,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: if (!channel) return; setLoading(true); try { - setItems((await listAdminChannelModels(channel.id)).models); + const models = (await listAdminChannelModels(channel.id)).models; + const availableIDs = new Set(models.map((item) => item.id)); + setItems(models); + setSelectedModelIds((current) => current.filter((id) => availableIDs.has(id))); } catch (error) { message.error(error instanceof Error ? error.message : "读取渠道模型失败"); } finally { @@ -61,6 +74,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: void loadProtocols(); setEditing(null); setEditorOpen(false); + setSelectedModelIds([]); resetFetchPreview(); setKeyword(""); setCapability("all"); @@ -139,6 +153,32 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: } }; + const confirmBatchRemove = () => { + if (!selectedModelIds.length) return; + const count = selectedModelIds.length; + modal.confirm({ + title: `删除已选的 ${count} 个模型?`, + content: "删除后模型不再显示,且不能在页面恢复。只要其中任一模型仍被前台供应线路或进行中任务使用,本次就不会删除任何模型。", + okText: "批量删除", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: async () => { + setDeletingSelected(true); + try { + const result = await deleteAdminChannelModels(channel.id, selectedModelIds); + setSelectedModelIds([]); + setPage(1); + await reload(); + await onChanged(); + message.success(`已删除 ${result.deleted} 个模型`); + } catch (error) { + message.error(error instanceof Error ? error.message : "批量删除模型失败"); + } finally { + setDeletingSelected(false); + } + }, + }); + }; const columns: ColumnsType = [ { title: "模型", @@ -178,11 +218,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: width: 180, render: (_, item) => ( - void remove(item)}> - + + } table={{ className: "app-data-table", rowKey: "id", size: "small", loading, + rowSelection: { + selectedRowKeys: selectedModelIds, + preserveSelectedRowKeys: true, + onChange: (keys) => { + const next = keys.map(String); + if (next.length > 100) message.warning("单次最多选择 100 个模型"); + setSelectedModelIds(next.slice(0, 100)); + }, + }, columns, dataSource: pagedItems, pagination: false, @@ -357,9 +414,28 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ]} >
-

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

+

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。

+
+ + 已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型 + + + + + +
- setSelectedFetchModels(values as string[])} /> + setSelectedFetchModels(values as string[])} + />
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"} diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index fd92a75f3..509f1bd29 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -295,6 +295,10 @@ export function deleteAdminChannelModel(channelId: string, id: string) { return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`)); } +export function deleteAdminChannelModels(channelId: string, modelIds: string[]) { + return request<{ deleted: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-delete`, { modelIds })); +} + export type AdminFinanceListParams = { keyword?: string; status?: string; validity?: string; page?: number; limit?: number }; export function listAdminRedeemBatches(params: AdminFinanceListParams = {}) { diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts index b3a9f6e24..02ce32534 100644 --- a/web/test/admin-ui-regressions.test.ts +++ b/web/test/admin-ui-regressions.test.ts @@ -87,6 +87,13 @@ test("channel model fetch requires explicit selection before import", async () = expect(component).toContain("默认已全选"); expect(component).toContain("setFetchPreviewOpen(true)"); expect(component).toContain("setSelectedFetchModels(result.models)"); + expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型"); + expect(component).toContain("disabled={importing || allFetchModelsSelected}"); + expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}"); + expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}"); + expect(component).toContain("onClick={() => setSelectedFetchModels([])}"); + expect(component).toContain("取消全选"); + expect(component).toContain("disabled={importing}"); expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)"); expect(component).toContain("disabled={!selectedFetchModels.length}"); expect(component).not.toContain("disabled: alreadyExists"); @@ -95,6 +102,22 @@ test("channel model fetch requires explicit selection before import", async () = expect(adminCssSource).toContain("border-color: var(--control-check-fg) !important"); }); +test("channel model manager supports bounded atomic batch deletion", async () => { + const [componentSource, apiSource] = 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()]); + const component = compactSource(componentSource); + + expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-delete`, { modelIds })"); + expect(component).toContain(" { const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text());