Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03e514b70 | ||
|
|
01abe7f86f | ||
|
|
f83bf11669 | ||
|
|
bc2720257d | ||
|
|
cfe463ae8b |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
==================================================
|
||||
ParamHub - 参数百科 v1.8.0
|
||||
==================================================
|
||||
模块化重构 + 后台登录认证
|
||||
访问地址: http://localhost:16041
|
||||
后台管理: http://localhost:16041/admin
|
||||
默认密码: admin123 (可在 config.json 中修改)
|
||||
==================================================
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: off
|
||||
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
||||
* Running on all addresses (0.0.0.0)
|
||||
* Running on http://127.0.0.1:16041
|
||||
* Running on http://192.168.0.101:16041
|
||||
Press CTRL+C to quit
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 CATEGORIES_FILE
|
||||
from utils import load_data, save_data
|
||||
|
||||
@@ -70,3 +71,157 @@ def api_toggle_category_visible(category_id):
|
||||
category['visible'] = not category.get('visible', True)
|
||||
save_data(CATEGORIES_FILE, categories)
|
||||
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'
|
||||
}
|
||||
|
||||
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-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
|
||||
}
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@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)
|
||||
if not category:
|
||||
return jsonify({'error': 'Category not found'}), 404
|
||||
|
||||
export_data = {
|
||||
'category': category,
|
||||
'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}-{datetime.now().strftime("%Y%m%d%H%M%S")}.json'
|
||||
}
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ─── 导入分类数据 ───────────────────────────────────────────────────────
|
||||
|
||||
@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
|
||||
|
||||
mode = request.args.get('mode', 'merge')
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'imported': 0,
|
||||
'updated': 0,
|
||||
'skipped': []
|
||||
}
|
||||
|
||||
existing_categories = load_data(CATEGORIES_FILE)
|
||||
imported_categories = import_data['categories']
|
||||
|
||||
for cat in imported_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'] += 1
|
||||
else:
|
||||
result['skipped'].append(cat['id'])
|
||||
else:
|
||||
existing_categories.append(cat)
|
||||
result['imported'] += 1
|
||||
|
||||
save_data(CATEGORIES_FILE, existing_categories)
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@cat_bp.route('/api/categories/import/<category_id>', methods=['POST'])
|
||||
def api_import_single_category(category_id):
|
||||
"""导入单个分类配置"""
|
||||
try:
|
||||
import_data = request.get_json()
|
||||
|
||||
if not import_data:
|
||||
return jsonify({'error': '无导入数据'}), 400
|
||||
|
||||
if 'category' not in import_data:
|
||||
return jsonify({'error': '缺少 category 字段'}), 400
|
||||
|
||||
mode = request.args.get('mode', 'merge')
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'imported': False,
|
||||
'updated': False
|
||||
}
|
||||
|
||||
categories = load_data(CATEGORIES_FILE)
|
||||
imported_cat = import_data['category']
|
||||
|
||||
existing = next((c for c in categories if c['id'] == category_id), None)
|
||||
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)
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+316
-1
@@ -128,7 +128,11 @@
|
||||
<section id="section-categories" class="hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">分类管理</h1>
|
||||
<button onclick="openAddModal('category')" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"><i class="ri-add-line mr-2"></i>添加分类</button>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="exportAllCategories()" 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="openImportModal()" 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="openAddModal('category')" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"><i class="ri-add-line mr-2"></i>添加分类</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-blue-700"><i class="ri-information-line mr-1"></i>内置分类(AI模型、GPU、CPU)的子类别配置可在此编辑,其数据管理入口在左侧导航栏的独立页面。</p>
|
||||
@@ -156,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>
|
||||
@@ -175,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>
|
||||
@@ -196,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>
|
||||
@@ -217,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>
|
||||
@@ -493,6 +505,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类导入弹窗 -->
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
<option value="merge">合并模式 - 保留现有数据,只添加新数据</option>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t flex justify-end gap-4">
|
||||
<button onclick="closeImportModal()" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300">取消</button>
|
||||
<button onclick="doImport()" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"><i class="ri-upload-line mr-1"></i>确认导入</button>
|
||||
</div>
|
||||
</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 = '';
|
||||
@@ -940,6 +1024,7 @@
|
||||
${subcatCount > 0 ? `<span class="px-2 py-1 bg-green-100 text-green-600 rounded text-xs">${subcatCount} 个</span>` : '<span class="text-gray-400">无</span>'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="exportSingleCategory('${c.id}')" class="text-teal-600 hover:text-teal-800 mr-2" title="导出"><i class="ri-download-line"></i></button>
|
||||
<button onclick="editItem('category', '${c.id}')" class="text-blue-600 hover:text-blue-800 mr-2" title="编辑"><i class="ri-edit-line"></i></button>
|
||||
${!isBuiltin ? `<button onclick="deleteItem('category', '${c.id}')" class="text-red-600 hover:text-red-800" title="删除"><i class="ri-delete-bin-line"></i></button>` : '<span class="text-gray-300 cursor-not-allowed"><i class="ri-delete-bin-line"></i></span>'}
|
||||
</td>
|
||||
@@ -2678,6 +2763,236 @@
|
||||
// 监听编辑弹框打开,显示智能补充按钮
|
||||
document.getElementById('editModal').addEventListener('showSmartUpdate', showSmartUpdateButton);
|
||||
|
||||
// ─── 分类导出导入功能 ─────────────────────────────────────────────────
|
||||
|
||||
// 导出所有分类
|
||||
async function exportAllCategories() {
|
||||
try {
|
||||
const res = await fetch('/api/categories/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 = `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); }
|
||||
}
|
||||
|
||||
// 导出单个分类配置
|
||||
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; }
|
||||
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 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'); }
|
||||
|
||||
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.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>';
|
||||
} else if (importData.category) {
|
||||
html += `<div class="font-medium text-gray-800"><i class="ri-folder-line mr-1"></i>单个分类: ${importData.category.name}</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; }
|
||||
const mode = document.getElementById('importMode').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 (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新增: ' + (result.imported||0) + '\n更新: ' + (result.updated||0);
|
||||
if (result.skipped?.length) msg += '\n跳过: ' + result.skipped.length + ' 条';
|
||||
alert(msg);
|
||||
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); }
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+329
-139
@@ -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>
|
||||
Reference in New Issue
Block a user