diff --git "a/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md"
new file mode 100644
index 00000000..af77e342
--- /dev/null
+++ "b/NEWAPI_MONITOR_\345\211\215\347\253\257\346\216\245\345\205\245\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md"
@@ -0,0 +1,675 @@
+# NewAPI 监控功能前端接入与配置说明
+
+## 1. 功能简介
+
+本功能为 VCP 管理面板新增了一组面向前端展示的 NewAPI 用量监控接口,用于给网页前端、管理面板页面、桌面挂件等场景提供统计数据。
+
+当前实现严格遵循最小可用原则,只提供前端已经明确需要的能力:
+
+- 总请求数统计
+- 总 Token 数统计
+- 总 Quota 统计
+- 当前实时 RPM / TPM
+- 按时间范围的趋势数据
+- 按模型维度的聚合数据
+- 按模型筛选 summary / trend
+
+当前**不包含**以下能力:
+
+- 区分 `/v1/chat/completions`、`/v1/responses` 等端点级统计
+- 用户排行
+- 渠道排行
+- Token 名排行
+- WebSocket 实时推送
+- 其他前端未明确需要的占位接口
+
+---
+
+## 2. 后端接入位置
+
+本功能对应的核心文件如下:
+
+- `routes/admin/newapiMonitor.js`
+- `routes/adminPanelRoutes.js`
+- `config.env.example`
+
+其中:
+
+### 2.1 `routes/admin/newapiMonitor.js`
+负责:
+
+- 解析查询参数
+- 连接 NewAPI 管理员接口
+- 管理 session 或账号密码登录
+- 拉取统计数据
+- 聚合并输出给前端
+
+### 2.2 `routes/adminPanelRoutes.js`
+负责把监控路由挂载到 VCP 管理接口下。
+
+### 2.3 `config.env.example`
+负责提供配置示例,说明如何填写 NewAPI 监控功能所需环境变量。
+
+---
+
+## 3. 前端应该请求哪个地址
+
+前端**不要直接请求 NewAPI**,而是请求 VCP 的管理接口。
+
+可用接口如下:
+
+- `GET /admin_api/newapi-monitor/summary`
+- `GET /admin_api/newapi-monitor/trend`
+- `GET /admin_api/newapi-monitor/models`
+
+这三个接口都由 VCP 后端代持 NewAPI 的管理员鉴权信息。
+
+也就是说:
+
+- 前端不需要持有 NewAPI 的 session cookie
+- 前端不需要自己拼 `New-Api-User`
+- 前端不需要自己处理 NewAPI 登录逻辑
+
+---
+
+## 4. 配置说明
+
+需要在 VCP 的运行配置中填写以下项目。
+
+参考配置写在 `config.env.example` 中。
+
+### 4.1 必填配置
+
+```env
+NEWAPI_MONITOR_BASE_URL=http://127.0.0.1:3000
+```
+
+含义:
+
+- 目标 NewAPI 后台地址
+
+### 4.2 可选配置:请求超时
+
+```env
+NEWAPI_MONITOR_TIMEOUT_MS=15000
+```
+
+含义:
+
+- VCP 调用 NewAPI 时的超时时间,单位毫秒
+
+### 4.3 鉴权方式二选一
+
+#### 方式 A:直接填写管理员 session cookie(推荐)
+
+适用于:
+
+- 开启验证码
+- 开启 2FA
+- 不方便让后端自动登录的实例
+
+```env
+NEWAPI_MONITOR_SESSION_COOKIE=
+```
+
+#### 方式 B:填写管理员用户名密码
+
+适用于:
+
+- 允许后端直接调用登录接口
+- 无验证码或交互式校验阻碍
+
+```env
+NEWAPI_MONITOR_USERNAME=
+NEWAPI_MONITOR_PASSWORD=
+```
+
+### 4.4 特殊兼容配置(可选)
+
+某些经过定制的 NewAPI 实例,除了 session 外,还会额外要求 `New-Api-User` 请求头。
+
+这类实例可以补充:
+
+```env
+NEWAPI_MONITOR_API_USER_ID=
+```
+
+说明:
+
+- 只有当目标实例明确要求 `New-Api-User` 时才需要填写
+- 普通标准实例可以留空
+
+---
+
+## 5. 数据来源说明
+
+VCP 侧会使用以下策略获取数据:
+
+### 5.1 优先使用 `/api/data/`
+优先从 NewAPI 的聚合数据接口拉取:
+
+- 请求数
+- token_used
+- quota
+- created_at
+- model_name
+
+优点:
+
+- 已按小时聚合
+- 性能更好
+- 更适合前端统计
+
+### 5.2 使用 `/api/log/stat` 获取实时值
+用于获取:
+
+- 当前 RPM
+- 当前 TPM
+
+### 5.3 `/api/data/` 无数据时自动回退到 `/api/log/`
+如果目标实例没有可用的 quota_data,则 VCP 会自动回退到消费日志分页拉取,再在本地聚合。
+
+因此本功能兼容两类 NewAPI:
+
+- 已启用聚合数据导出的实例
+- 仅有日志数据的实例
+
+---
+
+## 6. 接口说明
+
+## 6.1 summary 接口
+
+### 请求地址
+
+```text
+GET /admin_api/newapi-monitor/summary
+```
+
+### 支持参数
+
+| 参数 | 是否必填 | 说明 |
+|---|---:|---|
+| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 |
+| `end_timestamp` | 否 | 结束时间,默认当前时间 |
+| `model_name` | 否 | 按模型筛选 |
+
+### 返回内容
+
+- 总请求数
+- 总 Token 数
+- 总 Quota
+- 当前 RPM
+- 当前 TPM
+
+### 示例响应
+
+```json
+{
+ "success": true,
+ "data": {
+ "source": "quota_data",
+ "start_timestamp": 1711584000,
+ "end_timestamp": 1711670400,
+ "model_name": null,
+ "total_requests": 123,
+ "total_tokens": 456789,
+ "total_quota": 987654,
+ "current_rpm": 12,
+ "current_tpm": 34567
+ }
+}
+```
+
+---
+
+## 6.2 trend 接口
+
+### 请求地址
+
+```text
+GET /admin_api/newapi-monitor/trend
+```
+
+### 支持参数
+
+| 参数 | 是否必填 | 说明 |
+|---|---:|---|
+| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 |
+| `end_timestamp` | 否 | 结束时间,默认当前时间 |
+| `model_name` | 否 | 按模型筛选 |
+
+### 返回内容
+
+返回趋势数组,每个时间桶包含:
+
+- `created_at`
+- `requests`
+- `token_used`
+- `quota`
+
+### 示例响应
+
+```json
+{
+ "success": true,
+ "data": {
+ "source": "quota_data",
+ "start_timestamp": 1711584000,
+ "end_timestamp": 1711670400,
+ "model_name": null,
+ "items": [
+ {
+ "created_at": 1711584000,
+ "requests": 10,
+ "token_used": 20000,
+ "quota": 30000
+ }
+ ]
+ }
+}
+```
+
+---
+
+## 6.3 models 接口
+
+### 请求地址
+
+```text
+GET /admin_api/newapi-monitor/models
+```
+
+### 支持参数
+
+| 参数 | 是否必填 | 说明 |
+|---|---:|---|
+| `start_timestamp` | 否 | 开始时间,默认最近 24 小时 |
+| `end_timestamp` | 否 | 结束时间,默认当前时间 |
+
+### 返回内容
+
+返回按模型聚合后的统计数据:
+
+- `model_name`
+- `requests`
+- `token_used`
+- `quota`
+
+### 示例响应
+
+```json
+{
+ "success": true,
+ "data": {
+ "source": "quota_data",
+ "start_timestamp": 1711584000,
+ "end_timestamp": 1711670400,
+ "items": [
+ {
+ "model_name": "gpt-4o",
+ "requests": 100,
+ "token_used": 123456,
+ "quota": 789012
+ }
+ ]
+ }
+}
+```
+
+---
+
+## 7. 前端如何调用
+
+## 7.1 基础原则
+
+前端应该:
+
+- 只调用 VCP 的 `/admin_api/newapi-monitor/*`
+- 尽量使用同源请求
+- 使用浏览器原生 `fetch`
+- 不直接连接 NewAPI 后台接口
+- 不在前端保存 NewAPI 管理员鉴权信息
+
+---
+
+## 7.2 推荐封装一个通用请求函数
+
+```js
+async function requestMonitorJson(url) {
+ const response = await fetch(url, {
+ method: 'GET',
+ credentials: 'same-origin'
+ });
+
+ const result = await response.json();
+
+ if (!response.ok || result.success === false) {
+ throw new Error(result.error || result.message || '请求失败');
+ }
+
+ return result.data;
+}
+```
+
+---
+
+## 7.3 获取 summary
+
+```js
+async function fetchSummary({ startTimestamp, endTimestamp, modelName }) {
+ const params = new URLSearchParams();
+
+ if (startTimestamp) params.set('start_timestamp', String(startTimestamp));
+ if (endTimestamp) params.set('end_timestamp', String(endTimestamp));
+ if (modelName) params.set('model_name', modelName);
+
+ return requestMonitorJson(`/admin_api/newapi-monitor/summary?${params.toString()}`);
+}
+```
+
+---
+
+## 7.4 获取 trend
+
+```js
+async function fetchTrend({ startTimestamp, endTimestamp, modelName }) {
+ const params = new URLSearchParams();
+
+ if (startTimestamp) params.set('start_timestamp', String(startTimestamp));
+ if (endTimestamp) params.set('end_timestamp', String(endTimestamp));
+ if (modelName) params.set('model_name', modelName);
+
+ return requestMonitorJson(`/admin_api/newapi-monitor/trend?${params.toString()}`);
+}
+```
+
+---
+
+## 7.5 获取 models
+
+```js
+async function fetchModels({ startTimestamp, endTimestamp }) {
+ const params = new URLSearchParams();
+
+ if (startTimestamp) params.set('start_timestamp', String(startTimestamp));
+ if (endTimestamp) params.set('end_timestamp', String(endTimestamp));
+
+ return requestMonitorJson(`/admin_api/newapi-monitor/models?${params.toString()}`);
+}
+```
+
+---
+
+## 8. 前端页面建议结构
+
+建议页面分成三块:
+
+### 8.1 筛选区域
+建议包含:
+
+- 时间范围选择器
+- 模型选择器
+- 手动刷新按钮
+
+### 8.2 顶部统计卡片
+建议展示:
+
+- 总请求数
+- 总 Token
+- 总 Quota
+- 当前 RPM
+- 当前 TPM
+
+### 8.3 趋势与模型排行区域
+建议展示:
+
+- 趋势图
+- 模型排行表格
+
+---
+
+## 9. 页面初始化建议
+
+推荐初始化流程如下:
+
+1. 计算默认时间范围(最近 24 小时)
+2. 请求 `models`
+3. 请求 `summary`
+4. 请求 `trend`
+5. 渲染下拉框、卡片、图表和表格
+
+示例:
+
+```js
+async function initMonitorPage() {
+ const endTimestamp = Math.floor(Date.now() / 1000);
+ const startTimestamp = endTimestamp - 24 * 60 * 60;
+
+ const [modelsData, summaryData, trendData] = await Promise.all([
+ fetchModels({ startTimestamp, endTimestamp }),
+ fetchSummary({ startTimestamp, endTimestamp }),
+ fetchTrend({ startTimestamp, endTimestamp })
+ ]);
+
+ renderModelOptions(modelsData.items);
+ renderSummaryCards(summaryData);
+ renderTrendChart(trendData.items);
+ renderModelTable(modelsData.items);
+}
+```
+
+---
+
+## 10. 一个最小可用前端示例
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 11. 如何验证功能是否可用
+
+在前端正式接入前,建议先手动验证:
+
+1. 修改配置
+2. 重启 VCP
+3. 登录 VCP 管理面板
+4. 浏览器访问以下地址,确认返回 JSON:
+
+- `/admin_api/newapi-monitor/summary`
+- `/admin_api/newapi-monitor/trend`
+- `/admin_api/newapi-monitor/models`
+
+如果这三个接口都能正常返回数据,前端接入基本不会有问题。
+
+---
+
+## 12. 推荐刷新策略
+
+建议:
+
+- 页面初始化时加载全部数据
+- `summary` 每 30 秒自动刷新一次
+- `trend` 和 `models` 在筛选条件变化时刷新
+- 同时保留一个手动刷新按钮
+
+示例:
+
+```js
+let summaryTimer = null;
+
+function startSummaryAutoRefresh(getFilters) {
+ stopSummaryAutoRefresh();
+
+ summaryTimer = setInterval(async () => {
+ try {
+ const filters = getFilters();
+ const summaryData = await fetchSummary(filters);
+ renderSummaryCards(summaryData);
+ } catch (error) {
+ console.error('summary 自动刷新失败:', error);
+ }
+ }, 30000);
+}
+
+function stopSummaryAutoRefresh() {
+ if (summaryTimer) {
+ clearInterval(summaryTimer);
+ summaryTimer = null;
+ }
+}
+```
+
+---
+
+## 13. 前端错误处理建议
+
+后端错误通常返回:
+
+```json
+{
+ "success": false,
+ "error": "错误信息"
+}
+```
+
+前端建议:
+
+- 显示 loading 状态
+- 请求失败时显示错误提示
+- 提供重试按钮
+- 尽可能保留上一次成功的数据
+
+---
+
+## 14. 使用流程总结
+
+整个功能的典型使用流程如下:
+
+1. 在 VCP 中配置 NewAPI 监控所需环境变量
+2. 重启 VCP
+3. 用浏览器验证三个管理接口是否可返回 JSON
+4. 前端通过 `fetch` 请求 `/admin_api/newapi-monitor/*`
+5. 将 `summary` 渲染为统计卡片
+6. 将 `trend` 渲染为趋势图
+7. 将 `models` 渲染为模型排行表或模型下拉框
+
+---
+
+## 15. 维护注意事项
+
+- `NEWAPI_MONITOR_BASE_URL` 为必填项
+- 推荐优先使用 `NEWAPI_MONITOR_SESSION_COOKIE`
+- 用户名密码方式仅作备选
+- `NEWAPI_MONITOR_API_USER_ID` 只在某些特殊实例中需要
+- 前端不要根据 `source` 的单一取值写死逻辑
+- 若未来前端新增明确需求,再扩展接口,不要预埋无用能力
+
+---
+
+## 16. 一句话总结
+
+这套功能的核心原则只有一句话:
+
+**前端只请求 VCP 的 `/admin_api/newapi-monitor/*`,不要直接碰 NewAPI 管理员鉴权。**
\ No newline at end of file
diff --git a/config.env.example b/config.env.example
index 570b9a0e..a05afa3e 100644
--- a/config.env.example
+++ b/config.env.example
@@ -95,6 +95,24 @@ AdminPassword=YOUR_COMPLEX_PASSWORD_SUCH_AS_sd1iLm1xqSLfiI
# 如果你将VCP部署在服务器上,需要将其中的 "localhost" 替换为你的服务器公网IP或域名。
CALLBACK_BASE_URL="http://localhost:6005/plugin-callback"
+# -------------------------------------------------------------------
+# [NewAPI 用量监控]
+# -------------------------------------------------------------------
+# 该配置用于 VCP 管理面板中的 NewAPI 请求/Token 用量监控接口。
+# 只实现当前前端需要的 summary / trend / models 三个接口。
+#
+# 必填:NewAPI 后台地址
+NEWAPI_MONITOR_BASE_URL=http://127.0.0.1:3000
+#
+# 可选:请求超时(毫秒)
+NEWAPI_MONITOR_TIMEOUT_MS=15000
+#
+# 必填:NewAPI 系统访问令牌(在 NewAPI 个人设置 > 安全设置 > 系统访问令牌 中生成)
+NEWAPI_MONITOR_ACCESS_TOKEN=
+#
+# 必填:NewAPI 管理员用户 ID(与生成令牌的用户对应)
+NEWAPI_MONITOR_API_USER_ID=
+
# -------------------------------------------------------------------
# [模型路由]
# -------------------------------------------------------------------
diff --git a/routes/admin/newapiMonitor.js b/routes/admin/newapiMonitor.js
new file mode 100644
index 00000000..b9cc6444
--- /dev/null
+++ b/routes/admin/newapiMonitor.js
@@ -0,0 +1,467 @@
+const express = require('express');
+const axios = require('axios');
+
+const DEFAULT_LOOKBACK_SECONDS = 24 * 60 * 60;
+const DEFAULT_TIMEOUT_MS = 15000;
+const MAX_LOG_PAGES = 200;
+const CONSUME_LOG_TYPE = 2;
+
+function safeNumber(value, fallback = 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function normalizeUnixTimestamp(value, fallback = 0) {
+ const parsed = safeNumber(value, fallback);
+ if (parsed > 100000000000) {
+ return Math.floor(parsed / 1000);
+ }
+ return Math.floor(parsed);
+}
+
+function normalizeBaseUrl(baseUrl) {
+ if (typeof baseUrl !== 'string') {
+ return '';
+ }
+ return baseUrl.trim().replace(/\/+$/, '');
+}
+
+function buildError(message, status = 500) {
+ const error = new Error(message);
+ error.status = status;
+ return error;
+}
+
+function getModelNameFromQuery(query) {
+ const value = query.model_name ?? query.model ?? '';
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function getTimeRangeFromQuery(query) {
+ const now = Math.floor(Date.now() / 1000);
+ const startValue = query.start_timestamp;
+ const endValue = query.end_timestamp;
+
+ let endTimestamp = endValue ? normalizeUnixTimestamp(endValue, now) : now;
+ let startTimestamp = startValue
+ ? normalizeUnixTimestamp(startValue, endTimestamp - DEFAULT_LOOKBACK_SECONDS)
+ : endTimestamp - DEFAULT_LOOKBACK_SECONDS;
+
+ if (!(endTimestamp > 0)) {
+ endTimestamp = now;
+ }
+ if (startTimestamp < 0) {
+ startTimestamp = 0;
+ }
+ if (startTimestamp > endTimestamp) {
+ throw buildError('start_timestamp 不能大于 end_timestamp。', 400);
+ }
+
+ return { startTimestamp, endTimestamp };
+}
+
+function normalizeQuotaItem(item = {}) {
+ return {
+ model_name: typeof item.model_name === 'string' ? item.model_name : '',
+ created_at: normalizeUnixTimestamp(item.created_at, 0),
+ requests: safeNumber(item.count, 0),
+ token_used: safeNumber(item.token_used, 0),
+ quota: safeNumber(item.quota, 0)
+ };
+}
+
+function normalizeLogItem(item = {}) {
+ return {
+ created_at: normalizeUnixTimestamp(item.created_at, 0),
+ model_name: typeof item.model_name === 'string' ? item.model_name : '',
+ prompt_tokens: safeNumber(item.prompt_tokens, 0),
+ completion_tokens: safeNumber(item.completion_tokens, 0),
+ quota: safeNumber(item.quota, 0)
+ };
+}
+
+function toHourTimestamp(unixSeconds) {
+ const value = normalizeUnixTimestamp(unixSeconds, 0);
+ return value - (value % 3600);
+}
+
+function sortByCreatedAtAsc(a, b) {
+ return a.created_at - b.created_at;
+}
+
+function sortModelItems(items) {
+ return items.sort((a, b) => {
+ if (b.requests !== a.requests) {
+ return b.requests - a.requests;
+ }
+ if (b.token_used !== a.token_used) {
+ return b.token_used - a.token_used;
+ }
+ if (b.quota !== a.quota) {
+ return b.quota - a.quota;
+ }
+ return a.model_name.localeCompare(b.model_name);
+ });
+}
+
+class NewApiMonitorClient {
+ constructor({ baseUrl, accessToken, timeoutMs, debugMode, apiUserId }) {
+ this.baseUrl = normalizeBaseUrl(baseUrl);
+ this.accessToken = typeof accessToken === 'string' ? accessToken.trim() : '';
+ this.timeoutMs = safeNumber(timeoutMs, DEFAULT_TIMEOUT_MS);
+ this.debugMode = Boolean(debugMode);
+ this.apiUserId = typeof apiUserId === 'string' ? apiUserId.trim() : '';
+ }
+
+ get isConfigured() {
+ return Boolean(this.baseUrl) && Boolean(this.accessToken) && Boolean(this.apiUserId);
+ }
+
+ debugLog(...args) {
+ if (this.debugMode) {
+ console.log('[NewApiMonitor]', ...args);
+ }
+ }
+
+ buildAuthHeaders() {
+ return {
+ 'Authorization': this.accessToken,
+ 'New-Api-User': this.apiUserId
+ };
+ }
+
+ async request(path, { method = 'GET', params = {} } = {}) {
+ if (!this.isConfigured) {
+ throw buildError('NewAPI 监控未配置。请设置 NEWAPI_MONITOR_BASE_URL、NEWAPI_MONITOR_ACCESS_TOKEN 和 NEWAPI_MONITOR_API_USER_ID。', 503);
+ }
+
+ this.debugLog('Request:', method, path);
+
+ const response = await axios({
+ url: `${this.baseUrl}${path}`,
+ method,
+ params,
+ timeout: this.timeoutMs,
+ headers: this.buildAuthHeaders(),
+ validateStatus: () => true
+ });
+
+ const responseBody = response.data || {};
+ const responseMessage = typeof responseBody.message === 'string' ? responseBody.message : '';
+
+ if (response.status >= 400) {
+ throw buildError(`请求 NewAPI 失败(${response.status}):${responseMessage || response.statusText || path}`, 502);
+ }
+ if (responseBody.success === false) {
+ throw buildError(`NewAPI 返回失败:${responseMessage || path}`, 502);
+ }
+
+ return responseBody;
+ }
+
+ async getQuotaData(startTimestamp, endTimestamp) {
+ return this.request('/api/data/', {
+ params: {
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp
+ }
+ });
+ }
+
+ async getLogStat(startTimestamp, endTimestamp, modelName) {
+ const params = {
+ type: CONSUME_LOG_TYPE,
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp
+ };
+ if (modelName) {
+ params.model_name = modelName;
+ }
+
+ return this.request('/api/log/stat', { params });
+ }
+
+ async getConsumeLogs(startTimestamp, endTimestamp, modelName, page) {
+ const params = {
+ type: CONSUME_LOG_TYPE,
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp,
+ p: page,
+ page_size: 100
+ };
+ if (modelName) {
+ params.model_name = modelName;
+ }
+
+ return this.request('/api/log/', { params });
+ }
+}
+
+function createMonitorClient(debugMode) {
+ return new NewApiMonitorClient({
+ baseUrl: process.env.NEWAPI_MONITOR_BASE_URL,
+ accessToken: process.env.NEWAPI_MONITOR_ACCESS_TOKEN,
+ timeoutMs: process.env.NEWAPI_MONITOR_TIMEOUT_MS,
+ debugMode,
+ apiUserId: process.env.NEWAPI_MONITOR_API_USER_ID
+ });
+}
+
+async function fetchAllConsumeLogs(client, { startTimestamp, endTimestamp, modelName }) {
+ const logItems = [];
+
+ for (let page = 1; ; page += 1) {
+ if (page > MAX_LOG_PAGES) {
+ break;
+ }
+
+ const responseBody = await client.getConsumeLogs(startTimestamp, endTimestamp, modelName, page);
+ const pageInfo = responseBody && responseBody.data ? responseBody.data : {};
+ const currentItems = Array.isArray(pageInfo.items) ? pageInfo.items.map(normalizeLogItem) : [];
+ const total = safeNumber(pageInfo.total, 0);
+
+ logItems.push(...currentItems);
+
+ if (currentItems.length === 0) {
+ break;
+ }
+ if (!(currentItems.length >= 100)) {
+ break;
+ }
+ if (total > 0 && logItems.length >= total) {
+ break;
+ }
+ }
+
+ return logItems;
+}
+
+async function fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName }) {
+ const quotaResponseBody = await client.getQuotaData(startTimestamp, endTimestamp);
+ const quotaItems = Array.isArray(quotaResponseBody && quotaResponseBody.data)
+ ? quotaResponseBody.data.map(normalizeQuotaItem)
+ : [];
+
+ if (quotaItems.length > 0) {
+ return {
+ source: 'quota_data',
+ quotaItems,
+ logItems: []
+ };
+ }
+
+ const logItems = await fetchAllConsumeLogs(client, { startTimestamp, endTimestamp, modelName });
+ return {
+ source: 'consume_logs',
+ quotaItems: [],
+ logItems
+ };
+}
+
+function buildTrendItemsFromQuotaData(quotaItems, modelName) {
+ const trendMap = new Map();
+
+ for (const quotaItem of quotaItems) {
+ if (modelName && quotaItem.model_name !== modelName) {
+ continue;
+ }
+
+ const key = quotaItem.created_at;
+ if (!trendMap.has(key)) {
+ trendMap.set(key, {
+ created_at: key,
+ requests: 0,
+ token_used: 0,
+ quota: 0
+ });
+ }
+
+ const bucket = trendMap.get(key);
+ bucket.requests += quotaItem.requests;
+ bucket.token_used += quotaItem.token_used;
+ bucket.quota += quotaItem.quota;
+ }
+
+ return Array.from(trendMap.values()).sort(sortByCreatedAtAsc);
+}
+
+function buildTrendItemsFromLogs(logItems) {
+ const trendMap = new Map();
+
+ for (const logItem of logItems) {
+ const key = toHourTimestamp(logItem.created_at);
+ if (!trendMap.has(key)) {
+ trendMap.set(key, {
+ created_at: key,
+ requests: 0,
+ token_used: 0,
+ quota: 0
+ });
+ }
+
+ const bucket = trendMap.get(key);
+ bucket.requests += 1;
+ bucket.token_used += logItem.prompt_tokens + logItem.completion_tokens;
+ bucket.quota += logItem.quota;
+ }
+
+ return Array.from(trendMap.values()).sort(sortByCreatedAtAsc);
+}
+
+function buildModelItemsFromQuotaData(quotaItems) {
+ const modelMap = new Map();
+
+ for (const quotaItem of quotaItems) {
+ const modelName = quotaItem.model_name || '(unknown)';
+ if (!modelMap.has(modelName)) {
+ modelMap.set(modelName, {
+ model_name: modelName,
+ requests: 0,
+ token_used: 0,
+ quota: 0
+ });
+ }
+
+ const bucket = modelMap.get(modelName);
+ bucket.requests += quotaItem.requests;
+ bucket.token_used += quotaItem.token_used;
+ bucket.quota += quotaItem.quota;
+ }
+
+ return sortModelItems(Array.from(modelMap.values()));
+}
+
+function buildModelItemsFromLogs(logItems) {
+ const modelMap = new Map();
+
+ for (const logItem of logItems) {
+ const modelName = logItem.model_name || '(unknown)';
+ if (!modelMap.has(modelName)) {
+ modelMap.set(modelName, {
+ model_name: modelName,
+ requests: 0,
+ token_used: 0,
+ quota: 0
+ });
+ }
+
+ const bucket = modelMap.get(modelName);
+ bucket.requests += 1;
+ bucket.token_used += logItem.prompt_tokens + logItem.completion_tokens;
+ bucket.quota += logItem.quota;
+ }
+
+ return sortModelItems(Array.from(modelMap.values()));
+}
+
+function buildSummaryPayload(trendItems, realtimeStatBody) {
+ const totals = trendItems.reduce((accumulator, item) => {
+ accumulator.total_requests += item.requests;
+ accumulator.total_tokens += item.token_used;
+ accumulator.total_quota += item.quota;
+ return accumulator;
+ }, {
+ total_requests: 0,
+ total_tokens: 0,
+ total_quota: 0
+ });
+
+ const realtimeData = realtimeStatBody && realtimeStatBody.data ? realtimeStatBody.data : {};
+ return {
+ ...totals,
+ current_rpm: safeNumber(realtimeData.rpm, 0),
+ current_tpm: safeNumber(realtimeData.tpm, 0)
+ };
+}
+
+function handleRouteError(routeName, error, res) {
+ const status = safeNumber(error && error.status, 500);
+ const message = error && error.message ? error.message : 'Unknown error';
+ console.error(`[NewApiMonitor] ${routeName} failed:`, error);
+ res.status(status).json({
+ success: false,
+ error: message
+ });
+}
+
+module.exports = function newApiMonitorRoutes(options) {
+ const router = express.Router();
+ const debugMode = Boolean(options && options.DEBUG_MODE);
+
+ router.get('/newapi-monitor/summary', async (req, res) => {
+ try {
+ const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+ const modelName = getModelNameFromQuery(req.query);
+ const client = createMonitorClient(debugMode);
+ const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName });
+ const trendItems = usageDataset.source === 'quota_data'
+ ? buildTrendItemsFromQuotaData(usageDataset.quotaItems, modelName)
+ : buildTrendItemsFromLogs(usageDataset.logItems);
+ const realtimeStatBody = await client.getLogStat(startTimestamp, endTimestamp, modelName);
+ const summary = buildSummaryPayload(trendItems, realtimeStatBody);
+
+ res.json({
+ success: true,
+ data: {
+ source: usageDataset.source,
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp,
+ model_name: modelName || null,
+ ...summary
+ }
+ });
+ } catch (error) {
+ handleRouteError('summary', error, res);
+ }
+ });
+
+ router.get('/newapi-monitor/trend', async (req, res) => {
+ try {
+ const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+ const modelName = getModelNameFromQuery(req.query);
+ const client = createMonitorClient(debugMode);
+ const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName });
+ const items = usageDataset.source === 'quota_data'
+ ? buildTrendItemsFromQuotaData(usageDataset.quotaItems, modelName)
+ : buildTrendItemsFromLogs(usageDataset.logItems);
+
+ res.json({
+ success: true,
+ data: {
+ source: usageDataset.source,
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp,
+ model_name: modelName || null,
+ items
+ }
+ });
+ } catch (error) {
+ handleRouteError('trend', error, res);
+ }
+ });
+
+ router.get('/newapi-monitor/models', async (req, res) => {
+ try {
+ const { startTimestamp, endTimestamp } = getTimeRangeFromQuery(req.query);
+ const client = createMonitorClient(debugMode);
+ const usageDataset = await fetchUsageDataset(client, { startTimestamp, endTimestamp, modelName: '' });
+ const items = usageDataset.source === 'quota_data'
+ ? buildModelItemsFromQuotaData(usageDataset.quotaItems)
+ : buildModelItemsFromLogs(usageDataset.logItems);
+
+ res.json({
+ success: true,
+ data: {
+ source: usageDataset.source,
+ start_timestamp: startTimestamp,
+ end_timestamp: endTimestamp,
+ items
+ }
+ });
+ } catch (error) {
+ handleRouteError('models', error, res);
+ }
+ });
+
+ return router;
+};
\ No newline at end of file
diff --git a/routes/adminPanelRoutes.js b/routes/adminPanelRoutes.js
index 758b4559..5822042d 100644
--- a/routes/adminPanelRoutes.js
+++ b/routes/adminPanelRoutes.js
@@ -65,6 +65,7 @@ module.exports = function (DEBUG_MODE, dailyNoteRootPath, pluginManager, getCurr
mount('/', 'toolListEditor'); // Handles /tool-list/*
mount('/', 'dream'); // Handles /dream-logs/*, /dream-operation/*
mount('/', 'dailyNotes'); // Wrapper for existing dailyNotesRoutes (Handles /dailynotes/*)
+ mount('/', 'newapiMonitor'); // Handles /newapi-monitor/*
return adminApiRouter;
};
\ No newline at end of file
diff --git a/server.js b/server.js
index dac11e7a..cc3d5521 100644
--- a/server.js
+++ b/server.js
@@ -395,6 +395,17 @@ const adminAuth = (req, res, next) => {
// 验证登录的端点也需要特殊处理(允许无凭据时返回401而不是重定向)
const isVerifyEndpoint = req.path === '/admin_api/verify-login';
+ // ========== 新增:只读仪表板接口白名单(不计入登录失败次数)==========
+ const readOnlyDashboardPaths = [
+ '/admin_api/system-monitor',
+ '/admin_api/newapi-monitor',
+ '/admin_api/server-log',
+ '/admin_api/user-auth-code',
+ '/admin_api/weather'
+ ];
+ const isReadOnlyPath = readOnlyDashboardPaths.some(path => req.path.startsWith(path));
+ // ========== 新增结束 ==========
+
if (publicPaths.includes(req.path)) {
return next(); // 直接放行登录页面相关资源
}
@@ -420,9 +431,9 @@ const adminAuth = (req, res, next) => {
return; // 停止进一步处理
}
- // 2. 检查IP是否被临时封禁
+ // 2. 检查IP是否被临时封禁(仅对非只读接口生效)
const blockInfo = tempBlocks.get(clientIp);
- if (blockInfo && Date.now() < blockInfo.expires) {
+ if (blockInfo && Date.now() < blockInfo.expires && !isReadOnlyPath) {
console.warn(`[AdminAuth] Blocked login attempt from IP: ${clientIp}. Block expires at ${new Date(blockInfo.expires).toLocaleString()}.`);
const timeLeft = Math.ceil((blockInfo.expires - Date.now()) / 1000 / 60);
res.setHeader('Retry-After', Math.ceil((blockInfo.expires - Date.now()) / 1000)); // In seconds
@@ -463,8 +474,8 @@ const adminAuth = (req, res, next) => {
// 4. 验证凭据
if (!credentials || credentials.name !== ADMIN_USERNAME || credentials.pass !== ADMIN_PASSWORD) {
- // 认证失败,处理登录尝试计数
- if (clientIp) {
+ // 认证失败,处理登录尝试计数(仅对非只读接口计数)
+ if (clientIp && !isReadOnlyPath) {
const now = Date.now();
let attemptInfo = loginAttempts.get(clientIp) || { count: 0, firstAttempt: now };
@@ -1124,13 +1135,6 @@ app.post('/plugin-callback/:pluginName/:taskId', async (req, res) => {
return res.status(404).json({ status: "error", message: "Plugin not found, but callback noted." });
}
- // 🚀 核心导出点:通过 pluginManager 广播回调数据
- pluginManager.emit('plugin_async_callback', {
- pluginName,
- taskId,
- data: callbackData
- });
-
// 2. WebSocket push (existing logic)
if (pluginManifest.webSocketPush && pluginManifest.webSocketPush.enabled) {
const targetClientType = pluginManifest.webSocketPush.targetClientType || null;