4 Commits
13 changed files with 819 additions and 405 deletions
-1
View File
@@ -13,4 +13,3 @@ WARNING: This is a development server. Do not use it in a production deployment.
* Running on http://127.0.0.1:16041
* Running on http://192.168.0.101:16041
Press CTRL+C to quit
127.0.0.1 - - [10/Jul/2026 23:59:48] "GET /api/categories/export HTTP/1.1" 200 -
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34 -148
View File
@@ -5,8 +5,8 @@ import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify, Response
from config import CATEGORIES_FILE, DATA_DIR
from utils import load_data, save_data, load_config
from config import CATEGORIES_FILE
from utils import load_data, save_data
cat_bp = Blueprint('api_categories', __name__)
@@ -73,54 +73,27 @@ def api_toggle_category_visible(category_id):
return jsonify({'success': True, 'visible': category['visible']})
# ─── 导出分类相关数据 ───────────────────────────────────────────────────────
# ─── 导出分类数据 ───────────────────────────────────────────────────────
@cat_bp.route('/api/categories/export', methods=['GET'])
def api_export_categories():
"""导出所有分类及其相关数据(子类别、参数字段、关联数据项)"""
"""导出所有分类配置(仅分类本身,不含数据项)"""
try:
# 获取所有分类
categories = load_data(CATEGORIES_FILE)
# 获取各类别下的数据
export_data = {
'categories': categories,
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0'
}
# 加载各类别数据文件
# 内置分类:AI模型、GPU、CPU
models_file = DATA_DIR / 'models.json'
gpus_file = DATA_DIR / 'gpus.json'
cpus_file = DATA_DIR / 'cpus.json'
if models_file.exists():
export_data['models'] = load_data(models_file)
if gpus_file.exists():
export_data['gpus'] = load_data(gpus_file)
if cpus_file.exists():
export_data['cpus'] = load_data(cpus_file)
# 动态分类数据(每个自定义分类有独立的数据文件)
for cat in categories:
cat_id = cat['id']
# 排除内置分类
if cat_id in ['ai-models', 'gpus', 'cpus']:
continue
# 查找该分类的数据文件
cat_data_file = DATA_DIR / f'{cat_id}.json'
if cat_data_file.exists():
export_data[cat_id] = load_data(cat_data_file)
# 导出为 JSON 文件
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={
'Content-Disposition': f'attachment; filename=param-hub-categories-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
'Content-Disposition': f'attachment; filename=param-hub-categories-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
}
)
return response
@@ -130,7 +103,7 @@ def api_export_categories():
@cat_bp.route('/api/categories/export/<category_id>', methods=['GET'])
def api_export_single_category(category_id):
"""导出单个分类及其相关数据"""
"""导出单个分类配置"""
try:
categories = load_data(CATEGORIES_FILE)
category = next((c for c in categories if c['id'] == category_id), None)
@@ -143,32 +116,13 @@ def api_export_single_category(category_id):
'version': '1.0'
}
# 加载该分类的数据
if category_id == 'ai-models':
models_file = DATA_DIR / 'models.json'
if models_file.exists():
export_data['items'] = load_data(models_file)
elif category_id == 'gpus':
gpus_file = DATA_DIR / 'gpus.json'
if gpus_file.exists():
export_data['items'] = load_data(gpus_file)
elif category_id == 'cpus':
cpus_file = DATA_DIR / 'cpus.json'
if cpus_file.exists():
export_data['items'] = load_data(cpus_file)
else:
# 动态分类
cat_data_file = DATA_DIR / f'{category_id}.json'
if cat_data_file.exists():
export_data['items'] = load_data(cat_data_file)
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={
'Content-Disposition': f'attachment; filename={category_id}-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
'Content-Disposition': f'attachment; filename={category_id}-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
}
)
return response
@@ -176,33 +130,42 @@ def api_export_single_category(category_id):
return jsonify({'error': str(e)}), 500
# ─── 导入分类相关数据 ───────────────────────────────────────────────────────
# ─── 导入分类数据 ───────────────────────────────────────────────────────
@cat_bp.route('/api/categories/import', methods=['POST'])
@cat_bp.route('/api/categories/import', methods=['GET', 'POST'])
def api_import_categories():
"""导入分类及其相关数据"""
"""导入分类配置(仅分类本身,不含数据项)"""
try:
if request.method == 'GET':
# 返回导入接口说明
return jsonify({
'endpoint': '/api/categories/import',
'method': 'POST',
'params': {
'mode': 'merge(合并)或 replace(替换)'
},
'body': {
'categories': '分类数组'
}
})
import_data = request.get_json()
if not import_data:
return jsonify({'error': '无导入数据'}), 400
# 验证数据结构
if 'categories' not in import_data:
return jsonify({'error': '缺少 categories 字段'}), 400
# 导入模式:merge(合并)或 replace(替换)
mode = request.args.get('mode', 'merge')
result = {
'success': True,
'imported_categories': 0,
'imported_items': 0,
'skipped': [],
'updated': []
'imported': 0,
'updated': 0,
'skipped': []
}
# 处理分类
existing_categories = load_data(CATEGORIES_FILE)
imported_categories = import_data['categories']
@@ -210,68 +173,16 @@ def api_import_categories():
existing = next((c for c in existing_categories if c['id'] == cat['id']), None)
if existing:
if mode == 'replace':
# 替换模式:更新现有分类
existing.update(cat)
result['updated'].append(cat['id'])
result['updated'] += 1
else:
# 合并模式:跳过已存在的分类
result['skipped'].append(cat['id'])
else:
# 新分类:添加
existing_categories.append(cat)
result['imported_categories'] += 1
result['imported'] += 1
save_data(CATEGORIES_FILE, existing_categories)
# 导入各类别数据
data_mappings = {
'models': 'models.json',
'gpus': 'gpus.json',
'cpus': 'cpus.json'
}
for key, filename in data_mappings.items():
if key in import_data:
data_file = DATA_DIR / filename
existing_items = load_data(data_file) if data_file.exists() else []
imported_items = import_data[key]
for item in imported_items:
existing = next((i for i in existing_items if i['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
else:
continue # 合并模式跳过
else:
existing_items.append(item)
result['imported_items'] += 1
save_data(data_file, existing_items)
# 导入动态分类数据
for cat in imported_categories:
cat_id = cat['id']
if cat_id in ['ai-models', 'gpus', 'cpus']:
continue
if cat_id in import_data:
data_file = DATA_DIR / f'{cat_id}.json'
existing_items = load_data(data_file) if data_file.exists() else []
imported_items = import_data[cat_id]
for item in imported_items:
existing = next((i for i in existing_items if i['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
else:
continue
else:
existing_items.append(item)
result['imported_items'] += 1
save_data(data_file, existing_items)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -279,7 +190,7 @@ def api_import_categories():
@cat_bp.route('/api/categories/import/<category_id>', methods=['POST'])
def api_import_single_category(category_id):
"""导入单个分类数据"""
"""导入单个分类配置"""
try:
import_data = request.get_json()
@@ -293,11 +204,10 @@ def api_import_single_category(category_id):
result = {
'success': True,
'imported_items': 0,
'updated_items': 0
'imported': False,
'updated': False
}
# 处理分类
categories = load_data(CATEGORIES_FILE)
imported_cat = import_data['category']
@@ -305,37 +215,13 @@ def api_import_single_category(category_id):
if existing:
if mode == 'replace':
existing.update(imported_cat)
result['updated'] = True
else:
categories.append(imported_cat)
result['imported'] = True
save_data(CATEGORIES_FILE, categories)
# 处理数据项
if 'items' in import_data:
if category_id == 'ai-models':
data_file = DATA_DIR / 'models.json'
elif category_id == 'gpus':
data_file = DATA_DIR / 'gpus.json'
elif category_id == 'cpus':
data_file = DATA_DIR / 'cpus.json'
else:
data_file = DATA_DIR / f'{category_id}.json'
existing_items = load_data(data_file) if data_file.exists() else []
imported_items = import_data['items']
for item in imported_items:
existing = next((i for i in existing_items if i['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
result['updated_items'] += 1
else:
existing_items.append(item)
result['imported_items'] += 1
save_data(data_file, existing_items)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
return jsonify({'error': str(e)}), 500
+62 -1
View File
@@ -2,8 +2,9 @@
CPU CRUD API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, Response
from config import CPUS_FILE
from utils import load_data, save_data, parse_date_to_timestamp
@@ -95,3 +96,63 @@ def api_toggle_cpu_visible(cpu_id):
cpu['visible'] = not cpu.get('visible', True)
save_data(CPUS_FILE, cpus)
return jsonify({'success': True, 'visible': cpu['visible']})
# ─── 导出导入 ───────────────────────────────────────────────────────
@cpus_bp.route('/api/cpus/export', methods=['GET'])
def api_export_cpus():
"""导出所有CPU数据"""
try:
cpus = load_data(CPUS_FILE)
export_data = {
'type': 'cpus',
'items': cpus,
'count': len(cpus),
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0'
}
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename=cpus-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'}
)
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@cpus_bp.route('/api/cpus/import', methods=['GET', 'POST'])
def api_import_cpus():
"""导入CPU数据"""
try:
if request.method == 'GET':
return jsonify({'endpoint': '/api/cpus/import', 'method': 'POST', 'params': {'mode': 'merge 或 replace'}})
import_data = request.get_json()
if not import_data or 'items' not in import_data:
return jsonify({'error': '缺少 items 字段'}), 400
mode = request.args.get('mode', 'merge')
cpus = load_data(CPUS_FILE)
imported_items = import_data['items']
result = {'success': True, 'imported': 0, 'updated': 0, 'skipped': []}
for item in imported_items:
existing = next((c for c in cpus if c['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
result['updated'] += 1
else:
result['skipped'].append(item['id'])
else:
cpus.append(item)
result['imported'] += 1
save_data(CPUS_FILE, cpus)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
+62 -1
View File
@@ -2,8 +2,9 @@
GPU CRUD API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, Response
from config import GPUS_FILE
from utils import load_data, save_data, parse_date_to_timestamp
@@ -95,3 +96,63 @@ def api_toggle_gpu_visible(gpu_id):
gpu['visible'] = not gpu.get('visible', True)
save_data(GPUS_FILE, gpus)
return jsonify({'success': True, 'visible': gpu['visible']})
# ─── 导出导入 ───────────────────────────────────────────────────────
@gpus_bp.route('/api/gpus/export', methods=['GET'])
def api_export_gpus():
"""导出所有GPU数据"""
try:
gpus = load_data(GPUS_FILE)
export_data = {
'type': 'gpus',
'items': gpus,
'count': len(gpus),
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0'
}
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename=gpus-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'}
)
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@gpus_bp.route('/api/gpus/import', methods=['GET', 'POST'])
def api_import_gpus():
"""导入GPU数据"""
try:
if request.method == 'GET':
return jsonify({'endpoint': '/api/gpus/import', 'method': 'POST', 'params': {'mode': 'merge 或 replace'}})
import_data = request.get_json()
if not import_data or 'items' not in import_data:
return jsonify({'error': '缺少 items 字段'}), 400
mode = request.args.get('mode', 'merge')
gpus = load_data(GPUS_FILE)
imported_items = import_data['items']
result = {'success': True, 'imported': 0, 'updated': 0, 'skipped': []}
for item in imported_items:
existing = next((g for g in gpus if g['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
result['updated'] += 1
else:
result['skipped'].append(item['id'])
else:
gpus.append(item)
result['imported'] += 1
save_data(GPUS_FILE, gpus)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
+65 -1
View File
@@ -2,8 +2,9 @@
动态分类数据 API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, Response
from config import DATA_DIR
from utils import load_data, save_data, parse_date_to_timestamp
@@ -95,3 +96,66 @@ def api_toggle_item_visible(category_id, item_id):
item['visible'] = not item.get('visible', True)
save_data(items_file, items)
return jsonify({'success': True, 'visible': item['visible']})
# ─── 导出导入 ───────────────────────────────────────────────────────
@items_bp.route('/api/items/<category_id>/export', methods=['GET'])
def api_export_items(category_id):
"""导出指定分类的所有数据"""
try:
items_file = DATA_DIR / f'items_{category_id}.json'
items = load_data(items_file)
export_data = {
'type': 'items',
'category_id': category_id,
'items': items,
'count': len(items),
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0'
}
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename={category_id}-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'}
)
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@items_bp.route('/api/items/<category_id>/import', methods=['GET', 'POST'])
def api_import_items(category_id):
"""导入指定分类的数据"""
try:
if request.method == 'GET':
return jsonify({'endpoint': f'/api/items/{category_id}/import', 'method': 'POST', 'params': {'mode': 'merge 或 replace'}})
import_data = request.get_json()
if not import_data or 'items' not in import_data:
return jsonify({'error': '缺少 items 字段'}), 400
mode = request.args.get('mode', 'merge')
items_file = DATA_DIR / f'items_{category_id}.json'
items = load_data(items_file)
imported_items = import_data['items']
result = {'success': True, 'imported': 0, 'updated': 0, 'skipped': []}
for item in imported_items:
existing = next((i for i in items if i['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
result['updated'] += 1
else:
result['skipped'].append(item['id'])
else:
items.append(item)
result['imported'] += 1
save_data(items_file, items)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
+62 -1
View File
@@ -2,8 +2,9 @@
AI模型 CRUD API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, Response
from config import MODELS_FILE
from utils import load_data, save_data, parse_date_to_timestamp, safe_sort_key
@@ -88,3 +89,63 @@ def api_toggle_model_visible(model_id):
model['visible'] = not model.get('visible', True)
save_data(MODELS_FILE, models)
return jsonify({'success': True, 'visible': model['visible']})
# ─── 导出导入 ───────────────────────────────────────────────────────
@models_bp.route('/api/models/export', methods=['GET'])
def api_export_models():
"""导出所有模型数据"""
try:
models = load_data(MODELS_FILE)
export_data = {
'type': 'models',
'items': models,
'count': len(models),
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0'
}
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
response = Response(
json_str,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename=models-export-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'}
)
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@models_bp.route('/api/models/import', methods=['GET', 'POST'])
def api_import_models():
"""导入模型数据"""
try:
if request.method == 'GET':
return jsonify({'endpoint': '/api/models/import', 'method': 'POST', 'params': {'mode': 'merge 或 replace'}})
import_data = request.get_json()
if not import_data or 'items' not in import_data:
return jsonify({'error': '缺少 items 字段'}), 400
mode = request.args.get('mode', 'merge')
models = load_data(MODELS_FILE)
imported_items = import_data['items']
result = {'success': True, 'imported': 0, 'updated': 0, 'skipped': []}
for item in imported_items:
existing = next((m for m in models if m['id'] == item['id']), None)
if existing:
if mode == 'replace':
existing.update(item)
result['updated'] += 1
else:
result['skipped'].append(item['id'])
else:
models.append(item)
result['imported'] += 1
save_data(MODELS_FILE, models)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
+205 -113
View File
@@ -160,6 +160,8 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800" id="dynamic-title">数据管理</h1>
<div class="flex gap-2">
<button onclick="exportCategoryData(dynamicCategoryId)" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"><i class="ri-download-line mr-2"></i>导出</button>
<button onclick="openDataImportModal(dynamicCategoryId)" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-upload-line mr-2"></i>导入</button>
<button onclick="openSmartAddModal('dynamic')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"><i class="ri-magic-line mr-2"></i>智能添加</button>
<button onclick="openAddModal('dynamic')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"><i class="ri-add-line mr-2"></i>手动添加</button>
</div>
@@ -179,6 +181,8 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">大模型管理</h1>
<div class="flex gap-2">
<button onclick="exportModels()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"><i class="ri-download-line mr-2"></i>导出</button>
<button onclick="openDataImportModal('models')" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-upload-line mr-2"></i>导入</button>
<button onclick="openSmartAddModal('model')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"><i class="ri-magic-line mr-2"></i>智能添加</button>
<button onclick="openAddModal('model')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"><i class="ri-add-line mr-2"></i>手动添加</button>
</div>
@@ -200,6 +204,8 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">GPU管理</h1>
<div class="flex gap-2">
<button onclick="exportGpus()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"><i class="ri-download-line mr-2"></i>导出</button>
<button onclick="openDataImportModal('gpus')" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-upload-line mr-2"></i>导入</button>
<button onclick="openSmartAddModal('gpu')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"><i class="ri-magic-line mr-2"></i>智能添加</button>
<button onclick="openAddModal('gpu')" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"><i class="ri-add-line mr-2"></i>手动添加</button>
</div>
@@ -221,6 +227,8 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">CPU管理</h1>
<div class="flex gap-2">
<button onclick="exportCpus()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"><i class="ri-download-line mr-2"></i>导出</button>
<button onclick="openDataImportModal('cpus')" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-upload-line mr-2"></i>导入</button>
<button onclick="openSmartAddModal('cpu')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"><i class="ri-magic-line mr-2"></i>智能添加</button>
<button onclick="openAddModal('cpu')" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-add-line mr-2"></i>手动添加</button>
</div>
@@ -501,20 +509,18 @@
<div id="importModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl max-w-2xl w-full mx-4">
<div class="p-6 border-b flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800"><i class="ri-upload-line mr-2 text-purple-600"></i>导入分类数据</h2>
<h2 class="text-xl font-bold text-gray-800"><i class="ri-upload-line mr-2 text-purple-600"></i>导入分类配置</h2>
<button onclick="closeImportModal()" class="text-gray-400 hover:text-gray-600"><i class="ri-close-line text-2xl"></i></button>
</div>
<div class="p-6">
<div class="bg-purple-50 rounded-lg p-4 mb-4">
<p class="text-sm text-purple-700"><i class="ri-information-line mr-1"></i>导入分类数据时,可选择<strong>合并模式</strong>(保留现有数据,只添加新数据)或<strong>替换模式</strong>(覆盖现有数据)。</p>
<p class="text-sm text-purple-700"><i class="ri-information-line mr-1"></i>导入分类配置。可选择<strong>合并模式</strong>(保留现有数据)或<strong>替换模式</strong>(覆盖现有数据)。</p>
</div>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 mb-2 block">选择导入文件(JSON格式)</label>
<input type="file" id="importFileInput" accept=".json" class="w-full px-4 py-2 border rounded-lg" onchange="handleImportFile(event)">
</div>
<div>
<label class="text-sm text-gray-600 mb-2 block">导入模式</label>
<select id="importMode" class="w-full px-4 py-2 border rounded-lg">
@@ -522,8 +528,6 @@
<option value="replace">替换模式 - 覆盖已存在的数据</option>
</select>
</div>
<!-- 预览区域 -->
<div id="importPreview" class="hidden border rounded-lg p-4 bg-gray-50">
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="ri-eye-line mr-1"></i>导入预览</h3>
<div id="importPreviewContent"></div>
@@ -537,6 +541,42 @@
</div>
</div>
<!-- 数据导入弹窗 -->
<div id="dataImportModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl max-w-2xl w-full mx-4">
<div class="p-6 border-b flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800"><i class="ri-upload-line mr-2 text-teal-600"></i>导入数据</h2>
<button onclick="closeDataImportModal()" class="text-gray-400 hover:text-gray-600"><i class="ri-close-line text-2xl"></i></button>
</div>
<div class="p-6">
<div class="bg-teal-50 rounded-lg p-4 mb-4">
<p class="text-sm text-teal-700"><i class="ri-information-line mr-1"></i>导入数据条目。可选择<strong>合并模式</strong>(保留现有数据)或<strong>替换模式</strong>(覆盖现有数据)。</p>
</div>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 mb-2 block">选择导入文件(JSON格式)</label>
<input type="file" id="dataImportFileInput" accept=".json" class="w-full px-4 py-2 border rounded-lg" onchange="handleDataImportFile(event)">
</div>
<div>
<label class="text-sm text-gray-600 mb-2 block">导入模式</label>
<select id="dataImportMode" class="w-full px-4 py-2 border rounded-lg">
<option value="merge">合并模式 - 保留现有数据,只添加新数据</option>
<option value="replace">替换模式 - 覆盖已存在的数据</option>
</select>
</div>
<div id="dataImportPreview" class="hidden border rounded-lg p-4 bg-gray-50">
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="ri-eye-line mr-1"></i>导入预览</h3>
<div id="dataImportPreviewContent"></div>
</div>
</div>
</div>
<div class="p-6 border-t flex justify-end gap-4">
<button onclick="closeDataImportModal()" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300">取消</button>
<button onclick="doDataImport()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700"><i class="ri-upload-line mr-1"></i>确认导入</button>
</div>
</div>
</div>
<script>
let currentType = '';
let currentId = '';
@@ -2729,176 +2769,228 @@
async function exportAllCategories() {
try {
const res = await fetch('/api/categories/export');
if (!res.ok) {
const err = await res.json();
alert('导出失败: ' + err.error);
return;
}
// 获取文件并下载
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `param-hub-categories-export-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
a.download = `categories-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) {
alert('导出失败: ' + e.message);
}
} catch (e) { alert('导出失败: ' + e.message); }
}
// 导出单个分类
// 导出单个分类配置
async function exportSingleCategory(categoryId) {
try {
const res = await fetch(`/api/categories/export/${categoryId}`);
if (!res.ok) {
const err = await res.json();
alert('导出失败: ' + err.error);
return;
}
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${categoryId}-export-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
a.download = `${categoryId}-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) {
alert('导出失败: ' + e.message);
}
} catch (e) { alert('导出失败: ' + e.message); }
}
// 打开导入弹窗
// 打开分类导入弹窗
function openImportModal() {
document.getElementById('importModal').classList.remove('hidden');
document.getElementById('importPreview').classList.add('hidden');
document.getElementById('importFileInput').value = '';
importData = null;
}
// 关闭导入弹窗
function closeImportModal() {
document.getElementById('importModal').classList.add('hidden');
}
function closeImportModal() { document.getElementById('importModal').classList.add('hidden'); }
// 处理导入文件选择
let importData = null;
function handleImportFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
importData = JSON.parse(e.target.result);
showImportPreview();
} catch (err) {
alert('JSON 解析失败: ' + err.message);
}
};
reader.onload = (e) => { try { importData = JSON.parse(e.target.result); showImportPreview(); } catch (err) { alert('JSON 解析失败: ' + err.message); } };
reader.readAsText(file);
}
// 显示导入预览
function showImportPreview() {
if (!importData) return;
const preview = document.getElementById('importPreview');
const content = document.getElementById('importPreviewContent');
preview.classList.remove('hidden');
let html = '<div class="space-y-2">';
// 分类信息
if (importData.categories) {
html += `<div class="font-medium text-gray-800"><i class="ri-folder-line mr-1"></i>包含 ${importData.categories.length} 个分类</div>`;
html += '<div class="flex flex-wrap gap-2 ml-5">';
importData.categories.forEach(cat => {
html += `<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">${cat.name}</span>`;
});
html += '</div>';}
importData.categories.forEach(cat => { html += `<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">${cat.name}</span>`; });
html += '</div>';
} else if (importData.category) {
html += `<div class="font-medium text-gray-800"><i class="ri-folder-line mr-1"></i>单个分类: ${importData.category.name}</div>`;
}
// 数据项
const itemTypes = ['models', 'gpus', 'cpus', 'items'];
let totalItems = 0;
itemTypes.forEach(type => {
if (importData[type] && importData[type].length > 0) {
totalItems += importData[type].length;
html += `<div class="text-sm text-gray-600 ml-5"><i class="ri-database-line mr-1"></i>${type}: ${importData[type].length} 条</div>`;
}
});
// 动态分类数据
const dynamicCats = Object.keys(importData).filter(k =>
k !== 'categories' && k !== 'category' && k !== 'export_time' && k !== 'version' &&
!itemTypes.includes(k) && Array.isArray(importData[k])
);
dynamicCats.forEach(catId => {
totalItems += importData[catId].length;
html += `<div class="text-sm text-gray-600 ml-5"><i class="ri-database-line mr-1"></i>${catId}: ${importData[catId].length} 条</div>`;
});
html += `<div class="mt-3 text-sm text-gray-500">导出时间: ${importData.export_time || '未知'}</div>`;
html += '</div>';content.innerHTML = html;
}
// 执行导入
async function doImport() {
if (!importData) {
alert('请先选择导入文件');
return;
}
if (!importData) { alert('请先选择导入文件'); return; }
const mode = document.getElementById('importMode').value;
const categoryId = document.getElementById('importCategoryId').value;
try {
let endpoint = importData.category ? `/api/categories/import/${importData.category.id}?mode=${mode}` : `/api/categories/import?mode=${mode}`;
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(importData) });
const result = await res.json();
if (result.error) { alert('导入失败: ' + result.error); } else {
let msg = '导入成功!\n新增: ' + (result.imported||0) + '\n更新: ' + (result.updated||0);
if (result.skipped?.length) msg += '\n跳过: ' + result.skipped.join(', ');
alert(msg);
closeImportModal();
await loadCategories(); renderSidebar(); loadAdminCategories();
}
} catch (e) { alert('导入失败: ' + e.message); }
}
// ─── 数据导出导入功能 ─────────────────────────────────────────────────
let dataImportType = null;
let dataImportCategoryId = null;
let dataImportData = null;
// 导出模型
async function exportModels() {
try {
const res = await fetch('/api/models/export');
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `models-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) { alert('导出失败: ' + e.message); }
}
// 导出GPU
async function exportGpus() {
try {
const res = await fetch('/api/gpus/export');
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `gpus-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) { alert('导出失败: ' + e.message); }
}
// 导出CPU
async function exportCpus() {
try {
const res = await fetch('/api/cpus/export');
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cpus-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) { alert('导出失败: ' + e.message); }
}
// 导出动态分类数据
async function exportCategoryData(categoryId) {
if (!categoryId) { alert('未选择分类'); return; }
try {
const res = await fetch(`/api/items/${categoryId}/export`);
if (!res.ok) { const err = await res.json(); alert('导出失败: ' + err.error); return; }
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${categoryId}-${new Date().toISOString().slice(0,19).replace(/[:-]/g,'')}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) { alert('导出失败: ' + e.message); }
}
// 打开数据导入弹窗
function openDataImportModal(typeOrCategoryId) {
dataImportType = typeOrCategoryId;
dataImportCategoryId = null;
// 如果是动态分类,需要区分类型
if (typeOrCategoryId && !['models', 'gpus', 'cpus'].includes(typeOrCategoryId)) {
dataImportCategoryId = typeOrCategoryId;
dataImportType = 'items';
}
document.getElementById('dataImportModal').classList.remove('hidden');
document.getElementById('dataImportPreview').classList.add('hidden');
document.getElementById('dataImportFileInput').value = '';
dataImportData = null;
}
function closeDataImportModal() { document.getElementById('dataImportModal').classList.add('hidden'); }
function handleDataImportFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => { try { dataImportData = JSON.parse(e.target.result); showDataImportPreview(); } catch (err) { alert('JSON 解析失败: ' + err.message); } };
reader.readAsText(file);
}
function showDataImportPreview() {
if (!dataImportData) return;
const preview = document.getElementById('dataImportPreview');
const content = document.getElementById('dataImportPreviewContent');
preview.classList.remove('hidden');
let html = '<div class="space-y-2">';
html += `<div class="font-medium text-gray-800"><i class="ri-database-line mr-1"></i>数据类型: ${dataImportData.type || '未知'}</div>`;
html += `<div class="text-sm text-gray-600 ml-5">包含 ${dataImportData.count || dataImportData.items?.length || 0} 条数据</div>`;
html += `<div class="mt-3 text-sm text-gray-500">导出时间: ${dataImportData.export_time || '未知'}</div>`;
html += '</div>';content.innerHTML = html;
}
async function doDataImport() {
if (!dataImportData) { alert('请先选择导入文件'); return; }
const mode = document.getElementById('dataImportMode').value;
try {
let endpoint;
if (categoryId && importData.category) {
// 导入单个分类
endpoint = `/api/categories/import/${categoryId}?mode=${mode}`;
} else {
// 导入全部
endpoint = `/api/categories/import?mode=${mode}`;
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importData)
});
if (dataImportType === 'models') endpoint = '/api/models/import?mode=' + mode;
else if (dataImportType === 'gpus') endpoint = '/api/gpus/import?mode=' + mode;
else if (dataImportType === 'cpus') endpoint = '/api/cpus/import?mode=' + mode;
else if (dataImportCategoryId) endpoint = `/api/items/${dataImportCategoryId}/import?mode=` + mode;
else { alert('未知数据类型'); return; }
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(dataImportData) });
const result = await res.json();
if (result.error) {
alert('导入失败: ' + result.error);
} else {
let msg = '导入成功!\n';
msg += `新增分类: ${result.imported_categories || 0}\n`;msg += `新增数据: ${result.imported_items || 0}\n`;msg += `更新数据: ${result.updated_items || 0}`;
if (result.skipped && result.skipped.length > 0) {
msg += `\n跳过已存在分类: ${result.skipped.join(', ')}`;
}
if (result.error) { alert('导入失败: ' + result.error); } else {
let msg = '导入成功!\n新增: ' + (result.imported||0) + '\n更新: ' + (result.updated||0);
if (result.skipped?.length) msg += '\n跳过: ' + result.skipped.length + ' 条';
alert(msg);
closeImportModal();
await loadCategories();
renderSidebar();
loadAdminCategories();
closeDataImportModal();
// 刷新当前页面数据
if (dataImportType === 'models') loadAdminModels();
else if (dataImportType === 'gpus') loadAdminGpus();
else if (dataImportType === 'cpus') loadAdminCpus();
else if (dataImportCategoryId) loadDynamicCategoryData(dataImportCategoryId);
loadOverview();
}
} catch (e) {
alert('导入失败: ' + e.message);
}
} catch (e) { alert('导入失败: ' + e.message); }
}
init();
+329 -139
View File
@@ -6,7 +6,12 @@
<title>ParamHub - 参数百科</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.compare-card { transition: all 0.2s; }
.compare-card:hover { transform: translateY(-2px); }
.compare-card.selected { border-color: #4f46e5; background: #f5f3ff; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 导航栏 -->
@@ -16,9 +21,7 @@
<i class="ri-dashboard-3-line text-2xl text-indigo-600"></i>
<span class="text-xl font-bold text-gray-800">ParamHub</span>
</a>
<div class="flex gap-4 text-sm" id="topNav">
<!-- 动态加载 -->
</div>
<div class="flex gap-4 text-sm" id="topNav"></div>
</div>
</nav>
@@ -28,49 +31,60 @@
<i class="ri-git-merge-line text-purple-600"></i>
对比工具
</h1>
<p class="text-gray-500 mt-1">多维度对比模型或硬件参数</p>
<p class="text-gray-500 mt-1">选择类别,对比最多5个产品的参数</p>
</div>
<!-- 对比类型选择 -->
<!-- 类别选择 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4">
<button onclick="setCompareType('model')" id="btnModel" class="px-4 py-2 bg-indigo-600 text-white rounded-lg">
<i class="ri-robot-line mr-2"></i>模型对比
</button>
<button onclick="setCompareType('gpu')" id="btnGpu" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300">
<i class="ri-cpu-line mr-2"></i>GPU对比
</button>
<button onclick="setCompareType('cpu')" id="btnCpu" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300">
<i class="ri-cpu-line mr-2"></i>CPU对比
</button>
<label class="text-sm font-medium text-gray-600 mb-3 block">选择对比类别</label>
<div id="categoryButtons" class="flex flex-wrap gap-2">
<span class="text-gray-400">加载中...</span>
</div>
</div>
<!-- 选择列表 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-4">
<label class="text-sm font-medium text-gray-600 mb-2 block">选择第一项</label>
<select id="select1" class="w-full px-4 py-2 border border-gray-200 rounded-lg" onchange="compare()">
<option value="">请选择...</option>
</select>
<!-- 选择的产品 -->
<div id="selectedArea" class="bg-white rounded-xl shadow-sm p-4 mb-6 hidden">
<div class="flex justify-between items-center mb-3">
<label class="text-sm font-medium text-gray-600">
已选择 <span id="selectedCount">0</span>/5 个产品
</label>
<button onclick="clearSelection()" class="text-sm text-red-600 hover:text-red-700">
<i class="ri-close-line mr-1"></i>清空选择
</button>
</div>
<div class="bg-white rounded-xl shadow-sm p-4">
<label class="text-sm font-medium text-gray-600 mb-2 block">选择第二项</label>
<select id="select2" class="w-full px-4 py-2 border border-gray-200 rounded-lg" onchange="compare()">
<option value="">请选择...</option>
</select>
<div id="selectedProducts" class="flex flex-wrap gap-2"></div>
</div>
<!-- 产品列表 -->
<div id="productListArea" class="mb-6 hidden">
<div class="bg-white rounded-xl shadow-sm p-4 mb-4">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-gray-600">
点击选择要对比的产品(最多5个)
</label>
<input type="text" id="searchInput" placeholder="搜索产品..."
class="px-3 py-1 border rounded-lg text-sm w-48"
oninput="filterProducts()">
</div>
</div>
<div id="productList" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"></div>
</div>
<!-- 对比结果 -->
<div id="compareResult" class="bg-white rounded-xl shadow-sm p-6 hidden">
<h2 class="text-lg font-semibold text-gray-800 mb-4">对比结果</h2>
<div id="compareTable"></div>
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="ri-bar-chart-line mr-2 text-purple-600"></i>对比结果
</h2>
<div id="compareTable" class="overflow-x-auto"></div>
</div>
</main>
</div>
<script>
let categories = [];
let currentCategory = null;
let categoryData = {};
let selectedIds = [];
const MAX_COMPARE = 5;
// 加载导航栏
async function loadNav() {
@@ -104,135 +118,311 @@
document.getElementById('topNav').innerHTML = navHtml;
}
let compareType = 'model';
let allData = [];
async function setCompareType(type) {
compareType = type;
// 更新按钮样式
document.getElementById('btnModel').className = type === 'model'
? 'px-4 py-2 bg-indigo-600 text-white rounded-lg'
: 'px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300';
document.getElementById('btnGpu').className = type === 'gpu'
? 'px-4 py-2 bg-green-600 text-white rounded-lg'
: 'px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300';
document.getElementById('btnCpu').className = type === 'cpu'
? 'px-4 py-2 bg-purple-600 text-white rounded-lg'
: 'px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300';
// 加载数据
const res = await fetch(`/api/${type}s`);
allData = await res.json();
// 填充下拉框
const select1 = document.getElementById('select1');
const select2 = document.getElementById('select2');
select1.innerHTML = '<option value="">请选择...</option>' +
allData.map(d => `<option value="${d.id}">${d.name}</option>`).join('');
select2.innerHTML = '<option value="">请选择...</option>' +
allData.map(d => `<option value="${d.id}">${d.name}</option>`).join('');
// 渲染类别按钮
function renderCategoryButtons() {
const colorMap = {
blue: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
green: 'bg-green-100 text-green-700 hover:bg-green-200',
purple: 'bg-purple-100 text-purple-700 hover:bg-purple-200',
orange: 'bg-orange-100 text-orange-700 hover:bg-orange-200',
teal: 'bg-teal-100 text-teal-700 hover:bg-teal-200',
red: 'bg-red-100 text-red-700 hover:bg-red-200',
indigo: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
};
const html = categories.map(cat => {
const colorClass = colorMap[cat.color] || 'bg-gray-100 text-gray-700 hover:bg-gray-200';
const activeClass = currentCategory?.id === cat.id ? 'ring-2 ring-indigo-500 ring-offset-2' : '';
return `<button onclick="selectCategory('${cat.id}')"
class="px-4 py-2 rounded-lg ${colorClass} ${activeClass} transition">
<i class="${cat.icon} mr-1"></i>${cat.name}
</button>`;
}).join('');
document.getElementById('categoryButtons').innerHTML = html;
}
// 选择类别
async function selectCategory(categoryId) {
currentCategory = categories.find(c => c.id === categoryId);
if (!currentCategory) return;
// 更新按钮状态
renderCategoryButtons();
// 清空之前的选择
selectedIds = [];
updateSelectedArea();
// 加载该类别数据
const endpoint = getEndpoint(categoryId);
const res = await fetch(endpoint);
categoryData[categoryId] = await res.json();
// 显示产品列表
document.getElementById('productListArea').classList.remove('hidden');
renderProductList();
document.getElementById('compareResult').classList.add('hidden');
}
async function compare() {
const id1 = document.getElementById('select1').value;
const id2 = document.getElementById('select2').value;
if (!id1 || !id2) {
document.getElementById('compareResult').classList.add('hidden');
// 获取API端点
function getEndpoint(categoryId) {
const builtinMap = {
'ai-models': '/api/models',
'gpus': '/api/gpus',
'cpus': '/api/cpus'
};
return builtinMap[categoryId] || `/api/items/${categoryId}`;
}
// 渲染产品列表
function renderProductList() {
if (!currentCategory) return;
const products = categoryData[currentCategory.id] || [];
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const filtered = products.filter(p =>
(p.name || '').toLowerCase().includes(searchTerm) ||
(p.organization || p.manufacturer || p.brand || '').toLowerCase().includes(searchTerm)
);
const colorMap = {
blue: 'bg-blue-50 border-blue-200',
green: 'bg-green-50 border-green-200',
purple: 'bg-purple-50 border-purple-200',
orange: 'bg-orange-50 border-orange-200',
teal: 'bg-teal-50 border-teal-200'
};
const bgColor = colorMap[currentCategory.color] || 'bg-gray-50 border-gray-200';
if (filtered.length === 0) {
document.getElementById('productList').innerHTML = `
<div class="col-span-full text-center text-gray-400 py-8">暂无数据</div>
`;
return;
}
const res1 = await fetch(`/api/${compareType}s/${id1}`);
const res2 = await fetch(`/api/${compareType}s/${id2}`);
const item1 = await res1.json();
const item2 = await res2.json();
let fields = [];
if (compareType === 'model') {
fields = [
{ key: 'name', label: '名称' },
{ key: 'organization', label: '厂商' },
{ key: 'parameters', label: '参数量(B)', unit: 'B' },
{ key: 'context_length', label: '上下文长度' },
{ key: 'mmlu', label: 'MMLU分数', unit: '%' },
{ key: 'humaneval', label: 'HumanEval', unit: '%' },
{ key: 'is_open_source', label: '类型', format: v => v ? '开源' : '商业' },
{ key: 'input_price', label: '输入价格', unit: '$/1K' },
{ key: 'output_price', label: '输出价格', unit: '$/1K' },
];
} else if (compareType === 'gpu') {
fields = [
{ key: 'name', label: '名称' },
{ key: 'manufacturer', label: '厂商' },
{ key: 'architecture', label: '架构' },
{ key: 'memory_gb', label: '显存', unit: 'GB' },
{ key: 'cuda_cores', label: 'CUDA核心' },
{ key: 'fp16_tflops', label: 'FP16性能', unit: 'TF' },
{ key: 'price_usd', label: '价格', unit: '$' },
];
const html = filtered.map(p => {
const isSelected = selectedIds.includes(p.id);
const selectedClass = isSelected ? 'ring-2 ring-indigo-500 bg-indigo-50' : '';
return `
<div onclick="toggleProduct('${p.id}')"
class="compare-card p-3 rounded-lg border cursor-pointer ${bgColor} ${selectedClass}">
<div class="font-medium text-gray-800">${p.name || '-'}</div>
<div class="text-sm text-gray-500">${p.organization || p.manufacturer || p.brand || ''}</div>
${isSelected ? '<div class="mt-2 text-xs text-indigo-600"><i class="ri-check-line mr-1"></i>已选择</div>' : ''}
</div>
`;
}).join('');
document.getElementById('productList').innerHTML = html;
}
// 切换产品选择
function toggleProduct(productId) {
const idx = selectedIds.indexOf(productId);
if (idx >= 0) {
selectedIds.splice(idx, 1);
} else if (selectedIds.length < MAX_COMPARE) {
selectedIds.push(productId);
} else {
fields = [
{ key: 'name', label: '名称' },
{ key: 'manufacturer', label: '厂商' },
{ key: 'cores', label: '核心数' },
{ key: 'threads', label: '线程数' },
{ key: 'base_clock_ghz', label: '基础频率', unit: 'GHz' },
{ key: 'boost_clock_ghz', label: '加速频率', unit: 'GHz' },
{ key: 'l3_cache_mb', label: 'L3缓存', unit: 'MB' },
{ key: 'tdp_watts', label: 'TDP', unit: 'W' },
{ key: 'price_usd', label: '价格', unit: '$' },
];
alert(`最多只能对比${MAX_COMPARE}个产品`);
return;
}
updateSelectedArea();
renderProductList();
const html = `
if (selectedIds.length >= 2) {
showCompareResult();
} else {
document.getElementById('compareResult').classList.add('hidden');
}
}
// 更新已选择区域
function updateSelectedArea() {
const area = document.getElementById('selectedArea');
const container = document.getElementById('selectedProducts');
const count = document.getElementById('selectedCount');
if (selectedIds.length === 0) {
area.classList.add('hidden');
return;
}
area.classList.remove('hidden');
count.textContent = selectedIds.length;
const products = categoryData[currentCategory.id] || [];
const html = selectedIds.map(id => {
const p = products.find(x => x.id === id);
if (!p) return '';
return `
<div class="px-3 py-1 bg-indigo-100 text-indigo-700 rounded-full text-sm flex items-center gap-2">
<span>${p.name}</span>
<button onclick="event.stopPropagation(); toggleProduct('${id}')" class="hover:text-indigo-900">
<i class="ri-close-line"></i>
</button>
</div>
`;
}).join('');
container.innerHTML = html;
}
// 清空选择
function clearSelection() {
selectedIds = [];
updateSelectedArea();
renderProductList();
document.getElementById('compareResult').classList.add('hidden');
}
// 搜索过滤
function filterProducts() {
renderProductList();
}
// 显示对比结果
async function showCompareResult() {
if (selectedIds.length < 2) return;
const products = categoryData[currentCategory.id] || [];
const selected = selectedIds.map(id => products.find(p => p.id === id)).filter(Boolean);
// 获取对比字段
const fields = getCompareFields();
// 生成对比表格
let html = `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium text-gray-600">参数</th>
<th class="px-4 py-2 text-center text-sm font-medium text-gray-600">${item1.name}</th>
<th class="px-4 py-2 text-center text-sm font-medium text-gray-600">${item2.name}</th>
<th class="px-4 py-2 text-center text-sm font-medium text-gray-600">差异</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600 sticky left-0 bg-gray-50">参数</th>
${selected.map(p => `<th class="px-4 py-3 text-center text-sm font-medium text-gray-800 min-w-[150px]">${p.name}</th>`).join('')}
<th class="px-4 py-3 text-center text-sm font-medium text-gray-600 min-w-[100px]">差异范围</th>
</tr>
</thead>
<tbody class="divide-y">
${fields.map(f => {
const v1 = item1[f.key] || '-';
const v2 = item2[f.key] || '-';
const fv1 = f.format ? f.format(v1) : (v1 + (f.unit && typeof v1 === 'number' ? f.unit : ''));
const fv2 = f.format ? f.format(v2) : (v2 + (f.unit && typeof v2 === 'number' ? f.unit : ''));
let diff = '';
if (typeof v1 === 'number' && typeof v2 === 'number') {
const d = v2 - v1;
diff = d > 0 ? `<span class="text-green-600">+${d}${f.unit || ''}</span>` :
d < 0 ? `<span class="text-red-600">${d}${f.unit || ''}</span>` : '-';
}
return `
<tr>
<td class="px-4 py-2 text-gray-600">${f.label}</td>
<td class="px-4 py-2 text-center font-medium">${fv1}</td>
<td class="px-4 py-2 text-center font-medium">${fv2}</td>
<td class="px-4 py-2 text-center">${diff}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
fields.forEach(f => {
const values = selected.map(p => p[f.key]);
const hasNumber = values.some(v => typeof v === 'number' && v !== null);
let minVal = null, maxVal = null;
if (hasNumber) {
const nums = values.filter(v => typeof v === 'number' && v !== null);
if (nums.length > 0) {
minVal = Math.min(...nums);
maxVal = Math.max(...nums);
}
}
html += `<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-600 sticky left-0 bg-white">${f.label}</td>`;
values.forEach(v => {
let display = '-';
if (v !== null && v !== undefined && v !== '') {
if (f.type === 'boolean') {
display = v ? '<span class="text-green-600">是</span>' : '<span class="text-gray-400">否</span>';
} else if (f.type === 'number') {
display = v;
} else if (f.type === 'json' && typeof v === 'object') {
display = `<span class="text-xs text-gray-500">${JSON.stringify(v).substring(0, 30)}...</span>`;
} else {
display = String(v);
}
}
html += `<td class="px-4 py-3 text-center font-medium">${display}</td>`;
});
// 差异范围
let diffRange = '-';
if (minVal !== null && maxVal !== null && minVal !== maxVal) {
diffRange = `<span class="text-orange-600">${minVal} ~ ${maxVal}</span>`;
if (f.unit) diffRange += `<span class="text-gray-400 text-xs ml-1">${f.unit}</span>`;
} else if (minVal !== null) {
diffRange = '<span class="text-gray-400">相同</span>';
}
html += `<td class="px-4 py-3 text-center text-sm">${diffRange}</td>`;
html += '</tr>';
});
html += '</tbody></table>';
document.getElementById('compareTable').innerHTML = html;
document.getElementById('compareResult').classList.remove('hidden');
}
// 获取对比字段
function getCompareFields() {
if (!currentCategory) return [];
// 使用类别的 fields 配置
const categoryFields = currentCategory.fields || [];
if (categoryFields.length > 0) {
return categoryFields
.filter(f => !['id', 'created_at', 'updated_at', 'visible', 'raw_text', 'images', 'is_pinned'].includes(f.key))
.slice(0, 15) // 最多显示15个字段
.map(f => ({
key: f.key,
label: f.label,
type: f.type,
unit: f.unit || null
}));
}
// 兼容旧的内置类别(无 fields 配置时)
const builtinFields = {
'ai-models': [
{ key: 'name', label: '名称', type: 'text' },
{ key: 'organization', label: '厂商', type: 'text' },
{ key: 'parameters', label: '参数量', type: 'number', unit: 'B' },
{ key: 'context_length', label: '上下文长度', type: 'number' },
{ key: 'mmlu', label: 'MMLU', type: 'number', unit: '%' },
{ key: 'humaneval', label: 'HumanEval', type: 'number', unit: '%' },
{ key: 'is_open_source', label: '开源', type: 'boolean' },
{ key: 'input_price', label: '输入价格', type: 'number', unit: '$/1K' },
{ key: 'output_price', label: '输出价格', type: 'number', unit: '$/1K' },
],
'gpus': [
{ key: 'name', label: '名称', type: 'text' },
{ key: 'manufacturer', label: '厂商', type: 'text' },
{ key: 'architecture', label: '架构', type: 'text' },
{ key: 'memory_gb', label: '显存', type: 'number', unit: 'GB' },
{ key: 'cuda_cores', label: 'CUDA核心', type: 'number' },
{ key: 'fp16_tflops', label: 'FP16性能', type: 'number', unit: 'TF' },
{ key: 'price_usd', label: '价格', type: 'number', unit: '$' },
],
'cpus': [
{ key: 'name', label: '名称', type: 'text' },
{ key: 'manufacturer', label: '厂商', type: 'text' },
{ key: 'cores', label: '核心数', type: 'number' },
{ key: 'threads', label: '线程数', type: 'number' },
{ key: 'base_clock_ghz', label: '基础频率', type: 'number', unit: 'GHz' },
{ key: 'boost_clock_ghz', label: '加速频率', type: 'number', unit: 'GHz' },
{ key: 'l3_cache_mb', label: 'L3缓存', type: 'number', unit: 'MB' },
{ key: 'tdp_watts', label: 'TDP', type: 'number', unit: 'W' },
{ key: 'price_usd', label: '价格', type: 'number', unit: '$' },
]
};
return builtinFields[currentCategory.id] || [];
}
// 初始化
loadNav();
setCompareType('model');
async function init() {
await loadNav();
renderCategoryButtons();
}
init();
</script>
</body>
</html>