2 Commits
19 changed files with 1032 additions and 158 deletions
Binary file not shown.
+13 -3
View File
@@ -1,7 +1,7 @@
"""
ParamHub - 参数百科
AI大模型与硬件参数速查平台
v1.8.0 - 模块化重构 + 后台登录认证
v2.0.0 - 产品审核发布 + 后台通知系统
"""
from flask import Flask, request, jsonify, session, redirect, url_for
from flask_cors import CORS
@@ -10,6 +10,9 @@ from datetime import timedelta
from config import SECRET_KEY
from utils import load_config
# 审核开关:设为 True 则所有产品需要审核
REQUIRE_REVIEW = True
# ─── Flask 应用创建 ────────────────────────────────────────────
app = Flask(__name__, static_folder='static', static_url_path='/static')
@@ -79,17 +82,24 @@ app.register_blueprint(upload_bp)
from modules.routes.api_pin import pin_bp
app.register_blueprint(pin_bp)
from modules.routes.api_notifications import notifications_bp
app.register_blueprint(notifications_bp)
from modules.routes.api_reviews import reviews_bp
app.register_blueprint(reviews_bp)
# ─── 启动 ──────────────────────────────────────────────────────
if __name__ == '__main__':
print("=" * 50)
print("ParamHub - 参数百科 v1.8.0")
print("ParamHub - 参数百科 v2.0.0")
print("=" * 50)
print("模块化重构 + 后台登录认证")
print("产品审核发布 + 后台通知系统")
print(f"访问地址: http://localhost:16041")
print(f"后台管理: http://localhost:16041/admin")
print(f"默认密码: admin123 (可在 config.json 中修改)")
print(f"审核模式: {'开启' if REQUIRE_REVIEW else '关闭'}")
print("=" * 50)
app.run(host='0.0.0.0', port=16041, debug=False)
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+11 -16
View File
@@ -1,16 +1,11 @@
==================================================
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 - - [11/Jul/2026 00:34:38] "GET /api/models/export HTTP/1.1" 200 -
Traceback (most recent call last):
File "/home/openclaw/.openclaw/workspace-hz4th_coder/works/param-hub-python/app.py", line 52, in <module>
from modules.routes.api_models import models_bp
File "/home/openclaw/.openclaw/workspace-hz4th_coder/works/param-hub-python/modules/routes/api_models.py", line 18, in <module>
@models_bp.route('/api/models')
File "/home/openclaw/.local/lib/python3.12/site-packages/flask/sansio/scaffold.py", line 43, in wrapper_func
self._check_setup_finished(f_name)
File "/home/openclaw/.local/lib/python3.12/site-packages/flask/sansio/blueprints.py", line 215, in _check_setup_finished
raise AssertionError(
AssertionError: The setup method 'route' can no longer be called on the blueprint 'api_models'. It has already been registered at least once, any changes will not be applied consistently.
Make sure all imports, decorators, functions, etc. needed to set up the blueprint are done before registering it.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -11,6 +11,14 @@ from utils import load_data, save_data, parse_date_to_timestamp
cpus_bp = Blueprint('api_cpus', __name__)
def is_review_required():
try:
import app as app_module
return getattr(app_module, 'REQUIRE_REVIEW', False)
except:
return False
def _safe_sort_key(x, key):
val = x.get(key)
if val is None:
@@ -54,6 +62,23 @@ def api_cpu_detail(cpu_id):
@cpus_bp.route('/api/cpus', methods=['POST'])
def api_create_cpu():
data = request.get_json()
# 审核模式
if is_review_required():
from modules.routes.api_reviews import submit_for_review
from modules.routes.api_notifications import create_notification
review = submit_for_review('cpus', data, source='web')
product_name = data.get('name', '未知')
create_notification(
title='新产品待审核',
message=f'有新的CPU "{product_name}"待审核',
level='warning',
category='review',
data={'review_id': review['id'], 'category': 'cpus'}
)
return jsonify({'success': True, 'message': '已提交审核,请等待管理员确认', 'review_id': review['id']})
cpus = load_data(CPUS_FILE)
data['id'] = uuid.uuid4().hex[:12]
data['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+25
View File
@@ -11,6 +11,14 @@ from utils import load_data, save_data, parse_date_to_timestamp
gpus_bp = Blueprint('api_gpus', __name__)
def is_review_required():
try:
import app as app_module
return getattr(app_module, 'REQUIRE_REVIEW', False)
except:
return False
def _safe_sort_key(x, key):
val = x.get(key)
if val is None:
@@ -54,6 +62,23 @@ def api_gpu_detail(gpu_id):
@gpus_bp.route('/api/gpus', methods=['POST'])
def api_create_gpu():
data = request.get_json()
# 审核模式
if is_review_required():
from modules.routes.api_reviews import submit_for_review
from modules.routes.api_notifications import create_notification
review = submit_for_review('gpus', data, source='web')
product_name = data.get('name', '未知')
create_notification(
title='新产品待审核',
message=f'有新的GPU "{product_name}"待审核',
level='warning',
category='review',
data={'review_id': review['id'], 'category': 'gpus'}
)
return jsonify({'success': True, 'message': '已提交审核,请等待管理员确认', 'review_id': review['id']})
gpus = load_data(GPUS_FILE)
data['id'] = uuid.uuid4().hex[:12]
data['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+25
View File
@@ -11,6 +11,14 @@ from utils import load_data, save_data, parse_date_to_timestamp
items_bp = Blueprint('api_items', __name__)
def is_review_required():
try:
import app as app_module
return getattr(app_module, 'REQUIRE_REVIEW', False)
except:
return False
@items_bp.route('/api/items/<category_id>')
def api_items(category_id):
items_file = DATA_DIR / f'items_{category_id}.json'
@@ -49,6 +57,23 @@ def api_item_detail(category_id, item_id):
@items_bp.route('/api/items/<category_id>', methods=['POST'])
def api_create_item(category_id):
data = request.get_json()
# 审核模式
if is_review_required():
from modules.routes.api_reviews import submit_for_review
from modules.routes.api_notifications import create_notification
review = submit_for_review(category_id, data, source='web')
product_name = data.get('name', '未知')
create_notification(
title='新产品待审核',
message=f'有新的产品"{product_name}"待审核',
level='warning',
category='review',
data={'review_id': review['id'], 'category': category_id}
)
return jsonify({'success': True, 'message': '已提交审核,请等待管理员确认', 'review_id': review['id']})
items_file = DATA_DIR / f'items_{category_id}.json'
items = load_data(items_file)
data['id'] = uuid.uuid4().hex[:12]
+27
View File
@@ -11,6 +11,15 @@ from utils import load_data, save_data, parse_date_to_timestamp, safe_sort_key
models_bp = Blueprint('api_models', __name__)
def is_review_required():
"""检查是否需要审核"""
try:
import app as app_module
return getattr(app_module, 'REQUIRE_REVIEW', False)
except:
return False
@models_bp.route('/api/models')
def api_models():
models = load_data(MODELS_FILE)
@@ -47,6 +56,24 @@ def api_model_detail(model_id):
@models_bp.route('/api/models', methods=['POST'])
def api_create_model():
data = request.get_json()
# 审核模式:提交到审核队列
if is_review_required():
from modules.routes.api_reviews import submit_for_review
from modules.routes.api_notifications import create_notification
review = submit_for_review('ai-models', data, source='web')
product_name = data.get('name', '未知')
create_notification(
title='新产品待审核',
message=f'有新的AI模型"{product_name}"待审核',
level='warning',
category='review',
data={'review_id': review['id'], 'category': 'ai-models'}
)
return jsonify({'success': True, 'message': '已提交审核,请等待管理员确认', 'review_id': review['id']})
# 直接创建模式
models = load_data(MODELS_FILE)
data['id'] = uuid.uuid4().hex[:12]
data['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+109
View File
@@ -0,0 +1,109 @@
"""
通知管理 API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify
from config import DATA_DIR
from utils import load_data, save_data
notifications_bp = Blueprint('api_notifications', __name__)
NOTIFICATIONS_FILE = DATA_DIR / 'notifications.json'
@notifications_bp.route('/api/notifications')
def api_notifications():
"""获取通知列表"""
notifications = load_data(NOTIFICATIONS_FILE)
# 筛选参数
unread_only = request.args.get('unread', '0') == '1'
limit = int(request.args.get('limit', 50))
if unread_only:
notifications = [n for n in notifications if not n.get('read', False)]
# 按时间倒序
notifications = sorted(notifications, key=lambda x: x.get('created_at', ''), reverse=True)
return jsonify(notifications[:limit])
@notifications_bp.route('/api/notifications/unread-count')
def api_unread_count():
"""获取未读通知数量"""
notifications = load_data(NOTIFICATIONS_FILE)
count = len([n for n in notifications if not n.get('read', False)])
return jsonify({'count': count})
@notifications_bp.route('/api/notifications/<notification_id>/read', methods=['POST'])
def api_mark_read(notification_id):
"""标记通知为已读"""
notifications = load_data(NOTIFICATIONS_FILE)
notification = next((n for n in notifications if n['id'] == notification_id), None)
if not notification:
return jsonify({'error': 'Notification not found'}), 404
notification['read'] = True
notification['read_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
save_data(NOTIFICATIONS_FILE, notifications)
return jsonify({'success': True})
@notifications_bp.route('/api/notifications/read-all', methods=['POST'])
def api_mark_all_read():
"""标记所有通知为已读"""
notifications = load_data(NOTIFICATIONS_FILE)
for n in notifications:
if not n.get('read', False):
n['read'] = True
n['read_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
save_data(NOTIFICATIONS_FILE, notifications)
return jsonify({'success': True})
@notifications_bp.route('/api/notifications/<notification_id>', methods=['DELETE'])
def api_delete_notification(notification_id):
"""删除通知"""
notifications = load_data(NOTIFICATIONS_FILE)
notifications = [n for n in notifications if n['id'] != notification_id]
save_data(NOTIFICATIONS_FILE, notifications)
return jsonify({'success': True})
# ─── 内部函数:创建通知 ───────────────────────────────────────────────────────
def create_notification(title, message, level='info', category='system', data=None):
"""
创建一条通知
参数:
title: 通知标题
message: 通知内容
level: 级别 info/warning/error/success
category: 分类 system/api/review/statistics
data: 附加数据(dict)
"""
notifications = load_data(NOTIFICATIONS_FILE)
notification = {
'id': uuid.uuid4().hex[:12],
'title': title,
'message': message,
'level': level, # info, warning, error, success
'category': category, # system, api, review, statistics
'data': data or {},
'read': False,
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
notifications.append(notification)
# 只保留最近500条通知
if len(notifications) > 500:
notifications = sorted(notifications, key=lambda x: x.get('created_at', ''), reverse=True)[:500]
save_data(NOTIFICATIONS_FILE, notifications)
return notification
+159
View File
@@ -0,0 +1,159 @@
"""
产品审核 API
"""
import uuid
import json
from datetime import datetime
from flask import Blueprint, request, jsonify, session
from config import DATA_DIR, MODELS_FILE, GPUS_FILE, CPUS_FILE
from utils import load_data, save_data
reviews_bp = Blueprint('api_reviews', __name__)
PENDING_FILE = DATA_DIR / 'pending_reviews.json'
@reviews_bp.route('/api/reviews')
def api_reviews():
"""获取待审核列表"""
reviews = load_data(PENDING_FILE)
# 筛选参数
status = request.args.get('status', 'pending') # pending, approved, rejected, all
limit = int(request.args.get('limit', 100))
if status != 'all':
reviews = [r for r in reviews if r.get('status', 'pending') == status]
# 按时间倒序
reviews = sorted(reviews, key=lambda x: x.get('created_at', ''), reverse=True)
return jsonify(reviews[:limit])
@reviews_bp.route('/api/reviews/count')
def api_reviews_count():
"""获取待审核数量"""
reviews = load_data(PENDING_FILE)
pending_count = len([r for r in reviews if r.get('status', 'pending') == 'pending'])
return jsonify({'count': pending_count})
@reviews_bp.route('/api/reviews/<review_id>')
def api_review_detail(review_id):
"""获取审核详情"""
reviews = load_data(PENDING_FILE)
review = next((r for r in reviews if r['id'] == review_id), None)
if not review:
return jsonify({'error': 'Review not found'}), 404
return jsonify(review)
@reviews_bp.route('/api/reviews/<review_id>/approve', methods=['POST'])
def api_approve_review(review_id):
"""通过审核"""
reviews = load_data(PENDING_FILE)
review = next((r for r in reviews if r['id'] == review_id), None)
if not review:
return jsonify({'error': 'Review not found'}), 404
if review.get('status') != 'pending':
return jsonify({'error': '该申请已处理'}), 400
# 获取数据文件
category_id = review.get('category_id')
data_file = get_data_file(category_id)
if not data_file:
return jsonify({'error': 'Unknown category'}), 400
# 添加数据到正式文件
items = load_data(data_file)
item_data = review.get('data', {})
# 确保有ID和时间戳
if 'id' not in item_data or not item_data['id']:
item_data['id'] = uuid.uuid4().hex[:12]
item_data['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
item_data['visible'] = True
item_data['approved'] = True
item_data['approved_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
items.append(item_data)
save_data(data_file, items)
# 更新审核状态
review['status'] = 'approved'
review['approved_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
review['approved_by'] = session.get('username', 'admin')
save_data(PENDING_FILE, reviews)
return jsonify({'success': True, 'item': item_data})
@reviews_bp.route('/api/reviews/<review_id>/reject', methods=['POST'])
def api_reject_review(review_id):
"""拒绝审核"""
reviews = load_data(PENDING_FILE)
review = next((r for r in reviews if r['id'] == review_id), None)
if not review:
return jsonify({'error': 'Review not found'}), 404
if review.get('status') != 'pending':
return jsonify({'error': '该申请已处理'}), 400
data = request.get_json() or {}
reason = data.get('reason', '')
# 更新审核状态
review['status'] = 'rejected'
review['rejected_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
review['rejected_by'] = session.get('username', 'admin')
review['reject_reason'] = reason
save_data(PENDING_FILE, reviews)
return jsonify({'success': True})
def get_data_file(category_id):
"""获取类别对应的数据文件"""
builtin_map = {
'ai-models': MODELS_FILE,
'gpus': GPUS_FILE,
'cpus': CPUS_FILE
}
if category_id in builtin_map:
return builtin_map[category_id]
# 动态分类
return DATA_DIR / f'items_{category_id}.json'
# ─── 内部函数:提交审核 ───────────────────────────────────────────────────────
def submit_for_review(category_id, data, source='web', submitter=None):
"""
提交产品到审核队列
参数:
category_id: 分类ID
data: 产品数据(dict)
source: 来源 web/api
submitter: 提交者
"""
reviews = load_data(PENDING_FILE)
review = {
'id': uuid.uuid4().hex[:12],
'category_id': category_id,
'data': data,
'source': source,
'submitter': submitter or 'anonymous',
'status': 'pending', # pending, approved, rejected
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
reviews.append(review)
save_data(PENDING_FILE, reviews)
return review
+307
View File
@@ -41,6 +41,21 @@
<!-- 概览 -->
<section id="section-overview">
<h1 class="text-2xl font-bold text-gray-800 mb-6">管理概览</h1>
<!-- 待办提醒 -->
<div id="todoAlert" class="mb-6 hidden">
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<i class="ri-notification-badge-line text-2xl text-orange-600"></i>
<div>
<div class="font-medium text-orange-800"><span id="todoCount">0</span> 条待处理事项</div>
<div class="text-sm text-orange-600" id="todoDetail"></div>
</div>
</div>
<button onclick="showSection('reviews')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700">立即处理</button>
</div>
</div>
<div class="grid grid-cols-5 gap-4 mb-8" id="statsCards"><div class="text-center text-gray-400 py-4">加载中...</div></div>
<div class="bg-white rounded-xl p-6 shadow-sm mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">快捷操作</h2>
@@ -275,6 +290,49 @@
</table>
</div>
</section>
<!-- 通知中心 -->
<section id="section-notifications" class="hidden">
<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="markAllRead()" class="px-4 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300"><i class="ri-check-double-line mr-1"></i>全部已读</button>
</div>
</div>
<div id="notificationsList" class="space-y-3">
<div class="text-center text-gray-400 py-8">加载中...</div>
</div>
</section>
<!-- 审核管理 -->
<section id="section-reviews" class="hidden">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">审核管理</h1>
<div class="flex gap-2">
<select id="reviewStatusFilter" onchange="loadReviews()" class="px-4 py-2 border rounded-lg">
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
<option value="all">全部</option>
</select>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">产品名称</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">分类</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">来源</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">提交时间</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">状态</th>
<th class="px-4 py-3 text-center text-sm font-medium text-gray-600">操作</th>
</tr>
</thead>
<tbody id="reviewsTable"><tr><td colspan="6" class="text-center text-gray-400 py-8">加载中...</td></tr></tbody>
</table>
</div>
</section>
</main>
<!-- 编辑弹窗 -->
@@ -766,6 +824,24 @@
</a>
`;
// 通知中心(显示未读数)
html += `
<a href="#notifications" onclick="showSection('notifications')" class="sidebar-link flex items-center gap-2 px-3 py-2 rounded-lg text-gray-300" data-section="notifications">
<i class="ri-notification-line"></i>
<span>通知中心</span>
<span id="navNotificationBadge" class="ml-auto px-1.5 py-0.5 bg-red-500 text-white text-xs rounded-full hidden">0</span>
</a>
`;
// 审核管理(显示待审核数)
html += `
<a href="#reviews" onclick="showSection('reviews')" class="sidebar-link flex items-center gap-2 px-3 py-2 rounded-lg text-gray-300" data-section="reviews">
<i class="ri-checkbox-circle-line"></i>
<span>审核管理</span>
<span id="navReviewBadge" class="ml-auto px-1.5 py-0.5 bg-orange-500 text-white text-xs rounded-full hidden">0</span>
</a>
`;
document.getElementById('sidebarNav').innerHTML = html;
}
@@ -882,6 +958,8 @@
if (section === 'gpus') loadAdminGpus();
if (section === 'cpus') loadAdminCpus();
if (section === 'knowledge') loadAdminKnowledge();
if (section === 'notifications') loadNotifications();
if (section === 'reviews') loadReviews();
}
// 加载网站配置
@@ -990,6 +1068,9 @@
document.getElementById('recent-models').innerHTML = models.length > 0
? models.map(m => `<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"><div><span class="font-medium text-gray-800">${m.name}</span><span class="text-sm text-gray-500 ml-2">${m.organization}</span></div><div class="text-sm text-gray-400">${m.is_open_source ? '开源' : '商业'}</div></div>`).join('')
: '<div class="text-gray-400">暂无数据</div>';
// 加载通知计数
loadNotificationCounts();
}
// 内置分类列表
@@ -2993,6 +3074,232 @@
} catch (e) { alert('导入失败: ' + e.message); }
}
// ─── 通知和审核功能 ─────────────────────────────────────────────────
// 加载未读通知和待审核数
async function loadNotificationCounts() {
try {
const [notifRes, reviewRes] = await Promise.all([
fetch('/api/notifications/unread-count'),
fetch('/api/reviews/count')
]);
const notifData = await notifRes.json();
const reviewData = await reviewRes.json();
// 更新导航栏徽章
const notifBadge = document.getElementById('navNotificationBadge');
const reviewBadge = document.getElementById('navReviewBadge');
if (notifData.count > 0) {
notifBadge.textContent = notifData.count > 99 ? '99+' : notifData.count;
notifBadge.classList.remove('hidden');
} else {
notifBadge.classList.add('hidden');
}
if (reviewData.count > 0) {
reviewBadge.textContent = reviewData.count > 99 ? '99+' : reviewData.count;
reviewBadge.classList.remove('hidden');
} else {
reviewBadge.classList.add('hidden');
}
// 更新概览页的待办提醒
const todoAlert = document.getElementById('todoAlert');
const totalCount = notifData.count + reviewData.count;
if (totalCount > 0) {
todoAlert.classList.remove('hidden');
document.getElementById('todoCount').textContent = totalCount;
document.getElementById('todoDetail').textContent =
`${notifData.count} 条未读通知,${reviewData.count} 条待审核产品`;
} else {
todoAlert.classList.add('hidden');
}
} catch (e) {
console.error('加载通知数失败:', e);
}
}
// 加载通知列表
async function loadNotifications() {
try {
const res = await fetch('/api/notifications?limit=50');
const notifications = await res.json();
if (notifications.length === 0) {
document.getElementById('notificationsList').innerHTML = `
<div class="bg-white rounded-xl p-8 text-center text-gray-400">
<i class="ri-notification-off-line text-4xl mb-2"></i>
<div>暂无通知</div>
</div>
`;
return;
}
const levelColors = {
info: 'bg-blue-50 border-blue-200',
warning: 'bg-orange-50 border-orange-200',
error: 'bg-red-50 border-red-200',
success: 'bg-green-50 border-green-200'
};
const levelIcons = {
info: 'ri-information-line text-blue-600',
warning: 'ri-alert-line text-orange-600',
error: 'ri-error-warning-line text-red-600',
success: 'ri-checkbox-circle-line text-green-600'
};
const html = notifications.map(n => `
<div class="bg-white rounded-xl p-4 border ${n.read ? 'opacity-60' : ''} ${levelColors[n.level] || 'bg-gray-50 border-gray-200'}" id="notif-${n.id}">
<div class="flex items-start gap-3">
<i class="${levelIcons[n.level] || 'ri-notification-line text-gray-600'} text-xl mt-0.5"></i>
<div class="flex-1">
<div class="font-medium text-gray-800">${n.title}</div>
<div class="text-sm text-gray-600 mt-1">${n.message}</div>
<div class="text-xs text-gray-400 mt-2">${n.created_at}</div>
</div>
${!n.read ? `<button onclick="markRead('${n.id}')" class="text-xs text-indigo-600 hover:text-indigo-800">标为已读</button>` : ''}
</div>
</div>
`).join('');
document.getElementById('notificationsList').innerHTML = html;
} catch (e) {
document.getElementById('notificationsList').innerHTML = `
<div class="bg-white rounded-xl p-8 text-center text-red-500">加载失败: ${e.message}</div>
`;
}
}
// 标记单个通知已读
async function markRead(notifId) {
await fetch(`/api/notifications/${notifId}/read`, {method: 'POST'});
loadNotifications();
loadNotificationCounts();
}
// 标记全部已读
async function markAllRead() {
await fetch('/api/notifications/read-all', {method: 'POST'});
loadNotifications();
loadNotificationCounts();
}
// 加载审核列表
async function loadReviews() {
const status = document.getElementById('reviewStatusFilter').value;
try {
const res = await fetch(`/api/reviews?status=${status}`);
const reviews = await res.json();
if (reviews.length === 0) {
document.getElementById('reviewsTable').innerHTML = `
<tr><td colspan="6" class="text-center text-gray-400 py-8">暂无数据</td></tr>
`;
return;
}
const statusLabels = {
pending: '<span class="px-2 py-1 bg-orange-100 text-orange-700 rounded text-xs">待审核</span>',
approved: '<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">已通过</span>',
rejected: '<span class="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">已拒绝</span>'
};
const html = reviews.map(r => {
const catName = categories.find(c => c.id === r.category_id)?.name || r.category_id;
return `
<tr class="border-b hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-800">${r.data?.name || '-'}</td>
<td class="px-4 py-3 text-gray-600">${catName}</td>
<td class="px-4 py-3 text-sm">
${r.source === 'api' ? '<span class="text-indigo-600">API</span>' : '<span class="text-gray-500">网页</span>'}
</td>
<td class="px-4 py-3 text-sm text-gray-500">${r.created_at}</td>
<td class="px-4 py-3">${statusLabels[r.status] || r.status}</td>
<td class="px-4 py-3 text-center">
${r.status === 'pending' ? `
<button onclick="approveReview('${r.id}')" class="text-green-600 hover:text-green-800 mr-2" title="通过"><i class="ri-check-line"></i></button>
<button onclick="rejectReview('${r.id}')" class="text-red-600 hover:text-red-800" title="拒绝"><i class="ri-close-line"></i></button>
<button onclick="viewReviewDetail('${r.id}')" class="text-blue-600 hover:text-blue-800 ml-2" title="查看详情"><i class="ri-eye-line"></i></button>
` : `
<button onclick="viewReviewDetail('${r.id}')" class="text-blue-600 hover:text-blue-800" title="查看详情"><i class="ri-eye-line"></i></button>
`}
</td>
</tr>
`;
}).join('');
document.getElementById('reviewsTable').innerHTML = html;
} catch (e) {
document.getElementById('reviewsTable').innerHTML = `
<tr><td colspan="6" class="text-center text-red-500 py-8">加载失败: ${e.message}</td></tr>
`;
}
}
// 通过审核
async function approveReview(reviewId) {
if (!confirm('确认通过该产品审核?')) return;
try {
const res = await fetch(`/api/reviews/${reviewId}/approve`, {method: 'POST'});
const data = await res.json();
if (data.error) {
alert('操作失败: ' + data.error);
} else {
alert('审核通过,产品已发布!');
loadReviews();
loadNotificationCounts();
loadOverview();
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 拒绝审核
async function rejectReview(reviewId) {
const reason = prompt('请输入拒绝原因(可选):');
if (reason === null) return; // 用户取消
try {
const res = await fetch(`/api/reviews/${reviewId}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason})
});
const data = await res.json();
if (data.error) {
alert('操作失败: ' + data.error);
} else {
alert('已拒绝该产品!');
loadReviews();
loadNotificationCounts();
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 查看审核详情
async function viewReviewDetail(reviewId) {
try {
const res = await fetch(`/api/reviews/${reviewId}`);
const review = await res.json();
let html = '<div class="space-y-4">';
html += `<div><span class="text-gray-500">状态:</span> ${review.status}</div>`;
html += `<div><span class="text-gray-500">分类:</span> ${review.category_id}</div>`;
html += `<div><span class="text-gray-500">来源:</span> ${review.source}</div>`;
html += `<div><span class="text-gray-500">提交时间:</span> ${review.created_at}</div>`;
html += '<div class="border-t pt-4"><h3 class="font-medium mb-2">产品数据:</h3>';
html += '<pre class="bg-gray-50 p-3 rounded text-sm overflow-auto max-h-60">' + JSON.stringify(review.data, null, 2) + '</pre>';
html += '</div></div>';
alert(html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
} catch (e) {
alert('加载失败: ' + e.message);
}
}
init();
</script>
</body>
+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>