From e74d3f4a8f31f5a6117cb821c52647b30e5d0989 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 3 Aug 2025 22:51:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20polish=20=E2=80=9CMissing?= =?UTF-8?q?=20Models=E2=80=9D=20UX=20&=20mobile=20actions=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- controller/missing_models.go | 27 +++ model/missing_models.go | 30 +++ router/api-router.go | 3 +- web/src/components/common/ui/CardPro.js | 1 + .../components/table/models/ModelsActions.jsx | 24 ++- .../table/models/ModelsColumnDefs.js | 5 + .../table/models/modals/EditModelModal.jsx | 18 +- .../models/modals/MissingModelsModal.jsx | 175 ++++++++++++++++++ 8 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 controller/missing_models.go create mode 100644 model/missing_models.go create mode 100644 web/src/components/table/models/modals/MissingModelsModal.jsx diff --git a/controller/missing_models.go b/controller/missing_models.go new file mode 100644 index 00000000..a3409e29 --- /dev/null +++ b/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, + }) +} diff --git a/model/missing_models.go b/model/missing_models.go new file mode 100644 index 00000000..57269f5f --- /dev/null +++ b/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 +} diff --git a/router/api-router.go b/router/api-router.go index e2b35be0..a70c2ad4 100644 --- a/router/api-router.go +++ b/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) diff --git a/web/src/components/common/ui/CardPro.js b/web/src/components/common/ui/CardPro.js index 5745b9b3..ad6dda85 100644 --- a/web/src/components/common/ui/CardPro.js +++ b/web/src/components/common/ui/CardPro.js @@ -112,6 +112,7 @@ const CardPro = ({ icon={showMobileActions ? : } type="tertiary" size="small" + theme='outline' block > {showMobileActions ? t('隐藏操作项') : t('显示操作项')} diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index 78d3d5b0..b27d51e4 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/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 = ({ + + + + setShowMissingModal(false)} + onConfigureModel={(name) => { + setEditingModel({ id: undefined, model_name: name }); + setShowEdit(true); + setShowMissingModal(false); + }} + t={t} + /> ); }; diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index d2da5b0a..7e12ed6f 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -201,6 +201,11 @@ export const getModelsColumns = ({ { title: t('模型名称'), dataIndex: 'model_name', + render: (text) => ( + e.stopPropagation()}> + {text} + + ), }, { title: t('描述'), diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index f1539d07..eeff5d2b 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/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 /> diff --git a/web/src/components/table/models/modals/MissingModelsModal.jsx b/web/src/components/table/models/modals/MissingModelsModal.jsx new file mode 100644 index 00000000..5bd53944 --- /dev/null +++ b/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 . + +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) => ( +
+ {text} +
+ ) + }, + { + title: '', + dataIndex: 'operate', + render: (text, record) => ( + + ) + } + ]; + + return ( + +
+ + {t('未配置的模型列表')} + + + {t('共')} {missingModels.length} {t('个未配置模型')} + +
+ + } + visible={visible} + onCancel={onClose} + footer={null} + width={700} + className="!rounded-lg" + > + + {missingModels.length === 0 && !loading ? ( + } + darkModeImage={} + description={t('暂无缺失模型')} + style={{ padding: 30 }} + /> + ) : ( +
+ {/* 搜索框 */} +
+ { + setSearchKeyword(v); + setCurrentPage(1); + }} + className="!w-full" + prefix={} + showClear + /> +
+ + {/* 表格 */} + {filteredModels.length > 0 ? ( + setCurrentPage(page), + }} + /> + ) : ( + } + darkModeImage={} + description={searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')} + style={{ padding: 20 }} + /> + )} + + )} + + + ); +}; + +export default MissingModelsModal;