Jelajahi Sumber

✨ feat: polish “Missing Models” UX & mobile actions layout

Overview
• Re-designed `MissingModelsModal` to align with `ModelTestModal` and deliver a cleaner, paginated experience.
• Improved mobile responsiveness for action buttons in `ModelsActions`.

Details
1. MissingModelsModal.jsx
   • Switched from `List` to `Table` for a more structured view.
   • Added search bar with live keyword filtering and clear icon.
   • Implemented pagination via `MODEL_TABLE_PAGE_SIZE`; auto-resets on search.
   • Dynamic rendering: when no data, show unified Empty state without column header.
   • Enhanced header layout with total-count subtitle and modal corner rounding.
   • Removed unused `Typography.Text` import.

2. ModelsActions.jsx
   • Set “Delete Selected Models” and “Missing Models” buttons to `flex-1 md:flex-initial`, placing them on the same row as “Add Model” on small screens.

Result
The “Missing Models” workflow now offers quicker discovery, a familiar table interface, and full mobile friendliness—without altering API behavior.
t0ng7u 7 bulan lalu
induk
melakukan
e74d3f4a8f

+ 27 - 0
controller/missing_models.go

@@ -0,0 +1,27 @@
+package controller
+
+import (
+    "net/http"
+    "one-api/model"
+
+    "github.com/gin-gonic/gin"
+)
+
+// GetMissingModels returns the list of model names that are referenced by channels
+// but do not have corresponding records in the models meta table.
+// This helps administrators quickly discover models that need configuration.
+func GetMissingModels(c *gin.Context) {
+    missing, err := model.GetMissingModels()
+    if err != nil {
+        c.JSON(http.StatusOK, gin.H{
+            "success": false,
+            "message": err.Error(),
+        })
+        return
+    }
+
+    c.JSON(http.StatusOK, gin.H{
+        "success": true,
+        "data":    missing,
+    })
+}

+ 30 - 0
model/missing_models.go

@@ -0,0 +1,30 @@
+package model
+
+// GetMissingModels returns model names that are referenced in the system
+func GetMissingModels() ([]string, error) {
+    // 1. 获取所有已启用模型(去重)
+    models := GetEnabledModels()
+    if len(models) == 0 {
+        return []string{}, nil
+    }
+
+    // 2. 查询已有的元数据模型名
+    var existing []string
+    if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
+        return nil, err
+    }
+
+    existingSet := make(map[string]struct{}, len(existing))
+    for _, e := range existing {
+        existingSet[e] = struct{}{}
+    }
+
+    // 3. 收集缺失模型
+    var missing []string
+    for _, name := range models {
+        if _, ok := existingSet[name]; !ok {
+            missing = append(missing, name)
+        }
+    }
+    return missing, nil
+}

+ 2 - 1
router/api-router.go

@@ -190,7 +190,8 @@ func SetApiRouter(router *gin.Engine) {
         modelsRoute := apiRouter.Group("/models")
 		modelsRoute.Use(middleware.AdminAuth())
 		{
-			modelsRoute.GET("/", controller.GetAllModelsMeta)
+			modelsRoute.GET("/missing", controller.GetMissingModels)
+            modelsRoute.GET("/", controller.GetAllModelsMeta)
             modelsRoute.GET("/search", controller.SearchModelsMeta)
 			modelsRoute.GET("/:id", controller.GetModelMeta)
 			modelsRoute.POST("/", controller.CreateModelMeta)

+ 1 - 0
web/src/components/common/ui/CardPro.js

@@ -112,6 +112,7 @@ const CardPro = ({
                 icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
                 type="tertiary"
                 size="small"
+                theme='outline'
                 block
               >
                 {showMobileActions ? t('隐藏操作项') : t('显示操作项')}

+ 23 - 1
web/src/components/table/models/ModelsActions.jsx

@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import React, { useState } from 'react';
+import MissingModelsModal from './modals/MissingModelsModal.jsx';
 import { Button, Space, Modal } from '@douyinfe/semi-ui';
 import CompactModeToggle from '../../common/ui/CompactModeToggle';
 import { showError } from '../../../helpers';
@@ -33,6 +34,7 @@ const ModelsActions = ({
 }) => {
   // Modal states
   const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [showMissingModal, setShowMissingModal] = useState(false);
 
   // Handle delete selected models with confirmation
   const handleDeleteSelectedModels = () => {
@@ -68,13 +70,22 @@ const ModelsActions = ({
 
         <Button
           type='danger'
-          className="w-full md:w-auto"
+          className="flex-1 md:flex-initial"
           onClick={handleDeleteSelectedModels}
           size="small"
         >
           {t('删除所选模型')}
         </Button>
 
+        <Button
+          type="secondary"
+          className="flex-1 md:flex-initial"
+          size="small"
+          onClick={() => setShowMissingModal(true)}
+        >
+          {t('未配置模型')}
+        </Button>
+
         <CompactModeToggle
           compactMode={compactMode}
           setCompactMode={setCompactMode}
@@ -93,6 +104,17 @@ const ModelsActions = ({
           {t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
         </div>
       </Modal>
+
+      <MissingModelsModal
+        visible={showMissingModal}
+        onClose={() => setShowMissingModal(false)}
+        onConfigureModel={(name) => {
+          setEditingModel({ id: undefined, model_name: name });
+          setShowEdit(true);
+          setShowMissingModal(false);
+        }}
+        t={t}
+      />
     </>
   );
 };

+ 5 - 0
web/src/components/table/models/ModelsColumnDefs.js

@@ -201,6 +201,11 @@ export const getModelsColumns = ({
     {
       title: t('模型名称'),
       dataIndex: 'model_name',
+      render: (text) => (
+        <Text copyable onClick={(e) => e.stopPropagation()}>
+          {text}
+        </Text>
+      ),
     },
     {
       title: t('描述'),

+ 12 - 6
web/src/components/table/models/modals/EditModelModal.jsx

@@ -81,7 +81,7 @@ const EditModelModal = (props) => {
   }, [props.visiable]);
 
   const getInitValues = () => ({
-    model_name: '',
+    model_name: props.editingModel?.model_name || '',
     description: '',
     tags: [],
     vendor_id: undefined,
@@ -136,22 +136,28 @@ const EditModelModal = (props) => {
   useEffect(() => {
     if (formApiRef.current) {
       if (!isEdit) {
-        formApiRef.current.setValues(getInitValues());
+        formApiRef.current.setValues({
+          ...getInitValues(),
+          model_name: props.editingModel?.model_name || '',
+        });
       }
     }
-  }, [props.editingModel?.id]);
+  }, [props.editingModel?.id, props.editingModel?.model_name]);
 
   useEffect(() => {
     if (props.visiable) {
       if (isEdit) {
         loadModel();
       } else {
-        formApiRef.current?.setValues(getInitValues());
+        formApiRef.current?.setValues({
+          ...getInitValues(),
+          model_name: props.editingModel?.model_name || '',
+        });
       }
     } else {
       formApiRef.current?.reset();
     }
-  }, [props.visiable, props.editingModel?.id]);
+  }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
 
   const submit = async (values) => {
     setLoading(true);
@@ -268,7 +274,7 @@ const EditModelModal = (props) => {
                       label={t('模型名称')}
                       placeholder={t('请输入模型名称,如:gpt-4')}
                       rules={[{ required: true, message: t('请输入模型名称') }]}
-                      disabled={isEdit}
+                      disabled={isEdit || !!props.editingModel?.model_name}
                       showClear
                     />
                   </Col>

+ 175 - 0
web/src/components/table/models/modals/MissingModelsModal.jsx

@@ -0,0 +1,175 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useState } from 'react';
+import { Modal, Table, Spin, Button, Typography, Empty, Input } from '@douyinfe/semi-ui';
+import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { API, showError } from '../../../../helpers';
+import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants/index.js';
+
+const MissingModelsModal = ({
+  visible,
+  onClose,
+  onConfigureModel,
+  t,
+}) => {
+  const [loading, setLoading] = useState(false);
+  const [missingModels, setMissingModels] = useState([]);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [currentPage, setCurrentPage] = useState(1);
+
+  const fetchMissing = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/models/missing');
+      if (res.data.success) {
+        setMissingModels(res.data.data || []);
+      } else {
+        showError(res.data.message);
+      }
+    } catch (_) {
+      showError(t('获取未配置模型失败'));
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    if (visible) {
+      fetchMissing();
+      setSearchKeyword('');
+      setCurrentPage(1);
+    } else {
+      setMissingModels([]);
+    }
+  }, [visible]);
+
+  // 过滤和分页逻辑
+  const filteredModels = missingModels.filter((model) =>
+    model.toLowerCase().includes(searchKeyword.toLowerCase())
+  );
+
+  const dataSource = (() => {
+    const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
+    const end = start + MODEL_TABLE_PAGE_SIZE;
+    return filteredModels.slice(start, end).map((model) => ({
+      model,
+      key: model,
+    }));
+  })();
+
+  const columns = [
+    {
+      title: t('模型名称'),
+      dataIndex: 'model',
+      render: (text) => (
+        <div className="flex items-center">
+          <Typography.Text strong>{text}</Typography.Text>
+        </div>
+      )
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      render: (text, record) => (
+        <Button
+          type="primary"
+          size="small"
+          onClick={() => onConfigureModel(record.model)}
+        >
+          {t('配置')}
+        </Button>
+      )
+    }
+  ];
+
+  return (
+    <Modal
+      title={
+        <div className="flex flex-col gap-2 w-full">
+          <div className="flex items-center gap-2">
+            <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
+              {t('未配置的模型列表')}
+            </Typography.Text>
+            <Typography.Text type="tertiary" className="!text-xs flex items-center">
+              {t('共')} {missingModels.length} {t('个未配置模型')}
+            </Typography.Text>
+          </div>
+        </div>
+      }
+      visible={visible}
+      onCancel={onClose}
+      footer={null}
+      width={700}
+      className="!rounded-lg"
+    >
+      <Spin spinning={loading}>
+        {missingModels.length === 0 && !loading ? (
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+            description={t('暂无缺失模型')}
+            style={{ padding: 30 }}
+          />
+        ) : (
+          <div className="missing-models-content">
+            {/* 搜索框 */}
+            <div className="flex items-center justify-end gap-2 w-full mb-4">
+              <Input
+                placeholder={t('搜索模型...')}
+                value={searchKeyword}
+                onChange={(v) => {
+                  setSearchKeyword(v);
+                  setCurrentPage(1);
+                }}
+                className="!w-full"
+                prefix={<IconSearch />}
+                showClear
+              />
+            </div>
+
+            {/* 表格 */}
+            {filteredModels.length > 0 ? (
+              <Table
+                columns={columns}
+                dataSource={dataSource}
+                pagination={{
+                  currentPage: currentPage,
+                  pageSize: MODEL_TABLE_PAGE_SIZE,
+                  total: filteredModels.length,
+                  showSizeChanger: false,
+                  onPageChange: (page) => setCurrentPage(page),
+                }}
+              />
+            ) : (
+              <Empty
+                image={<IllustrationNoResult style={{ width: 100, height: 100 }} />}
+                darkModeImage={<IllustrationNoResultDark style={{ width: 100, height: 100 }} />}
+                description={searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')}
+                style={{ padding: 20 }}
+              />
+            )}
+          </div>
+        )}
+      </Spin>
+    </Modal>
+  );
+};
+
+export default MissingModelsModal;