添加分类管理导出导入功能

This commit is contained in:
2026-07-10 23:59:53 +08:00
parent 7d376533f3
commit cfe463ae8b
21 changed files with 512 additions and 4 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
==================================================
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
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+272 -3
View File
@@ -2,10 +2,11 @@
分类管理 API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from config import CATEGORIES_FILE
from utils import load_data, save_data
from flask import Blueprint, request, jsonify, Response
from config import CATEGORIES_FILE, DATA_DIR
from utils import load_data, save_data, load_config
cat_bp = Blueprint('api_categories', __name__)
@@ -70,3 +71,271 @@ 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'
}
# 加载各类别数据文件
# 内置分类: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'
}
)
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'
}
# 加载该分类的数据
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'
}
)
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── 导入分类相关数据 ───────────────────────────────────────────────────────
@cat_bp.route('/api/categories/import', methods=['POST'])
def api_import_categories():
"""导入分类及其相关数据"""
try:
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': []
}
# 处理分类
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'].append(cat['id'])
else:
# 合并模式:跳过已存在的分类
result['skipped'].append(cat['id'])
else:
# 新分类:添加
existing_categories.append(cat)
result['imported_categories'] += 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
@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_items': 0,
'updated_items': 0
}
# 处理分类
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)
else:
categories.append(imported_cat)
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
+224 -1
View File
@@ -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>
@@ -493,6 +497,46 @@
</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>
<script>
let currentType = '';
let currentId = '';
@@ -940,6 +984,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 +2723,184 @@
// 监听编辑弹框打开,显示智能补充按钮
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 = `param-hub-categories-export-${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}-export-${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 = '';
}
// 关闭导入弹窗
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>`;
}
// 数据项
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;
}
const mode = document.getElementById('importMode').value;
const categoryId = document.getElementById('importCategoryId').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)
});
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(', ')}`;
}
alert(msg);
closeImportModal();
await loadCategories();
renderSidebar();
loadAdminCategories();
loadOverview();
}
} catch (e) {
alert('导入失败: ' + e.message);
}
}
init();
</script>
</body>