update UI AND README

This commit is contained in:
Von committed 2026-03-13 00:35:28 +08:00
1 parent 3a0cd8a867
commit a7e3c946c0
26 files changed
+1275 -1129

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.db binary
*.sqlite binary
*.sqlite3 binary
-1
View File
@@ -5,6 +5,5 @@ data
logs
.opencode
.history
config.yaml
.record
+2 -1
View File
@@ -1,9 +1,10 @@
# 项目记忆(LLM_Proxy)
适用范围:`d:\001Code\008llm\003LLM_Proxy`
适用范围:`LLM_Proxy` 仓库根目录
## 基础约束
- 所有文本文件使用 `UTF-8`,无 BOM。
- 所有文本文件统一使用 `LF` (`\n`),禁止 `CRLF` (`\r\n`)。
- 运行日志统一使用英文。
- 代码注释和 docstring 目标规范为中文。
- Python 字符串风格默认使用双引号 `"..."`。
+36 -6
View File
@@ -1,9 +1,25 @@
# LLM_Proxy
一个基于 Flask + gevent 的 OpenAI 兼容代理服务,重点不只是“填 API Key 转发”,而是通过 Hook 机制把不同上游协议、鉴权方式和请求格式收敛成统一的 OpenAI 风格接口。
## 界面截图
### 统计概览
![统计概览](docs/images/dashboard.png)
### 用户管理
![用户管理](docs/images/users.png)
### Provider 管理
![Provider 管理](docs/images/providers.png)
一个基于 Flask + gevent 的 OpenAI 兼容代理服务,重点不只是“填 API Key 转发”,而是通过 Hook 机制把不同上游协议、鉴权方式和请求格式收敛成统一的 OpenAI 风格接口。对接入方来说,它也可以被理解为一个可控的 ONE API 接入层。
## 项目亮点
- ONE API 接入定位
- 对外统一提供 OpenAI 兼容入口,便于内部系统、脚本或第三方工具按 ONE API 方式接入
- OpenAI 兼容入口
- 提供 `POST /v1/chat/completions` 和 `GET /v1/models`
- 多 Provider / 多模型路由
@@ -15,6 +31,7 @@
- 也适合处理经过授权的会话型上游集成,例如额外 Cookie、session token、自定义 header、特定 body 字段
- 管理后台
- 支持登录、用户管理、Provider 管理、模型探测、白名单控制
- 可启用白名单用户管理机制,仅允许白名单内用户使用聊天能力
- 请求统计
- 自动记录调用明细和每日聚合 Token 统计
@@ -27,9 +44,11 @@
- 不同厂商响应结构不同,需要统一成 OpenAI 风格
- 某些模型接入只在特定客户端或内部工具里可用,需要做受控的协议适配
LLM_Proxy 的核心价值就在这里:
LLM_Proxy 的核心价值就在这里:
把“代理转发”升级成“可编排、可适配、可管理的统一入口”。
如果从接入视角描述,这个项目不是单纯的转发层,而是一个带后台管理、路由能力和协议适配能力的 ONE API 接入层。
## 合规说明
Hook 适配能力的设计目标是支持合法授权前提下的协议兼容和私有集成。
@@ -68,6 +87,8 @@ Hook 适配能力的设计目标是支持合法授权前提下的协议兼容和
- 后台登录
- 用户白名单
- 支持全局白名单开关
- 支持按用户/IP 维度控制是否允许访问
- Provider 配置增删改查
- Provider 模型列表探测
- 请求日志与聚合统计
@@ -76,7 +97,7 @@ Hook 适配能力的设计目标是支持合法授权前提下的协议兼容和
完整的 Mermaid 版 4+1 架构视图见:
- [docs/architecture-4plus1.md](/d:/001Code/008llm/003LLM_Proxy/docs/architecture-4plus1.md)
- [docs/architecture-4plus1.md](docs/architecture-4plus1.md)
其中包含:
@@ -100,13 +121,13 @@ pip install flask gevent requests pyyaml urllib3
- `server.host`
- `server.port`
- `chat.whitelist_enabled`
- `providers[].name`
- `providers[].api`
- `providers[].model_list`
可选字段:
- `chat.whitelist_enabled`
- `providers[].api_key`
- `providers[].proxy`
- `providers[].timeout_seconds`
@@ -119,9 +140,16 @@ pip install flask gevent requests pyyaml urllib3
- `logging.path`
- `logging.level`
其中:
- `chat.whitelist_enabled`
- 为 `true` 时启用白名单访问控制,仅允许已录入且 `whitelist_access_enabled` 为启用状态的用户 IP 调用 `/v1/chat/completions`
- 为 `false` 时关闭白名单限制,代理按普通开放模式工作
参考样例见:
- [config.sample.yaml](/d:/001Code/008llm/003LLM_Proxy/config.sample.yaml)
- [config.sample.yaml](config.sample.yaml)
### 3. 启动服务
@@ -221,7 +249,7 @@ class Hook(BaseHook):
现成示例见:
- [hooks/example_hook.py](/d:/001Code/008llm/003LLM_Proxy/hooks/example_hook.py)
- [hooks/example_hook.py](hooks/example_hook.py)
## Hook 适配的典型场景
@@ -266,6 +294,7 @@ class Hook(BaseHook):
- 登录认证
- 用户管理
- 白名单开关
- 白名单用户启停管理
- Provider 管理
- 模型探测
- 请求日志
@@ -300,6 +329,7 @@ class Hook(BaseHook):
也就是说:
- 对接入方来说,它可以作为统一的 ONE API 接入层使用
- 标准 Provider 可以零适配接入
- 非标准 Provider 可以最小成本做 Hook 兼容
- 业务方看到的仍然是统一的 OpenAI 接口
+24
View File
@@ -0,0 +1,24 @@
server:
port: 8080
host: 0.0.0.0
chat:
whitelist_enabled: true
admin:
username: admin
password: admin
providers:
- name: volcengine
api: https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions
api_key:
model_list:
- kimi-k2.5
# logging: # 可选
# path:
# level:
# database: # 可选
# path:
+5 -5
View File
@@ -24,11 +24,11 @@
关键代码位置:
- 启动与装配:[main.py](/d:/001Code/008llm/003LLM_Proxy/main.py)
- 组合根:[application.py](/d:/001Code/008llm/003LLM_Proxy/src/application/application.py)
- 配置与 Provider 运行时:[config_manager.py](/d:/001Code/008llm/003LLM_Proxy/src/config/config_manager.py) [provider_manager.py](/d:/001Code/008llm/003LLM_Proxy/src/config/provider_manager.py)
- Hook 协议:[contracts.py](/d:/001Code/008llm/003LLM_Proxy/src/hooks/contracts.py)
- 代理主链路:[proxy_controller.py](/d:/001Code/008llm/003LLM_Proxy/src/presentation/proxy_controller.py) [proxy_service.py](/d:/001Code/008llm/003LLM_Proxy/src/services/proxy_service.py)
- 启动与装配:[main.py](../main.py)
- 组合根:[application.py](../src/application/application.py)
- 配置与 Provider 运行时:[config_manager.py](../src/config/config_manager.py) [provider_manager.py](../src/config/provider_manager.py)
- Hook 协议:[contracts.py](../src/hooks/contracts.py)
- 代理主链路:[proxy_controller.py](../src/presentation/proxy_controller.py) [proxy_service.py](../src/services/proxy_service.py)
## 1. Logical View
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+1 -1
View File
@@ -6,4 +6,4 @@ from .llm_provider import LLMProvider
from .response_adapter import build_proxy_response
from .stream_probe import probe_stream_response
__all__ = ["LLMProvider", "build_proxy_response", "probe_stream_response"]
__all__ = ["LLMProvider", "build_proxy_response", "probe_stream_response"]
+1 -1
View File
@@ -184,4 +184,4 @@ def build_proxy_response(
status=response.status_code,
headers=filter_headers_func(response.headers),
mimetype=response.headers.get("Content-Type", "text/event-stream"),
)
)
+1 -1
View File
@@ -57,4 +57,4 @@ def probe_stream_response(upstream_response: requests.Response) -> Tuple[Any, bo
return PrefetchedStreamResponse(upstream_response, first_chunk), True
remaining = b"".join(chunk_iter)
return BufferedUpstreamResponse(upstream_response, first_chunk + remaining), False
return BufferedUpstreamResponse(upstream_response, first_chunk + remaining), False
+33 -12
View File
@@ -1,5 +1,6 @@
:root {
--app-container-max-width: min(96vw, 1860px);
--page-card-inset: 34px;
--bg-page: #f4f8fc;
--surface: #ffffff;
--surface-soft: #f8fbff;
@@ -89,13 +90,11 @@ body.app-page {
}
.app-page .header {
padding: 24px 30px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
}
:root[data-theme="dark"] .app-page .header {
background: linear-gradient(180deg, #151b23 0%, #0f141b 100%);
padding: 20px var(--page-card-inset) 0;
border-bottom: none;
background: transparent;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.app-page .header-content {
@@ -103,6 +102,24 @@ body.app-page {
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 22px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background:
linear-gradient(180deg, #fcfeff 0%, #f5f9ff 100%);
box-shadow: var(--shadow-card);
}
:root[data-theme="dark"] .app-page .header-content {
border-color: #30363d;
background:
linear-gradient(180deg, #151b23 0%, #10161f 100%);
}
.app-page .container > :last-child {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
background-clip: padding-box;
}
.app-page .header-title h1 {
@@ -124,14 +141,14 @@ body.app-page {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
gap: 10px;
}
.app-page .header-user {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
@@ -148,8 +165,8 @@ body.app-page {
.app-page .header-nav {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
gap: 4px;
padding: 4px 5px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
@@ -164,7 +181,7 @@ body.app-page {
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid transparent;
color: var(--text-secondary);
@@ -719,6 +736,10 @@ body.app-page {
border-radius: 12px;
}
.app-page .header {
padding: 14px 16px 0;
}
.app-page .right-controls {
justify-content: flex-end;
}
+50 -13
View File
@@ -12,16 +12,13 @@
.dashboard-page .filters-section,
.dashboard-page .stats-section,
.dashboard-page .table-section {
padding: 22px 26px;
padding: 22px var(--page-card-inset);
}
.dashboard-page .filters-section {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
:root[data-theme="dark"] .dashboard-page .filters-section {
background: #0f141b;
padding: 22px var(--page-card-inset) 0;
border-bottom: none;
background: transparent;
}
.dashboard-page .filters {
@@ -29,6 +26,16 @@
gap: 14px;
flex-wrap: wrap;
align-items: flex-end;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-card);
}
:root[data-theme="dark"] .dashboard-page .filters {
border-color: #30363d;
background: #161b22;
}
.dashboard-page .filters .btn {
@@ -140,16 +147,21 @@
}
.dashboard-page .stats-section {
padding-top: 16px;
border-bottom: 1px solid var(--line);
background: var(--surface-soft);
padding: 18px var(--page-card-inset) 0;
border-bottom: none;
background: transparent;
}
.dashboard-page .table-section {
padding: 18px var(--page-card-inset) 0;
background: transparent;
}
.dashboard-page .stats-container,
.dashboard-page .table-container {
border: 1px solid var(--line);
border-radius: var(--radius-md);
overflow-x: hidden;
overflow: hidden;
background: #fff;
box-shadow: var(--shadow-card);
}
@@ -317,7 +329,27 @@
}
.dashboard-page .table-footer {
padding: 14px 20px;
padding: 18px var(--page-card-inset) 22px;
border-top: none;
background: transparent;
}
:root[data-theme="dark"] .dashboard-page .table-footer {
background: transparent;
}
.dashboard-page .table-footer .right-controls {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-card);
}
:root[data-theme="dark"] .dashboard-page .table-footer .right-controls {
border-color: #30363d;
background: #161b22;
}
.dashboard-page .table-container td {
@@ -329,7 +361,8 @@
.dashboard-page .header,
.dashboard-page .filters-section,
.dashboard-page .stats-section,
.dashboard-page .table-section {
.dashboard-page .table-section,
.dashboard-page .table-footer {
padding-left: 16px;
padding-right: 16px;
}
@@ -361,4 +394,8 @@
.dashboard-page .table-footer {
padding: 12px;
}
.dashboard-page .table-footer .right-controls {
padding: 12px;
}
}
+19 -11
View File
@@ -1,15 +1,12 @@
.providers-page .providers-toolbar-section,
.providers-page .providers-section {
padding: 22px 26px;
padding: 22px var(--page-card-inset);
}
.providers-page .providers-toolbar-section {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
:root[data-theme="dark"] .providers-page .providers-toolbar-section {
background: #0f141b;
padding: 22px var(--page-card-inset) 0;
border-bottom: none;
background: transparent;
}
.providers-page .toolbar-card {
@@ -17,6 +14,11 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-card);
}
.providers-page .toolbar-summary {
@@ -34,7 +36,8 @@
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.72);
background: #fff;
box-shadow: var(--shadow-card);
}
.providers-page .toolbar-setting-card + .toolbar-card {
@@ -117,8 +120,8 @@
}
.providers-page .providers-section {
background: var(--surface-soft);
padding-top: 16px;
padding: 18px var(--page-card-inset) 22px;
background: transparent;
}
.providers-page .providers-table-shell {
@@ -191,7 +194,12 @@
:root[data-theme="dark"] .providers-page .toolbar-setting-card {
border-color: #313b47;
background: rgba(255, 255, 255, 0.03);
background: #161b22;
}
:root[data-theme="dark"] .providers-page .toolbar-card {
border-color: #313b47;
background: #161b22;
}
:root[data-theme="dark"] .providers-page .toolbar-setting-card[data-enabled="true"] {
+40 -13
View File
@@ -7,21 +7,18 @@
.users-page .add-user-section,
.users-page .users-section {
padding: 22px 26px;
padding: 22px var(--page-card-inset);
}
.users-page .add-user-section {
border-bottom: 1px solid var(--line);
background: var(--surface);
padding: 22px var(--page-card-inset) 0;
border-bottom: none;
background: transparent;
}
.users-page .users-section {
background: var(--surface-soft);
padding-top: 16px;
}
:root[data-theme="dark"] .users-page .add-user-section {
background: #0f141b;
padding: 18px var(--page-card-inset) 0;
background: transparent;
}
.users-page .users-section h2 {
@@ -51,6 +48,16 @@
gap: 14px;
align-items: flex-end;
margin-bottom: 0;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-card);
}
:root[data-theme="dark"] .users-page .form-row {
border-color: #30363d;
background: #161b22;
}
.users-page .form-group {
@@ -353,12 +360,30 @@
}
.users-page .table-footer {
padding: 14px 0 0;
margin-top: 14px;
padding: 18px var(--page-card-inset) 22px;
margin-top: 0;
border-top: 0;
background: transparent;
}
:root[data-theme="dark"] .users-page .table-footer {
background: transparent;
}
.users-page .table-footer .right-controls {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-card);
}
:root[data-theme="dark"] .users-page .table-footer .right-controls {
border-color: #30363d;
background: #161b22;
}
.users-page .modal-content {
border: 1px solid var(--line);
border-radius: var(--radius-md);
@@ -382,7 +407,8 @@
@media (max-width: 900px) {
.users-page .header,
.users-page .add-user-section,
.users-page .users-section {
.users-page .users-section,
.users-page .table-footer {
padding-left: 16px;
padding-right: 16px;
}
@@ -404,7 +430,8 @@
@media (max-width: 640px) {
.users-page .add-user-section,
.users-page .users-section {
.users-page .users-section,
.users-page .table-footer {
padding-left: 14px;
padding-right: 14px;
}
+44
View File
@@ -0,0 +1,44 @@
(function() {
const themeConfig = window.__LLM_PROXY_THEME_CONFIG__ || {};
const defaultTheme = themeConfig.defaultTheme === 'light' ? 'light' : 'dark';
function normalizeTheme(theme) {
return theme === 'light' ? 'light' : 'dark';
}
function getCurrentTheme() {
return normalizeTheme(document.documentElement.getAttribute('data-theme') || defaultTheme);
}
function applyTheme(theme) {
const nextTheme = normalizeTheme(theme);
document.documentElement.setAttribute('data-theme', nextTheme);
localStorage.setItem('theme', nextTheme);
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) {
return nextTheme;
}
const label = nextTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
themeToggle.setAttribute('aria-label', label);
themeToggle.setAttribute('title', label);
return nextTheme;
}
function initThemeToggle() {
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) {
return;
}
applyTheme(getCurrentTheme());
themeToggle.addEventListener('click', function() {
const currentTheme = getCurrentTheme();
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
}
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
})();
@@ -0,0 +1,9 @@
<script>
(function() {
const defaultTheme = 'dark';
window.__LLM_PROXY_THEME_CONFIG__ = { defaultTheme: defaultTheme };
const savedTheme = localStorage.getItem('theme');
const theme = savedTheme === 'light' ? 'light' : defaultTheme;
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
+2 -7
View File
@@ -3,13 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
const theme = savedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
{% include "_theme_bootstrap.html" %}
<title>{% block page_title %}LLM Proxy{% endblock %}</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/admin-base.css">
@@ -49,6 +43,7 @@
</div>
{% block after_container %}{% endblock %}
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="/static/js/theme.js"></script>
{% block page_scripts %}{% endblock %}
</body>
</html>
+444 -465
View File
@@ -1,43 +1,43 @@
{% extends "base_admin.html" %}
{% extends "base_admin.html" %}
{% block page_title %}LLM使用统计{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/index.css">{% endblock %}
{% block body_class %}dashboard-page{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/index.css">{% endblock %}
{% block body_class %}dashboard-page{% endblock %}
{% block header_title %}LLM使用统计{% endblock %}
{% block header_subtitle %}实时监控 API 请求和 Token 消耗{% endblock %}
{% block content %}
<div class="filters-section">
<div class="filters">
<div class="filter-group filter-group-date">
<label>开始日期:</label>
<input type="date" id="startDate">
</div>
<div class="filter-group filter-group-date">
<label>结束日期:</label>
<input type="date" id="endDate">
</div>
<div class="filter-group filter-group-select">
<label>用户名</label>
<select id="username">
<option value="">全部</option>
</select>
</div>
<div class="filter-group filter-group-select">
<label>请求模型:</label>
<select id="requestModel">
<option value="">全部</option>
</select>
</div>
<button class="btn btn-primary" onclick="applyFilters()">查询</button>
</div>
</div>
<div class="stats-section">
<div class="stats-container">
<table class="stats-table">
<thead>
<tr>
{% block content %}
<div class="filters-section">
<div class="filters">
<div class="filter-group filter-group-date">
<label>开始日期:</label>
<input type="date" id="startDate">
</div>
<div class="filter-group filter-group-date">
<label>结束日期:</label>
<input type="date" id="endDate">
</div>
<div class="filter-group filter-group-select">
<label>用户名</label>
<select id="username">
<option value="">全部</option>
</select>
</div>
<div class="filter-group filter-group-select">
<label>请求模型:</label>
<select id="requestModel">
<option value="">全部</option>
</select>
</div>
<button class="btn btn-primary" onclick="applyFilters()">查询</button>
</div>
</div>
<div class="stats-section">
<div class="stats-container">
<table class="stats-table">
<thead>
<tr>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('ip_address', 'string')">IP <span class="sort-indicator" id="statsSortIndicator_ip_address"></span></button></th>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('username', 'string')">用户名 <span class="sort-indicator" id="statsSortIndicator_username"></span></button></th>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('request_model', 'string')">请求模型 <span class="sort-indicator" id="statsSortIndicator_request_model"></span></button></th>
@@ -46,22 +46,22 @@
<th class="sortable-th num-col" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('completion_tokens', 'number')">输出token <span class="sort-indicator" id="statsSortIndicator_completion_tokens"></span></button></th>
<th class="sortable-th num-col" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('total_tokens', 'number')">总 Token <span class="sort-indicator" id="statsSortIndicator_total_tokens"></span></button></th>
<th class="sortable-th num-col" aria-sort="none"><button type="button" class="sort-button" onclick="toggleStatsSort('request_count', 'number')">请求数 <span class="sort-indicator" id="statsSortIndicator_request_count"></span></button></th>
</tr>
</thead>
<tbody id="statsTable">
<tr>
<td colspan="8" class="loading">加载统计数据...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="table-section">
<div class="table-container">
<table>
<thead>
<tr>
</tr>
</thead>
<tbody id="statsTable">
<tr>
<td colspan="8" class="loading">加载统计数据...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="table-section">
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('ip_address', 'string')">IP <span class="sort-indicator" id="logsSortIndicator_ip_address"></span></button></th>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('username', 'string')">用户名 <span class="sort-indicator" id="logsSortIndicator_username"></span></button></th>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('request_model', 'string')">请求模型 <span class="sort-indicator" id="logsSortIndicator_request_model"></span></button></th>
@@ -72,46 +72,46 @@
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('start_time', 'date')">开始时间 <span class="sort-indicator" id="logsSortIndicator_start_time"></span></button></th>
<th class="sortable-th" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('end_time', 'date')">结束时间 <span class="sort-indicator" id="logsSortIndicator_end_time"></span></button></th>
<th class="sortable-th num-col" aria-sort="none"><button type="button" class="sort-button" onclick="toggleLogsSort('duration', 'number')">耗时(秒) <span class="sort-indicator" id="logsSortIndicator_duration"></span></button></th>
</tr>
</thead>
<tbody id="logsTable">
<tr>
<td colspan="10" class="loading">加载请求数据...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="table-footer" id="tableFooter" style="display: none;">
<div class="right-controls">
<div class="pagination-controls">
<span id="prevBtn" class="page-nav-text disabled" onclick="prevPage()">&lt;</span>
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">&gt;</span>
</div>
<div class="quick-jump">
<label for="pageJumpInput">跳至</label>
<input id="pageJumpInput" type="number" min="1" value="1">
</div>
<div class="page-size-controls">
<label for="pageSizeSelect">每页显示:</label>
<select id="pageSizeSelect" onchange="changePageSize()">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<span class="total-count" id="totalCount">共 0 条</span>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/js/ui-message.js"></script>
<script>
let currentPage = 1;
let pageSize = 50;
</tr>
</thead>
<tbody id="logsTable">
<tr>
<td colspan="10" class="loading">加载请求数据...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="table-footer" id="tableFooter" style="display: none;">
<div class="right-controls">
<div class="pagination-controls">
<span id="prevBtn" class="page-nav-text disabled" onclick="prevPage()">&lt;</span>
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">&gt;</span>
</div>
<div class="quick-jump">
<label for="pageJumpInput">跳至</label>
<input id="pageJumpInput" type="number" min="1" value="1">
</div>
<div class="page-size-controls">
<label for="pageSizeSelect">每页显示:</label>
<select id="pageSizeSelect" onchange="changePageSize()">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<span class="total-count" id="totalCount">共 0 条</span>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script src="/static/js/ui-message.js"></script>
<script>
let currentPage = 1;
let pageSize = 50;
let currentFilters = {};
let jumpDebounceTimer = null;
let currentTotalPages = 1;
@@ -128,37 +128,16 @@
direction: 'desc',
type: 'date'
};
function closeAllCustomSelects() {
document.querySelectorAll('.custom-select.open').forEach(select => {
select.classList.remove('open');
const trigger = select.querySelector('.custom-select-trigger');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
activeCustomSelect = null;
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) {
themeToggle.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
themeToggle.setAttribute('title', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
}
}
function initThemeToggle() {
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) return;
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(currentTheme);
themeToggle.addEventListener('click', function() {
const nextTheme = (document.documentElement.getAttribute('data-theme') || 'light') === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
});
}
function closeAllCustomSelects() {
document.querySelectorAll('.custom-select.open').forEach(select => {
select.classList.remove('open');
const trigger = select.querySelector('.custom-select-trigger');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
activeCustomSelect = null;
}
function normalizeNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
@@ -173,16 +152,16 @@
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')}</span>`;
}
function formatNumberFull(value) {
return normalizeNumber(value).toLocaleString();
}
function formatNumberDisplay(value) {
const n = normalizeNumber(value);
return n.toLocaleString();
}
function formatNumberFull(value) {
return normalizeNumber(value).toLocaleString();
}
function formatNumberDisplay(value) {
const n = normalizeNumber(value);
return n.toLocaleString();
}
function renderNumber(value) {
const full = formatNumberFull(value);
const display = formatNumberDisplay(value);
@@ -305,146 +284,146 @@
applyLogsSort();
renderLogs(logsData);
}
function syncCustomSelectValue(selectId) {
const nativeSelect = document.getElementById(selectId);
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!nativeSelect || !customSelect) return;
const trigger = customSelect.querySelector('.custom-select-trigger');
const selectedOption = nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0];
trigger.textContent = selectedOption ? selectedOption.textContent : '请选择';
customSelect.querySelectorAll('.custom-select-option').forEach(option => {
option.classList.toggle('selected', option.dataset.value === nativeSelect.value);
});
}
function renderCustomSelectOptions(selectId) {
const nativeSelect = document.getElementById(selectId);
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!nativeSelect || !customSelect) return;
const menu = customSelect.querySelector('.custom-select-menu');
menu.innerHTML = '';
Array.from(nativeSelect.options).forEach(option => {
const optionButton = document.createElement('button');
optionButton.type = 'button';
optionButton.className = 'custom-select-option';
optionButton.textContent = option.textContent;
optionButton.dataset.value = option.value;
optionButton.addEventListener('click', function() {
if (nativeSelect.value !== option.value) {
nativeSelect.value = option.value;
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
syncCustomSelectValue(selectId);
closeAllCustomSelects();
});
menu.appendChild(optionButton);
});
syncCustomSelectValue(selectId);
}
function setupCustomSelect(selectId) {
const nativeSelect = document.getElementById(selectId);
if (!nativeSelect) return;
nativeSelect.classList.add('native-select-hidden');
let customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!customSelect) {
customSelect = document.createElement('div');
customSelect.className = 'custom-select';
customSelect.dataset.for = selectId;
customSelect.innerHTML = `
<button type="button" class="custom-select-trigger" aria-haspopup="listbox" aria-expanded="false"></button>
<div class="custom-select-menu" role="listbox"></div>
`;
nativeSelect.insertAdjacentElement('afterend', customSelect);
const trigger = customSelect.querySelector('.custom-select-trigger');
trigger.addEventListener('click', function(event) {
event.stopPropagation();
const isOpen = customSelect.classList.contains('open');
closeAllCustomSelects();
if (!isOpen) {
const rect = customSelect.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
customSelect.classList.toggle('drop-up', spaceBelow < 236);
customSelect.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
activeCustomSelect = customSelect;
}
});
trigger.addEventListener('keydown', function(event) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
trigger.click();
} else if (event.key === 'Escape') {
closeAllCustomSelects();
}
});
nativeSelect.addEventListener('change', function() {
syncCustomSelectValue(selectId);
});
}
renderCustomSelectOptions(selectId);
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
initThemeToggle();
setupCustomSelect('username');
setupCustomSelect('requestModel');
setupCustomSelect('pageSizeSelect');
setDefaultDateRange();
function syncCustomSelectValue(selectId) {
const nativeSelect = document.getElementById(selectId);
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!nativeSelect || !customSelect) return;
const trigger = customSelect.querySelector('.custom-select-trigger');
const selectedOption = nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0];
trigger.textContent = selectedOption ? selectedOption.textContent : '请选择';
customSelect.querySelectorAll('.custom-select-option').forEach(option => {
option.classList.toggle('selected', option.dataset.value === nativeSelect.value);
});
}
function renderCustomSelectOptions(selectId) {
const nativeSelect = document.getElementById(selectId);
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!nativeSelect || !customSelect) return;
const menu = customSelect.querySelector('.custom-select-menu');
menu.innerHTML = '';
Array.from(nativeSelect.options).forEach(option => {
const optionButton = document.createElement('button');
optionButton.type = 'button';
optionButton.className = 'custom-select-option';
optionButton.textContent = option.textContent;
optionButton.dataset.value = option.value;
optionButton.addEventListener('click', function() {
if (nativeSelect.value !== option.value) {
nativeSelect.value = option.value;
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
syncCustomSelectValue(selectId);
closeAllCustomSelects();
});
menu.appendChild(optionButton);
});
syncCustomSelectValue(selectId);
}
function setupCustomSelect(selectId) {
const nativeSelect = document.getElementById(selectId);
if (!nativeSelect) return;
nativeSelect.classList.add('native-select-hidden');
let customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
if (!customSelect) {
customSelect = document.createElement('div');
customSelect.className = 'custom-select';
customSelect.dataset.for = selectId;
customSelect.innerHTML = `
<button type="button" class="custom-select-trigger" aria-haspopup="listbox" aria-expanded="false"></button>
<div class="custom-select-menu" role="listbox"></div>
`;
nativeSelect.insertAdjacentElement('afterend', customSelect);
const trigger = customSelect.querySelector('.custom-select-trigger');
trigger.addEventListener('click', function(event) {
event.stopPropagation();
const isOpen = customSelect.classList.contains('open');
closeAllCustomSelects();
if (!isOpen) {
const rect = customSelect.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
customSelect.classList.toggle('drop-up', spaceBelow < 236);
customSelect.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
activeCustomSelect = customSelect;
}
});
trigger.addEventListener('keydown', function(event) {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
trigger.click();
} else if (event.key === 'Escape') {
closeAllCustomSelects();
}
});
nativeSelect.addEventListener('change', function() {
syncCustomSelectValue(selectId);
});
}
renderCustomSelectOptions(selectId);
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
initThemeToggle();
setupCustomSelect('username');
setupCustomSelect('requestModel');
setupCustomSelect('pageSizeSelect');
setDefaultDateRange();
loadStatistics();
loadUsernames();
loadRequestModels();
loadLogs();
updateSortIndicators('stats', statsSortState, ['ip_address', 'username', 'request_model', 'response_model', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'request_count']);
updateSortIndicators('logs', logsSortState, ['ip_address', 'username', 'request_model', 'response_model', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'start_time', 'end_time', 'duration']);
// 设置默认每页大小
document.getElementById('pageSizeSelect').value = pageSize;
syncCustomSelectValue('pageSizeSelect');
const pageJumpInput = document.getElementById('pageJumpInput');
if (pageJumpInput) {
pageJumpInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
jumpToPage(true);
}
});
pageJumpInput.addEventListener('blur', function() {
jumpToPage(true);
});
pageJumpInput.addEventListener('input', function() {
clearTimeout(jumpDebounceTimer);
jumpDebounceTimer = setTimeout(function() {
jumpToPage(false);
}, 450);
});
}
});
document.addEventListener('click', function(event) {
if (!event.target.closest('.custom-select')) {
closeAllCustomSelects();
}
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeAllCustomSelects();
}
});
// 设置默认每页大小
document.getElementById('pageSizeSelect').value = pageSize;
syncCustomSelectValue('pageSizeSelect');
const pageJumpInput = document.getElementById('pageJumpInput');
if (pageJumpInput) {
pageJumpInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
jumpToPage(true);
}
});
pageJumpInput.addEventListener('blur', function() {
jumpToPage(true);
});
pageJumpInput.addEventListener('input', function() {
clearTimeout(jumpDebounceTimer);
jumpDebounceTimer = setTimeout(function() {
jumpToPage(false);
}, 450);
});
}
});
document.addEventListener('click', function(event) {
if (!event.target.closest('.custom-select')) {
closeAllCustomSelects();
}
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeAllCustomSelects();
}
});
// 加载统计数据
async function loadStatistics() {
try {
@@ -507,56 +486,56 @@
`;
statsTable.innerHTML = html;
}
// 加载用户名列表
async function loadUsernames() {
try {
const response = await fetch('/api/usernames');
const usernames = await response.json();
const select = document.getElementById('username');
usernames.forEach(username => {
const option = document.createElement('option');
option.value = username;
option.textContent = username;
select.appendChild(option);
});
renderCustomSelectOptions('username');
} catch (error) {
console.error('加载用户名列表失败', error);
showMessage('加载用户名列表失败', 'warning');
}
}
async function loadRequestModels() {
try {
const response = await fetch('/api/request-models');
const models = await response.json();
const select = document.getElementById('requestModel');
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
select.appendChild(option);
});
renderCustomSelectOptions('requestModel');
} catch (error) {
console.error('加载请求模型列表失败', error);
showMessage('加载请求模型列表失败', 'warning');
}
}
// 加载日志数据
async function loadLogs() {
// 显示加载状态
document.getElementById('logsTable').innerHTML = '<tr><td colspan="10" class="loading">加载请求数据...</td></tr>';
try {
const params = new URLSearchParams({
page: currentPage,
page_size: pageSize,
...currentFilters
// 加载用户名列表
async function loadUsernames() {
try {
const response = await fetch('/api/usernames');
const usernames = await response.json();
const select = document.getElementById('username');
usernames.forEach(username => {
const option = document.createElement('option');
option.value = username;
option.textContent = username;
select.appendChild(option);
});
renderCustomSelectOptions('username');
} catch (error) {
console.error('加载用户名列表失败', error);
showMessage('加载用户名列表失败', 'warning');
}
}
async function loadRequestModels() {
try {
const response = await fetch('/api/request-models');
const models = await response.json();
const select = document.getElementById('requestModel');
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
select.appendChild(option);
});
renderCustomSelectOptions('requestModel');
} catch (error) {
console.error('加载请求模型列表失败', error);
showMessage('加载请求模型列表失败', 'warning');
}
}
// 加载日志数据
async function loadLogs() {
// 显示加载状态
document.getElementById('logsTable').innerHTML = '<tr><td colspan="10" class="loading">加载请求数据...</td></tr>';
try {
const params = new URLSearchParams({
page: currentPage,
page_size: pageSize,
...currentFilters
});
const response = await fetch(`/api/request-logs?${params}`);
@@ -570,9 +549,9 @@
console.error('加载日志失败:', error);
document.getElementById('logsTable').innerHTML = '<tr><td colspan="10" class="error">加载日志失败</td></tr>';
showMessage('加载日志失败,请稍后重试', 'error');
}
}
}
}
// 渲染日志表格
function renderLogs(logs) {
const tbody = document.getElementById('logsTable');
@@ -582,24 +561,24 @@
tbody.innerHTML = `
<tr>
<td colspan="10" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:40px;height:40px;max-width:40px;max-height:40px;display:block;margin:0 auto 8px;">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>暂无请求数据</p>
</td>
</tr>
`;
// 隐藏表格底部
document.getElementById('tableFooter').style.display = 'none';
return;
}
let html = '';
logs.forEach(log => {
const startTime = new Date(log.start_time);
const endTime = new Date(log.end_time);
const duration = ((endTime - startTime) / 1000).toFixed(2);
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:40px;height:40px;max-width:40px;max-height:40px;display:block;margin:0 auto 8px;">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>暂无请求数据</p>
</td>
</tr>
`;
// 隐藏表格底部
document.getElementById('tableFooter').style.display = 'none';
return;
}
let html = '';
logs.forEach(log => {
const startTime = new Date(log.start_time);
const endTime = new Date(log.end_time);
const duration = ((endTime - startTime) / 1000).toFixed(2);
html += `
<tr>
<td>${log.ip_address ? `<span class="ip-address">${log.ip_address}</span>` : '-'}</td>
@@ -609,130 +588,130 @@
<td class="num-col">${renderNumber(log.prompt_tokens || 0)}</td>
<td class="num-col">${renderNumber(log.completion_tokens || 0)}</td>
<td class="num-col">${renderNumber(log.total_tokens || 0)}</td>
<td>${formatDateTime(startTime)}</td>
<td>${formatDateTime(endTime)}</td>
<td class="num-col"><span class="numeric-value">${duration}</span></td>
</tr>
`;
});
tbody.innerHTML = html;
}
// 更新分页
function updatePagination(data) {
currentTotalPages = data.total_pages;
document.getElementById('pageInfo').textContent = `第 ${data.page} 页 / 共 ${data.total_pages} 页`;
document.getElementById('prevBtn').classList.toggle('disabled', data.page <= 1);
document.getElementById('nextBtn').classList.toggle('disabled', data.page >= data.total_pages);
document.getElementById('totalCount').textContent = `共 ${data.total} 条`;
const pageSizeSelect = document.getElementById('pageSizeSelect');
pageSizeSelect.value = pageSize;
syncCustomSelectValue('pageSizeSelect');
const pageJumpInput = document.getElementById('pageJumpInput');
if (pageJumpInput) {
pageJumpInput.max = data.total_pages;
pageJumpInput.value = data.page;
}
document.getElementById('tableFooter').style.display = 'flex';
}
function jumpToPage(force = false) {
const input = document.getElementById('pageJumpInput');
if (!input) return;
const page = parseInt(input.value, 10);
if (Number.isNaN(page)) {
if (force) input.value = currentPage;
return;
}
const totalPages = Math.max(1, parseInt(input.max || '1', 10));
const target = Math.min(totalPages, Math.max(1, page));
if (target === currentPage) {
input.value = currentPage;
return;
}
currentPage = target;
loadLogs();
}
// 格式化日期时间
function formatDateTime(date) {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// 应用过滤
function applyFilters() {
currentFilters = {};
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const username = document.getElementById('username').value;
const requestModel = document.getElementById('requestModel').value;
if (startDate) currentFilters.start_date = startDate;
if (endDate) currentFilters.end_date = endDate;
if (username) currentFilters.username = username;
if (requestModel) currentFilters.request_model = requestModel;
currentPage = 1;
loadStatistics();
loadLogs();
}
function setDefaultDateRange() {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 2);
const toDateInput = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
document.getElementById('startDate').value = toDateInput(start);
document.getElementById('endDate').value = toDateInput(end);
currentFilters = {
start_date: toDateInput(start),
end_date: toDateInput(end),
};
}
// 上一页
function prevPage() {
if (currentPage <= 1) return;
currentPage--;
loadLogs();
}
// 下一页
function nextPage() {
if (currentPage >= currentTotalPages) return;
currentPage++;
loadLogs();
}
// 更改每页大小
function changePageSize() {
const newPageSize = parseInt(document.getElementById('pageSizeSelect').value);
if (newPageSize !== pageSize) {
pageSize = newPageSize;
currentPage = 1; // 重置到第一页
loadLogs();
}
}
</script>
<td>${formatDateTime(startTime)}</td>
<td>${formatDateTime(endTime)}</td>
<td class="num-col"><span class="numeric-value">${duration}</span></td>
</tr>
`;
});
tbody.innerHTML = html;
}
// 更新分页
function updatePagination(data) {
currentTotalPages = data.total_pages;
document.getElementById('pageInfo').textContent = `第 ${data.page} 页 / 共 ${data.total_pages} 页`;
document.getElementById('prevBtn').classList.toggle('disabled', data.page <= 1);
document.getElementById('nextBtn').classList.toggle('disabled', data.page >= data.total_pages);
document.getElementById('totalCount').textContent = `共 ${data.total} 条`;
const pageSizeSelect = document.getElementById('pageSizeSelect');
pageSizeSelect.value = pageSize;
syncCustomSelectValue('pageSizeSelect');
const pageJumpInput = document.getElementById('pageJumpInput');
if (pageJumpInput) {
pageJumpInput.max = data.total_pages;
pageJumpInput.value = data.page;
}
document.getElementById('tableFooter').style.display = 'flex';
}
function jumpToPage(force = false) {
const input = document.getElementById('pageJumpInput');
if (!input) return;
const page = parseInt(input.value, 10);
if (Number.isNaN(page)) {
if (force) input.value = currentPage;
return;
}
const totalPages = Math.max(1, parseInt(input.max || '1', 10));
const target = Math.min(totalPages, Math.max(1, page));
if (target === currentPage) {
input.value = currentPage;
return;
}
currentPage = target;
loadLogs();
}
// 格式化日期时间
function formatDateTime(date) {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// 应用过滤
function applyFilters() {
currentFilters = {};
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const username = document.getElementById('username').value;
const requestModel = document.getElementById('requestModel').value;
if (startDate) currentFilters.start_date = startDate;
if (endDate) currentFilters.end_date = endDate;
if (username) currentFilters.username = username;
if (requestModel) currentFilters.request_model = requestModel;
currentPage = 1;
loadStatistics();
loadLogs();
}
function setDefaultDateRange() {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 2);
const toDateInput = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
document.getElementById('startDate').value = toDateInput(start);
document.getElementById('endDate').value = toDateInput(end);
currentFilters = {
start_date: toDateInput(start),
end_date: toDateInput(end),
};
}
// 上一页
function prevPage() {
if (currentPage <= 1) return;
currentPage--;
loadLogs();
}
// 下一页
function nextPage() {
if (currentPage >= currentTotalPages) return;
currentPage++;
loadLogs();
}
// 更改每页大小
function changePageSize() {
const newPageSize = parseInt(document.getElementById('pageSizeSelect').value);
if (newPageSize !== pageSize) {
pageSize = newPageSize;
currentPage = 1; // 重置到第一页
loadLogs();
}
}
</script>
{% endblock %}
+2 -7
View File
@@ -3,13 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
const theme = savedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
{% include "_theme_bootstrap.html" %}
<title>管理员登录 - LLM Proxy</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/admin-base.css">
@@ -36,6 +30,7 @@
</div>
<script src="/static/js/ui-message.js"></script>
<script src="/static/js/theme.js"></script>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
-20
View File
@@ -184,26 +184,6 @@
let fetchedModelSelection = new Set();
let fetchedModelExistingSelection = new Set();
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) return;
const label = theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
themeToggle.setAttribute('aria-label', label);
themeToggle.setAttribute('title', label);
}
function initThemeToggle() {
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) return;
applyTheme(document.documentElement.getAttribute('data-theme') || 'light');
themeToggle.addEventListener('click', function() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -188,4 +188,4 @@ class ProxyService:
"upgrade",
"set-cookie",
}
return {k: v for k, v in headers.items() if k.lower() not in excluded}
return {k: v for k, v in headers.items() if k.lower() not in excluded}