update UI AND README
This commit is contained in:
1 parent
3a0cd8a867
commit
a7e3c946c0
26 files changed
+1275
-1129
No files matched your search
@@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
@@ -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
|
||||
@@ -5,6 +5,5 @@ data
|
||||
logs
|
||||
.opencode
|
||||
.history
|
||||
config.yaml
|
||||
.record
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
# 项目记忆(LLM_Proxy)
|
||||
|
||||
适用范围:`d:\001Code\008llm\003LLM_Proxy`
|
||||
适用范围:`LLM_Proxy` 仓库根目录
|
||||
|
||||
## 基础约束
|
||||
- 所有文本文件使用 `UTF-8`,无 BOM。
|
||||
- 所有文本文件统一使用 `LF` (`\n`),禁止 `CRLF` (`\r\n`)。
|
||||
- 运行日志统一使用英文。
|
||||
- 代码注释和 docstring 目标规范为中文。
|
||||
- Python 字符串风格默认使用双引号 `"..."`。
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
# LLM_Proxy
|
||||
|
||||
一个基于 Flask + gevent 的 OpenAI 兼容代理服务,重点不只是“填 API Key 转发”,而是通过 Hook 机制把不同上游协议、鉴权方式和请求格式收敛成统一的 OpenAI 风格接口。
|
||||
## 界面截图
|
||||
|
||||
### 统计概览
|
||||
|
||||

|
||||
|
||||
### 用户管理
|
||||
|
||||

|
||||
|
||||
### Provider 管理
|
||||
|
||||

|
||||
|
||||
一个基于 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
@@ -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:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
Vendored
+1
-1
@@ -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"]
|
||||
Vendored
+1
-1
@@ -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"),
|
||||
)
|
||||
)
|
||||
Vendored
+1
-1
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()"><</span>
|
||||
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
|
||||
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">></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()"><</span>
|
||||
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
|
||||
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">></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, '"')
|
||||
.replace(/'/g, ''')}</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 %}
|
||||
@@ -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();
|
||||
|
||||
@@ -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, '&')
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
Reference in new issue
Block a user