Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- templates/dashboard.html +477 -205
templates/dashboard.html
CHANGED
@@ -3,7 +3,11 @@
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
6 |
<title>Abacus Chat代理仪表板</title>
|
|
|
|
|
7 |
<style>
|
8 |
:root {
|
9 |
--primary-color: #6366f1;
|
@@ -1296,242 +1300,186 @@
|
|
1296 |
</style>
|
1297 |
</head>
|
1298 |
<body class="loading">
|
1299 |
-
|
1300 |
-
|
1301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1302 |
<div class="brand">
|
1303 |
-
<img src="/static/logo.png" alt="Abacus Chat Logo" class="logo">
|
1304 |
<h1>Abacus Chat代理仪表板</h1>
|
1305 |
</div>
|
1306 |
<div class="nav-actions">
|
1307 |
-
<button class="btn btn-secondary" onclick="location.href='/logout'">
|
1308 |
-
<span class="material-icons">logout</span>
|
1309 |
-
|
1310 |
</button>
|
1311 |
</div>
|
1312 |
</nav>
|
1313 |
-
|
1314 |
-
<div class="container">
|
1315 |
-
<div class="grid">
|
1316 |
-
<!-- 系统状态卡片 -->
|
1317 |
-
<div class="card">
|
1318 |
-
<div class="card-header">
|
1319 |
-
<h2 class="card-title">
|
1320 |
-
<div class="card-icon">📊</div>
|
1321 |
-
系统状态
|
1322 |
-
</h2>
|
1323 |
-
</div>
|
1324 |
-
<div class="status-item">
|
1325 |
-
<span class="status-label">运行时间</span>
|
1326 |
-
<span class="status-value">{{ uptime }}</span>
|
1327 |
-
</div>
|
1328 |
-
<div class="status-item">
|
1329 |
-
<span class="status-label">健康检查</span>
|
1330 |
-
<span class="health-status">
|
1331 |
-
<span>✓</span>
|
1332 |
-
<span>{{ health_checks }}次</span>
|
1333 |
-
</span>
|
1334 |
-
</div>
|
1335 |
-
<div class="status-item">
|
1336 |
-
<span class="status-label">用户数量</span>
|
1337 |
-
<span class="status-value">{{ user_count }}</span>
|
1338 |
-
</div>
|
1339 |
-
</div>
|
1340 |
|
1341 |
-
|
1342 |
-
|
1343 |
-
|
1344 |
-
|
1345 |
-
|
1346 |
-
|
1347 |
-
|
1348 |
-
|
1349 |
-
|
1350 |
-
|
1351 |
-
<span class="token-count">{{ "{:,}".format(total_tokens["prompt"]) }}</span>
|
1352 |
-
</div>
|
1353 |
-
<div class="status-item">
|
1354 |
-
<span class="status-label">输出Token</span>
|
1355 |
-
<span class="token-count">{{ "{:,}".format(total_tokens["completion"]) }}</span>
|
1356 |
-
</div>
|
1357 |
-
<div class="status-item">
|
1358 |
-
<span class="status-label">总Token</span>
|
1359 |
-
<span class="token-count">{{ "{:,}".format(total_tokens["total"]) }}</span>
|
1360 |
-
</div>
|
1361 |
-
<div class="token-note">
|
1362 |
-
注意:此处显示的Token数量仅包含通过代理使用的Token,不包含在Abacus网站上直接使用的Token。这是一个粗略估计,可能与实际使用量有所偏差。
|
1363 |
-
</div>
|
1364 |
</div>
|
1365 |
-
|
1366 |
-
|
1367 |
-
|
1368 |
-
|
1369 |
-
|
1370 |
-
|
1371 |
-
|
1372 |
-
|
1373 |
-
|
1374 |
-
|
1375 |
-
|
1376 |
-
|
1377 |
-
|
1378 |
-
|
1379 |
-
|
1380 |
-
|
1381 |
-
|
1382 |
-
|
1383 |
-
|
1384 |
-
<
|
1385 |
-
|
1386 |
-
|
1387 |
-
|
1388 |
-
|
1389 |
-
|
1390 |
</div>
|
1391 |
-
<span class="status-value{% if compute_points['percentage'] > 80 %} danger{% elif compute_points['percentage'] > 60 %} warning{% endif %}">{{ compute_points["percentage"] }}%</span>
|
1392 |
-
</div>
|
1393 |
-
{% if compute_points["last_update"] %}
|
1394 |
-
<div class="status-item">
|
1395 |
-
<span class="status-label">最后更新</span>
|
1396 |
-
<span class="status-value">{{ compute_points["last_update"].strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
1397 |
</div>
|
1398 |
-
{% endif %}
|
1399 |
-
</div>
|
1400 |
-
</div>
|
1401 |
-
|
1402 |
-
<!-- 可用模型卡片 -->
|
1403 |
-
<div class="card">
|
1404 |
-
<div class="card-header">
|
1405 |
-
<h2 class="card-title">
|
1406 |
-
<div class="card-icon">🤖</div>
|
1407 |
-
可用模型
|
1408 |
-
</h2>
|
1409 |
-
</div>
|
1410 |
-
<div class="models-list">
|
1411 |
-
{% for model in models %}
|
1412 |
-
<span class="model-tag">{{ model }}</span>
|
1413 |
-
{% endfor %}
|
1414 |
</div>
|
1415 |
-
</
|
1416 |
|
1417 |
-
<!--
|
1418 |
-
<
|
1419 |
<div class="card-header">
|
1420 |
-
<h2
|
1421 |
-
|
1422 |
-
|
1423 |
-
|
1424 |
-
<button class="btn-toggle" onclick="toggleModels(this)">
|
1425 |
-
<span class="text">显示全部</span>
|
1426 |
-
<span class="icon">▼</span>
|
1427 |
</button>
|
1428 |
</div>
|
1429 |
-
<div class="
|
1430 |
-
<div class="
|
1431 |
-
<
|
1432 |
-
<
|
1433 |
-
|
1434 |
-
|
1435 |
-
|
1436 |
-
|
1437 |
-
|
1438 |
-
|
1439 |
-
|
1440 |
-
|
1441 |
-
|
1442 |
-
|
1443 |
-
|
1444 |
-
|
1445 |
-
|
1446 |
-
|
1447 |
-
<
|
1448 |
-
|
1449 |
-
|
1450 |
-
|
1451 |
-
|
1452 |
-
|
1453 |
-
|
1454 |
-
|
1455 |
-
|
|
|
|
|
|
|
1456 |
</div>
|
1457 |
</div>
|
1458 |
-
</
|
1459 |
|
1460 |
-
<!--
|
1461 |
-
<
|
1462 |
<div class="card-header">
|
1463 |
-
<h2
|
1464 |
-
|
1465 |
-
|
1466 |
-
|
1467 |
-
|
1468 |
-
<div class="table-container">
|
1469 |
-
<table class="data-table">
|
1470 |
-
<thead>
|
1471 |
-
<tr>
|
1472 |
-
{% for key, value in compute_points_log.columns.items() %}
|
1473 |
-
<th>{{ value }}</th>
|
1474 |
-
{% endfor %}
|
1475 |
-
</tr>
|
1476 |
-
</thead>
|
1477 |
-
<tbody>
|
1478 |
-
{% for entry in compute_points_log.log %}
|
1479 |
-
<tr>
|
1480 |
-
{% for key, value in compute_points_log.columns.items() %}
|
1481 |
-
<td>
|
1482 |
-
{% if key == 'timestamp' %}
|
1483 |
-
{{ entry.get(key, '').split('T')[0] }}
|
1484 |
-
{% elif key == 'computePoints' %}
|
1485 |
-
<span class="compute-points">{{ "{:,}".format(entry.get(key, 0)) }}</span>
|
1486 |
-
{% elif key == 'llmName' %}
|
1487 |
-
<span class="model-tag">{{ entry.get(key, 'Unknown') }}</span>
|
1488 |
-
{% else %}
|
1489 |
-
{{ entry.get(key, 0) }}
|
1490 |
-
{% endif %}
|
1491 |
-
</td>
|
1492 |
-
{% endfor %}
|
1493 |
-
</tr>
|
1494 |
-
{% endfor %}
|
1495 |
-
</tbody>
|
1496 |
-
</table>
|
1497 |
</div>
|
1498 |
-
<div class="
|
1499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1500 |
</div>
|
1501 |
-
</
|
1502 |
|
1503 |
<!-- API端点卡片 -->
|
1504 |
-
<
|
1505 |
<div class="card-header">
|
1506 |
-
<h2
|
1507 |
-
|
1508 |
-
API
|
1509 |
-
|
1510 |
-
|
1511 |
-
<div class="endpoint-item">
|
1512 |
-
<div>获取可用模型列表</div>
|
1513 |
-
<div class="endpoint-url" onclick="copyToClipboard(this)">GET /v1/models</div>
|
1514 |
-
</div>
|
1515 |
-
<div class="endpoint-item">
|
1516 |
-
<div>发送聊天请求</div>
|
1517 |
-
<div class="endpoint-url" onclick="copyToClipboard(this)">POST /v1/chat/completions</div>
|
1518 |
</div>
|
1519 |
-
<div class="
|
1520 |
-
<div
|
1521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1522 |
</div>
|
1523 |
-
</
|
1524 |
-
|
1525 |
-
<footer class="footer">
|
1526 |
-
<p>© {{ year }} Abacus Chat Proxy. All rights reserved.</p>
|
1527 |
-
</footer>
|
1528 |
-
</div>
|
1529 |
|
1530 |
<!-- 返回顶部按钮 -->
|
1531 |
-
<button class="back-to-top"
|
1532 |
-
|
1533 |
</button>
|
1534 |
|
|
|
|
|
1535 |
<!-- 添加页面加载器到body -->
|
1536 |
<div class="page-loader">
|
1537 |
<div class="loader"></div>
|
@@ -1792,6 +1740,330 @@
|
|
1792 |
});
|
1793 |
}
|
1794 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1795 |
</script>
|
1796 |
</body>
|
1797 |
</html>
|
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<meta name="description" content="Abacus Chat代理仪表板 - 监控系统状态、Token使用情况和API端点">
|
7 |
+
<meta name="theme-color" content="#6366f1">
|
8 |
<title>Abacus Chat代理仪表板</title>
|
9 |
+
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
10 |
+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
11 |
<style>
|
12 |
:root {
|
13 |
--primary-color: #6366f1;
|
|
|
1300 |
</style>
|
1301 |
</head>
|
1302 |
<body class="loading">
|
1303 |
+
<!-- 跳转到主要内容的链接 -->
|
1304 |
+
<a href="#main-content" class="skip-link">跳转到主要内容</a>
|
1305 |
+
|
1306 |
+
<!-- 页面加载动画 -->
|
1307 |
+
<div class="loading-overlay" role="progressbar" aria-label="页面加载中">
|
1308 |
+
<div class="loader" aria-hidden="true"></div>
|
1309 |
+
<p>加载中...</p>
|
1310 |
+
</div>
|
1311 |
+
|
1312 |
+
<!-- 导航栏 -->
|
1313 |
+
<nav class="navbar" role="navigation" aria-label="主导航">
|
1314 |
<div class="brand">
|
1315 |
+
<img src="/static/logo.png" alt="Abacus Chat Logo" class="logo" width="32" height="32">
|
1316 |
<h1>Abacus Chat代理仪表板</h1>
|
1317 |
</div>
|
1318 |
<div class="nav-actions">
|
1319 |
+
<button class="btn btn-secondary" onclick="location.href='/logout'" aria-label="退出登录">
|
1320 |
+
<span class="material-icons" aria-hidden="true">logout</span>
|
1321 |
+
<span>退出登录</span>
|
1322 |
</button>
|
1323 |
</div>
|
1324 |
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1325 |
|
1326 |
+
<!-- 主要内容 -->
|
1327 |
+
<main id="main-content" class="dashboard-content" role="main">
|
1328 |
+
<!-- 系统状态卡片 -->
|
1329 |
+
<section class="card" id="system-status" aria-labelledby="status-title">
|
1330 |
+
<div class="card-header">
|
1331 |
+
<h2 id="status-title">系统状态</h2>
|
1332 |
+
<button class="btn-toggle" aria-expanded="true" aria-controls="status-content">
|
1333 |
+
<span class="sr-only">折叠系统状态</span>
|
1334 |
+
<span class="toggle-icon" aria-hidden="true">▼</span>
|
1335 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1336 |
</div>
|
1337 |
+
<div id="status-content" class="card-body">
|
1338 |
+
<div class="status-grid" role="list">
|
1339 |
+
<div class="status-item" role="listitem">
|
1340 |
+
<span class="material-icons" aria-hidden="true">timer</span>
|
1341 |
+
<div class="status-info">
|
1342 |
+
<h3>运行时间</h3>
|
1343 |
+
<p class="uptime">{{ uptime }}</p>
|
1344 |
+
</div>
|
1345 |
+
</div>
|
1346 |
+
<div class="status-item" role="listitem">
|
1347 |
+
<span class="material-icons" aria-hidden="true">health_and_safety</span>
|
1348 |
+
<div class="status-info">
|
1349 |
+
<h3>健康状态</h3>
|
1350 |
+
<div class="health-status" data-status="{{ health_status }}" role="status">
|
1351 |
+
<span class="status-indicator" aria-hidden="true"></span>
|
1352 |
+
<span class="status-text">{{ health_status }}</span>
|
1353 |
+
</div>
|
1354 |
+
</div>
|
1355 |
+
</div>
|
1356 |
+
<div class="status-item" role="listitem">
|
1357 |
+
<span class="material-icons" aria-hidden="true">group</span>
|
1358 |
+
<div class="status-info">
|
1359 |
+
<h3>用户数量</h3>
|
1360 |
+
<p class="user-count" data-value="{{ user_count }}" aria-live="polite">{{ user_count }}</p>
|
1361 |
+
</div>
|
1362 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
1363 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1364 |
</div>
|
1365 |
+
</section>
|
1366 |
|
1367 |
+
<!-- Token使用统计卡片 -->
|
1368 |
+
<section class="card" id="token-stats" aria-labelledby="token-title">
|
1369 |
<div class="card-header">
|
1370 |
+
<h2 id="token-title">Token使用统计</h2>
|
1371 |
+
<button class="btn-toggle" aria-expanded="true" aria-controls="token-content">
|
1372 |
+
<span class="sr-only">折叠Token统计</span>
|
1373 |
+
<span class="toggle-icon" aria-hidden="true">▼</span>
|
|
|
|
|
|
|
1374 |
</button>
|
1375 |
</div>
|
1376 |
+
<div id="token-content" class="card-body">
|
1377 |
+
<div class="token-overview">
|
1378 |
+
<div class="token-total">
|
1379 |
+
<h3>总Token使用量</h3>
|
1380 |
+
<p class="token-count" data-value="{{ total_tokens }}" aria-live="polite">{{ total_tokens }}</p>
|
1381 |
+
<small class="token-note" role="note">*此数据仅包含代理使用的token,不包含Abacus网站使用的token。数据为粗略估计。</small>
|
1382 |
+
</div>
|
1383 |
+
<div class="token-breakdown">
|
1384 |
+
<h3>按模型统计</h3>
|
1385 |
+
<div class="table-container">
|
1386 |
+
<table class="token-table" aria-label="Token使用明细">
|
1387 |
+
<thead>
|
1388 |
+
<tr>
|
1389 |
+
<th scope="col">模型</th>
|
1390 |
+
<th scope="col">Token使用量</th>
|
1391 |
+
<th scope="col">占比</th>
|
1392 |
+
</tr>
|
1393 |
+
</thead>
|
1394 |
+
<tbody>
|
1395 |
+
{% for model in token_stats %}
|
1396 |
+
<tr>
|
1397 |
+
<th scope="row">{{ model.name }}</th>
|
1398 |
+
<td class="token-count" data-value="{{ model.tokens }}">{{ model.tokens }}</td>
|
1399 |
+
<td>{{ model.percentage }}%</td>
|
1400 |
+
</tr>
|
1401 |
+
{% endfor %}
|
1402 |
+
</tbody>
|
1403 |
+
</table>
|
1404 |
+
</div>
|
1405 |
+
</div>
|
1406 |
</div>
|
1407 |
</div>
|
1408 |
+
</section>
|
1409 |
|
1410 |
+
<!-- 计算点使用统计卡片 -->
|
1411 |
+
<section class="card" id="compute-points" aria-labelledby="points-title">
|
1412 |
<div class="card-header">
|
1413 |
+
<h2 id="points-title">计算点使用统计</h2>
|
1414 |
+
<button class="btn-toggle" aria-expanded="true" aria-controls="points-content">
|
1415 |
+
<span class="sr-only">折叠计算点统计</span>
|
1416 |
+
<span class="toggle-icon" aria-hidden="true">▼</span>
|
1417 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1418 |
</div>
|
1419 |
+
<div id="points-content" class="card-body">
|
1420 |
+
<div class="points-overview">
|
1421 |
+
<div class="points-total">
|
1422 |
+
<h3>总计算点使用量</h3>
|
1423 |
+
<p class="compute-points" data-value="{{ total_compute_points }}" aria-live="polite">{{ total_compute_points }}</p>
|
1424 |
+
</div>
|
1425 |
+
<div class="points-breakdown">
|
1426 |
+
<h3>使用记录</h3>
|
1427 |
+
<div class="table-container">
|
1428 |
+
<table class="points-table" aria-label="计算点使用记录">
|
1429 |
+
<thead>
|
1430 |
+
<tr>
|
1431 |
+
<th scope="col">时间</th>
|
1432 |
+
<th scope="col">计算点</th>
|
1433 |
+
<th scope="col">模型</th>
|
1434 |
+
</tr>
|
1435 |
+
</thead>
|
1436 |
+
<tbody>
|
1437 |
+
{% for entry in compute_points_log %}
|
1438 |
+
<tr>
|
1439 |
+
<td>{{ entry.timestamp }}</td>
|
1440 |
+
<td class="compute-points">{{ entry.points }}</td>
|
1441 |
+
<td>{{ entry.model }}</td>
|
1442 |
+
</tr>
|
1443 |
+
{% endfor %}
|
1444 |
+
</tbody>
|
1445 |
+
</table>
|
1446 |
+
</div>
|
1447 |
+
<small class="points-note" role="note">*每小时更新一次</small>
|
1448 |
+
</div>
|
1449 |
+
</div>
|
1450 |
</div>
|
1451 |
+
</section>
|
1452 |
|
1453 |
<!-- API端点卡片 -->
|
1454 |
+
<section class="card" id="api-endpoints" aria-labelledby="endpoints-title">
|
1455 |
<div class="card-header">
|
1456 |
+
<h2 id="endpoints-title">API端点</h2>
|
1457 |
+
<button class="btn-toggle" aria-expanded="true" aria-controls="endpoints-content">
|
1458 |
+
<span class="sr-only">折叠API端点</span>
|
1459 |
+
<span class="toggle-icon" aria-hidden="true">▼</span>
|
1460 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1461 |
</div>
|
1462 |
+
<div id="endpoints-content" class="card-body">
|
1463 |
+
<div class="endpoints-grid" role="list">
|
1464 |
+
{% for endpoint in api_endpoints %}
|
1465 |
+
<div class="endpoint-card" role="listitem">
|
1466 |
+
<h3>{{ endpoint.name }}</h3>
|
1467 |
+
<p class="api-endpoint" role="button" tabindex="0" aria-label="复制API端点: {{ endpoint.url }}">{{ endpoint.url }}</p>
|
1468 |
+
<small class="endpoint-note" aria-hidden="true">点击复制</small>
|
1469 |
+
</div>
|
1470 |
+
{% endfor %}
|
1471 |
+
</div>
|
1472 |
</div>
|
1473 |
+
</section>
|
1474 |
+
</main>
|
|
|
|
|
|
|
|
|
1475 |
|
1476 |
<!-- 返回顶部按钮 -->
|
1477 |
+
<button class="back-to-top" aria-label="返回页面顶部">
|
1478 |
+
<span class="material-icons" aria-hidden="true">arrow_upward</span>
|
1479 |
</button>
|
1480 |
|
1481 |
+
<!-- 主题切换按钮 -->
|
1482 |
+
<button class="theme-toggle" aria-label="切换深色/浅色主题">
|
1483 |
<!-- 添加页面加载器到body -->
|
1484 |
<div class="page-loader">
|
1485 |
<div class="loader"></div>
|
|
|
1740 |
});
|
1741 |
}
|
1742 |
});
|
1743 |
+
|
1744 |
+
// 无障碍支持
|
1745 |
+
class A11yManager {
|
1746 |
+
constructor() {
|
1747 |
+
this.focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
1748 |
+
this.init();
|
1749 |
+
}
|
1750 |
+
|
1751 |
+
init() {
|
1752 |
+
this.setupFocusTrap();
|
1753 |
+
this.setupKeyboardNavigation();
|
1754 |
+
this.setupSkipLink();
|
1755 |
+
this.setupAnnouncer();
|
1756 |
+
}
|
1757 |
+
|
1758 |
+
setupFocusTrap() {
|
1759 |
+
document.addEventListener('keydown', (e) => {
|
1760 |
+
if (e.key === 'Tab') {
|
1761 |
+
const modal = document.querySelector('.modal.show');
|
1762 |
+
if (!modal) return;
|
1763 |
+
|
1764 |
+
const focusableElements = modal.querySelectorAll(this.focusableElements);
|
1765 |
+
const firstFocusable = focusableElements[0];
|
1766 |
+
const lastFocusable = focusableElements[focusableElements.length - 1];
|
1767 |
+
|
1768 |
+
if (e.shiftKey && document.activeElement === firstFocusable) {
|
1769 |
+
e.preventDefault();
|
1770 |
+
lastFocusable.focus();
|
1771 |
+
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
|
1772 |
+
e.preventDefault();
|
1773 |
+
firstFocusable.focus();
|
1774 |
+
}
|
1775 |
+
}
|
1776 |
+
});
|
1777 |
+
}
|
1778 |
+
|
1779 |
+
setupKeyboardNavigation() {
|
1780 |
+
document.addEventListener('keydown', (e) => {
|
1781 |
+
if (e.key === 'Escape') {
|
1782 |
+
const modal = document.querySelector('.modal.show');
|
1783 |
+
if (modal) {
|
1784 |
+
this.closeModal(modal);
|
1785 |
+
}
|
1786 |
+
|
1787 |
+
const notifications = document.querySelectorAll('.notification.show');
|
1788 |
+
notifications.forEach(notification => {
|
1789 |
+
this.closeNotification(notification);
|
1790 |
+
});
|
1791 |
+
}
|
1792 |
+
});
|
1793 |
+
}
|
1794 |
+
|
1795 |
+
setupSkipLink() {
|
1796 |
+
const skipLink = document.querySelector('.skip-link');
|
1797 |
+
if (!skipLink) return;
|
1798 |
+
|
1799 |
+
skipLink.addEventListener('click', (e) => {
|
1800 |
+
e.preventDefault();
|
1801 |
+
const target = document.querySelector(skipLink.getAttribute('href'));
|
1802 |
+
if (target) {
|
1803 |
+
target.setAttribute('tabindex', '-1');
|
1804 |
+
target.focus();
|
1805 |
+
}
|
1806 |
+
});
|
1807 |
+
}
|
1808 |
+
|
1809 |
+
setupAnnouncer() {
|
1810 |
+
const announcer = document.createElement('div');
|
1811 |
+
announcer.setAttribute('aria-live', 'polite');
|
1812 |
+
announcer.setAttribute('aria-atomic', 'true');
|
1813 |
+
announcer.classList.add('sr-only');
|
1814 |
+
document.body.appendChild(announcer);
|
1815 |
+
this.announcer = announcer;
|
1816 |
+
}
|
1817 |
+
|
1818 |
+
announce(message) {
|
1819 |
+
if (!this.announcer) return;
|
1820 |
+
this.announcer.textContent = message;
|
1821 |
+
}
|
1822 |
+
|
1823 |
+
closeModal(modal) {
|
1824 |
+
modal.classList.remove('show');
|
1825 |
+
this.announce('模态框已关闭');
|
1826 |
+
}
|
1827 |
+
|
1828 |
+
closeNotification(notification) {
|
1829 |
+
notification.classList.remove('show');
|
1830 |
+
this.announce('通知已关闭');
|
1831 |
+
}
|
1832 |
+
}
|
1833 |
+
|
1834 |
+
// 主题管理
|
1835 |
+
class ThemeManager {
|
1836 |
+
constructor() {
|
1837 |
+
this.init();
|
1838 |
+
}
|
1839 |
+
|
1840 |
+
init() {
|
1841 |
+
this.setupThemeToggle();
|
1842 |
+
this.loadSavedTheme();
|
1843 |
+
this.setupSystemThemeListener();
|
1844 |
+
}
|
1845 |
+
|
1846 |
+
setupThemeToggle() {
|
1847 |
+
const themeToggle = document.querySelector('.theme-toggle');
|
1848 |
+
if (!themeToggle) return;
|
1849 |
+
|
1850 |
+
themeToggle.addEventListener('click', () => {
|
1851 |
+
const currentTheme = document.documentElement.getAttribute('data-theme');
|
1852 |
+
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
1853 |
+
this.setTheme(newTheme);
|
1854 |
+
this.announce(`已切换到${newTheme === 'dark' ? '深色' : '浅色'}主题`);
|
1855 |
+
});
|
1856 |
+
}
|
1857 |
+
|
1858 |
+
loadSavedTheme() {
|
1859 |
+
const savedTheme = localStorage.getItem('theme');
|
1860 |
+
if (savedTheme) {
|
1861 |
+
this.setTheme(savedTheme);
|
1862 |
+
} else {
|
1863 |
+
this.setTheme(this.getSystemTheme());
|
1864 |
+
}
|
1865 |
+
}
|
1866 |
+
|
1867 |
+
setupSystemThemeListener() {
|
1868 |
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
1869 |
+
mediaQuery.addListener((e) => {
|
1870 |
+
if (!localStorage.getItem('theme')) {
|
1871 |
+
this.setTheme(e.matches ? 'dark' : 'light');
|
1872 |
+
}
|
1873 |
+
});
|
1874 |
+
}
|
1875 |
+
|
1876 |
+
getSystemTheme() {
|
1877 |
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
1878 |
+
}
|
1879 |
+
|
1880 |
+
setTheme(theme) {
|
1881 |
+
document.documentElement.setAttribute('data-theme', theme);
|
1882 |
+
localStorage.setItem('theme', theme);
|
1883 |
+
}
|
1884 |
+
|
1885 |
+
announce(message) {
|
1886 |
+
const announcer = document.querySelector('[aria-live="polite"]');
|
1887 |
+
if (announcer) {
|
1888 |
+
announcer.textContent = message;
|
1889 |
+
}
|
1890 |
+
}
|
1891 |
+
}
|
1892 |
+
|
1893 |
+
// 数据管理
|
1894 |
+
class DataManager {
|
1895 |
+
constructor() {
|
1896 |
+
this.init();
|
1897 |
+
}
|
1898 |
+
|
1899 |
+
init() {
|
1900 |
+
this.setupTableSearch();
|
1901 |
+
this.setupTableSort();
|
1902 |
+
this.setupDataRefresh();
|
1903 |
+
}
|
1904 |
+
|
1905 |
+
setupTableSearch() {
|
1906 |
+
document.querySelectorAll('.table-container').forEach(container => {
|
1907 |
+
const table = container.querySelector('table');
|
1908 |
+
if (!table) return;
|
1909 |
+
|
1910 |
+
const search = document.createElement('input');
|
1911 |
+
search.type = 'text';
|
1912 |
+
search.className = 'table-search';
|
1913 |
+
search.placeholder = '搜索表格内容...';
|
1914 |
+
search.setAttribute('aria-label', '搜索表格内容');
|
1915 |
+
container.insertBefore(search, table);
|
1916 |
+
|
1917 |
+
search.addEventListener('input', (e) => {
|
1918 |
+
const searchText = e.target.value.toLowerCase();
|
1919 |
+
const rows = table.querySelectorAll('tbody tr');
|
1920 |
+
|
1921 |
+
rows.forEach(row => {
|
1922 |
+
const text = row.textContent.toLowerCase();
|
1923 |
+
const display = text.includes(searchText) ? '' : 'none';
|
1924 |
+
row.style.display = display;
|
1925 |
+
row.setAttribute('aria-hidden', display === 'none');
|
1926 |
+
});
|
1927 |
+
|
1928 |
+
this.announce(`找到 ${Array.from(rows).filter(row => row.style.display !== 'none').length} 条匹配记录`);
|
1929 |
+
});
|
1930 |
+
});
|
1931 |
+
}
|
1932 |
+
|
1933 |
+
setupTableSort() {
|
1934 |
+
document.querySelectorAll('table th').forEach(th => {
|
1935 |
+
if (th.getAttribute('data-sortable') === 'false') return;
|
1936 |
+
|
1937 |
+
th.style.cursor = 'pointer';
|
1938 |
+
th.setAttribute('role', 'button');
|
1939 |
+
th.setAttribute('aria-sort', 'none');
|
1940 |
+
|
1941 |
+
th.addEventListener('click', () => {
|
1942 |
+
const table = th.closest('table');
|
1943 |
+
const tbody = table.querySelector('tbody');
|
1944 |
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
1945 |
+
const index = Array.from(th.parentNode.children).indexOf(th);
|
1946 |
+
const direction = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
|
1947 |
+
|
1948 |
+
// 重置其他列的排序状态
|
1949 |
+
th.parentNode.querySelectorAll('th').forEach(header => {
|
1950 |
+
header.setAttribute('aria-sort', 'none');
|
1951 |
+
});
|
1952 |
+
|
1953 |
+
th.setAttribute('aria-sort', direction);
|
1954 |
+
|
1955 |
+
const sortedRows = rows.sort((a, b) => {
|
1956 |
+
const aValue = a.children[index].textContent;
|
1957 |
+
const bValue = b.children[index].textContent;
|
1958 |
+
|
1959 |
+
if (this.isNumeric(aValue) && this.isNumeric(bValue)) {
|
1960 |
+
return direction === 'ascending' ?
|
1961 |
+
this.parseNumber(aValue) - this.parseNumber(bValue) :
|
1962 |
+
this.parseNumber(bValue) - this.parseNumber(aValue);
|
1963 |
+
}
|
1964 |
+
|
1965 |
+
return direction === 'ascending' ?
|
1966 |
+
aValue.localeCompare(bValue) :
|
1967 |
+
bValue.localeCompare(aValue);
|
1968 |
+
});
|
1969 |
+
|
1970 |
+
tbody.append(...sortedRows);
|
1971 |
+
this.announce(`表格已按${th.textContent}${direction === 'ascending' ? '升序' : '降序'}排序`);
|
1972 |
+
});
|
1973 |
+
});
|
1974 |
+
}
|
1975 |
+
|
1976 |
+
setupDataRefresh() {
|
1977 |
+
setInterval(async () => {
|
1978 |
+
try {
|
1979 |
+
const response = await fetch('/api/dashboard/data');
|
1980 |
+
const data = await response.json();
|
1981 |
+
this.updateDashboard(data);
|
1982 |
+
} catch (err) {
|
1983 |
+
console.error('数据刷新失败:', err);
|
1984 |
+
}
|
1985 |
+
}, 60000); // 每分钟更新一次
|
1986 |
+
}
|
1987 |
+
|
1988 |
+
updateDashboard(data) {
|
1989 |
+
// 更新系统状态
|
1990 |
+
if (data.uptime) {
|
1991 |
+
document.querySelector('.uptime').textContent = data.uptime;
|
1992 |
+
}
|
1993 |
+
|
1994 |
+
if (data.health_status) {
|
1995 |
+
const healthStatus = document.querySelector('.health-status');
|
1996 |
+
healthStatus.setAttribute('data-status', data.health_status);
|
1997 |
+
healthStatus.querySelector('.status-text').textContent = data.health_status;
|
1998 |
+
}
|
1999 |
+
|
2000 |
+
if (data.user_count) {
|
2001 |
+
const userCount = document.querySelector('.user-count');
|
2002 |
+
this.animateNumber(userCount, parseInt(userCount.textContent), data.user_count);
|
2003 |
+
}
|
2004 |
+
|
2005 |
+
// 更新Token统计
|
2006 |
+
if (data.total_tokens) {
|
2007 |
+
const tokenCount = document.querySelector('.token-count');
|
2008 |
+
this.animateNumber(tokenCount, parseInt(tokenCount.textContent), data.total_tokens);
|
2009 |
+
}
|
2010 |
+
|
2011 |
+
// 更新计算点统计
|
2012 |
+
if (data.compute_points) {
|
2013 |
+
const computePoints = document.querySelector('.compute-points');
|
2014 |
+
this.animateNumber(computePoints, parseInt(computePoints.textContent), data.compute_points);
|
2015 |
+
}
|
2016 |
+
|
2017 |
+
this.announce('仪表板数据已更新');
|
2018 |
+
}
|
2019 |
+
|
2020 |
+
animateNumber(element, start, end) {
|
2021 |
+
if (start === end) return;
|
2022 |
+
|
2023 |
+
const duration = 1000;
|
2024 |
+
const startTime = performance.now();
|
2025 |
+
const range = end - start;
|
2026 |
+
|
2027 |
+
const update = (currentTime) => {
|
2028 |
+
const elapsed = currentTime - startTime;
|
2029 |
+
const progress = Math.min(elapsed / duration, 1);
|
2030 |
+
|
2031 |
+
const current = Math.floor(start + range * progress);
|
2032 |
+
element.textContent = new Intl.NumberFormat().format(current);
|
2033 |
+
|
2034 |
+
if (progress < 1) {
|
2035 |
+
requestAnimationFrame(update);
|
2036 |
+
}
|
2037 |
+
};
|
2038 |
+
|
2039 |
+
requestAnimationFrame(update);
|
2040 |
+
}
|
2041 |
+
|
2042 |
+
isNumeric(value) {
|
2043 |
+
return !isNaN(this.parseNumber(value));
|
2044 |
+
}
|
2045 |
+
|
2046 |
+
parseNumber(value) {
|
2047 |
+
return parseFloat(value.replace(/[^0-9.-]+/g, ''));
|
2048 |
+
}
|
2049 |
+
|
2050 |
+
announce(message) {
|
2051 |
+
const announcer = document.querySelector('[aria-live="polite"]');
|
2052 |
+
if (announcer) {
|
2053 |
+
announcer.textContent = message;
|
2054 |
+
}
|
2055 |
+
}
|
2056 |
+
}
|
2057 |
+
|
2058 |
+
// 初始化
|
2059 |
+
document.addEventListener('DOMContentLoaded', () => {
|
2060 |
+
const a11y = new A11yManager();
|
2061 |
+
const theme = new ThemeManager();
|
2062 |
+
const data = new DataManager();
|
2063 |
+
|
2064 |
+
// 移除加载状态
|
2065 |
+
document.body.classList.remove('loading');
|
2066 |
+
});
|
2067 |
</script>
|
2068 |
</body>
|
2069 |
</html>
|