Преглед изворни кода

新增批量删除,支持excel上传导入

lxylxy123321 пре 1 месец
родитељ
комит
3afdab8b71

+ 53 - 0
backend/app/routers/models.py

@@ -139,3 +139,56 @@ async def delete_model(model_id: int) -> Response:
     if result == "DELETE 0":
         raise HTTPException(status_code=404, detail="模型不存在")
     return Response(status_code=204)
+
+
+class BatchDeleteIn(BaseModel):
+    ids: List[int]
+
+
+@router.post("/models/batch-delete", status_code=200)
+async def batch_delete_models(body: BatchDeleteIn) -> dict:
+    if not body.ids:
+        raise HTTPException(status_code=400, detail="ids 不能为空")
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        result = await conn.execute(
+            "DELETE FROM models WHERE id = ANY($1::int[])",
+            body.ids,
+        )
+    deleted = int(result.split()[-1])
+    return {"deleted": deleted}
+
+
+class UpsertModelIn(BaseModel):
+    name: str
+    url: str
+    api_key_id: Optional[int] = None
+    group_id: Optional[int] = None
+
+
+@router.post("/models/upsert", response_model=ModelOut, status_code=200)
+async def upsert_model(body: UpsertModelIn) -> ModelOut:
+    """按 URL 做 upsert:URL 已存在则更新 name,不存在则插入。"""
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        row = await conn.fetchrow(
+            """
+            INSERT INTO models (name, url, api_key_id, group_id)
+            VALUES ($1, $2, $3, $4)
+            ON CONFLICT (url) DO UPDATE
+                SET name      = EXCLUDED.name,
+                    api_key_id = COALESCE(EXCLUDED.api_key_id, models.api_key_id),
+                    group_id   = COALESCE(EXCLUDED.group_id,   models.group_id)
+            RETURNING id, name, url, api_key_id, group_id, created_at
+            """,
+            body.name, body.url, body.api_key_id, body.group_id,
+        )
+        api_key_name = None
+        if row["api_key_id"]:
+            k = await conn.fetchrow("SELECT name FROM api_keys WHERE id = $1", row["api_key_id"])
+            api_key_name = k["name"] if k else None
+        group_name = None
+        if row["group_id"]:
+            g = await conn.fetchrow("SELECT name FROM model_groups WHERE id = $1", row["group_id"])
+            group_name = g["name"] if g else None
+    return ModelOut(**dict(row), api_key_name=api_key_name, group_name=group_name)

+ 108 - 4
frontend/package-lock.json

@@ -18,7 +18,8 @@
         "react": "^19.2.4",
         "react-dom": "^19.2.4",
         "react-router-dom": "^7.13.2",
-        "vitest": "^4.1.2"
+        "vitest": "^4.1.2",
+        "xlsx": "0.18.5"
       },
       "devDependencies": {
         "@eslint/js": "^9.39.4",
@@ -500,9 +501,9 @@
       }
     },
     "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
-      "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
       "license": "MIT",
       "optional": true,
       "dependencies": {
@@ -1709,6 +1710,15 @@
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/ajv": {
       "version": "6.14.0",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -1899,6 +1909,19 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/chai": {
       "version": "6.2.2",
       "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1925,6 +1948,15 @@
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1971,6 +2003,18 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2442,6 +2486,15 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3632,6 +3685,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/stackback": {
       "version": "0.0.2",
       "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -4134,6 +4199,24 @@
         "node": ">=8"
       }
     },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/word-wrap": {
       "version": "1.2.5",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4144,6 +4227,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/xml-name-validator": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

+ 2 - 1
frontend/package.json

@@ -20,7 +20,8 @@
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
     "react-router-dom": "^7.13.2",
-    "vitest": "^4.1.2"
+    "vitest": "^4.1.2",
+    "xlsx": "0.18.5"
   },
   "devDependencies": {
     "@eslint/js": "^9.39.4",

+ 20 - 0
frontend/src/api.ts

@@ -59,6 +59,26 @@ export async function deleteModel(id: number): Promise<void> {
   if (!res.ok) throw new Error(`删除失败: ${res.status}`);
 }
 
+export async function batchDeleteModels(ids: number[]): Promise<{ deleted: number }> {
+  const res = await fetch(`${BASE}/api/models/batch-delete`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ ids }),
+  });
+  if (!res.ok) throw new Error(`批量删除失败: ${res.status}`);
+  return res.json() as Promise<{ deleted: number }>;
+}
+
+export async function upsertModel(name: string, url: string, api_key_id?: number, group_id?: number): Promise<Model> {
+  const res = await fetch(`${BASE}/api/models/upsert`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ name, url, api_key_id: api_key_id ?? null, group_id: group_id ?? null }),
+  });
+  if (!res.ok) throw new Error(`upsert 失败: ${res.status}`);
+  return res.json() as Promise<Model>;
+}
+
 export interface Schedule {
   enabled: boolean;
   interval_days: number;

+ 132 - 0
frontend/src/pages/Models.css

@@ -178,6 +178,7 @@
 
 .models-row:hover td { background: rgba(0, 212, 255, 0.03); }
 .models-row--active td { background: rgba(0, 212, 255, 0.05); }
+.models-row--selected td { background: rgba(248, 81, 73, 0.06); }
 
 /* 编辑抽屉行 */
 .models-row-edit td {
@@ -627,3 +628,134 @@
   color: #b083f0;
 }
 .models-batch-btn--group:hover { background: #8957e522; }
+
+/* 下载示例按钮 */
+.models-batch-btn--template {
+  background: #e3b34111;
+  border-color: #e3b341;
+  color: #e3b341;
+}
+.models-batch-btn--template:hover { background: #e3b34122; }
+
+/* 批量删除按钮 */
+.models-batch-btn--delete {
+  background: rgba(248, 81, 73, 0.1);
+  border-color: #f85149;
+  color: #f85149;
+}
+.models-batch-btn--delete:hover:not(:disabled) { background: rgba(248, 81, 73, 0.2); }
+.models-batch-btn--delete:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* 导入 Excel label 按钮 */
+.models-import-label {
+  display: inline-flex;
+  align-items: center;
+  cursor: pointer;
+}
+
+/* ── Excel 导入预览卡片 ── */
+.import-preview-card {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 8px;
+  padding: 16px 20px;
+  margin-bottom: 20px;
+}
+
+.import-preview-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.import-preview-title {
+  font-size: 13px;
+  color: #00d4ff;
+  font-weight: 600;
+}
+
+.import-preview-table-wrap {
+  max-height: 260px;
+  overflow-y: auto;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  margin-bottom: 12px;
+}
+
+.import-preview-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+}
+
+.import-preview-table th {
+  background: #0d1117;
+  color: #8b949e;
+  padding: 6px 10px;
+  text-align: left;
+  font-weight: 500;
+  position: sticky;
+  top: 0;
+}
+
+.import-preview-table td {
+  padding: 5px 10px;
+  border-top: 1px solid #21262d;
+  color: #e6edf3;
+  vertical-align: top;
+}
+
+.import-preview-table tr:hover td { background: #1c2128; }
+
+.import-url-cell {
+  color: #8b949e;
+  font-family: monospace;
+  font-size: 11px;
+  word-break: break-all;
+  max-width: 500px;
+}
+
+.import-tag {
+  font-size: 10px;
+  padding: 2px 7px;
+  border-radius: 10px;
+  white-space: nowrap;
+}
+
+.import-tag--new {
+  border: 1px solid #3fb950;
+  color: #3fb950;
+}
+
+.import-tag--update {
+  border: 1px solid #e3b341;
+  color: #e3b341;
+}
+
+/* ── 导入结果 ── */
+.import-result-card {
+  border-radius: 6px;
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  font-size: 13px;
+}
+
+.import-result-card--ok {
+  background: rgba(63, 185, 80, 0.1);
+  border: 1px solid #3fb950;
+  color: #3fb950;
+}
+
+.import-result-card--warn {
+  background: rgba(227, 179, 65, 0.1);
+  border: 1px solid #e3b341;
+  color: #e3b341;
+}
+
+.import-error-list {
+  margin: 8px 0 0 16px;
+  font-size: 11px;
+  opacity: 0.85;
+  line-height: 1.6;
+}

+ 200 - 5
frontend/src/pages/Models.tsx

@@ -1,10 +1,11 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { Link } from 'react-router-dom';
+import * as XLSX from 'xlsx';
 import {
-  batchAssignApiKey, batchAssignModelGroup,
+  batchAssignApiKey, batchAssignModelGroup, batchDeleteModels,
   createModel, deleteModel,
   fetchApiKeys, fetchModelGroups, fetchModels,
-  updateModel,
+  updateModel, upsertModel,
 } from '../api';
 import type { ApiKey, Model, ModelGroup } from '../api';
 import './Models.css';
@@ -219,6 +220,17 @@ export function Models() {
   const [showBatchKey, setShowBatchKey] = useState(false);
   const [showBatchGroup, setShowBatchGroup] = useState(false);
 
+  // 多选(用于批量删除)
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [batchDeleting, setBatchDeleting] = useState(false);
+
+  // Excel 导入
+  const [importRows, setImportRows] = useState<{ name: string; url: string }[]>([]);
+  const [importError, setImportError] = useState<string | null>(null);
+  const [importing, setImporting] = useState(false);
+  const [importResult, setImportResult] = useState<{ inserted: number; updated: number; fail: number; errors: string[] } | null>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
   const load = () => {
     setLoading(true);
     Promise.all([fetchModels(), fetchApiKeys(), fetchModelGroups()])
@@ -229,6 +241,108 @@ export function Models() {
 
   useEffect(() => { load(); }, []);
 
+  // ── Excel 示例下载 ──
+  const handleDownloadTemplate = () => {
+    const ws = XLSX.utils.aoa_to_sheet([
+      ['名称', 'URL'],
+      ['qwen3-max', 'https://bailian.console.aliyun.com/cn-beijing?spm=xxx&tab=model#/model-market/detail/qwen3-max'],
+      ['glm-5.1', 'https://bailian.console.aliyun.com/cn-beijing?spm=xxx&tab=model#/model-market/detail/glm-5.1?serviceSite=asia-pacific-china'],
+      ['deepseek-v3', 'https://bailian.console.aliyun.com/cn-beijing?spm=xxx&tab=model#/model-market/detail/deepseek-v3'],
+    ]);
+    // 设置列宽
+    ws['!cols'] = [{ wch: 20 }, { wch: 100 }];
+    const wb = XLSX.utils.book_new();
+    XLSX.utils.book_append_sheet(wb, ws, '模型列表');
+    XLSX.writeFile(wb, '模型导入示例.xlsx');
+  };
+
+  // ── Excel 文件解析 ──
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+    setImportError(null);
+    setImportResult(null);
+    const reader = new FileReader();
+    reader.onload = (ev) => {
+      try {
+        const data = new Uint8Array(ev.target!.result as ArrayBuffer);
+        const wb = XLSX.read(data, { type: 'array' });
+        const ws = wb.Sheets[wb.SheetNames[0]];
+        const rows = XLSX.utils.sheet_to_json<string[]>(ws, { header: 1 }) as unknown as string[][];
+        // 找表头行(第一行),支持中英文列名
+        const header = rows[0]?.map(h => String(h ?? '').trim().toLowerCase());
+        if (!header) { setImportError('Excel 文件为空'); return; }
+        const nameIdx = header.findIndex(h => h === '名称' || h === 'name');
+        const urlIdx = header.findIndex(h => h === 'url');
+        if (nameIdx === -1 || urlIdx === -1) {
+          setImportError('未找到「名称」和「URL」列,请参考示例文件格式');
+          return;
+        }
+        const parsed = rows.slice(1)
+          .map(row => ({ name: String(row[nameIdx] ?? '').trim(), url: String(row[urlIdx] ?? '').trim() }))
+          .filter(r => r.name && r.url);
+        if (parsed.length === 0) { setImportError('未解析到有效数据行'); return; }
+        setImportRows(parsed);
+      } catch {
+        setImportError('文件解析失败,请确认是有效的 Excel 文件');
+      }
+    };
+    reader.readAsArrayBuffer(file);
+    // 重置 input,允许重复选同一文件
+    e.target.value = '';
+  };
+
+  // ── 执行导入(upsert:已存在则更新,不存在则插入) ──
+  const handleImport = async () => {
+    if (importRows.length === 0) return;
+    setImporting(true);
+    setImportResult(null);
+    // 先拿当前已有 URL 集合,用于区分新增/更新
+    const existingUrls = new Set(models.map(m => m.url));
+    let inserted = 0, updated = 0, fail = 0;
+    const errors: string[] = [];
+    for (const row of importRows) {
+      try {
+        await upsertModel(row.name, row.url);
+        if (existingUrls.has(row.url)) updated++;
+        else inserted++;
+      } catch (e) {
+        fail++;
+        errors.push(`${row.name}: ${e instanceof Error ? e.message : String(e)}`);
+      }
+    }
+    setImporting(false);
+    setImportResult({ inserted, updated, fail, errors });
+    setImportRows([]);
+    if (inserted + updated > 0) load();
+  };
+
+  // ── 批量删除 ──
+  const handleBatchDelete = async () => {
+    if (selectedIds.size === 0) return;
+    if (!confirm(`确认删除选中的 ${selectedIds.size} 个模型?此操作不可恢复。`)) return;
+    setBatchDeleting(true);
+    try {
+      const r = await batchDeleteModels(Array.from(selectedIds));
+      setSelectedIds(new Set());
+      load();
+      alert(`已删除 ${r.deleted} 个模型`);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    } finally {
+      setBatchDeleting(false);
+    }
+  };
+
+  const toggleSelectId = (id: number) => setSelectedIds(prev => {
+    const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
+  });
+
+  const toggleSelectAll = () => {
+    if (selectedIds.size === models.length) setSelectedIds(new Set());
+    else setSelectedIds(new Set(models.map(m => m.id)));
+  };
+
   const handleAdd = async () => {
     if (!newName.trim() || !newUrl.trim()) { setAddError('名称和 URL 不能为空'); return; }
     setAdding(true); setAddError(null);
@@ -280,12 +394,82 @@ export function Models() {
           <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})
+            </button>
+          )}
+          <button className="models-batch-btn models-batch-btn--template" onClick={handleDownloadTemplate}>
+            ↓ 下载示例
+          </button>
+          <label className="models-add-btn models-import-label">
+            ↑ 导入 Excel
+            <input ref={fileInputRef} type="file" accept=".xlsx,.xls" style={{ display: 'none' }} onChange={handleFileChange} />
+          </label>
           <button className="models-add-btn" onClick={() => { setShowAdd(v => !v); setAddError(null); }}>
             + 添加模型
           </button>
         </div>
       </div>
 
+      {/* Excel 导入预览 */}
+      {importRows.length > 0 && (
+        <div className="import-preview-card">
+          <div className="import-preview-header">
+            <span className="import-preview-title">待导入 {importRows.length} 条数据</span>
+            <button className="models-btn models-btn--cancel" onClick={() => setImportRows([])}>取消</button>
+          </div>
+          <div className="import-preview-table-wrap">
+            <table className="import-preview-table">
+              <thead><tr><th>#</th><th>名称</th><th>URL</th><th>状态</th></tr></thead>
+              <tbody>
+                {importRows.map((r, i) => {
+                  const isDup = models.some(m => m.url === r.url);
+                  return (
+                    <tr key={i}>
+                      <td>{i + 1}</td>
+                      <td>{r.name}</td>
+                      <td className="import-url-cell">{r.url}</td>
+                      <td>{isDup
+                        ? <span className="import-tag import-tag--update">覆盖更新</span>
+                        : <span className="import-tag import-tag--new">新增</span>}
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          </div>
+          {importError && <div className="models-form-error">{importError}</div>}
+          <div className="models-form-actions">
+            <button className="models-btn models-btn--confirm" onClick={handleImport} disabled={importing}>
+              {importing ? '导入中…' : `确认导入(${importRows.length} 条)`}
+            </button>
+            <button className="models-btn models-btn--cancel" onClick={() => { setImportRows([]); setImportError(null); }}>取消</button>
+          </div>
+        </div>
+      )}
+
+      {/* 导入结果 */}
+      {importResult && (
+        <div className={`import-result-card ${importResult.fail > 0 ? 'import-result-card--warn' : 'import-result-card--ok'}`}>
+          <span>
+            导入完成:新增 {importResult.inserted} 条,更新 {importResult.updated} 条
+            {importResult.fail > 0 ? `,失败 ${importResult.fail} 条` : ''}
+          </span>
+          {importResult.errors.length > 0 && (
+            <ul className="import-error-list">
+              {importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
+            </ul>
+          )}
+          <button className="models-btn models-btn--cancel" style={{ marginTop: 6 }} onClick={() => setImportResult(null)}>关闭</button>
+        </div>
+      )}
+
+      {importError && importRows.length === 0 && (
+        <div className="models-error">{importError}</div>
+      )}
+
       {error && <div className="models-error">{error}</div>}
 
       {showAdd && (
@@ -335,6 +519,14 @@ export function Models() {
           <table className="models-table">
             <thead>
               <tr>
+                <th style={{ width: 36 }}>
+                  <input
+                    type="checkbox"
+                    checked={models.length > 0 && selectedIds.size === models.length}
+                    onChange={toggleSelectAll}
+                    title="全选"
+                  />
+                </th>
                 <th style={{ width: 150 }}>名称</th>
                 <th>URL</th>
                 <th style={{ width: 120 }}>分组</th>
@@ -346,7 +538,10 @@ export function Models() {
             <tbody>
               {models.map(m => (
                 <>
-                  <tr key={m.id} className={`models-row${editState?.id === m.id ? ' models-row--active' : ''}`}>
+                  <tr key={m.id} className={`models-row${editState?.id === m.id ? ' models-row--active' : ''}${selectedIds.has(m.id) ? ' models-row--selected' : ''}`}>
+                    <td>
+                      <input type="checkbox" checked={selectedIds.has(m.id)} onChange={() => toggleSelectId(m.id)} />
+                    </td>
                     <td className="models-td-name">{m.name}</td>
                     <td className="models-td-url">
                       <a href={m.url} target="_blank" rel="noopener noreferrer" className="models-url-link" title={m.url}>{m.url}</a>
@@ -373,7 +568,7 @@ export function Models() {
                   </tr>
                   {editState?.id === m.id && (
                     <tr key={`edit-${m.id}`} className="models-row-edit">
-                      <td colSpan={6}>
+                      <td colSpan={7}>
                         <div className="models-edit-drawer">
                           <div className="models-edit-fields">
                             <div className="models-edit-field">