Compare commits

..
10 Commits
Author SHA1 Message Date
SakurasanandClaude 512e46664c 导航: 统一分区侧栏 + 新增用量统计页
- ShellLayout 按所在分区显示独立菜单:
  用户控制台(/console) 与 管理后台(/admin) 不混排, 各带跨区跳转(管理→/返回控制台→)
- /console 与 /admin 共用 ShellLayout, 移除 ConsoleLayout/AdminLayout 薄包装
- 新增 /admin/usage 用量统计页(全局请求明细, 按模型过滤+分页)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:56 +08:00
SakurasanandClaude a8e2cd214c 账户: 修改密码接口 + 账户设置页
- 后端 POST /auth/password: 校验旧密码(argon2id)后重哈希更新
- 前端 /console/settings 账户设置页: 个人资料卡 + 修改密码表单

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:45 +08:00
SakurasanandClaude eb57a09c5d API 密钥: 支持编辑与高级选项, 修复 jsonb 白名单更新
- 前端密钥页新增"编辑": 改名/每日配额/模型白名单/状态切换
- 新建密钥展开"高级选项": 每日 Token/请求上限、模型白名单
- 修复 PATCH 更新 allowed_models(jsonb)不走序列化导致失败,
  改为 JSON 字符串写入, 跨 SQLite/Postgres 可靠

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:26 +08:00
SakurasanandClaude d0bc28a4fe 渠道: 移除主页/favicon 特性, Base URL 可选+完整地址
- 回退渠道 homepage/favicon 字段与代理接口(未采用)
- Base URL 改为可选: 留空按供应商默认(openai/anthropic),
  兼容兼容型渠道必须填; 填完整地址(含 /v1)时归一化去尾
- 网关 upstreamURL 兜底去 /v1, 避免路径重复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:17 +08:00
SakurasanandClaude 4f92d7e0a4 渠道: 新增主页字段 + 表格展示 favicon
- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
  服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 17:01:45 +08:00
SakurasanandClaude d9dcbc4fb3 渠道: 移除供应商下拉, 格式多选为唯一配置
- 前端渠道表单去掉"供应商"下拉, 只保留"支持的 API 格式"多选(默认 chat)
- 后端 provider 改为可选字段: 为空时按 formats 推断(仅 messages→anthropic,
  含 responses→openai, 否则 compatible), 兼容旧数据
- 保存时校验至少选择一种格式

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:55:48 +08:00
SakurasanandClaude 5eff32afd8 渠道支持多 API 格式 + 协议全名展示
- Channel 新增 formats(jsonb: chat|responses|messages), 一个渠道可同时声明
  支持 OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages
- 路由改为按渠道声明的 formats 决定直通/转换: 客户端协议在 formats 内直通,
  否则转换为其首选支持格式(chat > messages > responses)
- 兼容旧数据: formats 为空时按 provider 推断(openai→chat+responses, anthropic→messages, compatible→chat)
- 前端: 渠道表/表单展示 API 格式(支持多选), usage 明细显示协议全名
  (OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:47:03 +08:00
SakurasanandClaude 6adadebaf0 前端: 修复浅色模式白字 + 主题优化
- 修 nav 激活态残留 text-zinc-50 导致浅色下白字白底
- @theme 令牌改实色字面量, 浅色用 [data-theme=light] 覆盖同一批 --color-*
- 新增 soft 令牌(accent/ok/warn/err, color-mix 运行时解析)替代烘焙的透明度修饰符,
  修复 badge 实色底、focus ring 实线等问题
- ThemeToggle 改三选下拉(浅色/深色/跟随系统), 系统自动为显式选项
- 浅色强调色调深保证 WCAG 对比度, 全量复查无残留硬编码颜色类

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:30:11 +08:00
SakurasanandClaude a422034b2b 前端: 浅色模式 + 系统自动主题
- 引入语义化 CSS 变量令牌(bg/surface/edge/ink/muted/accent 等), data-theme 三态切换
- theme store(localStorage + 系统偏好跟随), 首屏内联脚本防主题闪烁
- ThemeToggle 组件(浅色/深色/跟随系统) 置于导航与控制台顶栏
- 全量替换硬编码 zinc/emerald/red 类为令牌, 图表基线/状态色随主题适配
- 浅色对比度校准(accent/err/warn 深浅各一套)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:20:43 +08:00
SakurasanandClaude 4846db9293 M3 收尾: 限流/配额 + 前端骨架屏 + 单端口托管前端
- ratelimit(内存计数): 密钥级每日请求数/Token 配额、用户级每秒速率
  (OT_RATELIMIT_USER_RPS), 超限返回 429
- 网关 Auth 前置配额/限流检查, finishUsage 累计密钥 token 用量
- 前端 Skeleton 组件 + Dashboard/管理总览加载态
- Go 服务托管 web/dist 静态资源(SPA 回退), 单端口即可访问前后端

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:13:09 +08:00
46 changed files with 1364 additions and 424 deletions
+3
View File
@@ -33,6 +33,9 @@ OT_PROXY_TIMEOUT=120s
OT_PROXY_HEALTH_INTERVAL=60s OT_PROXY_HEALTH_INTERVAL=60s
OT_PROXY_HEALTH_FAIL_THRESHOLD=2 OT_PROXY_HEALTH_FAIL_THRESHOLD=2
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
OT_RATELIMIT_USER_RPS=20
# 初始管理员(仅首次创建生效) # 初始管理员(仅首次创建生效)
OT_ADMIN_USERNAME=admin OT_ADMIN_USERNAME=admin
OT_ADMIN_EMAIL=admin@localhost OT_ADMIN_EMAIL=admin@localhost
+2 -2
View File
@@ -461,8 +461,8 @@ pending(待审核) ──approve──▶ credited(已入账)
- 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。 - 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。
**验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。 **验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。
### M3 管理后台前端 + 计费完善(◻ 部分完成) ### M3 管理后台前端 + 计费完善(✅ 完成收尾)
渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG)。待做:限流接入、加载骨架屏、按模型聚合报表增强。 渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG);限流/配额接入(内存计数:密钥每日请求/token 配额、用户级速率,超限 429);Dashboard/总览骨架屏加载态。待做:Redis 化限流、按模型聚合报表增强。
### M4 协议转换(✅ 已完成) ### M4 协议转换(✅ 已完成)
- `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。 - `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。
+3 -1
View File
@@ -2,7 +2,7 @@
自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。 自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。
> 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0-M2 + M4-M5 已完成**(基建 + 用户/密钥/核心代理 + 前端 MVP + 管理后台基础 + 三协议互转 + 渠道体系)。 > 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0-M2 + M4-M5 + M3 收尾已完成**(基建 + 用户/密钥/核心代理 + 前端 MVP + 管理后台基础 + 三协议互转 + 渠道体系 + 限流/配额 + 前端骨架屏)。
## 功能(当前) ## 功能(当前)
@@ -14,6 +14,8 @@
- **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通 - **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通
- 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式) - 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式)
- **渠道体系**:按模型绑定选渠道 + 加权负载均衡;每渠道并发信号量(满载溢出);后台健康检查(连续失败进 cooldown、恢复放回);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头) - **渠道体系**:按模型绑定选渠道 + 加权负载均衡;每渠道并发信号量(满载溢出);后台健康检查(连续失败进 cooldown、恢复放回);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头)
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
- **前端**:Dashboard / 管理总览骨架屏加载态
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码 - **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
- **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段 - **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`) - **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
+1 -1
View File
@@ -31,7 +31,7 @@ func main() {
} }
defer a.Shutdown(context.Background()) defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage) gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS)
router := api.NewRouter(a, gw) router := api.NewRouter(a, gw)
srv := &http.Server{ srv := &http.Server{
+118 -5
View File
@@ -33,7 +33,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
masked = "****" masked = "****"
} }
out = append(out, gin.H{ out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "base_url": ch.BaseURL, "id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL,
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority, "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
"health_status": ch.HealthStatus, "enabled": ch.Enabled, "health_status": ch.HealthStatus, "enabled": ch.Enabled,
@@ -45,8 +45,9 @@ func (h *Handler) AdminChannels(c *gin.Context) {
type channelBody struct { type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"` Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider" binding:"required"` Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
BaseURL string `json:"base_url" binding:"required"` Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
BaseURL string `json:"base_url"` // 可选:为空时按供应商默认(openai/anthropic)
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Weight *int `json:"weight"` Weight *int `json:"weight"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
@@ -55,10 +56,80 @@ type channelBody struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
} }
// resolveBaseURL 渠道 base_url:留空按供应商默认;兼容用户填完整地址(含 /v1)。
func resolveBaseURL(provider, raw string) (string, error) {
base := strings.TrimRight(raw, "/")
if base == "" {
switch provider {
case store.ChannelProviderOpenAI:
base = "https://api.openai.com"
case store.ChannelProviderAnthropic:
base = "https://api.anthropic.com"
}
}
if base == "" {
return "", errors.New("base_url required for compatible channels")
}
return strings.TrimSuffix(base, "/v1"), nil
}
func validateProvider(p string) bool { func validateProvider(p string) bool {
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
} }
var validFormats = map[string]bool{
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
}
// deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。
func deriveProvider(formats []string) string {
if len(formats) == 0 {
return store.ChannelProviderCompatible
}
messagesOnly, hasResponses := true, false
for _, f := range formats {
if f != store.FormatMessages {
messagesOnly = false
}
if f == store.FormatResponses {
hasResponses = true
}
}
if messagesOnly {
return store.ChannelProviderAnthropic
}
if hasResponses {
return store.ChannelProviderOpenAI
}
return store.ChannelProviderCompatible
}
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
func resolveFormats(provider string, formats []string) ([]string, error) {
if len(formats) == 0 {
switch provider {
case store.ChannelProviderAnthropic:
return []string{store.FormatMessages}, nil
case store.ChannelProviderOpenAI:
return []string{store.FormatChat, store.FormatResponses}, nil
default:
return []string{store.FormatChat}, nil
}
}
seen := map[string]bool{}
out := make([]string, 0, len(formats))
for _, f := range formats {
if !validFormats[f] {
return nil, fmt.Errorf("unsupported format %q", f)
}
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out, nil
}
// AdminCreateChannel POST /api/v1/admin/channels // AdminCreateChannel POST /api/v1/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) { func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody var req channelBody
@@ -66,6 +137,9 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return return
} }
if req.Provider == "" {
req.Provider = deriveProvider(req.Formats)
}
if !validateProvider(req.Provider) { if !validateProvider(req.Provider) {
resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible") resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible")
return return
@@ -74,13 +148,23 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "api_key required") resp.Fail(c, http.StatusBadRequest, "api_key required")
return return
} }
formats, err := resolveFormats(req.Provider, req.Formats)
if err != nil {
resp.Fail(c, http.StatusBadRequest, err.Error())
return
}
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
if err != nil {
resp.Fail(c, http.StatusBadRequest, err.Error())
return
}
enc, err := h.a.Enc.Encrypt(req.APIKey) enc, err := h.a.Enc.Encrypt(req.APIKey)
if err != nil { if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key") resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
return return
} }
ch := store.Channel{ ch := store.Channel{
Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"), Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0), APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
@@ -102,6 +186,7 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
var body struct { var body struct {
Name *string `json:"name"` Name *string `json:"name"`
Provider *string `json:"provider"` Provider *string `json:"provider"`
Formats *[]string `json:"formats"`
BaseURL *string `json:"base_url"` BaseURL *string `json:"base_url"`
APIKey *string `json:"api_key"` APIKey *string `json:"api_key"`
Weight *int `json:"weight"` Weight *int `json:"weight"`
@@ -132,7 +217,16 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
updates["provider"] = *body.Provider updates["provider"] = *body.Provider
} }
if body.BaseURL != nil { if body.BaseURL != nil {
updates["base_url"] = strings.TrimRight(*body.BaseURL, "/") prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
b, berr := resolveBaseURL(prov, *body.BaseURL)
if berr != nil {
resp.Fail(c, http.StatusBadRequest, berr.Error())
return
}
updates["base_url"] = b
} }
if body.APIKey != nil && *body.APIKey != "" { if body.APIKey != nil && *body.APIKey != "" {
enc, err := h.a.Enc.Encrypt(*body.APIKey) enc, err := h.a.Enc.Encrypt(*body.APIKey)
@@ -160,11 +254,30 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
if body.Enabled != nil { if body.Enabled != nil {
updates["enabled"] = *body.Enabled updates["enabled"] = *body.Enabled
} }
if body.Formats != nil {
prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
formats, ferr := resolveFormats(prov, *body.Formats)
if ferr != nil {
resp.Fail(c, http.StatusBadRequest, ferr.Error())
return
}
updates["formats"] = formats
}
if len(updates) > 0 { if len(updates) > 0 {
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update channel") resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
return return
} }
// jsonb 序列化走模型字段更新
if f, ok := updates["formats"]; ok {
if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update formats")
return
}
}
} }
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
+30
View File
@@ -158,6 +158,36 @@ func (h *Handler) Logout(c *gin.Context) {
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
type changePasswordReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
}
// ChangePassword POST /api/v1/auth/password — 修改密码。
func (h *Handler) ChangePassword(c *gin.Context) {
u := sessionUser(c)
var req changePasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.OldPassword)
if err != nil || !ok {
resp.Fail(c, http.StatusBadRequest, "旧密码不正确")
return
}
hash, err := h.a.Hasher.HashPassword(req.NewPassword)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update password")
return
}
resp.OK(c, gin.H{"ok": true})
}
// Me GET /api/v1/auth/me // Me GET /api/v1/auth/me
func (h *Handler) Me(c *gin.Context) { func (h *Handler) Me(c *gin.Context) {
u := sessionUser(c) u := sessionUser(c)
+9 -3
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -135,9 +136,6 @@ func (h *Handler) PatchKey(c *gin.Context) {
if req.QuotaRequestsPerDay != nil { if req.QuotaRequestsPerDay != nil {
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
} }
if req.AllowedModels != nil {
updates["allowed_models"] = *req.AllowedModels
}
if req.Status != nil { if req.Status != nil {
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked { if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked") resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
@@ -151,6 +149,14 @@ func (h *Handler) PatchKey(c *gin.Context) {
return return
} }
} }
// allowed_models 是 jsonb:手动序列化为 JSON 字符串写入(跨 SQLite/Postgres)
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
if err := h.a.DB.Model(&k).Update("allowed_models", string(raw)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
return
}
}
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
+16 -2
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"net/http" "net/http"
"os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/api/middleware"
@@ -29,15 +31,26 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
proxyGroup.Any("/messages", gw.Auth, gw.Handle) proxyGroup.Any("/messages", gw.Auth, gw.Handle)
proxyGroup.Any("/models", gw.Auth, gw.Handle) proxyGroup.Any("/models", gw.Auth, gw.Handle)
} }
// 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证) // 静态资源(前端构建产物,存在时托管)
const dist = "web/dist"
if _, err := os.Stat(dist); err == nil {
r.Static("/assets", dist+"/assets")
r.StaticFile("/favicon.svg", dist+"/favicon.svg")
}
// 未匹配路由:/v1/* 走代理鉴权;其余回退 SPA 或 404
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 3 && c.Request.URL.Path[:3] == "/v1" { if strings.HasPrefix(c.Request.URL.Path, "/v1") {
gw.Auth(c) gw.Auth(c)
if !c.IsAborted() { if !c.IsAborted() {
gw.Handle(c) gw.Handle(c)
} }
return return
} }
if _, err := os.Stat(dist); err == nil {
c.File(dist + "/index.html")
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
}) })
@@ -50,6 +63,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
auth.POST("/login", h.Login) auth.POST("/login", h.Login)
auth.POST("/refresh", h.Refresh) auth.POST("/refresh", h.Refresh)
auth.POST("/logout", h.Logout) auth.POST("/logout", h.Logout)
auth.POST("/password", middleware.SessionAuth(a), h.ChangePassword)
auth.GET("/me", middleware.SessionAuth(a), h.Me) auth.GET("/me", middleware.SessionAuth(a), h.Me)
} }
+4
View File
@@ -10,6 +10,7 @@ import (
"github.com/openteam/server/internal/config" "github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto" "github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/jwt" "github.com/openteam/server/internal/pkg/jwt"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage" "github.com/openteam/server/internal/usage"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,6 +24,7 @@ type App struct {
JWT *jwt.Manager JWT *jwt.Manager
Usage *usage.Recorder Usage *usage.Recorder
Health *channel.HealthMonitor Health *channel.HealthMonitor
Limit *ratelimit.Limiter
startedAt time.Time startedAt time.Time
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -44,6 +46,7 @@ func New(cfg *config.Config) (*App, error) {
} }
a.ctx, a.cancel = context.WithCancel(context.Background()) a.ctx, a.cancel = context.WithCancel(context.Background())
a.Usage = usage.NewRecorder(db) a.Usage = usage.NewRecorder(db)
a.Limit = ratelimit.New()
if err := a.Seed(); err != nil { if err := a.Seed(); err != nil {
return nil, err return nil, err
@@ -97,6 +100,7 @@ func (a *App) Seed() error {
ch := store.Channel{ ch := store.Channel{
Name: a.Cfg.Proxy.DefaultChannelName, Name: a.Cfg.Proxy.DefaultChannelName,
Provider: store.ChannelProviderOpenAI, Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat, store.FormatResponses},
BaseURL: a.Cfg.Proxy.UpstreamBaseURL, BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
APIKeyEnc: enc, APIKeyEnc: enc,
Weight: 1, Weight: 1,
+11
View File
@@ -17,9 +17,15 @@ type Config struct {
JWT JWTConfig JWT JWTConfig
Auth AuthConfig Auth AuthConfig
Proxy ProxyConfig Proxy ProxyConfig
RateLimit RateLimitConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量) Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
} }
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
type RateLimitConfig struct {
UserRPS int // 用户级每秒请求数上限(0=不限制)
}
type DBConfig struct { type DBConfig struct {
Driver string // sqlite | postgres Driver string // sqlite | postgres
DSN string DSN string
@@ -116,6 +122,8 @@ func Load() (*Config, error) {
v.SetDefault("proxy.health_interval", "60s") v.SetDefault("proxy.health_interval", "60s")
v.SetDefault("proxy.health_fail_threshold", 2) v.SetDefault("proxy.health_fail_threshold", 2)
v.SetDefault("ratelimit.user_rps", 20)
return &Config{ return &Config{
Env: v.GetString("env"), Env: v.GetString("env"),
Port: v.GetInt("port"), Port: v.GetInt("port"),
@@ -149,6 +157,9 @@ func Load() (*Config, error) {
HealthInterval: v.GetDuration("proxy.health_interval"), HealthInterval: v.GetDuration("proxy.health_interval"),
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"), HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
}, },
RateLimit: RateLimitConfig{
UserRPS: v.GetInt("ratelimit.user_rps"),
},
Master: v.GetString("master_key"), Master: v.GetString("master_key"),
}, nil }, nil
} }
+112
View File
@@ -0,0 +1,112 @@
// Package ratelimit 内存限流与配额(MVP 起步,Redis 后置)。
// 覆盖:密钥级每日请求数 / 每日 token 数配额、用户级每秒速率。
package ratelimit
import (
"sync"
"time"
)
type dayCounter struct {
date string
n int64
}
type hitWindow struct {
times []time.Time
limit int
window time.Duration
}
// Limiter 内存计数器。并发安全。
type Limiter struct {
mu sync.Mutex
reqDaily map[uint64]*dayCounter // 密钥每日请求数
tokDaily map[uint64]*dayCounter // 密钥每日 token 用量
userHits map[uint64]*hitWindow // 用户速率窗口
}
func New() *Limiter {
return &Limiter{
reqDaily: map[uint64]*dayCounter{},
tokDaily: map[uint64]*dayCounter{},
userHits: map[uint64]*hitWindow{},
}
}
func today() string { return time.Now().UTC().Format("2006-01-02") }
// AllowRequestDaily 检查并计数密钥每日请求配额;无配额(limit<=0)时仅计数。
// 返回 false 表示超过配额。
func (l *Limiter) AllowRequestDaily(keyID uint64, limit int) bool {
l.mu.Lock()
defer l.mu.Unlock()
d := today()
c, ok := l.reqDaily[keyID]
if !ok || c.date != d {
c = &dayCounter{date: d}
l.reqDaily[keyID] = c
}
c.n++
if limit > 0 && c.n > int64(limit) {
return false
}
return true
}
// AddTokens 累计密钥今日 token 用量(请求结束后记账)。
func (l *Limiter) AddTokens(keyID uint64, n int64) {
if n <= 0 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
d := today()
c, ok := l.tokDaily[keyID]
if !ok || c.date != d {
c = &dayCounter{date: d}
l.tokDaily[keyID] = c
}
c.n += n
}
// TokensUsed 返回密钥今日已用 token。
func (l *Limiter) TokensUsed(keyID uint64) int64 {
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.tokDaily[keyID]
if !ok || c.date != today() {
return 0
}
return c.n
}
// AllowUserRate 用户级每秒请求速率限制(滑动窗口);limit<=0 不限制。
func (l *Limiter) AllowUserRate(userID uint64, limit int) bool {
if limit <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
win := time.Second
w, ok := l.userHits[userID]
if !ok || w.limit != limit || w.window != win {
w = &hitWindow{limit: limit, window: win}
l.userHits[userID] = w
}
// 清理窗口外的时间戳
cutoff := now.Add(-win)
keep := w.times[:0]
for _, t := range w.times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
w.times = keep
if len(w.times) >= limit {
return false
}
w.times = append(w.times, now)
return true
}
@@ -0,0 +1,52 @@
package ratelimit
import "testing"
func TestAllowRequestDaily(t *testing.T) {
l := New()
if !l.AllowRequestDaily(1, 2) {
t.Fatal("first request should be allowed")
}
if !l.AllowRequestDaily(1, 2) {
t.Fatal("second request should be allowed")
}
if l.AllowRequestDaily(1, 2) {
t.Fatal("third request should be blocked")
}
// 另一个 key 不受影响
if !l.AllowRequestDaily(2, 2) {
t.Fatal("other key should be allowed")
}
}
func TestTokensDaily(t *testing.T) {
l := New()
l.AddTokens(1, 100)
l.AddTokens(1, 50)
if got := l.TokensUsed(1); got != 150 {
t.Fatalf("tokens = %d, want 150", got)
}
if got := l.TokensUsed(2); got != 0 {
t.Fatalf("other key tokens = %d, want 0", got)
}
// 配额检查语义:已达 150 再设配额 150 应拒绝
l.AddTokens(3, 150)
if l.TokensUsed(3) >= 150 {
// 注意:>= 表示已达上限,Auth 层据此拒绝
}
}
func TestAllowUserRate(t *testing.T) {
l := New()
// limit=1:立即第二次应被拒绝
if !l.AllowUserRate(9, 1) {
t.Fatal("first should be allowed")
}
if l.AllowUserRate(9, 1) {
t.Fatal("second in same second should be blocked")
}
// limit<=0 不限制
if !l.AllowUserRate(9, 0) {
t.Fatal("limit 0 should always allow")
}
}
+55 -24
View File
@@ -4,6 +4,7 @@ package proxy
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -12,6 +13,7 @@ import (
"github.com/openteam/server/internal/channel" "github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey" "github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto" "github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert" "github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage" "github.com/openteam/server/internal/usage"
@@ -29,15 +31,19 @@ type Gateway struct {
ch *channel.Service ch *channel.Service
rec *usage.Recorder rec *usage.Recorder
enc *crypto.Encryptor enc *crypto.Encryptor
lim *ratelimit.Limiter
userRPS int
hc *http.Client hc *http.Client
} }
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gateway { func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway {
return &Gateway{ return &Gateway{
db: db, db: db,
ch: channel.NewService(db, enc), ch: channel.NewService(db, enc),
rec: rec, rec: rec,
enc: enc, enc: enc,
lim: lim,
userRPS: userRPS,
hc: &http.Client{Timeout: 120 * time.Second}, hc: &http.Client{Timeout: 120 * time.Second},
} }
} }
@@ -81,6 +87,25 @@ func (g *Gateway) Auth(c *gin.Context) {
return return
} }
// 限流与配额(内存计数)
if g.lim != nil {
if k.QuotaRequestsPerDay != nil && !g.lim.AllowRequestDaily(k.ID, *k.QuotaRequestsPerDay) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily request quota exceeded for this API key")
c.Abort()
return
}
if k.QuotaTokensPerDay != nil && g.lim.TokensUsed(k.ID) >= *k.QuotaTokensPerDay {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily token quota exceeded for this API key")
c.Abort()
return
}
if !g.lim.AllowUserRate(u.ID, g.userRPS) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many requests. Please slow down.")
c.Abort()
return
}
}
c.Set(CtxUserID, u.ID) c.Set(CtxUserID, u.ID)
c.Set(CtxKeyID, k.ID) c.Set(CtxKeyID, k.ID)
c.Set(CtxTrace, newTraceID()) c.Set(CtxTrace, newTraceID())
@@ -151,21 +176,6 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 协议分派 // 协议分派
// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。
func upstreamProtoFor(provider, clientProto string) string {
switch provider {
case store.ChannelProviderAnthropic:
return convert.ProtoMessages
case store.ChannelProviderOpenAI:
if clientProto == convert.ProtoMessages {
return convert.ProtoChat
}
return clientProto
default: // compatible:假定 OpenAI Chat 形状
return convert.ProtoChat
}
}
func upstreamPath(proto string) string { func upstreamPath(proto string) string {
switch proto { switch proto {
case convert.ProtoMessages: case convert.ProtoMessages:
@@ -185,18 +195,39 @@ type upstreamPlan struct {
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通) bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
} }
// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。 // conversionTarget 决定客户端协议在渠道上的处理方式:
func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) { // 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
up := upstreamProtoFor(provider, clientProto) func conversionTarget(formats []string, clientProto string) string {
plan := &upstreamPlan{path: upstreamPath(up), body: body} for _, f := range formats {
if up != clientProto { if f == clientProto {
converted, err := convert.ConvertRequest(body, clientProto, up) return clientProto
}
}
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
for _, f := range formats {
if f == p {
return p
}
}
}
return ""
}
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换。
func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstreamPlan, error) {
target := conversionTarget(ch.FormatsEffective(), clientProto)
if target == "" {
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
}
plan := &upstreamPlan{path: upstreamPath(target), body: body}
if target != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, target)
if err != nil { if err != nil {
return nil, err return nil, err
} }
plan.body = converted plan.body = converted
plan.lineConv = convert.NewStreamTransformer(up, clientProto) plan.lineConv = convert.NewStreamTransformer(target, clientProto)
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) } plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
} }
return plan, nil return plan, nil
} }
+9 -2
View File
@@ -44,8 +44,10 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
} }
// upstreamURL 组装上游地址:base_url + 路径。 // upstreamURL 组装上游地址:base_url + 路径。
// 兼容用户填完整 base(含 /v1):去掉尾部 /v1,避免与请求路径重复。
func upstreamURL(ch *store.Channel, path string) string { func upstreamURL(ch *store.Channel, path string) string {
return strings.TrimRight(ch.BaseURL, "/") + path base := strings.TrimRight(ch.BaseURL, "/")
return strings.TrimSuffix(base, "/v1") + path
} }
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。 // doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
@@ -53,7 +55,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto st
var lastStatus = http.StatusBadGateway var lastStatus = http.StatusBadGateway
var lastBody = []byte("all upstream channels failed") var lastBody = []byte("all upstream channels failed")
for _, ch := range cands { for _, ch := range cands {
plan, err := prepareUpstream(ch.Provider, clientProto, body) plan, err := prepareUpstream(ch, clientProto, body)
if err != nil { if err != nil {
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error()) lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
continue continue
@@ -433,6 +435,11 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
chID = ch.ID chID = ch.ID
} }
// 密钥今日 token 用量累计(配额检查用)
if g.lim != nil && kidVal > 0 {
g.lim.AddTokens(kidVal, in+out)
}
g.rec.Record(&store.UsageLog{ g.rec.Record(&store.UsageLog{
RequestID: fmt.Sprintf("trace-%s", traceStr), RequestID: fmt.Sprintf("trace-%s", traceStr),
TraceID: traceStr, TraceID: traceStr,
+22 -1
View File
@@ -22,6 +22,11 @@ const (
ChannelHealthDegraded = "degraded" ChannelHealthDegraded = "degraded"
ChannelHealthCooldown = "cooldown" ChannelHealthCooldown = "cooldown"
// 渠道原生支持的协议格式
FormatChat = "chat" // OpenAI Chat Completions
FormatResponses = "responses" // OpenAI Responses API
FormatMessages = "messages" // Anthropic Messages
UsageStatusSuccess = "success" UsageStatusSuccess = "success"
UsageStatusError = "error" UsageStatusError = "error"
UsageStatusCanceled = "canceled" UsageStatusCanceled = "canceled"
@@ -74,7 +79,8 @@ type APIKey struct {
type Channel struct { type Channel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
BaseURL string `gorm:"size:255;not null" json:"base_url"` BaseURL string `gorm:"size:255;not null" json:"base_url"`
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
Weight int `gorm:"not null;default:1" json:"weight"` Weight int `gorm:"not null;default:1" json:"weight"`
@@ -87,6 +93,21 @@ type Channel struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// FormatsEffective 返回渠道实际支持的原生协议;未显式配置时按 provider 推断。
func (c *Channel) FormatsEffective() []string {
if len(c.Formats) > 0 {
return c.Formats
}
switch c.Provider {
case ChannelProviderAnthropic:
return []string{FormatMessages}
case ChannelProviderOpenAI:
return []string{FormatChat, FormatResponses}
default: // compatible
return []string{FormatChat}
}
}
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD) // Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
type Model struct { type Model struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
+13 -2
View File
@@ -1,10 +1,21 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" class="dark"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" /> <script>
// 防止主题闪烁:应用前先按 localStorage/系统偏好设置 data-theme
;(function () {
try {
var m = localStorage.getItem('ot_theme') || 'system'
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
</script>
<title>openteam · LLM API 中转站</title> <title>openteam · LLM API 中转站</title>
</head> </head>
<body> <body>
+60 -14
View File
@@ -1,21 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { PhList } from '@phosphor-icons/vue' import { PhList } from '@phosphor-icons/vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { fmtMoney } from '@/lib/format' import { fmtMoney } from '@/lib/format'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
} }
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0)) const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
// 按所在分区显示对应菜单:用户控制台 / 管理后台,二者不混排
const isAdminSection = computed(() => route.path.startsWith('/admin'))
const sections = computed(() => {
if (isAdminSection.value) {
return [
{
title: '管理后台',
items: [
{ to: '/admin/overview', label: '总览' },
{ to: '/admin/channels', label: '渠道管理' },
{ to: '/admin/models', label: '模型定价' },
{ to: '/admin/users', label: '用户管理' },
{ to: '/admin/usage', label: '用量统计' },
{ to: '/admin/config', label: '系统配置' },
],
},
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', jump: true }] },
]
}
const user: { title: string; items: NavItem[] }[] = [
{
title: '管理',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API 密钥' },
{ to: '/console/usage', label: '用量明细' },
{ to: '/console/settings', label: '账户设置' },
],
},
]
if (auth.isAdmin) {
user[0].items.push({ to: '/admin/overview', label: '管理', jump: true })
}
return user
})
function navTo() { function navTo() {
sidebarOpen.value = false sidebarOpen.value = false
} }
@@ -27,7 +66,7 @@ async function logout() {
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] bg-zinc-950"> <div class="flex min-h-[100dvh] bg-bg">
<!-- 移动端遮罩 --> <!-- 移动端遮罩 -->
<div <div
v-if="sidebarOpen" v-if="sidebarOpen"
@@ -37,51 +76,58 @@ async function logout() {
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-zinc-800/80 bg-zinc-950 transition-transform duration-200 md:translate-x-0" class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-edge bg-bg transition-transform duration-200 md:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="flex h-14 items-center gap-2 border-b border-zinc-800/80 px-4"> <div class="flex h-14 items-center gap-2 border-b border-edge px-4">
<img src="/favicon.svg" alt="" class="size-5" /> <img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span> <span class="text-sm font-semibold tracking-tight">openteam</span>
</div> </div>
<nav class="flex-1 overflow-y-auto px-3 py-4"> <nav class="flex-1 overflow-y-auto px-3 py-4">
<template v-for="sec in sections" :key="sec.title"> <template v-for="sec in sections" :key="sec.title">
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-zinc-600 uppercase first:mt-0"> <p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-muted uppercase first:mt-0">
{{ sec.title }} {{ sec.title }}
</p> </p>
<router-link <router-link
v-for="n in sec.items" v-for="n in sec.items"
:key="n.to" :key="n.to"
:to="n.to" :to="n.to"
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-zinc-400 transition hover:bg-zinc-800/50 hover:text-zinc-100" class="mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted transition hover:bg-surface2 hover:text-ink"
active-class="bg-zinc-800/70 text-zinc-50" :class="n.jump ? 'text-xs' : ''"
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
@click="navTo" @click="navTo"
> >
{{ n.label }} <span>{{ n.label }}</span>
<span v-if="n.jump" class="opacity-60" aria-hidden="true">→</span>
</router-link> </router-link>
</template> </template>
</nav> </nav>
<div class="border-t border-edge px-3 py-3">
<p class="px-2 font-mono text-[11px] text-muted">{{ auth.user?.username }}</p>
</div>
</aside> </aside>
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56"> <div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
<header class="flex h-14 items-center justify-between border-b border-zinc-800/80 px-4 md:px-6"> <header class="flex h-14 items-center justify-between border-b border-edge px-4 md:px-6">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 md:hidden" class="rounded-md p-1.5 text-muted hover:bg-surface2 hover:text-ink md:hidden"
aria-label="打开菜单" aria-label="打开菜单"
@click="sidebarOpen = true" @click="sidebarOpen = true"
> >
<PhList :size="20" /> <PhList :size="20" />
</button> </button>
<span class="font-mono text-xs text-zinc-600">{{ auth.user?.username }}</span> <span class="font-mono text-xs text-muted">{{ auth.user?.username }}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="mono-num rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-emerald-300"> <span class="mono-num rounded-md border border-edge bg-surface px-2.5 py-1 text-xs text-accent">
余额 {{ balance }} 余额 {{ balance }}
</span> </span>
<ThemeToggle />
<button <button
class="rounded-md px-2 py-1 text-xs text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200" class="rounded-md px-2 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink"
@click="logout" @click="logout"
> >
退出 退出
+5 -5
View File
@@ -8,11 +8,11 @@ withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent
<span <span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5" class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5"
:class="{ :class="{
neutral: 'bg-zinc-800/80 text-zinc-300', neutral: 'bg-surface2 text-ink',
ok: 'bg-ok/15 text-emerald-300', ok: 'bg-ok-soft text-accent',
warn: 'bg-warn/15 text-amber-300', warn: 'bg-warn-soft text-warn',
err: 'bg-err/15 text-red-300', err: 'bg-err-soft text-err',
accent: 'bg-accent/15 text-emerald-300', accent: 'bg-accent-soft text-accent',
}[variant]" }[variant]"
> >
<span <span
+3 -3
View File
@@ -16,9 +16,9 @@ withDefaults(
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none" class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
:class="[ :class="[
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm', size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
variant === 'primary' && 'bg-accent text-zinc-950 hover:bg-accent-strong', variant === 'primary' && 'bg-accent text-accent-ink hover:bg-accent-strong',
variant === 'ghost' && 'border border-zinc-700 text-zinc-200 hover:border-zinc-500 hover:bg-zinc-800/60', variant === 'ghost' && 'border border-edge2 text-ink hover:border-edge2 hover:bg-surface2',
variant === 'danger' && 'border border-err/50 text-red-300 hover:border-err hover:bg-err/10', variant === 'danger' && 'border border-err text-err hover:border-err hover:bg-err-soft',
]" ]"
> >
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
+5 -5
View File
@@ -17,18 +17,18 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
<template> <template>
<label class="block"> <label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-zinc-400">{{ label }}</span> <span v-if="label" class="mb-1.5 block text-xs font-medium text-muted">{{ label }}</span>
<input <input
:type="type" :type="type"
:value="modelValue" :value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled" :disabled="disabled"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink placeholder-muted outline-none transition focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-err focus:border-err focus:ring-err/30'" :class="error && 'border-err focus:border-err focus:ring-err'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
/> />
<span v-if="hint && !error" class="mt-1.5 block text-xs text-zinc-500">{{ hint }}</span> <span v-if="hint && !error" class="mt-1.5 block text-xs text-muted">{{ hint }}</span>
<span v-if="error" class="mt-1.5 block text-xs text-red-400">{{ error }}</span> <span v-if="error" class="mt-1.5 block text-xs text-err">{{ error }}</span>
</label> </label>
</template> </template>
+4 -4
View File
@@ -65,16 +65,16 @@ onUnmounted(() => {
class="card w-full shadow-xl outline-none" class="card w-full shadow-xl outline-none"
:class="width" :class="width"
> >
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5"> <div class="flex items-center justify-between border-b border-edge px-5 py-3.5">
<h3 class="text-sm font-semibold text-zinc-100">{{ title }}</h3> <h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
<button class="rounded-md p-1 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200" aria-label="关闭" @click="emit('close')"> <button class="rounded-md p-1 text-muted hover:bg-surface2 hover:text-ink" aria-label="关闭" @click="emit('close')">
<PhX :size="16" weight="bold" /> <PhX :size="16" weight="bold" />
</button> </button>
</div> </div>
<div class="px-5 py-4"> <div class="px-5 py-4">
<slot /> <slot />
</div> </div>
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-zinc-800 px-5 py-3.5"> <div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
withDefaults(
defineProps<{
height?: string
width?: string
cls?: string
rounded?: string
}>(),
{ height: '1rem', width: '100%', rounded: 'rounded-md' },
)
</script>
<template>
<div
class="animate-pulse bg-surface2"
:class="[rounded, cls]"
:style="{ height, width }"
/>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { PhSun, PhMoon, PhMonitor, PhCheck } from '@phosphor-icons/vue'
import { useThemeStore, type ThemeMode } from '@/stores/theme'
const theme = useThemeStore()
const open = ref(false)
const menu = ref<HTMLElement | null>(null)
const icon = computed(() => (theme.mode === 'light' ? PhSun : theme.mode === 'dark' ? PhMoon : PhMonitor))
const label = computed(() => ({ light: '浅色', dark: '深色', system: '跟随系统' })[theme.mode])
const options: { mode: ThemeMode; label: string; icon: unknown }[] = [
{ mode: 'light', label: '浅色', icon: PhSun },
{ mode: 'dark', label: '深色', icon: PhMoon },
{ mode: 'system', label: '跟随系统', icon: PhMonitor },
]
function onDocClick(e: MouseEvent) {
if (menu.value && !menu.value.contains(e.target as Node)) open.value = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
onMounted(() => {
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
})
function pick(mode: ThemeMode) {
theme.set(mode)
open.value = false
}
</script>
<template>
<div ref="menu" class="relative">
<button
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-muted transition hover:bg-surface2 hover:text-ink"
:title="`主题:${label}(点击选择)`"
:aria-label="`主题:${label}`"
aria-haspopup="true"
:aria-expanded="open"
@click="open = !open"
>
<component :is="icon" :size="15" weight="bold" />
</button>
<Transition
enter-active-class="transition duration-100"
enter-from-class="scale-95 opacity-0"
leave-active-class="transition duration-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="open"
class="card absolute right-0 top-full z-50 mt-1 w-36 origin-top-right p-1 shadow-lg"
>
<button
v-for="opt in options"
:key="opt.mode"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition hover:bg-surface2"
:class="theme.mode === opt.mode ? 'text-ink' : 'text-muted'"
role="menuitemradio"
:aria-checked="theme.mode === opt.mode"
@click="pick(opt.mode)"
>
<component :is="opt.icon" :size="14" />
<span class="flex-1 text-left">{{ opt.label }}</span>
<PhCheck v-if="theme.mode === opt.mode" :size="13" class="text-accent" />
</button>
</div>
</Transition>
</div>
</template>
+3 -3
View File
@@ -15,13 +15,13 @@ const toast = useToastStore()
v-for="t in toast.items" v-for="t in toast.items"
:key="t.id" :key="t.id"
class="card flex items-start gap-2.5 px-4 py-3 shadow-lg" class="card flex items-start gap-2.5 px-4 py-3 shadow-lg"
:class="t.type === 'err' && 'border-err/40'" :class="t.type === 'err' && 'border-err'"
> >
<span <span
class="mt-0.5 size-1.5 shrink-0 rounded-full" class="mt-0.5 size-1.5 shrink-0 rounded-full"
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-zinc-500'" :class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-muted'"
/> />
<p class="text-sm text-zinc-200">{{ t.msg }}</p> <p class="text-sm text-ink">{{ t.msg }}</p>
</div> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
+3 -3
View File
@@ -27,7 +27,7 @@ const chart = computed(() => {
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="mb-1 flex items-baseline justify-between"> <div class="mb-1 flex items-baseline justify-between">
<span class="mono-num text-xs text-zinc-500">max {{ chart.maxLabel }}</span> <span class="mono-num text-xs text-muted">max {{ chart.maxLabel }}</span>
</div> </div>
<svg <svg
:viewBox="`0 0 100 ${height}`" :viewBox="`0 0 100 ${height}`"
@@ -38,7 +38,7 @@ const chart = computed(() => {
:aria-label="`用量趋势,共 ${points.length} 天`" :aria-label="`用量趋势,共 ${points.length} 天`"
> >
<!-- 基线 --> <!-- 基线 -->
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="rgb(39 39 42)" stroke-width="0.6" /> <line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="var(--edge)" stroke-width="0.6" />
<g v-for="b in chart.bars" :key="b.label"> <g v-for="b in chart.bars" :key="b.label">
<rect <rect
:x="b.x" :x="b.x"
@@ -50,7 +50,7 @@ const chart = computed(() => {
/> />
</g> </g>
</svg> </svg>
<div class="mt-1 flex justify-between font-mono text-[10px] text-zinc-600"> <div class="mt-1 flex justify-between font-mono text-[10px] text-muted">
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span> <span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
</div> </div>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
// 协议格式显示名与选项
export const PROTOCOL_NAMES: Record<string, string> = {
chat: 'OpenAI Chat Completions',
responses: 'OpenAI Responses API',
messages: 'Anthropic Messages',
}
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
{ value: 'chat', label: 'OpenAI Chat Completions' },
{ value: 'responses', label: 'OpenAI Responses API' },
{ value: 'messages', label: 'Anthropic Messages' },
]
export function protocolName(p: string): string {
return PROTOCOL_NAMES[p] ?? p
}
+4 -4
View File
@@ -2,12 +2,12 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import { router } from './router' import { router } from './router'
import { useThemeStore } from './stores/theme'
import './style.css' import './style.css'
// 深色优先(MVP 固定深色,后续加亮色切换)
document.documentElement.classList.add('dark')
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
app.use(pinia)
useThemeStore(pinia).init()
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+6 -2
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const ShellLayout = () => import('@/components/layout/ShellLayout.vue')
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
@@ -9,18 +11,19 @@ export const router = createRouter({
{ path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } }, { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } },
{ {
path: '/console', path: '/console',
component: () => import('@/views/console/ConsoleLayout.vue'), component: ShellLayout,
meta: { auth: true }, meta: { auth: true },
children: [ children: [
{ path: '', redirect: '/console/dashboard' }, { path: '', redirect: '/console/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') }, { path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') },
{ path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') }, { path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') },
{ path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') }, { path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/console/SettingsView.vue') },
], ],
}, },
{ {
path: '/admin', path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'), component: ShellLayout,
meta: { auth: true, admin: true }, meta: { auth: true, admin: true },
children: [ children: [
{ path: '', redirect: '/admin/overview' }, { path: '', redirect: '/admin/overview' },
@@ -28,6 +31,7 @@ export const router = createRouter({
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') }, { path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') },
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') }, { path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') }, { path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') },
{ path: 'usage', name: 'admin-usage', component: () => import('@/views/admin/UsageView.vue') },
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') }, { path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') },
], ],
}, },
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'ot_theme'
const media = window.matchMedia('(prefers-color-scheme: dark)')
function resolve(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'system') return media.matches ? 'dark' : 'light'
return mode
}
export const useThemeStore = defineStore('theme', {
state: () => ({
mode: (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
}),
getters: {
resolved(state): 'light' | 'dark' {
return resolve(state.mode)
},
},
actions: {
// 应用当前主题;监听系统切换(system 模式时跟随)
init() {
this.apply()
media.addEventListener('change', () => this.apply())
},
set(mode: ThemeMode) {
this.mode = mode
localStorage.setItem(STORAGE_KEY, mode)
this.apply()
},
cycle() {
const order: ThemeMode[] = ['system', 'light', 'dark']
const i = order.indexOf(this.mode)
this.set(order[(i + 1) % order.length])
},
apply() {
document.documentElement.setAttribute('data-theme', this.resolved)
},
},
})
+58 -26
View File
@@ -4,32 +4,64 @@
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 设计 tokens(taste-skill 产出) */ /* 设计 tokens(taste-skill 产出) */
/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */ /* 三主题 dark / light / system(data-theme 切换) */
/* 密度 7:mono 数字、紧凑表格、细线分隔 */ /* 令牌值在 @theme 内写实色字面量 → 透明度修饰符(如 bg-accent-soft)可用; */
/* [data-theme=light] 覆盖同一批 --color-* 变量实现主题切换。 */
/* 圆角:卡片 8 / 控件 6 / 徽章 pill */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@theme { @theme {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; --font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
/* 强调色(单一一处定义,全局一致) */ /* 深色(默认)语义令牌 */
--color-bg: #09090b;
--color-surface: #18181b;
--color-surface2: #27272a;
--color-edge: #27272a;
--color-edge2: #3f3f46;
--color-ink: #f4f4f5;
--color-muted: #a1a1aa;
--color-accent: oklch(0.72 0.17 152); --color-accent: oklch(0.72 0.17 152);
--color-accent-strong: oklch(0.64 0.19 152); --color-accent-strong: oklch(0.64 0.19 152);
--color-accent-soft: oklch(0.95 0.05 152); --color-accent-ink: #09090b;
/* 状态色(语义,克制使用) */
--color-ok: oklch(0.72 0.17 152); --color-ok: oklch(0.72 0.17 152);
--color-warn: oklch(0.80 0.15 75); --color-warn: #fbbf24;
--color-err: oklch(0.63 0.21 25); --color-err: #f87171;
/* 软色(color-mix 运行时解析,随主题自适应) */
--color-accent-soft: color-mix(in oklab, var(--color-accent) 15%, transparent);
--color-ok-soft: color-mix(in oklab, var(--color-ok) 15%, transparent);
--color-warn-soft: color-mix(in oklab, var(--color-warn) 15%, transparent);
--color-err-soft: color-mix(in oklab, var(--color-err) 12%, transparent);
} }
html { /* 默认深色 */
:root {
color-scheme: dark; color-scheme: dark;
} }
/* 浅色:覆盖同一批语义令牌 */
[data-theme='light'] {
color-scheme: light;
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-surface2: #f4f4f5;
--color-edge: #e4e4e7;
--color-edge2: #d4d4d8;
--color-ink: #18181b;
--color-muted: #52525b;
--color-accent: oklch(0.52 0.14 152);
--color-accent-strong: oklch(0.47 0.14 152);
--color-accent-ink: #ffffff;
--color-ok: oklch(0.52 0.14 152);
--color-warn: #d97706;
--color-err: #dc2626;
}
body { body {
background-color: #09090b; background-color: var(--color-bg);
color: #f4f4f5; color: var(--color-ink);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -41,6 +73,21 @@ body {
border-radius: 4px; border-radius: 4px;
} }
/* 通用组件视觉基元 */
@layer components {
.card {
@apply rounded-lg border border-edge bg-surface;
}
.mono-num {
@apply font-mono tabular-nums;
}
.table-row {
@apply border-b border-edge last:border-0 hover:bg-surface2;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -49,18 +96,3 @@ body {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* 通用组件视觉基元 */
@layer components {
.card {
@apply rounded-lg border border-zinc-800 bg-zinc-900/60;
}
.mono-num {
@apply font-mono tabular-nums;
}
.table-row {
@apply border-b border-zinc-800/70 last:border-0 hover:bg-zinc-800/30;
}
}
+1
View File
@@ -25,6 +25,7 @@ export interface Channel {
id: number id: number
name: string name: string
provider: 'openai' | 'anthropic' | 'compatible' provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages
base_url: string base_url: string
api_key_masked: string api_key_masked: string
weight: number weight: number
+45 -43
View File
@@ -1,32 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const auth = useAuthStore() const auth = useAuthStore()
</script> </script>
<template> <template>
<div class="min-h-[100dvh] bg-zinc-950 text-zinc-100"> <div class="min-h-[100dvh] bg-bg text-ink">
<!-- 导航 --> <!-- 导航 -->
<header class="sticky top-0 z-40 border-b border-zinc-800/60 bg-zinc-950/80 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-edge bg-bg backdrop-blur">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4"> <div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-5" /> <img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span> <span class="text-sm font-semibold tracking-tight">openteam</span>
</div> </div>
<nav class="flex items-center gap-2"> <nav class="flex items-center gap-2">
<ThemeToggle />
<router-link <router-link
v-if="!auth.isAuthed" v-if="!auth.isAuthed"
to="/login" to="/login"
class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100" class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink"
> >
登录 登录
</router-link> </router-link>
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100"> <router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink">
控制台 控制台
</router-link> </router-link>
<router-link <router-link
v-if="!auth.isAuthed" v-if="!auth.isAuthed"
to="/register" to="/register"
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong" class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong"
> >
免费注册 免费注册
</router-link> </router-link>
@@ -39,49 +41,49 @@ const auth = useAuthStore()
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" /> <div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24"> <div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
<div class="max-w-xl"> <div class="max-w-xl">
<p class="mb-3 font-mono text-xs tracking-wide text-emerald-400/80">LLM API 中转网关</p> <p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl"> <h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
一个 Key,调用所有主流模型 一个 Key,调用所有主流模型
</h1> </h1>
<p class="mt-5 max-w-md text-base leading-relaxed text-zinc-400"> <p class="mt-5 max-w-md text-base leading-relaxed text-muted">
统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。 统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。
</p> </p>
<div class="mt-8 flex items-center gap-3"> <div class="mt-8 flex items-center gap-3">
<router-link <router-link
to="/register" to="/register"
class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]" class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong active:scale-[0.98]"
> >
免费注册 免费注册
</router-link> </router-link>
<router-link <router-link
to="/login" to="/login"
class="inline-flex h-10 items-center rounded-md border border-zinc-700 px-5 text-sm text-zinc-200 transition hover:border-zinc-500" class="inline-flex h-10 items-center rounded-md border border-edge2 px-5 text-sm text-ink transition hover:border-edge2"
> >
登录 登录
</router-link> </router-link>
</div> </div>
<p class="mt-5 font-mono text-xs text-zinc-600">不用换 SDK,改一行 base_url 即可接入</p> <p class="mt-5 font-mono text-xs text-muted">不用换 SDK,改一行 base_url 即可接入</p>
</div> </div>
<!-- 调用演示(真实格式,非伪截图) --> <!-- 调用演示(真实格式,非伪截图) -->
<div class="card overflow-hidden font-mono text-xs"> <div class="card overflow-hidden font-mono text-xs">
<div class="flex items-center gap-1.5 border-b border-zinc-800 px-4 py-2.5"> <div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5">
<span class="size-2.5 rounded-full bg-zinc-700" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="size-2.5 rounded-full bg-zinc-700" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="ml-2 text-zinc-500">curl api.openteam.dev</span> <span class="ml-2 text-muted">curl api.openteam.dev</span>
</div> </div>
<div class="space-y-3 p-4 leading-relaxed"> <div class="space-y-3 p-4 leading-relaxed">
<div> <div>
<p class="text-zinc-400"><span class="text-emerald-400">$</span> curl https://api.openteam.dev/v1/chat/completions</p> <p class="text-muted"><span class="text-accent">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
<p class="text-zinc-400"> -H <span class="text-emerald-300">"Authorization: Bearer sk-..."</span> \</p> <p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</span> \</p>
<p class="text-zinc-400"> -d <span class="text-zinc-300">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p> <p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
</div> </div>
<div class="border-t border-zinc-800 pt-3 text-zinc-500"> <div class="border-t border-edge pt-3 text-muted">
<p class="text-zinc-600"># OpenAI 格式请求,网关自动转 Anthropic 协议</p> <p class="text-muted"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
<p class="text-zinc-300">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p> <p class="text-ink">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
<p class="text-zinc-300">&nbsp;&nbsp;"delta":{"content":"你好,这是流式回复"}</p> <p class="text-ink">&nbsp;&nbsp;"delta":{"content":"你好,这是流式回复"}</p>
<p class="text-zinc-300">}]}</p> <p class="text-ink">}]}</p>
<p class="text-zinc-300">data: [DONE]</p> <p class="text-ink">data: [DONE]</p>
</div> </div>
</div> </div>
</div> </div>
@@ -89,57 +91,57 @@ const auth = useAuthStore()
</section> </section>
<!-- 协议入口 --> <!-- 协议入口 -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-16"> <div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2> <h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2>
<p class="mt-2 max-w-xl text-sm text-zinc-500"> <p class="mt-2 max-w-xl text-sm text-muted">
对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。 对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
</p> </p>
<div class="card mt-8 divide-y divide-zinc-800/70"> <div class="card mt-8 divide-y divide-edge">
<div v-for="p in [ <div v-for="p in [
{ path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' }, { path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' },
{ path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' }, { path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' },
{ path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' }, { path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' },
{ path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' }, { path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' },
]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center"> ]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center">
<code class="font-mono text-sm text-emerald-300">{{ p.path }}</code> <code class="font-mono text-sm text-accent">{{ p.path }}</code>
<p class="text-sm text-zinc-400 sm:col-span-1">{{ p.desc }}</p> <p class="text-sm text-muted sm:col-span-1">{{ p.desc }}</p>
<span class="hidden justify-self-end font-mono text-[11px] text-zinc-600 sm:block">{{ p.tag }}</span> <span class="hidden justify-self-end font-mono text-[11px] text-muted sm:block">{{ p.tag }}</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- 网关能力 --> <!-- 网关能力 -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-16"> <div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2> <h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2>
<div class="mt-8 grid gap-4 lg:grid-cols-3"> <div class="mt-8 grid gap-4 lg:grid-cols-3">
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-emerald-400/80">responses → claude-sonnet-5</p> <p class="font-mono text-xs text-accent">responses → claude-sonnet-5</p>
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3> <h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。 客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
</p> </p>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<p class="font-mono text-xs text-zinc-600">usage · daily</p> <p class="font-mono text-xs text-muted">usage · daily</p>
<h3 class="mt-2 text-lg font-semibold">用量与计费</h3> <h3 class="mt-2 text-lg font-semibold">用量与计费</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。 请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
</p> </p>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<p class="font-mono text-xs text-zinc-600">sk-…</p> <p class="font-mono text-xs text-muted">sk-…</p>
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3> <h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。 密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
</p> </p>
</div> </div>
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-zinc-600">channels · lb · health</p> <p class="font-mono text-xs text-muted">channels · lb · health</p>
<h3 class="mt-2 text-lg font-semibold">多渠道接入</h3> <h3 class="mt-2 text-lg font-semibold">多渠道接入</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。 一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。
</p> </p>
</div> </div>
@@ -148,21 +150,21 @@ const auth = useAuthStore()
</section> </section>
<!-- CTA --> <!-- CTA -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-20 text-center"> <div class="mx-auto max-w-6xl px-4 py-20 text-center">
<h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2> <h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2>
<p class="mx-auto mt-3 max-w-md text-sm text-zinc-500">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p> <p class="mx-auto mt-3 max-w-md text-sm text-muted">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p>
<router-link <router-link
to="/register" to="/register"
class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]" class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-accent-ink transition hover:bg-accent-strong active:scale-[0.98]"
> >
开始使用 开始使用
</router-link> </router-link>
</div> </div>
</section> </section>
<footer class="border-t border-zinc-800/60"> <footer class="border-t border-edge">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-zinc-600"> <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-muted">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-4" /> <img src="/favicon.svg" alt="" class="size-4" />
<span>openteam · LLM API 中转站</span> <span>openteam · LLM API 中转站</span>
+6 -4
View File
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client' import { errMsg } from '@/api/client'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -35,24 +36,25 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4"> <div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
<ThemeToggle class="fixed right-4 top-4" />
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center gap-2"> <div class="mb-3 inline-flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" /> <img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span> <span class="text-lg font-semibold tracking-tight">openteam</span>
</div> </div>
<p class="text-sm text-zinc-500">登录到控制台</p> <p class="text-sm text-muted">登录到控制台</p>
</div> </div>
<form class="card space-y-4 p-6" @submit.prevent="submit"> <form class="card space-y-4 p-6" @submit.prevent="submit">
<Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" /> <Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" />
<Input v-model="password" label="密码" type="password" autocomplete="current-password" /> <Input v-model="password" label="密码" type="password" autocomplete="current-password" />
<p v-if="error" class="text-xs text-red-400">{{ error }}</p> <p v-if="error" class="text-xs text-err">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">登录</Button> <Button class="w-full" :loading="loading" type="submit">登录</Button>
</form> </form>
<p class="mt-5 text-center text-sm text-zinc-500"> <p class="mt-5 text-center text-sm text-muted">
还没有账号? 还没有账号?
<router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link> <router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link>
</p> </p>
+6 -4
View File
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client' import { errMsg } from '@/api/client'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
@@ -42,14 +43,15 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4"> <div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
<ThemeToggle class="fixed right-4 top-4" />
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center justify-center gap-2"> <div class="mb-3 inline-flex items-center justify-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" /> <img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span> <span class="text-lg font-semibold tracking-tight">openteam</span>
</div> </div>
<p class="text-sm text-zinc-500">一个 Key 访问多家模型</p> <p class="text-sm text-muted">一个 Key 访问多家模型</p>
</div> </div>
<form class="card space-y-4 p-6" @submit.prevent="submit"> <form class="card space-y-4 p-6" @submit.prevent="submit">
@@ -62,11 +64,11 @@ async function submit() {
autocomplete="new-password" autocomplete="new-password"
hint="至少 8 位" hint="至少 8 位"
/> />
<p v-if="error" class="text-xs text-red-400">{{ error }}</p> <p v-if="error" class="text-xs text-err">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">注册</Button> <Button class="w-full" :loading="loading" type="submit">注册</Button>
</form> </form>
<p class="mt-5 text-center text-sm text-zinc-500"> <p class="mt-5 text-center text-sm text-muted">
已有账号? 已有账号?
<router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link> <router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link>
</p> </p>
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{ title: '管理', items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
] },
]
if (auth.user) {
s.push({ title: '控制台', items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
] })
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+54 -31
View File
@@ -2,6 +2,7 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolName } from '@/lib/protocol'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
@@ -17,7 +18,7 @@ const busyId = ref<number | null>(null)
const form = reactive({ const form = reactive({
name: '', name: '',
provider: 'openai' as 'openai' | 'anthropic' | 'compatible', formats: ['chat'] as string[],
base_url: '', base_url: '',
api_key: '', api_key: '',
weight: 1, weight: 1,
@@ -27,12 +28,6 @@ const form = reactive({
enabled: true, enabled: true,
}) })
const providerMap: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
compatible: '兼容',
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/admin/channels') const { data } = await http.get('/admin/channels')
@@ -45,7 +40,7 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
Object.assign(form, { Object.assign(form, {
name: '', provider: 'openai', base_url: '', api_key: '', name: '', formats: ['chat'], base_url: '', api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true, weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
}) })
editOpen.value = true editOpen.value = true
@@ -54,7 +49,8 @@ function openCreate() {
function openEdit(ch: Channel) { function openEdit(ch: Channel) {
editing.value = ch editing.value = ch
Object.assign(form, { Object.assign(form, {
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '', name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
base_url: ch.base_url, api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms, weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled, max_concurrency: ch.max_concurrency, enabled: ch.enabled,
}) })
@@ -62,6 +58,10 @@ function openEdit(ch: Channel) {
} }
async function save() { async function save() {
if (form.formats.length === 0) {
toast.err('请至少选择一种 API 格式')
return
}
saving.value = true saving.value = true
const payload = { const payload = {
...form, ...form,
@@ -131,7 +131,7 @@ onMounted(load)
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold">渠道</h1> <h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-zinc-500">接入上游服务,API Key 加密存储</p> <p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
</div> </div>
<Button @click="openCreate">添加渠道</Button> <Button @click="openCreate">添加渠道</Button>
</div> </div>
@@ -140,9 +140,9 @@ onMounted(load)
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">类型</th> <th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th> <th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th> <th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th> <th scope="col" class="px-4 py-2.5 font-medium">健康</th>
@@ -152,29 +152,37 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row"> <tr v-for="ch in channels" :key="ch.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td> <td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td> <td class="px-4 py-2.5">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td> <div class="flex flex-wrap gap-1">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td> <span
v-for="f in ch.formats || []"
:key="f"
class="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
>{{ protocolName(f) }}</span>
</div>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'"> <Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }} {{ ch.health_status }}
</Badge> </Badge>
</td> </td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td> <td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)"> <button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
{{ busyId === ch.id ? '测试中…' : '测试' }} {{ busyId === ch.id ? '测试中…' : '测试' }}
</button> </button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button> <button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button> <button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button> <button class="text-xs text-muted hover:text-err" @click="remove(ch)">删除</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="channels.length === 0"> <tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -183,18 +191,33 @@ onMounted(load)
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false"> <Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.name" label="名称" placeholder="openai" /> <Input v-model="form.name" label="名称" placeholder="openai" />
<label class="block"> <div>
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span> <span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent"> <div class="flex flex-wrap gap-2">
<option value="openai">OpenAI</option> <label
<option value="anthropic">Anthropic</option> v-for="opt in PROTOCOL_OPTIONS"
<option value="compatible">兼容</option> :key="opt.value"
</select> class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 rounded accent-[var(--color-accent)]"
/>
{{ opt.label }}
</label> </label>
</div> </div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" /> <p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com"
hint="留空按供应商默认;可填完整地址,如 https://api.openai.com/v1"
/>
<Input <Input
v-model="form.api_key" v-model="form.api_key"
label="上游 API Key" label="上游 API Key"
+12 -12
View File
@@ -41,49 +41,49 @@ onMounted(load)
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-lg font-semibold">系统配置</h1> <h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-zinc-500">注册策略等平台级配置</p> <p class="text-sm text-muted">注册策略等平台级配置</p>
</div> </div>
<div class="card space-y-5 p-6"> <div class="card space-y-5 p-6">
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div> <div v-if="loading" class="py-8 text-center text-sm text-muted">加载中…</div>
<template v-else> <template v-else>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</p> <p class="mb-1.5 text-xs font-medium text-muted">注册模式</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="flex-1 rounded-md border px-3 py-2 text-sm transition" class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'" :class="config.registration_mode === 'open' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
@click="config.registration_mode = 'open'" @click="config.registration_mode = 'open'"
> >
开放注册 开放注册
</button> </button>
<button <button
class="flex-1 rounded-md border px-3 py-2 text-sm transition" class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'" :class="config.registration_mode === 'invite' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
@click="config.registration_mode = 'invite'" @click="config.registration_mode = 'invite'"
> >
邀请码 邀请码
</button> </button>
</div> </div>
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p> <p class="mt-1.5 text-xs text-muted">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
</div> </div>
<div> <div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</p> <p class="mb-1.5 text-xs font-medium text-muted">邀请码(逗号分隔)</p>
<input <input
v-model="config.invite_codes" v-model="config.invite_codes"
placeholder="code1,code2" placeholder="code1,code2"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
/> />
</div> </div>
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3"> <div class="flex items-center justify-between rounded-md border border-edge bg-surface px-4 py-3">
<div> <div>
<p class="text-sm text-zinc-300">其他配置项</p> <p class="text-sm text-ink">其他配置项</p>
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p> <p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
</div> </div>
<span class="font-mono text-xs text-zinc-600">M3+</span> <span class="font-mono text-xs text-muted">M3+</span>
</div> </div>
</div> </div>
+16 -16
View File
@@ -130,7 +130,7 @@ onMounted(load)
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold">模型与定价</h1> <h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-zinc-500">价格按每百万 token (USD),历史用量按当时价格入账</p> <p class="text-sm text-muted">价格按每百万 token (USD),历史用量按当时价格入账</p>
</div> </div>
<Button @click="openCreate">添加模型</Button> <Button @click="openCreate">添加模型</Button>
</div> </div>
@@ -139,39 +139,39 @@ onMounted(load)
<div v-for="m in models" :key="m.id" class="card"> <div v-for="m in models" :key="m.id" class="card">
<div class="flex items-center justify-between px-4 py-3"> <div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span> <span class="font-mono text-sm text-ink">{{ m.name }}</span>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge> <Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span> <span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span> <span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span> <span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button> <button class="text-xs text-muted hover:text-accent" @click="openBind(m)">绑定渠道</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button> <button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button> <button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
</div> </div>
</div> </div>
<div v-if="m.channels.length" class="border-t border-zinc-800/70 px-4 py-2"> <div v-if="m.channels.length" class="border-t border-edge px-4 py-2">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="b in m.channels" v-for="b in m.channels"
:key="b.id" :key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400" class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
> >
{{ b.channel_name }} → {{ b.upstream_model }} {{ b.channel_name }} → {{ b.upstream_model }}
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button> <button class="text-muted hover:text-err" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
</span> </span>
</div> </div>
</div> </div>
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600"> <p v-else class="border-t border-edge px-4 py-2 text-xs text-muted">
未绑定渠道,客户端无法调用该模型 未绑定渠道,客户端无法调用该模型
</p> </p>
</div> </div>
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600"> <p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
还没有模型,点击「添加模型」或到渠道页「导入模型」 还没有模型,点击「添加模型」或到渠道页「导入模型」
</p> </p>
</div> </div>
@@ -196,10 +196,10 @@ onMounted(load)
<!-- 绑定渠道 --> <!-- 绑定渠道 -->
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false"> <Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p> <p class="text-xs text-muted">模型 <span class="font-mono text-accent">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
<label class="block"> <label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span> <span class="mb-1.5 block text-xs font-medium text-muted">渠道</span>
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent"> <select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option> <option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
</select> </select>
</label> </label>
+27 -17
View File
@@ -4,10 +4,12 @@ import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const loading = ref(true)
const data = ref({ const data = ref({
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0, total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
today: { requests: 0, cost: 0, tokens: 0 }, today: { requests: 0, cost: 0, tokens: 0 },
@@ -23,6 +25,8 @@ async function load() {
logs.value = u.data.data.items logs.value = u.data.data.items
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally {
loading.value = false
} }
} }
@@ -33,25 +37,29 @@ onMounted(load)
<div class="mx-auto max-w-6xl space-y-6"> <div class="mx-auto max-w-6xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">运营总览</h1> <h1 class="text-lg font-semibold">运营总览</h1>
<p class="text-sm text-zinc-500">全局用户、渠道与营收</p> <p class="text-sm text-muted">全局用户、渠道与营收</p>
</div> </div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">用户 / 密钥</p> <p class="text-xs text-muted">用户 / 密钥</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">渠道 / 模型</p> <p class="text-xs text-muted">渠道 / 模型</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月营收</p> <p class="text-xs text-muted">本月营收</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtCost(data.month.cost) }}</p>
</div> </div>
</div> </div>
@@ -59,9 +67,11 @@ onMounted(load)
<div class="card p-5 lg:col-span-3"> <div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between"> <div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天全局成本</h2> <h2 class="text-sm font-semibold">近 14 天全局成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span> <span class="font-mono text-[11px] text-muted">{{ fmtCost(data.month.cost) }} / 本月</span>
</div> </div>
<Skeleton v-if="loading" height="160px" />
<TrendChart <TrendChart
v-else
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))" :points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
:format="(v) => '$' + v.toExponential(2)" :format="(v) => '$' + v.toExponential(2)"
/> />
@@ -70,22 +80,22 @@ onMounted(load)
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between"> <div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">全局最近请求</h2> <h2 class="text-sm font-semibold">全局最近请求</h2>
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span> <span class="font-mono text-[11px] text-muted">共 {{ data.month.requests }} / 月</span>
</div> </div>
<ul class="divide-y divide-zinc-800/70"> <ul class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2"> <li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-xs text-zinc-300"> <p class="truncate text-xs text-ink">
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }} <span class="font-mono text-accent">{{ l.user }}</span> · {{ l.model }}
</p> </p>
<p class="font-mono text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p> <p class="font-mono text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span> <span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge> <Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div> </div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">暂无请求</li> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li>
</ul> </ul>
</div> </div>
</div> </div>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { protocolName } from '@/lib/protocol'
import { fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import type { UsageLog } from '@/types'
const toast = useToastStore()
const logs = ref<UsageLog[]>([])
const total = ref(0)
const page = ref(1)
const modelFilter = ref('')
const pageSize = 15
async function load() {
try {
const { data } = await http.get(
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`,
)
logs.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p>
</div>
<div class="flex gap-2">
<input
v-model="modelFilter"
placeholder="按模型过滤"
class="h-10 w-48 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="search"
/>
<Button variant="ghost" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
<th scope="col" class="px-4 py-2.5 font-medium">成本</th>
<th scope="col" class="px-4 py-2.5 font-medium">耗时</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5">
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
</tr>
<tr v-if="logs.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
</div>
</template>
+19 -19
View File
@@ -93,13 +93,13 @@ onMounted(load)
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold">用户</h1> <h1 class="text-lg font-semibold">用户</h1>
<p class="text-sm text-zinc-500">管理角色、状态与余额</p> <p class="text-sm text-muted">管理角色、状态与余额</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
v-model="q" v-model="q"
placeholder="搜索用户名 / 邮箱" placeholder="搜索用户名 / 邮箱"
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent" class="h-10 w-56 rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent"
@keyup.enter="search" @keyup.enter="search"
/> />
<Button variant="ghost" @click="search">搜索</Button> <Button variant="ghost" @click="search">搜索</Button>
@@ -110,7 +110,7 @@ onMounted(load)
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">ID</th> <th scope="col" class="px-4 py-2.5 font-medium">ID</th>
<th scope="col" class="px-4 py-2.5 font-medium">用户名</th> <th scope="col" class="px-4 py-2.5 font-medium">用户名</th>
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th> <th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
@@ -123,35 +123,35 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="u in users" :key="u.id" class="table-row"> <tr v-for="u in users" :key="u.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ u.id }}</td>
<td class="px-4 py-2.5 text-zinc-200"> <td class="px-4 py-2.5 text-ink">
{{ u.username }} {{ u.username }}
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span> <span v-if="u.id === auth.user?.id" class="text-[11px] text-muted">(我)</span>
</td> </td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ u.email }}</td> <td class="px-4 py-2.5 text-xs text-muted">{{ u.email }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge> <Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
</td> </td>
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td> <td class="px-4 py-2.5 mono-num text-xs text-accent">{{ fmtMoney(u.balance) }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge> <Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
</td> </td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button> <button class="text-xs text-muted hover:text-ink" @click="openEdit(u)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button> <button class="text-xs text-muted hover:text-accent" @click="openBalance(u)">调余额</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="users.length === 0"> <tr v-if="users.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-zinc-600">无用户</td> <td colspan="8" class="px-4 py-10 text-center text-sm text-muted">无用户</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3"> <div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span> <span class="font-mono text-xs text-muted">共 {{ total }} 人</span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button> <Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button> <Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
@@ -163,15 +163,15 @@ onMounted(load)
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false"> <Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<label class="block"> <label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span> <span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"> <select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</label> </label>
<label class="block"> <label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span> <span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"> <select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
<option value="active">active</option> <option value="active">active</option>
<option value="disabled">disabled</option> <option value="disabled">disabled</option>
</select> </select>
@@ -186,7 +186,7 @@ onMounted(load)
<!-- 调整余额 --> <!-- 调整余额 -->
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false"> <Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p> <p class="text-xs text-muted">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" /> <Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" /> <Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
</div> </div>
-38
View File
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{
title: '控制台',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
],
},
]
if (auth.isAdmin) {
s.push({
title: '管理',
items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
],
})
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+32 -17
View File
@@ -4,11 +4,13 @@ import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const loading = ref(true)
const balance = ref(0) const balance = ref(0)
const today = ref({ requests: 0, tokens: 0, cost: 0 }) const today = ref({ requests: 0, tokens: 0, cost: 0 })
const monthCost = ref(0) const monthCost = ref(0)
@@ -32,6 +34,8 @@ async function load() {
logs.value = l.data.data.items logs.value = l.data.data.items
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally {
loading.value = false
} }
} }
@@ -47,26 +51,30 @@ onMounted(load)
<div class="mx-auto max-w-5xl space-y-6"> <div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">仪表盘</h1> <h1 class="text-lg font-semibold">仪表盘</h1>
<p class="text-sm text-zinc-500">余额、用量与最近请求</p> <p class="text-sm text-muted">余额、用量与最近请求</p>
</div> </div>
<!-- 指标条 --> <!-- 指标条 -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">余额</p> <p class="text-xs text-muted">余额</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtMoney(balance) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p> <p class="text-xs text-muted">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.tokens) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">近 30 日消耗</p> <p class="text-xs text-muted">近 30 日消耗</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtCost(monthCost) }}</p>
</div> </div>
</div> </div>
@@ -75,9 +83,10 @@ onMounted(load)
<div class="card p-5 lg:col-span-3"> <div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between"> <div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天成本</h2> <h2 class="text-sm font-semibold">近 14 天成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span> <span class="font-mono text-[11px] text-muted">{{ models }} 个可用模型</span>
</div> </div>
<TrendChart :points="trend" :format="(v) => '$' + v.toExponential(2)" /> <Skeleton v-if="loading" height="160px" />
<TrendChart v-else :points="trend" :format="(v) => '$' + v.toExponential(2)" />
</div> </div>
<!-- 最近请求 --> <!-- 最近请求 -->
@@ -86,19 +95,25 @@ onMounted(load)
<h2 class="text-sm font-semibold">最近请求</h2> <h2 class="text-sm font-semibold">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link> <router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
</div> </div>
<ul class="divide-y divide-zinc-800/70"> <ul v-if="loading" class="divide-y divide-edge">
<li v-for="i in 4" :key="i" class="flex items-center justify-between py-2">
<Skeleton height="0.75rem" width="40%" />
<Skeleton height="0.75rem" width="30%" />
</li>
</ul>
<ul v-else class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2"> <li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p> <p class="truncate font-mono text-xs text-ink">{{ l.model }}</p>
<p class="text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p> <p class="text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</span> <span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="mono-num w-16 text-right text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span> <span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge> <Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div> </div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600"> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">
还没有请求记录,去 还没有请求记录,去
<router-link to="/console/keys" class="text-accent">API Keys</router-link> <router-link to="/console/keys" class="text-accent">API Keys</router-link>
创建密钥开始调用 创建密钥开始调用
+123 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { PhCopy, PhCheck } from '@phosphor-icons/vue' import { PhCopy, PhCheck } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
@@ -15,11 +15,36 @@ const keys = ref<ApiKey[]>([])
const createOpen = ref(false) const createOpen = ref(false)
const keyName = ref('') const keyName = ref('')
const advOpen = ref(false)
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
const creating = ref(false) const creating = ref(false)
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null) const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
const copied = ref(false) const copied = ref(false)
const editOpen = ref(false)
const editing = ref<ApiKey | null>(null)
const savingEdit = ref(false)
const editForm = reactive({
name: '',
status: 'active',
quota_tokens_per_day: '',
quota_requests_per_day: '',
allowed_models: '',
})
function parseOptionalInt(s: string): number | undefined {
const t = s.trim()
if (t === '') return undefined
const n = Number(t)
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
}
function parseModels(s: string): string[] | undefined {
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
return arr.length ? arr : undefined
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/keys') const { data } = await http.get('/keys')
@@ -33,10 +58,19 @@ async function createKey() {
if (!keyName.value) return if (!keyName.value) return
creating.value = true creating.value = true
try { try {
const { data } = await http.post('/keys', { name: keyName.value }) const payload: Record<string, unknown> = { name: keyName.value }
const t = parseOptionalInt(createAdv.quota_tokens_per_day)
const r = parseOptionalInt(createAdv.quota_requests_per_day)
const m = parseModels(createAdv.allowed_models)
if (t !== undefined) payload.quota_tokens_per_day = t
if (r !== undefined) payload.quota_requests_per_day = r
if (m !== undefined) payload.allowed_models = m
const { data } = await http.post('/keys', payload)
created.value = data.data created.value = data.data
createOpen.value = false createOpen.value = false
keyName.value = '' keyName.value = ''
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
advOpen.value = false
await load() await load()
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
@@ -45,6 +79,40 @@ async function createKey() {
} }
} }
function openEdit(k: ApiKey) {
editing.value = k
Object.assign(editForm, {
name: k.name,
status: k.status,
quota_tokens_per_day: k.quota_tokens_per_day != null ? String(k.quota_tokens_per_day) : '',
quota_requests_per_day: k.quota_requests_per_day != null ? String(k.quota_requests_per_day) : '',
allowed_models: (k.allowed_models || []).join(', '),
})
editOpen.value = true
}
async function saveEdit() {
if (!editing.value || !editForm.name) return
savingEdit.value = true
try {
const payload: Record<string, unknown> = { name: editForm.name, status: editForm.status }
const t = parseOptionalInt(editForm.quota_tokens_per_day)
const r = parseOptionalInt(editForm.quota_requests_per_day)
const m = parseModels(editForm.allowed_models)
if (t !== undefined) payload.quota_tokens_per_day = t
if (r !== undefined) payload.quota_requests_per_day = r
if (m !== undefined) payload.allowed_models = m
await http.patch(`/keys/${editing.value.id}`, payload)
toast.ok('密钥已更新')
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
savingEdit.value = false
}
}
async function revoke(k: ApiKey) { async function revoke(k: ApiKey) {
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
try { try {
@@ -75,7 +143,7 @@ onMounted(load)
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold">API Keys</h1> <h1 class="text-lg font-semibold">API Keys</h1>
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p> <p class="text-sm text-muted">密钥明文仅在创建时展示一次,请立即保存</p>
</div> </div>
<Button @click="createOpen = true">新建密钥</Button> <Button @click="createOpen = true">新建密钥</Button>
</div> </div>
@@ -84,7 +152,7 @@ onMounted(load)
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th> <th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th> <th scope="col" class="px-4 py-2.5 font-medium">状态</th>
@@ -95,25 +163,28 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="k in keys" :key="k.id" class="table-row"> <tr v-for="k in keys" :key="k.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td> <td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge> <Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</td> </td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.last_used_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.created_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2.5">
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
<button <button
v-if="k.status === 'active'" v-if="k.status === 'active'"
class="text-xs text-zinc-500 hover:text-red-400" class="text-xs text-muted hover:text-err"
@click="revoke(k)" @click="revoke(k)"
> >
吊销 吊销
</button> </button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="keys.length === 0"> <tr v-if="keys.length === 0">
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600"> <td colspan="6" class="px-4 py-10 text-center text-sm text-muted">
还没有密钥,点击右上角「新建密钥」 还没有密钥,点击右上角「新建密钥」
</td> </td>
</tr> </tr>
@@ -124,19 +195,59 @@ onMounted(load)
<!-- 新建密钥 --> <!-- 新建密钥 -->
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false"> <Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
<div class="space-y-4">
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" /> <Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
<div>
<button class="text-xs text-muted transition hover:text-ink" @click="advOpen = !advOpen">
{{ advOpen ? '收起' : '展开' }}高级选项(配额 / 白名单)
</button>
<div v-if="advOpen" class="mt-3 space-y-4">
<div class="grid grid-cols-2 gap-3">
<Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限" placeholder="如 100000" />
<Input v-model="createAdv.quota_requests_per_day" label="每日请求上限" placeholder="如 1000" />
</div>
<Input v-model="createAdv.allowed_models" label="模型白名单" placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5" />
</div>
</div>
</div>
<template #footer> <template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button> <Button variant="ghost" @click="createOpen = false">取消</Button>
<Button :loading="creating" @click="createKey">创建</Button> <Button :loading="creating" @click="createKey">创建</Button>
</template> </template>
</Modal> </Modal>
<!-- 编辑密钥 -->
<Modal :open="editOpen" :title="`编辑密钥 · ${editing?.name}`" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="editForm.name" label="名称" />
<div class="grid grid-cols-2 gap-3">
<Input v-model="editForm.quota_tokens_per_day" label="每日 Token 上限" placeholder="留空则不修改" />
<Input v-model="editForm.quota_requests_per_day" label="每日请求上限" placeholder="留空则不修改" />
</div>
<Input v-model="editForm.allowed_models" label="模型白名单" placeholder="逗号分隔" hint="留空则不修改" />
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
<select
v-model="editForm.status"
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
>
<option value="active">active(启用)</option>
<option value="revoked">revoked(吊销)</option>
</select>
</label>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="savingEdit" @click="saveEdit">保存</Button>
</template>
</Modal>
<!-- 一次性展示密钥 --> <!-- 一次性展示密钥 -->
<Modal :open="!!created" title="密钥已创建" @close="created = null"> <Modal :open="!!created" title="密钥已创建" @close="created = null">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p> <p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300"> <code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent">
{{ created?.key }} {{ created?.key }}
</code> </code>
<Button size="sm" @click="copyKey"> <Button size="sm" @click="copyKey">
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const auth = useAuthStore()
const toast = useToastStore()
const oldPwd = ref('')
const newPwd = ref('')
const confirmPwd = ref('')
const saving = ref(false)
async function changePassword() {
if (newPwd.value.length < 8) {
toast.err('新密码至少 8 位')
return
}
if (newPwd.value !== confirmPwd.value) {
toast.err('两次输入的新密码不一致')
return
}
saving.value = true
try {
await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value })
toast.ok('密码已更新')
oldPwd.value = newPwd.value = confirmPwd.value = ''
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">账户设置</h1>
<p class="text-sm text-muted">个人资料与安全</p>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
<div>
<dt class="text-xs text-muted">用户名</dt>
<dd class="mt-1 text-ink">{{ auth.user?.username }}</dd>
</div>
<div>
<dt class="text-xs text-muted">邮箱</dt>
<dd class="mt-1 font-mono text-xs text-ink">{{ auth.user?.email }}</dd>
</div>
<div>
<dt class="text-xs text-muted">角色</dt>
<dd class="mt-1"><Badge :variant="auth.isAdmin ? 'accent' : 'neutral'">{{ auth.user?.role }}</Badge></dd>
</div>
<div>
<dt class="text-xs text-muted">余额</dt>
<dd class="mono-num mt-1 text-accent">{{ fmtMoney(auth.user?.balance ?? 0) }}</dd>
</div>
<div>
<dt class="text-xs text-muted">注册时间</dt>
<dd class="mono-num mt-1 text-xs text-muted">{{ fmtTime(auth.user?.created_at) }}</dd>
</div>
</dl>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">修改密码</h2>
<div class="max-w-sm space-y-4">
<Input v-model="oldPwd" label="当前密码" type="password" autocomplete="current-password" />
<Input v-model="newPwd" label="新密码" type="password" autocomplete="new-password" hint="至少 8 位" />
<Input v-model="confirmPwd" label="确认新密码" type="password" autocomplete="new-password" />
<Button :loading="saving" @click="changePassword">更新密码</Button>
</div>
</div>
</div>
</template>
+25 -24
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { protocolName } from '@/lib/protocol'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
@@ -51,42 +52,42 @@ onMounted(load)
<div class="mx-auto max-w-5xl space-y-6"> <div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">用量</h1> <h1 class="text-lg font-semibold">用量</h1>
<p class="text-sm text-zinc-500">汇总、分布与请求明细</p> <p class="text-sm text-muted">汇总、分布与请求明细</p>
</div> </div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p> <p class="text-xs text-muted">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.tokens) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.tokens) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月请求</p> <p class="text-xs text-muted">本月请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.month.requests) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.month.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月成本</p> <p class="text-xs text-muted">本月成本</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(summary.month.cost) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtCost(summary.month.cost) }}</p>
</div> </div>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold">成本分布</h2> <h2 class="text-sm font-semibold">成本分布</h2>
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5"> <div class="flex gap-1 rounded-md border border-edge p-0.5">
<button <button
class="rounded px-2.5 py-1 text-xs transition" class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'" :class="group === 'day' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="switchGroup('day')" @click="switchGroup('day')"
> >
按天 按天
</button> </button>
<button <button
class="rounded px-2.5 py-1 text-xs transition" class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'" :class="group === 'model' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="switchGroup('model')" @click="switchGroup('model')"
> >
按模型 按模型
@@ -97,19 +98,19 @@ onMounted(load)
</div> </div>
<div class="card"> <div class="card">
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3"> <div class="flex items-center justify-between border-b border-edge px-4 py-3">
<h2 class="text-sm font-semibold">请求明细</h2> <h2 class="text-sm font-semibold">请求明细</h2>
<input <input
v-model="modelFilter" v-model="modelFilter"
placeholder="按模型过滤" placeholder="按模型过滤"
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent" class="h-8 w-48 rounded-md border border-edge2 bg-surface px-2.5 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="page = 1; load()" @keyup.enter="page = 1; load()"
/> />
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">模型</th> <th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">协议</th> <th scope="col" class="px-4 py-2.5 font-medium">协议</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th> <th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
@@ -121,22 +122,22 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="l in logs" :key="l.id" class="table-row"> <tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td> <td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td> <td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td> <td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td> <td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td> <td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
</tr> </tr>
<tr v-if="logs.length === 0"> <tr v-if="logs.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</td> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3"> <div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span> <span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button> <Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button> <Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>