Jelajahi Sumber

feat: 添加模型批量操作和前端优化

新增模型批量删除/状态设置 API,前端模型卡片支持多选,应用概览
页面添加 API 限流配置入口。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 6 hari lalu
induk
melakukan
21a2ad67a7

+ 2 - 0
apps/models_provider/urls.py

@@ -18,6 +18,8 @@ urlpatterns = [
     path('workspace/<str:workspace_id>/model/<str:model_id>', views.ModelSetting.Operate.as_view()),
     path('workspace/<str:workspace_id>/model/<str:model_id>/pause_download', views.ModelSetting.PauseDownload.as_view()),
     path('workspace/<str:workspace_id>/model/<str:model_id>/meta', views.ModelSetting.ModelMeta.as_view()),
+    path('workspace/<str:workspace_id>/model/batch_delete', views.ModelSetting.BatchDelete.as_view()),
+    path('workspace/<str:workspace_id>/model/batch_operate', views.ModelSetting.BatchOperate.as_view()),
     path('system/shared/workspace/<str:workspace_id>/model', views.WorkspaceSharedModelSetting.as_view()),
     # 平台 API Key 管理(已包含在 builder/api/ 前缀下)
     path('platform/api-keys', views.ApiKeyView.as_view()),

+ 54 - 1
apps/models_provider/views/model.py

@@ -20,7 +20,7 @@ from common.result import result
 from common.utils.common import query_params_to_single_dict
 from models_provider.api.model import ModelCreateAPI, GetModelApi, ModelEditApi, ModelListResponse, DefaultModelResponse
 from models_provider.api.provide import ProvideApi
-from models_provider.models import Model
+from models_provider.models import Model, Status
 from models_provider.serializers.model_serializer import ModelSerializer, \
     WorkspaceSharedModelSerializer
 from system_manage.views import encryption_str
@@ -264,6 +264,59 @@ class ModelSetting(APIView):
             return result.success(
                 ModelSerializer.Operate(data={'id': model_id, 'workspace_id': workspace_id}).pause_download())
 
+    class BatchDelete(APIView):
+        """批量删除模型"""
+        authentication_classes = [TokenAuth]
+
+        @extend_schema(methods=['POST'],
+                       summary=_('Batch delete models'),
+                       description=_('Batch delete models'),
+                       operation_id=_('Batch delete models'),
+                       tags=[_('Model')])
+        @has_permissions(PermissionConstants.MODEL_DELETE.get_workspace_permission(),
+                         PermissionConstants.MODEL_DELETE.get_workspace_permission_workspace_manage_role(),
+                         RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+                         RoleConstants.USER.get_workspace_role())
+        def post(self, request: Request, workspace_id: str):
+            model_ids = request.data.get('model_ids', [])
+            if not model_ids:
+                return result.success({'deleted_count': 0})
+
+            deleted_count = 0
+            for model_id in model_ids:
+                try:
+                    ModelSerializer.Operate(
+                        data={'id': model_id, 'user_id': request.user.id, 'workspace_id': workspace_id}
+                    ).delete()
+                    deleted_count += 1
+                except Exception:
+                    pass
+            return result.success({'deleted_count': deleted_count})
+
+    class BatchOperate(APIView):
+        """批量设置模型状态"""
+        authentication_classes = [TokenAuth]
+
+        @extend_schema(methods=['POST'],
+                       summary=_('Batch update model status'),
+                       description=_('Batch update model status'),
+                       operation_id=_('Batch update model status'),
+                       tags=[_('Model')])
+        @has_permissions(PermissionConstants.MODEL_EDIT.get_workspace_permission(),
+                         PermissionConstants.MODEL_EDIT.get_workspace_permission_workspace_manage_role(),
+                         RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+                         RoleConstants.USER.get_workspace_role())
+        def post(self, request: Request, workspace_id: str):
+            model_ids = request.data.get('model_ids', [])
+            status = request.data.get('status', '')
+            if not model_ids or status not in [s[0] for s in Status.choices]:
+                return result.success({'updated_count': 0})
+
+            updated_count = QuerySet(Model).filter(
+                id__in=model_ids, workspace_id=workspace_id
+            ).update(status=status)
+            return result.success({'updated_count': updated_count})
+
 
 class WorkspaceSharedModelSetting(APIView):
     authentication_classes = [TokenAuth]

+ 31 - 0
ui/src/api/model/model.ts

@@ -148,6 +148,35 @@ const deleteModel: (model_id: string, loading?: Ref<boolean>) => Promise<Result<
 ) => {
   return del(`${prefix.value}/model/${model_id}`, undefined, {}, loading)
 }
+
+/**
+ * 批量删除模型
+ * @param model_ids 模型id列表
+ * @param loading 加载器
+ * @returns
+ */
+const batchDeleteModels: (
+  model_ids: string[],
+  loading?: Ref<boolean>,
+) => Promise<Result<{ deleted_count: number }>> = (model_ids, loading) => {
+  return post(`${prefix.value}/model/batch_delete`, { model_ids }, {}, loading)
+}
+
+/**
+ * 批量设置模型状态
+ * @param model_ids 模型id列表
+ * @param status 目标状态
+ * @param loading 加载器
+ * @returns
+ */
+const batchOperateModels: (
+  model_ids: string[],
+  status: string,
+  loading?: Ref<boolean>,
+) => Promise<Result<{ updated_count: number }>> = (model_ids, status, loading) => {
+  return post(`${prefix.value}/model/batch_operate`, { model_ids, status }, {}, loading)
+}
+
 export default {
   getModelList,
   createModel,
@@ -159,4 +188,6 @@ export default {
   getModelParamsForm,
   updateModelParamsForm,
   getSelectModelList,
+  batchDeleteModels,
+  batchOperateModels,
 }

+ 13 - 0
ui/src/views/application-overview/index.vue

@@ -83,6 +83,11 @@
                     <AppIcon iconName="app-lock" class="mr-4"></AppIcon>
                     {{ $t('views.applicationOverview.appInfo.accessControl') }}
                   </el-button>
+                  <!-- API限流 -->
+                  <el-button @click="openRateLimitDialog">
+                    <AppIcon iconName="app-setting" class="mr-4"></AppIcon>
+                    API Rate Limit
+                  </el-button>
                   <!-- 显示设置 -->
                   <el-button
                     @click="openDisplaySettingDialog"
@@ -188,6 +193,8 @@
     <component :is="currentLimitDialog" ref="LimitDialogRef" @refresh="refresh" />
     <!-- 显示设置 -->
     <component :is="currentDisplaySettingDialog" ref="DisplaySettingDialogRef" @refresh="refresh" />
+    <!-- API限流 -->
+    <RateLimitDialog ref="RateLimitDialogRef" @refresh="refresh" />
   </div>
 </template>
 <script setup lang="ts">
@@ -199,6 +206,7 @@ import LimitDialog from './component/LimitDialog.vue'
 import XPackLimitDrawer from './xpack-component/XPackLimitDrawer.vue'
 import DisplaySettingDialog from './component/DisplaySettingDialog.vue'
 import XPackDisplaySettingDialog from './xpack-component/XPackDisplaySettingDialog.vue'
+import RateLimitDialog from './component/RateLimitDialog.vue'
 import StatisticsCharts from './component/StatisticsCharts.vue'
 import { nowDate, beforeDay } from '@/utils/time'
 import { MsgSuccess, MsgConfirm } from '@/utils/message'
@@ -233,6 +241,7 @@ const baseUrl = window.location.origin + `${window.MaxKB.chatPrefix}/api/`
 
 const APIKeyDialogRef = ref()
 const EmbedDialogRef = ref()
+const RateLimitDialogRef = ref()
 
 const accessToken = ref<any>({})
 const detail = ref<any>(null)
@@ -420,6 +429,10 @@ function openDialog() {
   EmbedDialogRef.value.open(accessToken.value?.access_token)
 }
 
+function openRateLimitDialog() {
+  RateLimitDialogRef.value?.open()
+}
+
 function getAccessToken() {
   loadSharedApi({ type: 'application', systemType: apiType.value })
     .getAccessToken(id, loading)

+ 23 - 2
ui/src/views/model/component/ModelCard.vue

@@ -1,6 +1,13 @@
 <template>
-  <card-box :title="model.name" shadow="hover" class="model-card">
+  <card-box :title="model.name" shadow="hover" class="model-card" :class="{ 'selected': isSelected }">
     <template #icon>
+      <el-checkbox
+        v-if="showCheckbox"
+        :model-value="isSelected"
+        @change="$emit('toggle-select', model.id)"
+        @click.stop
+        class="model-checkbox"
+      />
       <span style="height: 32px; width: 32px" :innerHTML="icon"></span>
     </template>
     <template #title>
@@ -179,6 +186,8 @@ const props = defineProps<{
   isShared?: boolean | undefined
   isSystemShare?: boolean | undefined
   apiType: 'systemShare' | 'workspace' | 'systemManage'
+  showCheckbox?: boolean
+  isSelected?: boolean
 }>()
 const openResourceMappingDrawer = (model: any) => {
   resourceMappingDrawerRef.value?.open('MODEL', model)
@@ -226,7 +235,7 @@ const errMessage = computed(() => {
   }
   return ''
 })
-const emit = defineEmits(['change', 'update:model'])
+const emit = defineEmits(['change', 'update:model', 'toggle-select'])
 const editModelRef = ref<InstanceType<typeof EditModel>>()
 let interval: any
 const deleteModel = () => {
@@ -323,6 +332,18 @@ onBeforeUnmount(() => {
 .model-card {
   min-height: 135px;
   min-width: auto;
+  position: relative;
+
+  &.selected {
+    border: 2px solid var(--el-color-primary);
+  }
+
+  .model-checkbox {
+    position: absolute;
+    top: 8px;
+    left: 8px;
+    z-index: 10;
+  }
 
   .operation-button {
     position: absolute;

+ 68 - 0
ui/src/views/model/index.vue

@@ -58,6 +58,18 @@
               </template>
             </el-select>
           </div>
+          <div class="flex batch-operations" v-if="selectedModels.length > 0 && !isShared">
+            <el-button type="danger" size="small" @click="handleBatchDelete">
+              批量删除 ({{ selectedModels.length }})
+            </el-button>
+            <el-select v-model="batchStatus" placeholder="批量设置状态" size="small" style="width: 140px">
+              <el-option label="启用" value="SUCCESS" />
+              <el-option label="暂停下载" value="PAUSE_DOWNLOAD" />
+            </el-select>
+            <el-button type="warning" size="small" @click="handleBatchOperate" :disabled="!batchStatus">
+              批量设置
+            </el-button>
+          </div>
           <el-button
             v-if="!isShared && permissionPrecise.create()"
             class="ml-16"
@@ -84,12 +96,15 @@
             >
               <ModelCard
                 @change="list_model"
+                @toggle-select="toggleModelSelection"
                 :updateModelById="updateModelById"
                 :model="model"
                 :provider_list="provider_list"
                 :isShared="isShared"
                 :isSystemShare="isSystemShare"
                 :apiType="apiType"
+                :showCheckbox="!isShared && permissionPrecise.delete(model.id)"
+                :isSelected="selectedModels.includes(model.id)"
               >
               </ModelCard>
             </el-col>
@@ -127,6 +142,8 @@ import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
 import useStore from '@/stores'
 import { useRoute, useRouter } from 'vue-router'
 import permissionMap from '@/permission'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import modelApi from '@/api/model/model'
 
 const route = useRoute()
 const router = useRouter()
@@ -166,6 +183,8 @@ const list_model_loading = ref<boolean>(false)
 const provider_list = ref<Array<Provider>>([])
 
 const model_list = ref<Array<Model>>([])
+const selectedModels = ref<string[]>([])
+const batchStatus = ref('')
 
 const isShared = computed(() => {
   return active_provider.value && active_provider.value.provider === 'share'
@@ -222,6 +241,50 @@ const search_type_change = () => {
   model_search_form.value = { name: '', create_user: '', model_type: '' }
 }
 
+const toggleModelSelection = (modelId: string) => {
+  const index = selectedModels.value.indexOf(modelId)
+  if (index === -1) {
+    selectedModels.value.push(modelId)
+  } else {
+    selectedModels.value.splice(index, 1)
+  }
+}
+
+const handleBatchDelete = async () => {
+  try {
+    await ElMessageBox.confirm(
+      `确定要删除选中的 ${selectedModels.value.length} 个模型吗?`,
+      '批量删除确认',
+      { type: 'warning' }
+    )
+    const result = await modelApi.batchDeleteModels(selectedModels.value)
+    if (result.code === 200) {
+      ElMessage.success(`成功删除 ${result.data.deleted_count} 个模型`)
+      selectedModels.value = []
+      list_model()
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('批量删除失败')
+    }
+  }
+}
+
+const handleBatchOperate = async () => {
+  if (!batchStatus.value) return
+  try {
+    const result = await modelApi.batchOperateModels(selectedModels.value, batchStatus.value)
+    if (result.code === 200) {
+      ElMessage.success(`成功更新 ${result.data.updated_count} 个模型状态`)
+      selectedModels.value = []
+      batchStatus.value = ''
+      list_model()
+    }
+  } catch (error) {
+    ElMessage.error('批量操作失败')
+  }
+}
+
 onMounted(() => {
   model.asyncGetProvider(loading).then((ok: any) => {
     active_provider.value = allObj
@@ -237,5 +300,10 @@ onMounted(() => {
     height: calc(var(--app-main-height));
     padding-right: 0 !important;
   }
+
+  .batch-operations {
+    gap: 8px;
+    align-items: center;
+  }
 }
 </style>