|
@@ -1,10 +1,11 @@
|
|
|
-import { useEffect, useState } from 'react';
|
|
|
|
|
|
|
+import { useEffect, useRef, useState } from 'react';
|
|
|
import { Link } from 'react-router-dom';
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
+import * as XLSX from 'xlsx';
|
|
|
import {
|
|
import {
|
|
|
- batchAssignApiKey, batchAssignModelGroup,
|
|
|
|
|
|
|
+ batchAssignApiKey, batchAssignModelGroup, batchDeleteModels,
|
|
|
createModel, deleteModel,
|
|
createModel, deleteModel,
|
|
|
fetchApiKeys, fetchModelGroups, fetchModels,
|
|
fetchApiKeys, fetchModelGroups, fetchModels,
|
|
|
- updateModel,
|
|
|
|
|
|
|
+ updateModel, upsertModel,
|
|
|
} from '../api';
|
|
} from '../api';
|
|
|
import type { ApiKey, Model, ModelGroup } from '../api';
|
|
import type { ApiKey, Model, ModelGroup } from '../api';
|
|
|
import './Models.css';
|
|
import './Models.css';
|
|
@@ -219,6 +220,17 @@ export function Models() {
|
|
|
const [showBatchKey, setShowBatchKey] = useState(false);
|
|
const [showBatchKey, setShowBatchKey] = useState(false);
|
|
|
const [showBatchGroup, setShowBatchGroup] = 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 = () => {
|
|
const load = () => {
|
|
|
setLoading(true);
|
|
setLoading(true);
|
|
|
Promise.all([fetchModels(), fetchApiKeys(), fetchModelGroups()])
|
|
Promise.all([fetchModels(), fetchApiKeys(), fetchModelGroups()])
|
|
@@ -229,6 +241,108 @@ export function Models() {
|
|
|
|
|
|
|
|
useEffect(() => { load(); }, []);
|
|
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 () => {
|
|
const handleAdd = async () => {
|
|
|
if (!newName.trim() || !newUrl.trim()) { setAddError('名称和 URL 不能为空'); return; }
|
|
if (!newName.trim() || !newUrl.trim()) { setAddError('名称和 URL 不能为空'); return; }
|
|
|
setAdding(true); setAddError(null);
|
|
setAdding(true); setAddError(null);
|
|
@@ -280,12 +394,82 @@ export function Models() {
|
|
|
<button className="models-batch-btn" onClick={() => setShowBatchKey(true)}>
|
|
<button className="models-batch-btn" onClick={() => setShowBatchKey(true)}>
|
|
|
🔑 批量配置 API Key
|
|
🔑 批量配置 API Key
|
|
|
</button>
|
|
</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 className="models-add-btn" onClick={() => { setShowAdd(v => !v); setAddError(null); }}>
|
|
|
+ 添加模型
|
|
+ 添加模型
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</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>}
|
|
{error && <div className="models-error">{error}</div>}
|
|
|
|
|
|
|
|
{showAdd && (
|
|
{showAdd && (
|
|
@@ -335,6 +519,14 @@ export function Models() {
|
|
|
<table className="models-table">
|
|
<table className="models-table">
|
|
|
<thead>
|
|
<thead>
|
|
|
<tr>
|
|
<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 style={{ width: 150 }}>名称</th>
|
|
|
<th>URL</th>
|
|
<th>URL</th>
|
|
|
<th style={{ width: 120 }}>分组</th>
|
|
<th style={{ width: 120 }}>分组</th>
|
|
@@ -346,7 +538,10 @@ export function Models() {
|
|
|
<tbody>
|
|
<tbody>
|
|
|
{models.map(m => (
|
|
{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-name">{m.name}</td>
|
|
|
<td className="models-td-url">
|
|
<td className="models-td-url">
|
|
|
<a href={m.url} target="_blank" rel="noopener noreferrer" className="models-url-link" title={m.url}>{m.url}</a>
|
|
<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>
|
|
</tr>
|
|
|
{editState?.id === m.id && (
|
|
{editState?.id === m.id && (
|
|
|
<tr key={`edit-${m.id}`} className="models-row-edit">
|
|
<tr key={`edit-${m.id}`} className="models-row-edit">
|
|
|
- <td colSpan={6}>
|
|
|
|
|
|
|
+ <td colSpan={7}>
|
|
|
<div className="models-edit-drawer">
|
|
<div className="models-edit-drawer">
|
|
|
<div className="models-edit-fields">
|
|
<div className="models-edit-fields">
|
|
|
<div className="models-edit-field">
|
|
<div className="models-edit-field">
|