Просмотр исходного кода

✨ feat: Improve models UX and robustness: add JSONEditor extraFooter, fix endpoints rendering, and clean up deps

- Why
  - Needed to separate help text from action buttons in JSONEditor for better layout and UX.
  - Models table should robustly render both new object-based endpoint mappings and legacy arrays.
  - Columns should re-render when vendor map changes.
  - Minor import cleanups for consistency.

- What
  - JSONEditor.js
    - Added optional prop extraFooter to render content below the extraText divider.
    - Kept extraText rendered via Divider; extraFooter appears on the next line for clear separation.
  - EditModelModal.jsx
    - Moved endpoint group buttons from extraText into extraFooter to display under the helper text.
    - Kept merge-logic: group items are merged into current endpoints JSON with key override semantics.
    - Consolidated lucide-react imports into a single line.
  - ModelsColumnDefs.js
    - Made endpoint renderer resilient:
      - Supports object-based JSON (keys as endpoint types) and legacy array format.
      - Displays keys/items as tags and limits the number shown; uses stringToColor for visual consistency.
    - Consolidated Semi UI imports into a single line.
  - ModelsTable.jsx
    - Fixed columns memoization dependency to include vendorMap, ensuring re-render when vendor data changes.

- Notes
  - Backward-compatible: extraFooter is additive; existing JSONEditor usage remains unchanged.
  - No API changes to backend.
  - No linter errors introduced.

- Files touched
  - web/src/components/common/ui/JSONEditor.js
  - web/src/components/table/models/modals/EditModelModal.jsx
  - web/src/components/table/models/ModelsColumnDefs.js
  - web/src/components/table/models/ModelsTable.jsx

- Impact
  - Clearer UI for endpoint editing (buttons now below helper text).
  - Correct endpoints display for object-based mappings in models list.
  - More reliable reactivity when vendor data updates.
t0ng7u 7 месяцев назад
Родитель
Сommit
4e75a9b3b3

+ 8 - 1
web/src/components/common/ui/JSONEditor.js

@@ -14,6 +14,7 @@ import {
   TextArea,
   TextArea,
   Row,
   Row,
   Col,
   Col,
+  Divider,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
 import {
 import {
   IconCode,
   IconCode,
@@ -31,6 +32,7 @@ const JSONEditor = ({
   label,
   label,
   placeholder,
   placeholder,
   extraText,
   extraText,
+  extraFooter,
   showClear = true,
   showClear = true,
   template,
   template,
   templateLabel,
   templateLabel,
@@ -634,8 +636,13 @@ const JSONEditor = ({
 
 
         {/* 额外文本显示在卡片底部 */}
         {/* 额外文本显示在卡片底部 */}
         {extraText && (
         {extraText && (
-          <div className="text-gray-600 mt-3 pt-3">
+          <Divider margin='12px' align='center'>
             {extraText}
             {extraText}
+          </Divider>
+        )}
+        {extraFooter && (
+          <div className="mt-1">
+            {extraFooter}
           </div>
           </div>
         )}
         )}
       </Card>
       </Card>

+ 33 - 21
web/src/components/table/models/ModelsColumnDefs.js

@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
 */
 */
 
 
 import React from 'react';
 import React from 'react';
-import {
-  Button,
-  Space,
-  Tag,
-  Typography,
-  Modal
-} from '@douyinfe/semi-ui';
+import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
 import {
 import {
   timestamp2string,
   timestamp2string,
   getLobeHubIcon,
   getLobeHubIcon,
@@ -81,21 +75,39 @@ const renderTags = (text) => {
   });
   });
 };
 };
 
 
-// Render endpoints
-const renderEndpoints = (text) => {
-  let arr;
+// Render endpoints (supports object map or legacy array)
+const renderEndpoints = (value) => {
   try {
   try {
-    arr = JSON.parse(text);
-  } catch (_) { }
-  if (!Array.isArray(arr)) return text || '-';
-  return renderLimitedItems({
-    items: arr,
-    renderItem: (ep, idx) => (
-      <Tag key={idx} color="white" size="small" shape='circle'>
-        {ep}
-      </Tag>
-    ),
-  });
+    const parsed = typeof value === 'string' ? JSON.parse(value) : value;
+    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+      const keys = Object.keys(parsed || {});
+      if (keys.length === 0) return '-';
+      return renderLimitedItems({
+        items: keys,
+        renderItem: (key, idx) => (
+          <Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
+            {key}
+          </Tag>
+        ),
+        maxDisplay: 3,
+      });
+    }
+    if (Array.isArray(parsed)) {
+      if (parsed.length === 0) return '-';
+      return renderLimitedItems({
+        items: parsed,
+        renderItem: (ep, idx) => (
+          <Tag key={idx} color="white" size="small" shape='circle'>
+            {ep}
+          </Tag>
+        ),
+        maxDisplay: 3,
+      });
+    }
+    return value || '-';
+  } catch (_) {
+    return value || '-';
+  }
 };
 };
 
 
 // Render quota type
 // Render quota type

+ 1 - 7
web/src/components/table/models/ModelsTable.jsx

@@ -56,13 +56,7 @@ const ModelsTable = (modelsData) => {
       refresh,
       refresh,
       vendorMap,
       vendorMap,
     });
     });
-  }, [
-    t,
-    manageModel,
-    setEditingModel,
-    setShowEdit,
-    refresh,
-  ]);
+  }, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
 
 
   // Handle compact mode by removing fixed positioning
   // Handle compact mode by removing fixed positioning
   const tableColumns = useMemo(() => {
   const tableColumns = useMemo(() => {

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

@@ -32,17 +32,20 @@ import {
   Col,
   Col,
   Row,
   Row,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
-import {
-  Save,
-  X,
-  FileText,
-} from 'lucide-react';
+import { Save, X, FileText } from 'lucide-react';
 import { API, showError, showSuccess } from '../../../../helpers';
 import { API, showError, showSuccess } from '../../../../helpers';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
 
 
 const { Text, Title } = Typography;
 const { Text, Title } = Typography;
 
 
+// Example endpoint template for quick fill
+const ENDPOINT_TEMPLATE = {
+  openai: { path: '/v1/chat/completions', method: 'POST' },
+  anthropic: { path: '/v1/messages', method: 'POST' },
+  'image-generation': { path: '/v1/images/generations', method: 'POST' },
+};
+
 const nameRuleOptions = [
 const nameRuleOptions = [
   { label: '精确名称匹配', value: 0 },
   { label: '精确名称匹配', value: 0 },
   { label: '前缀名称匹配', value: 1 },
   { label: '前缀名称匹配', value: 1 },
@@ -385,7 +388,37 @@ const EditModelModal = (props) => {
                       onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
                       onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
                       formApi={formApiRef.current}
                       formApi={formApiRef.current}
                       editorType='object'
                       editorType='object'
-                      extraText={t('留空则使用默认端点;支持 {path, method}')}
+                      template={ENDPOINT_TEMPLATE}
+                      templateLabel={t('填入模板')}
+                      extraText={(<Text type="tertiary" size="small">{t('留空则使用默认端点;支持 {path, method}')}</Text>)}
+                      extraFooter={endpointGroups.length > 0 && (
+                        <Space wrap>
+                          {endpointGroups.map(group => (
+                            <Button
+                              key={group.id}
+                              size='small'
+                              type='primary'
+                              onClick={() => {
+                                try {
+                                  const current = formApiRef.current?.getValue('endpoints') || '';
+                                  let base = {};
+                                  if (current && current.trim()) base = JSON.parse(current);
+                                  const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
+                                  const merged = { ...base, ...groupObj };
+                                  formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
+                                } catch (e) {
+                                  try {
+                                    const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
+                                    formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
+                                  } catch { }
+                                }
+                              }}
+                            >
+                              {group.name}
+                            </Button>
+                          ))}
+                        </Space>
+                      )}
                     />
                     />
                   </Col>
                   </Col>
                   <Col span={24}>
                   <Col span={24}>

+ 9 - 0
web/src/components/table/models/modals/EditPrefillGroupModal.jsx

@@ -43,6 +43,13 @@ import { useIsMobile } from '../../../../hooks/common/useIsMobile';
 
 
 const { Text, Title } = Typography;
 const { Text, Title } = Typography;
 
 
+// Example endpoint template for quick fill
+const ENDPOINT_TEMPLATE = {
+  openai: { path: '/v1/chat/completions', method: 'POST' },
+  anthropic: { path: '/v1/messages', method: 'POST' },
+  'image-generation': { path: '/v1/images/generations', method: 'POST' },
+};
+
 const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
 const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
@@ -240,6 +247,8 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
                       onChange={(val) => formRef.current?.setValue('items', val)}
                       onChange={(val) => formRef.current?.setValue('items', val)}
                       editorType='object'
                       editorType='object'
                       placeholder={'{\n  "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
                       placeholder={'{\n  "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
+                      template={ENDPOINT_TEMPLATE}
+                      templateLabel={t('填入模板')}
                       extraText={t('键为端点类型,值为路径和方法对象')}
                       extraText={t('键为端点类型,值为路径和方法对象')}
                     />
                     />
                   ) : (
                   ) : (