Skip to content

Commit 8a3f563

Browse files
committed
重写管理面板 UI 并新增请求统计;默认端口改为 10081
改动目的 - 重写管理面板前端:告别旧的白绿配色,改为浅色/深色编辑风格 (单文件内嵌 HTML,零外部依赖,无 emoji) - 新增请求统计:总量(成功 / 失败 / 成功率、流式 / 同步)、 按模型明细、近 24 小时趋势图,随 data.json 持久化 - 默认监听端口由 18080 改为 10081 主要变更 - admin/webui.go:前端整体重写 —— 侧栏 + 移动端导航、统计页 (纯 CSS 柱状图 + 模型明细表)、深浅模式切换(localStorage + 系统偏好跟随,首帧前初始化避免闪烁)、主题化 CSS 变量; 修复 CSS 规则顺序导致移动端导航被隐藏的问题 - stats/:新增包 —— 线程安全的 Recorder,聚合总量、按模型、 按小时桶(保留 96 小时),30 秒批量写入 data.json, 持久化时使用深拷贝快照(消除指针别名与并发竞争) - bridge/bridge.go:MakeChatHandler 接入统计记录器;流式中途 失败(StreamReportedError)正确计入失败;统计用的模型标签 截断为 64 字符 - store/store.go:新增 LoadStats/SaveStats,配置中增加 stats 字段 - admin/admin.go:New() 接收记录器;新增需登录的 GET /admin/api/stats - main.go:接线记录器并启动 30 秒定时落盘协程 - Dockerfile / docker-compose.yaml / README / 配置页占位符:端口 10081 测试 - stats:快照隔离、恢复数据克隆、并发 Record/Flush/Report - store:统计持久化重载往返 - admin:统计端点(数据形状、nil 记录器、405) - bridge:未授权 / 非法 JSON 不计数、模型标签截断 影响范围 - 管理 API:新增 1 个端点(需会话鉴权);WebUI 原地替换 - data.json:新增可选 "stats" 字段,向后兼容 - 默认端口变更会影响原先指向 18080 的脚本与地址 - dist/ 下预编译二进制为旧构建,发布前需重新编译
1 parent 56e3193 commit 8a3f563

13 files changed

Lines changed: 1773 additions & 422 deletions

File tree

‎Dockerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ WORKDIR /app
1212
COPY --from=builder /build/qoder2api .
1313
COPY baseprompt.json .
1414
ENV QODER_HOST=0.0.0.0 \
15-
QODER_PORT=18080
16-
EXPOSE 18080
15+
QODER_PORT=10081
16+
EXPOSE 10081
1717
CMD ["./qoder2api"]

‎README.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- **多模态支持** —— 支持图片输入
1212
- **Tool Calls** —— 支持函数调用
1313
- **模型动态加载** —— 自动从网关获取可用模型列表
14-
- **Web 管理面板** —— 浅色白绿配色中文界面,支持密码登录、API 密钥管理、PAT 配置、模型查看
14+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 按模型 / 近 24 小时趋势)
1515
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
1616

1717
## 支持的模型
@@ -38,7 +38,7 @@
3838
# 编译
3939
go build -o qoder2api .
4040

41-
# 运行(默认监听 0.0.0.0:18080)
41+
# 运行(默认监听 0.0.0.0:10081)
4242
./qoder2api
4343
```
4444

@@ -50,7 +50,7 @@ docker-compose up -d
5050

5151
# 或手动构建
5252
docker build -t qoder2api .
53-
docker run -d -p 18080:18080 -v qoder2api-data:/app/data qoder2api
53+
docker run -d -p 10081:10081 -v qoder2api-data:/app/data qoder2api
5454
```
5555

5656
### 方式三:下载预编译二进制
@@ -64,19 +64,21 @@ docker run -d -p 18080:18080 -v qoder2api-data:/app/data qoder2api
6464
| 变量名 | 默认值 | 说明 |
6565
|-------|--------|------|
6666
| `QODER_HOST` | `0.0.0.0` | 监听地址 |
67-
| `QODER_PORT` | `18080` | 监听端口 |
67+
| `QODER_PORT` | `10081` | 监听端口 |
6868
| `QODER_DATA_PATH` | `data.json` | 数据文件路径 |
6969
| `QODER_ADMIN_PASSWORD` | `password` | 管理面板密码(覆盖 data.json 中的值) |
7070
| `QODER_SIGNATURE_SECRET` | 内置值 | 请求签名密钥 |
7171

72+
> 请求统计(`stats` 字段)随 data.json 持久化,服务每 30 秒批量写入一次;仅统计通过密钥鉴权且请求体合法的调用。
73+
7274
### 配置文件 (data.json)
7375

7476
首次运行时自动生成,也可通过 Web 管理面板修改:
7577

7678
```json
7779
{
7880
"host": "0.0.0.0",
79-
"port": 18080,
81+
"port": 10081,
8082
"pat": "pt-xxxxxxxx",
8183
"password": "password",
8284
"api_keys": [
@@ -94,7 +96,7 @@ docker run -d -p 18080:18080 -v qoder2api-data:/app/data qoder2api
9496

9597
### 1. 配置 PAT
9698

97-
启动服务后访问 `http://localhost:18080/admin`,输入默认密码 `password` 登录,在「令牌」标签页中填入你的 Qoder PAT。
99+
启动服务后访问 `http://localhost:10081/admin`,输入默认密码 `password` 登录,在「令牌」标签页中填入你的 Qoder PAT。
98100

99101
### 2. 创建 API 密钥
100102

@@ -103,7 +105,7 @@ docker run -d -p 18080:18080 -v qoder2api-data:/app/data qoder2api
103105
### 3. 调用 API
104106

105107
```bash
106-
curl http://localhost:18080/v1/chat/completions \
108+
curl http://localhost:10081/v1/chat/completions \
107109
-H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx" \
108110
-H "Content-Type: application/json" \
109111
-d '{
@@ -116,7 +118,7 @@ curl http://localhost:18080/v1/chat/completions \
116118
### 4. 获取模型列表
117119

118120
```bash
119-
curl http://localhost:18080/v1/models \
121+
curl http://localhost:10081/v1/models \
120122
-H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx"
121123
```
122124

@@ -131,6 +133,7 @@ curl http://localhost:18080/v1/models \
131133
| `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 |
132134
| `/admin/api/pat` | GET/POST | PAT 令牌管理 |
133135
| `/admin/api/models` | GET | 获取模型列表 |
136+
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势) |
134137
| `/admin/api/config` | GET/POST | 服务器配置 |
135138
| `/admin/api/password` | POST | 修改管理密码 |
136139

‎admin/admin.go‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"qoder2api/models"
16+
"qoder2api/stats"
1617
"qoder2api/store"
1718
)
1819

@@ -22,18 +23,20 @@ const sessionDuration = 24 * time.Hour
2223
type Admin struct {
2324
store *store.Store
2425
modelFetcher ModelFetcher
26+
stats *stats.Recorder
2527
sessions map[string]time.Time
2628
sessionMu sync.Mutex
2729
}
2830

2931
// ModelFetcher returns the current model catalog (dynamic or fallback).
3032
type ModelFetcher func(ctx context.Context) []string
3133

32-
// New creates an Admin instance.
33-
func New(s *store.Store, mf ModelFetcher) *Admin {
34+
// New creates an Admin instance. rec may be nil.
35+
func New(s *store.Store, mf ModelFetcher, rec *stats.Recorder) *Admin {
3436
return &Admin{
3537
store: s,
3638
modelFetcher: mf,
39+
stats: rec,
3740
sessions: make(map[string]time.Time),
3841
}
3942
}
@@ -53,6 +56,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
5356
mux.HandleFunc("/admin/api/keys", a.requireAuth(a.handleKeys))
5457
mux.HandleFunc("/admin/api/pat", a.requireAuth(a.handlePAT))
5558
mux.HandleFunc("/admin/api/models", a.requireAuth(a.handleModels))
59+
mux.HandleFunc("/admin/api/stats", a.requireAuth(a.handleStats))
5660
mux.HandleFunc("/admin/api/config", a.requireAuth(a.handleConfig))
5761
mux.HandleFunc("/admin/api/password", a.requireAuth(a.handlePassword))
5862
}
@@ -335,6 +339,20 @@ func (a *Admin) handleModels(w http.ResponseWriter, r *http.Request) {
335339
writeJSON(w, 200, map[string]interface{}{"models": modelList})
336340
}
337341

342+
// --- Stats endpoint ---
343+
344+
func (a *Admin) handleStats(w http.ResponseWriter, r *http.Request) {
345+
if r.Method != http.MethodGet {
346+
writeJSONError(w, 405, "方法不允许")
347+
return
348+
}
349+
if a.stats == nil {
350+
writeJSON(w, 200, stats.Report{})
351+
return
352+
}
353+
writeJSON(w, 200, a.stats.Report())
354+
}
355+
338356
// --- WebUI ---
339357

340358
func (a *Admin) serveIndex(w http.ResponseWriter, r *http.Request) {

‎admin/admin_test.go‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
"qoder2api/models"
12+
"qoder2api/stats"
1213
"qoder2api/store"
1314
)
1415

@@ -28,7 +29,7 @@ func newAdmin(t *testing.T) (*Admin, *store.Store) {
2829
mf := func(ctx context.Context) []string {
2930
return models.DefaultCatalog().Keys()
3031
}
31-
return New(s, mf), s
32+
return New(s, mf, nil), s
3233
}
3334

3435
func doRequest(t *testing.T, handler http.HandlerFunc, method, path string, body interface{}) *httptest.ResponseRecorder {
@@ -204,6 +205,73 @@ func TestConfigSetDefaults(t *testing.T) {
204205
}
205206
}
206207

208+
// -- Stats --
209+
210+
func TestStatsEndpoint(t *testing.T) {
211+
s := tempStore(t)
212+
mf := func(ctx context.Context) []string {
213+
return models.DefaultCatalog().Keys()
214+
}
215+
rec := stats.NewRecorder(nil)
216+
rec.Record("Qwen3.7-Max", true, true)
217+
rec.Record("Qwen3.7-Max", false, true)
218+
rec.Record("DeepSeek-V4-Pro", true, false)
219+
a := New(s, mf, rec)
220+
221+
w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil)
222+
if w.Code != 200 {
223+
t.Errorf("expected 200, got %d", w.Code)
224+
}
225+
var resp struct {
226+
Total int64 `json:"total"`
227+
Success int64 `json:"success"`
228+
Failed int64 `json:"failed"`
229+
ByModel []struct {
230+
Model string `json:"model"`
231+
Total int64 `json:"total"`
232+
} `json:"by_model"`
233+
Hourly []struct {
234+
Total int64 `json:"total"`
235+
} `json:"hourly"`
236+
}
237+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
238+
t.Fatalf("bad response JSON: %v", err)
239+
}
240+
if resp.Total != 3 || resp.Success != 2 || resp.Failed != 1 {
241+
t.Errorf("unexpected totals: %+v", resp)
242+
}
243+
if len(resp.ByModel) != 2 {
244+
t.Errorf("expected 2 model rows, got %d", len(resp.ByModel))
245+
}
246+
if resp.ByModel[0].Model != "Qwen3.7-Max" || resp.ByModel[0].Total != 2 {
247+
t.Errorf("expected Qwen3.7-Max first (sorted by total desc), got %+v", resp.ByModel[0])
248+
}
249+
if len(resp.Hourly) != 24 {
250+
t.Errorf("expected 24 hourly buckets, got %d", len(resp.Hourly))
251+
}
252+
}
253+
254+
func TestStatsEndpointNilRecorder(t *testing.T) {
255+
a, _ := newAdmin(t)
256+
w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil)
257+
if w.Code != 200 {
258+
t.Errorf("expected 200 with nil recorder, got %d", w.Code)
259+
}
260+
var resp map[string]interface{}
261+
json.Unmarshal(w.Body.Bytes(), &resp)
262+
if v, ok := resp["total"].(float64); !ok || v != 0 {
263+
t.Errorf("expected zero total, got %v", resp["total"])
264+
}
265+
}
266+
267+
func TestStatsEndpointMethodNotAllowed(t *testing.T) {
268+
a, _ := newAdmin(t)
269+
w := doRequest(t, a.handleStats, "POST", "/admin/api/stats", nil)
270+
if w.Code != 405 {
271+
t.Errorf("expected 405, got %d", w.Code)
272+
}
273+
}
274+
207275
// -- WebUI --
208276

209277
func TestServeIndex(t *testing.T) {

0 commit comments

Comments
 (0)