소스 검색

feat: 号池推送优化 + 修复 API Key 取消绑定

- 号池推送定时任务从每5分钟改为每天凌晨3点
- 推送逻辑兼容旧方式(模型绑定key)和新方式(分组绑定key)
- 修复 update_model 使用 model_fields_set,支持取消绑定 API Key
- 前端 Models.tsx 清理
mengboxin137-blip 2 주 전
부모
커밋
312643d938

+ 7 - 4
backend/app/routers/models.py

@@ -99,11 +99,14 @@ async def update_model(model_id: int, body: ModelUpdate) -> ModelOut:
         )
         if existing is None:
             raise HTTPException(status_code=404, detail="模型不存在")
-        new_name      = body.name       if body.name       is not None else existing["name"]
-        new_url       = body.url        if body.url        is not None else existing["url"]
-        new_api_key_id = body.api_key_id if body.api_key_id is not None else existing["api_key_id"]
-        new_group_id  = body.group_id   if body.group_id   is not None else existing["group_id"]
+
+        # 只更新客户端显式传递的字段(Pydantic v2: model_fields_set)
+        new_name = body.name if 'name' in body.model_fields_set else existing["name"]
+        new_url = body.url if 'url' in body.model_fields_set else existing["url"]
+        new_api_key_id = body.api_key_id if 'api_key_id' in body.model_fields_set else existing["api_key_id"]
+        new_group_id = body.group_id if 'group_id' in body.model_fields_set else existing["group_id"]
         api_key_changed = new_api_key_id != existing["api_key_id"]
+
         try:
             async with conn.transaction():
                 row = await conn.fetchrow(

+ 18 - 11
backend/app/routers/public.py

@@ -157,29 +157,32 @@ async def get_public_prices(
     if version != 0 and version == current_version:
         return UpToDateResponse(up_to_date=True, version=current_version)
 
-    # 从 price_snapshot 读取数据,LEFT JOIN models 取 group
-    # 新增:从 group_keys 获取分组的多个key(号池)
+    # 从 models 表出发,LEFT JOIN price_snapshot 取价格数据
+    # 这样手动添加的模型也会被包含
+    # 同时从 group_keys 获取分组的多个key(号池)
     if url is None:
         rows = await pool.fetch(
             """
-            SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
-                   ps.tool_prices, ps.icon, ps.updated_at,
+            SELECT m.url, COALESCE(ps.model_name, m.name) AS model_name,
+                   COALESCE(ps.prices, '{}') AS prices, COALESCE(ps.model_info, '{}') AS model_info,
+                   COALESCE(ps.rate_limits, '{}') AS rate_limits,
+                   COALESCE(ps.tool_prices, '{}') AS tool_prices, ps.icon,
+                   COALESCE(ps.updated_at, m.created_at) AS updated_at,
                    k.key_value AS api_key,
                    m.group_id, g.name AS group_name,
-                   -- 获取分组号池的所有key(聚合为数组)
                    COALESCE(
                        ARRAY_AGG(gk_keys.key_value) FILTER (WHERE gk_keys.key_value IS NOT NULL),
                        ARRAY[]::text[]
                    ) AS group_api_keys
-            FROM price_snapshot ps
-            LEFT JOIN models m ON m.url = ps.url
+            FROM models m
+            LEFT JOIN price_snapshot ps ON ps.url = m.url
             LEFT JOIN api_keys k ON k.id = m.api_key_id
             LEFT JOIN model_groups g ON g.id = m.group_id
             LEFT JOIN group_keys gk ON gk.group_id = m.group_id AND gk.is_active = TRUE AND gk.status = 'active'
             LEFT JOIN api_keys gk_keys ON gk_keys.id = gk.api_key_id
-            GROUP BY ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
-                     ps.tool_prices, ps.icon, ps.updated_at, k.key_value, m.group_id, g.name
-            ORDER BY ps.url
+            GROUP BY m.url, m.name, ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
+                     ps.tool_prices, ps.icon, ps.updated_at, m.created_at, k.key_value, m.group_id, g.name
+            ORDER BY m.url
             """
         )
     else:
@@ -223,13 +226,17 @@ async def get_public_prices(
         result = [t for t in tags if t in TYPE_TAGS]
         return result if result else None
 
+    def _to_list(v):
+        parsed = _j(v)
+        return parsed if isinstance(parsed, list) else None
+
     models = [PublicPriceOut(
         url=r["url"],
         model_name=r["model_name"],
         prices=_j(r["prices"]) or {},
         model_info=_j(r["model_info"]),
         rate_limits=_j(r["rate_limits"]),
-        tool_prices=_j(r["tool_prices"]),
+        tool_prices=_to_list(r["tool_prices"]),
         icon=r["icon"],
         scraped_at=r["updated_at"],
         discount=model_custom_prices.get(r["model_name"]) if model_custom_prices.get(r["model_name"]) is not None else discount_rate,

+ 82 - 20
backend/app/services/key_pool_pusher.py

@@ -1,6 +1,10 @@
 """
 号池数据推送服务
-定时将分组和key数据推送到company_base_pool
+定时将模型和 key 数据推送到 company_base_pool
+
+兼容两种绑定方式:
+1. 旧方式:模型直接绑定 key(models.api_key_id)
+2. 新方式:模型通过分组绑定 key(models.group_id)
 """
 import logging
 import asyncio
@@ -10,23 +14,77 @@ import os
 
 logger = logging.getLogger(__name__)
 
-# company_base_pool的API地址
+# company_base_pool  API 地址
 COMPANY_BASE_POOL_URL = os.getenv("COMPANY_BASE_POOL_URL", "http://localhost:8010")
-SYNC_ENDPOINT = f"{COMPANY_BASE_POOL_URL}/api/admin/key-pool/sync"
+SYNC_ENDPOINT = f"{COMPANY_BASE_POOL_URL}/api/crawler/sync"
 
 
-async def push_group_keys_to_pool(pool, group_id: int, api_key_ids: list):
+async def push_model_api_keys_to_pool(pool):
     """
-    推送分组的key到company_base_pool
+    推送模型和 API Key 绑定数据到 company_base_pool(旧方式兼容)
 
-    Args:
-        pool: 数据库连接池
-        group_id: 分组ID
-        api_key_ids: key ID列表
+    逻辑:
+    - 只推送有 api_key_id 的模型
+    - 按 group_name 聚合,每个分组推送一个对象
     """
     try:
         async with pool.acquire() as conn:
-            # 设置search_path
+            # 设置 search_path
+            await conn.execute("SET search_path TO crawl")
+
+            # 获取所有有 api_key_id 的模型
+            models = await conn.fetch("""
+                SELECT m.name, m.url, m.api_key_id, m.group_id,
+                       g.name AS group_name,
+                       ak.key_value AS api_key
+                FROM models m
+                LEFT JOIN model_groups g ON g.id = m.group_id
+                LEFT JOIN api_keys ak ON ak.id = m.api_key_id
+                WHERE m.api_key_id IS NOT NULL
+            """)
+
+            if not models:
+                logger.info("没有模型绑定 API Key,跳过推送")
+                return
+
+            # 按 group_name 聚合
+            groups_map = {}
+            for m in models:
+                group_name = m['group_name'] or '未分组'
+                if group_name not in groups_map:
+                    groups_map[group_name] = {
+                        'group_name': group_name,
+                        'provider': 'dashscope',
+                        'base_url': 'https://dashscope.aliyuncs.com',
+                        'model_name': None,
+                        'api_keys': []
+                    }
+                if m['api_key'] and m['api_key'] not in groups_map[group_name]['api_keys']:
+                    groups_map[group_name]['api_keys'].append(m['api_key'])
+
+            # 推送到 company_base_pool
+            data = list(groups_map.values())
+
+            async with httpx.AsyncClient(timeout=30) as client:
+                response = await client.post(SYNC_ENDPOINT, json=data)
+
+                if response.status_code == 200:
+                    result = response.json()
+                    logger.info(f"推送模型 API Key 到号池:{len(data)} 个分组,{sum(len(g['api_keys']) for g in data)} 个 key")
+                else:
+                    logger.error(f"推送失败:{response.status_code} {response.text}")
+
+    except Exception as e:
+        logger.error(f"推送模型 API Key 失败:{e}")
+
+
+async def push_group_keys_to_pool(pool, group_id: int):
+    """
+    推送分组的 key 到 company_base_pool(新方式)
+    """
+    try:
+        async with pool.acquire() as conn:
+            # 设置 search_path
             await conn.execute("SET search_path TO crawl")
 
             # 获取分组信息
@@ -34,7 +92,7 @@ async def push_group_keys_to_pool(pool, group_id: int, api_key_ids: list):
             if not group:
                 return
 
-            # 获取keys
+            # 获取 keys
             keys = await conn.fetch("""
                 SELECT ak.key_value
                 FROM api_keys ak
@@ -47,7 +105,7 @@ async def push_group_keys_to_pool(pool, group_id: int, api_key_ids: list):
 
             api_keys = [k['key_value'] for k in keys]
 
-            # 推送到company_base_pool
+            # 推送到 company_base_pool
             data = [{
                 "group_name": group['name'],
                 "provider": "dashscope",
@@ -61,22 +119,26 @@ async def push_group_keys_to_pool(pool, group_id: int, api_key_ids: list):
 
                 if response.status_code == 200:
                     result = response.json()
-                    logger.info(f"推送分组 {group['name']} 到号池: {result}")
+                    logger.info(f"推送分组 {group['name']} 到号池{result}")
                 else:
-                    logger.error(f"推送失败: {response.status_code} {response.text}")
+                    logger.error(f"推送失败{response.status_code} {response.text}")
 
     except Exception as e:
-        logger.error(f"推送分组 {group_id} 失败: {e}")
+        logger.error(f"推送分组 {group_id} 失败{e}")
 
 
 async def push_all_groups_to_pool(pool):
-    """推送所有分组到company_base_pool"""
+    """推送所有数据到 company_base_pool(兼容旧方式和新方式)"""
     try:
+        # 方式 1:推送模型直接绑定的 API Key(旧方式)
+        await push_model_api_keys_to_pool(pool)
+
+        # 方式 2:推送分组和 key(新方式)
         async with pool.acquire() as conn:
-            # 设置search_path
+            # 设置 search_path
             await conn.execute("SET search_path TO crawl")
 
-            # 获取所有有key的分组
+            # 获取所有有 key 的分组
             groups = await conn.fetch("""
                 SELECT DISTINCT g.id, g.name
                 FROM model_groups g
@@ -87,9 +149,9 @@ async def push_all_groups_to_pool(pool):
             logger.info(f"开始推送 {len(groups)} 个分组到号池")
 
             for group in groups:
-                await push_group_keys_to_pool(pool, group['id'], [])
+                await push_group_keys_to_pool(pool, group['id'])
 
             logger.info("推送完成")
 
     except Exception as e:
-        logger.error(f"推送所有分组失败: {e}")
+        logger.error(f"推送所有分组失败{e}")

+ 6 - 3
backend/app/services/scheduler.py

@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
 
 from apscheduler.schedulers.asyncio import AsyncIOScheduler
 from apscheduler.triggers.interval import IntervalTrigger
+from apscheduler.triggers.cron import CronTrigger
 
 from app.db import get_pool
 from app.services.scraper import ScraperService
@@ -71,16 +72,18 @@ async def start_scheduler() -> None:
     start_hour = cfg["start_hour"] if cfg else 2
     _reschedule(interval, start_hour)
 
-    # 添加号池推送定时任务(每5分钟推送一次)
+    # 添加号池推送定时任务(每天凌晨 3 点推送一次)
+    tz_offset = int(os.environ.get("TZ_OFFSET_HOURS", "8"))
+    start_hour_utc = (3 - tz_offset) % 24
     _scheduler.add_job(
         _run_pool_push,
-        trigger=IntervalTrigger(minutes=5),
+        trigger=CronTrigger(hour=start_hour_utc, minute=0),
         id=JOB_ID_POOL_PUSH,
         replace_existing=True,
     )
 
     _scheduler.start()
-    logger.info("[scheduler] 调度器已启动(包含号池推送,每5分钟)")
+    logger.info(f"[scheduler] 调度器已启动(号池推送每天凌晨 3:00 本地时间 / UTC {start_hour_utc}:00)")
 
 
 async def _run_pool_push() -> None:

+ 4 - 4
backend/app/utils/price_parser.py

@@ -169,9 +169,9 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
                 unit = _parse_unit(sub_val)
                 if unit:
                     entry["unit"] = unit
-                if re.match(r"^输入$|^input$", sk, re.I):
+                if re.match(r"^输入|^input", sk, re.I):
                     entry["input_price"] = price
-                elif re.match(r"^输出$|^output$", sk, re.I):
+                elif re.match(r"^输出|^output", sk, re.I):
                     entry["output_price"] = price
             result.append(entry)
             continue
@@ -208,10 +208,10 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
             continue
 
         # ── 简单非阶梯(输入/输出) ──
-        if re.match(r"^输入$|^input$", key.strip(), re.I):
+        if re.match(r"^输入|^input", key.strip(), re.I):
             input_entry = {"price": price, "unit": unit}
             continue
-        if re.match(r"^输出$|^output$", key.strip(), re.I):
+        if re.match(r"^输出|^output", key.strip(), re.I):
             result.append({
                 "label": "input/output",
                 "tier_min": None,

+ 28 - 127
frontend/src/pages/Models.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { Link } from 'react-router-dom';
 import * as XLSX from 'xlsx';
 import {
-  batchAssignApiKey, batchAssignModelGroup, batchDeleteModels,
+  batchAssignModelGroup, batchDeleteModels,
   createModel, deleteModel,
   fetchApiKeys, fetchModelGroups, fetchModels,
   updateModel, upsertModel,
@@ -12,97 +12,6 @@ import './Models.css';
 
 type EditState = { id: number; name: string; url: string; api_key_id: number | null; group_id: number | null };
 
-// ── 批量配置 API Key Modal ─────────────────────────────────────────────────────
-function BatchKeyModal({ models, apiKeys, onClose, onDone }: {
-  models: Model[]; apiKeys: ApiKey[]; onClose: () => void; onDone: () => void;
-}) {
-  const [selectedKey, setSelectedKey] = useState<number | null | 'clear'>(null);
-  const [selectedModels, setSelectedModels] = useState<Set<number>>(new Set());
-  const [search, setSearch] = useState('');
-  const [submitting, setSubmitting] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-
-  const filtered = models.filter(m => !search || m.name.toLowerCase().includes(search.toLowerCase()));
-  const toggleModel = (id: number) => setSelectedModels(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
-  const toggleAll = () => selectedModels.size === filtered.length ? setSelectedModels(new Set()) : setSelectedModels(new Set(filtered.map(m => m.id)));
-
-  const handleSubmit = async () => {
-    if (selectedKey === null) { setError('请先选择一个 API Key'); return; }
-    if (selectedModels.size === 0) { setError('请至少选择一个模型'); return; }
-    setSubmitting(true); setError(null);
-    try {
-      const r = await batchAssignApiKey(selectedKey === 'clear' ? null : selectedKey, Array.from(selectedModels));
-      onDone(); onClose(); alert(`已成功配置 ${r.updated} 个模型`);
-    } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
-    finally { setSubmitting(false); }
-  };
-
-  const selectedKeyObj = typeof selectedKey === 'number' ? apiKeys.find(k => k.id === selectedKey) : null;
-
-  return (
-    <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
-      <div className="modal-box">
-        <div className="modal-header">
-          <span className="modal-title">批量配置 API Key</span>
-          <button className="modal-close" onClick={onClose}>✕</button>
-        </div>
-        <div className="modal-section">
-          <div className="modal-section-label">
-            选择 API Key <span className="modal-required">*</span>
-            <Link to="/api-keys" className="modal-manage-link" onClick={onClose}>去管理 Key →</Link>
-          </div>
-          {apiKeys.length === 0 ? (
-            <div className="modal-empty-hint">暂无可用 Key,请先前往 <Link to="/api-keys" onClick={onClose}>API Key 管理</Link> 添加</div>
-          ) : (
-            <div className="modal-key-list">
-              <label className={`modal-key-item${selectedKey === 'clear' ? ' modal-key-item--selected' : ''}`}>
-                <input type="radio" name="apikey" checked={selectedKey === 'clear'} onChange={() => setSelectedKey('clear')} />
-                <div className="modal-key-info"><span className="modal-key-name modal-key-name--clear">清除绑定</span><span className="modal-key-note">将所选模型的 API Key 设为空</span></div>
-              </label>
-              {apiKeys.map(k => (
-                <label key={k.id} className={`modal-key-item${selectedKey === k.id ? ' modal-key-item--selected' : ''}`}>
-                  <input type="radio" name="apikey" checked={selectedKey === k.id} onChange={() => setSelectedKey(k.id)} />
-                  <div className="modal-key-info"><span className="modal-key-name">{k.name}</span>{k.note && <span className="modal-key-note">{k.note}</span>}</div>
-                </label>
-              ))}
-            </div>
-          )}
-        </div>
-        <div className="modal-section">
-          <div className="modal-section-label">
-            选择模型
-            <span className="modal-count-badge">{selectedModels.size} / {models.length}</span>
-            <button className="modal-select-all" onClick={toggleAll}>{selectedModels.size === filtered.length && filtered.length > 0 ? '取消全选' : '全选'}</button>
-          </div>
-          <input className="modal-search" placeholder="搜索模型名称…" value={search} onChange={e => setSearch(e.target.value)} />
-          <div className="modal-model-list">
-            {filtered.map(m => (
-              <label key={m.id} className={`modal-model-item${selectedModels.has(m.id) ? ' modal-model-item--selected' : ''}`}>
-                <input type="checkbox" checked={selectedModels.has(m.id)} onChange={() => toggleModel(m.id)} />
-                <span className="modal-model-name">{m.name}</span>
-                {m.api_key_name ? <span className="modal-model-has-key">{m.api_key_name}</span> : <span className="modal-model-no-key">未绑定</span>}
-              </label>
-            ))}
-          </div>
-        </div>
-        {selectedKey !== null && selectedModels.size > 0 && (
-          <div className="modal-preview">
-            将 <strong>{selectedModels.size}</strong> 个模型绑定到
-            {selectedKey === 'clear' ? <span className="modal-preview-clear">「清除绑定」</span> : <span className="modal-preview-key">「{selectedKeyObj?.name}」</span>}
-          </div>
-        )}
-        {error && <div className="modal-error">{error}</div>}
-        <div className="modal-footer">
-          <button className="modal-btn modal-btn--cancel" onClick={onClose}>关闭</button>
-          <button className="modal-btn modal-btn--confirm" onClick={handleSubmit} disabled={submitting}>
-            {submitting ? '配置中…' : `确认配置(${selectedModels.size} 个模型)`}
-          </button>
-        </div>
-      </div>
-    </div>
-  );
-}
-
 // ── 批量分组 Modal ─────────────────────────────────────────────────────────────
 function BatchGroupModal({ models, groups, onClose, onDone }: {
   models: Model[]; groups: ModelGroup[]; onClose: () => void; onDone: () => void;
@@ -200,8 +109,8 @@ function BatchGroupModal({ models, groups, onClose, onDone }: {
 // ── 主页面 ────────────────────────────────────────────────────────────────────
 export function Models() {
   const [models, setModels] = useState<Model[]>([]);
-  const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
   const [groups, setGroups] = useState<ModelGroup[]>([]);
+  const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
 
@@ -217,7 +126,6 @@ export function Models() {
   const [editError, setEditError] = useState<string | null>(null);
   const [saving, setSaving] = useState(false);
 
-  const [showBatchKey, setShowBatchKey] = useState(false);
   const [showBatchGroup, setShowBatchGroup] = useState(false);
 
   // 多选(用于批量删除)
@@ -233,8 +141,8 @@ export function Models() {
 
   const load = () => {
     setLoading(true);
-    Promise.all([fetchModels(), fetchApiKeys(), fetchModelGroups()])
-      .then(([m, k, g]) => { setModels(m); setApiKeys(k); setGroups(g); })
+    Promise.all([fetchModels(), fetchModelGroups(), fetchApiKeys()])
+      .then(([m, g, k]) => { setModels(m); setGroups(g); setApiKeys(k); })
       .catch(() => setError('加载失败'))
       .finally(() => setLoading(false));
   };
@@ -376,7 +284,8 @@ export function Models() {
     try {
       await updateModel(editState.id, {
         name: editState.name.trim(), url: editState.url.trim(),
-        api_key_id: editState.api_key_id, group_id: editState.group_id,
+        api_key_id: editState.api_key_id,
+        group_id: editState.group_id,
       });
       setEditState(null); load();
     } catch (e) { setEditError(e instanceof Error ? e.message : String(e)); }
@@ -391,9 +300,6 @@ export function Models() {
           <button className="models-batch-btn models-batch-btn--group" onClick={() => setShowBatchGroup(true)}>
             ▤ 批量设置分组
           </button>
-          <button className="models-batch-btn" onClick={() => setShowBatchKey(true)}>
-            🔑 批量配置 API Key
-          </button>
           {selectedIds.size > 0 && (
             <button className="models-batch-btn models-batch-btn--delete" onClick={handleBatchDelete} disabled={batchDeleting}>
               🗑 删除选中 ({selectedIds.size})
@@ -485,14 +391,6 @@ export function Models() {
               <input className="models-input" placeholder="https://bailian.console.aliyun.com/..." value={newUrl}
                 onChange={e => setNewUrl(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} />
             </div>
-            <div className="models-form-field">
-              <label className="models-form-label">分组 <span className="models-optional">可选</span></label>
-              <select className="models-input models-select" value={newGroupId}
-                onChange={e => setNewGroupId(e.target.value === '' ? '' : Number(e.target.value))}>
-                <option value="">— 不分组 —</option>
-                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
-              </select>
-            </div>
             <div className="models-form-field">
               <label className="models-form-label">API Key <span className="models-optional">可选</span></label>
               <select className="models-input models-select" value={newApiKeyId}
@@ -501,6 +399,14 @@ export function Models() {
                 {apiKeys.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
               </select>
             </div>
+            <div className="models-form-field">
+              <label className="models-form-label">分组 <span className="models-optional">可选</span></label>
+              <select className="models-input models-select" value={newGroupId}
+                onChange={e => setNewGroupId(e.target.value === '' ? '' : Number(e.target.value))}>
+                <option value="">— 不分组 —</option>
+                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
+              </select>
+            </div>
           </div>
           {addError && <div className="models-form-error">{addError}</div>}
           <div className="models-form-actions">
@@ -529,8 +435,7 @@ export function Models() {
                 </th>
                 <th style={{ width: 150 }}>名称</th>
                 <th>URL</th>
-                <th style={{ width: 120 }}>分组</th>
-                <th style={{ width: 130 }}>API Key</th>
+                <th style={{ width: 180 }}>分组 / API Key</th>
                 <th style={{ width: 150 }}>创建时间</th>
                 <th style={{ width: 120 }}>操作</th>
               </tr>
@@ -549,12 +454,9 @@ export function Models() {
                     <td className="models-td-key">
                       {m.group_name
                         ? <span className="models-group-badge">{m.group_name}</span>
-                        : <span className="models-key-empty">—</span>}
-                    </td>
-                    <td className="models-td-key">
-                      {m.api_key_name
-                        ? <span className="models-key-badge">{m.api_key_name}</span>
-                        : <span className="models-key-empty">—</span>}
+                        : m.api_key_name
+                          ? <span className="models-key-badge">🔑 {m.api_key_name}</span>
+                          : <span className="models-key-empty">—</span>}
                     </td>
                     <td className="models-td-time">{new Date(m.created_at).toLocaleString()}</td>
                     <td>
@@ -582,16 +484,7 @@ export function Models() {
                                 onChange={e => setEditState(s => s ? { ...s, url: e.target.value } : s)} />
                             </div>
                             <div className="models-edit-field">
-                              <label className="models-form-label">分组</label>
-                              <select className="models-input models-select"
-                                value={editState.group_id ?? ''}
-                                onChange={e => setEditState(s => s ? { ...s, group_id: e.target.value === '' ? null : Number(e.target.value) } : s)}>
-                                <option value="">— 不分组 —</option>
-                                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
-                              </select>
-                            </div>
-                            <div className="models-edit-field">
-                              <label className="models-form-label">API Key</label>
+                              <label className="models-form-label">API Key <span className="models-optional">可选</span></label>
                               <select className="models-input models-select"
                                 value={editState.api_key_id ?? ''}
                                 onChange={e => setEditState(s => s ? { ...s, api_key_id: e.target.value === '' ? null : Number(e.target.value) } : s)}>
@@ -599,6 +492,15 @@ export function Models() {
                                 {apiKeys.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
                               </select>
                             </div>
+                            <div className="models-edit-field">
+                              <label className="models-form-label">分组 <span className="models-optional">可选</span></label>
+                              <select className="models-input models-select"
+                                value={editState.group_id ?? ''}
+                                onChange={e => setEditState(s => s ? { ...s, group_id: e.target.value === '' ? null : Number(e.target.value) } : s)}>
+                                <option value="">— 不分组 —</option>
+                                {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
+                              </select>
+                            </div>
                           </div>
                           {editError && <div className="models-form-error">{editError}</div>}
                           <div className="models-form-actions">
@@ -618,7 +520,6 @@ export function Models() {
 
       {models.length > 0 && <div className="models-count">共 {models.length} 个模型</div>}
 
-      {showBatchKey && <BatchKeyModal models={models} apiKeys={apiKeys} onClose={() => setShowBatchKey(false)} onDone={load} />}
       {showBatchGroup && <BatchGroupModal models={models} groups={groups} onClose={() => setShowBatchGroup(false)} onDone={load} />}
     </div>
   );

+ 8 - 0
frontend/src/pages/Scraper.css

@@ -672,3 +672,11 @@
   opacity: 0.8;
   cursor: default;
 }
+
+/* 分组徽章 */
+.model-group-badge {
+  font-size: 11px;
+  flex-shrink: 0;
+  opacity: 0.8;
+  cursor: default;
+}

+ 1 - 0
frontend/src/pages/Scraper.tsx

@@ -250,6 +250,7 @@ export function Scraper() {
                 <span className="model-name" title={m.url}>{m.name}</span>
               </label>
               {m.api_key_id && <span className="model-key-badge" title="已配置 API Key">🔑</span>}
+              {m.group_name && <span className="model-group-badge" title={`分组: ${m.group_name}`}>📦</span>}
             </li>
           ))}
         </ul>