merge: integrate PR #430 channel model batch operations
This commit is contained in:
@@ -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"))
|
||||
})
|
||||
|
||||
23
backend/internal/handler/finance_routes_test.go
Normal file
23
backend/internal/handler/finance_routes_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> }) {
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<ChannelModel[]>([]);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
||||
const [deletingSelected, setDeletingSelected] = useState(false);
|
||||
const [editing, setEditing] = useState<ChannelModel | null>(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<ChannelModel> = [
|
||||
{
|
||||
title: "模型",
|
||||
@@ -178,11 +218,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
width: 180,
|
||||
render: (_, item) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => startEdit(item)}>
|
||||
<Button size="small" disabled={deletingSelected} onClick={() => startEdit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
|
||||
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
|
||||
<Button size="small" danger disabled={deletingSelected} title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
@@ -201,6 +241,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
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 allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name));
|
||||
const fetchModelOptions = fetchPreviewModels.map((name) => {
|
||||
const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name));
|
||||
return {
|
||||
@@ -315,11 +356,27 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
setStatus("all");
|
||||
setPage(1);
|
||||
}}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedModelIds.length} onClear={() => setSelectedModelIds([])}>
|
||||
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deletingSelected} onClick={confirmBatchRemove}>
|
||||
批量删除
|
||||
</Button>
|
||||
</AdminBatchBar>
|
||||
}
|
||||
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:
|
||||
]}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,请取消本次不需要拉入的模型;已存在的模型不会重复导入。</p>
|
||||
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/25 px-3 py-2">
|
||||
<span className="text-sm font-medium text-foreground/70" aria-live="polite">
|
||||
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button size="small" disabled={importing || allFetchModelsSelected} onClick={() => setSelectedFetchModels(fetchPreviewModels)}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" disabled={importing || selectedFetchModels.length === 0} onClick={() => setSelectedFetchModels([])}>
|
||||
取消全选
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="max-h-[min(60vh,520px)] overflow-y-auto rounded-md border border-border/70 p-3">
|
||||
<Checkbox.Group className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2" value={selectedFetchModels} options={fetchModelOptions} onChange={(values) => setSelectedFetchModels(values as string[])} />
|
||||
<Checkbox.Group
|
||||
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
value={selectedFetchModels}
|
||||
options={fetchModelOptions}
|
||||
disabled={importing}
|
||||
onChange={(values) => setSelectedFetchModels(values as string[])}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-foreground/50">
|
||||
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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("<AdminBatchBar count={selectedModelIds.length}");
|
||||
expect(component).toContain("rowSelection:");
|
||||
expect(component).toContain("selectedRowKeys: selectedModelIds");
|
||||
expect(component).toContain("preserveSelectedRowKeys: true");
|
||||
expect(component).toContain("setSelectedModelIds(next.slice(0, 100))");
|
||||
expect(component).toContain("deleteAdminChannelModels(channel.id, selectedModelIds)");
|
||||
expect(component).toContain("okButtonProps: { danger: true }");
|
||||
expect(component).toContain("本次就不会删除任何模型");
|
||||
expect(component).toContain("批量删除");
|
||||
});
|
||||
|
||||
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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user