Refactor server: extract shared helpers, fix tool-call ordering and finish_reason mapping

- Extract callUpstream helper to deduplicate ~30 lines between chatCompletions and responses
- Move HTML templates to internal/server/templates/ via go:embed (server.go 1749→1192 lines)
- Consolidate 4 copies of firstNonEmpty into util.FirstNonEmpty
- Extract chatStreamChunk named type shared by aggregateStream and aggregateResponsesStream
- Fix tool-call ordering: iterate sorted map keys instead of sequential 0..N
- Map upstream finish_reason to Responses API status (length/content_filter → incomplete)
- Surface /v1/models errors as 401/502 instead of silently returning empty 200
- Add Secure cookie flag via isTLSRequest helper
- forEachSSEChunk: use sseDataPayload parser, drop redundant json.Valid, distinguish bufio.ErrTooLong
- Fix StreamIdleTimout typo → StreamIdleTimeout
- sso.go: single Read → io.ReadAll(io.LimitReader), explicit unknown error fallback
This commit is contained in:
2026-08-20 15:46:47 +08:00
parent 2517b4f730
commit 9aba511abe
10 changed files with 824 additions and 765 deletions
+13 -14
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
@@ -39,9 +42,8 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b := make([]byte, 1024)
n, _ := resp.Body.Read(b)
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var exchange ExchangeResponse
@@ -49,11 +51,17 @@ func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey
return Profile{}, err
}
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
if exchange.State != "" && exchange.State != "OK" {
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
msg := util.FirstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
if msg == "" {
msg = "unknown error"
}
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
}
@@ -95,12 +103,3 @@ type ExchangeResponse struct {
Result map[string]any `json:"result"`
Data map[string]any `json:"data"`
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return "unknown error"
}
+4 -12
View File
@@ -6,6 +6,7 @@ import (
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Config struct {
@@ -25,7 +26,7 @@ type Config struct {
LoginPassword string
PluginVersion string
UpstreamTimeout time.Duration
StreamIdleTimout time.Duration
StreamIdleTimeout time.Duration
Debug bool
Credentials auth.Credentials
}
@@ -33,7 +34,7 @@ type Config struct {
func Load() (Config, error) {
cfg := Config{
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
MobileLoginBaseURL: firstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileLoginBaseURL: util.FirstNonEmpty(os.Getenv("ZHANLU_MOBILE_LOGIN_BASE_URL"), getenv("ZHANLU_SERVER_BASE_URL", "https://ecloud.10086.cn")),
MobileModelBaseURL: getenv("ZHANLU_MOBILE_MODEL_BASE_URL", "https://ecloud.10086.cn/api/query/aigateway"),
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/chat/completions"),
DBPath: getenv("ZHANLU_DB_FILE", "zhanlu.db"),
@@ -48,7 +49,7 @@ func Load() (Config, error) {
LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"),
PluginVersion: getenv("ZHANLU_PLUGIN_VERSION", "1.4.2"),
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second),
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
StreamIdleTimeout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
}
cfg.Credentials = auth.Credentials{
@@ -81,15 +82,6 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
return d
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
-----END PUBLIC KEY-----`
+33 -61
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"time"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
@@ -46,42 +47,11 @@ func (s *Server) responses(w http.ResponseWriter, r *http.Request) {
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, upstreamBody)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
s.record(chatReq.Model, clientWantsStream, nil, "upstream_error", start)
resp, ok := s.callUpstream(w, r, creds, chatReq.Model, upstreamBody, clientWantsStream, start)
if !ok {
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
s.record(chatReq.Model, clientWantsStream, nil, "upstream_error", start)
return
}
if clientWantsStream {
usage, status := s.proxyResponsesStream(w, resp, chatReq.Model, meta)
@@ -110,27 +80,7 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
var event chatStreamChunk
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
@@ -170,7 +120,14 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp
}
_ = id
_ = finishReason
// Map the upstream finish_reason onto the Responses API status: a
// length/content_filter cutoff means the response is incomplete, not
// completed. Default ("stop"/"tool_calls") stays "completed".
status := "completed"
switch finishReason {
case "length", "content_filter":
status = "incomplete"
}
respID := "resp_" + randomRequestID()
output := []any{}
@@ -195,11 +152,14 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp
}
if len(toolCalls) > 0 {
indices := make([]int, 0, len(toolCalls))
for i := range toolCalls {
indices = append(indices, i)
}
sort.Ints(indices)
ordered := make([]*toolCall, 0, len(toolCalls))
for i := 0; i < len(toolCalls); i++ {
if call := toolCalls[i]; call != nil {
ordered = append(ordered, call)
}
for _, i := range indices {
ordered = append(ordered, toolCalls[i])
}
for _, tc := range ordered {
output = append(output, map[string]any{
@@ -221,7 +181,7 @@ func (s *Server) aggregateResponsesStream(w http.ResponseWriter, resp *http.Resp
result := map[string]any{
"id": respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": "completed", "output": output,
"model": model, "status": status, "output": output,
"parallel_tool_calls": meta.ParallelToolCalls,
"tool_choice": meta.ToolChoice,
"tools": meta.Tools,
@@ -254,6 +214,7 @@ type responsesStreamState struct {
fullReasoning string
usage any
status string
finishReason string
// tool call tracking
tools map[int]*streamToolCall
@@ -504,9 +465,16 @@ func (st *responsesStreamState) finish(model string) {
})
}
// Map the upstream finish_reason onto the Responses API status, mirroring
// the non-streaming path: a length/content_filter cutoff is "incomplete".
respStatus := "completed"
switch st.finishReason {
case "length", "content_filter":
respStatus = "incomplete"
}
completedResp := map[string]any{
"id": st.respID, "object": "response", "created_at": time.Now().Unix(),
"model": model, "status": "completed", "output": finalOutput,
"model": model, "status": respStatus, "output": finalOutput,
"parallel_tool_calls": st.meta.ParallelToolCalls,
"tool_choice": st.meta.ToolChoice,
"tools": st.meta.Tools,
@@ -546,6 +514,7 @@ func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if jErr := json.Unmarshal([]byte(payload), &evt); jErr == nil {
@@ -557,6 +526,9 @@ func (s *Server) proxyResponsesStream(w http.ResponseWriter, resp *http.Response
}
if len(evt.Choices) > 0 {
delta := evt.Choices[0].Delta
if evt.Choices[0].FinishReason != nil {
st.finishReason = *evt.Choices[0].FinishReason
}
reasoningDelta := delta.ReasoningContent + delta.Reasoning
if reasoningDelta != "" {
st.handleReasoning(reasoningDelta)
+143 -666
View File
@@ -9,11 +9,11 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"math/rand"
"net/http"
"net/url"
"sort"
"strings"
"time"
@@ -23,6 +23,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
)
@@ -137,6 +138,17 @@ func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
})
}
// isTLSRequest reports whether the request arrived over TLS, either directly
// (r.TLS != nil) or behind a reverse proxy that set X-Forwarded-Proto: https.
// The session cookie is only marked Secure over TLS so it still works on the
// default http://127.0.0.1 loopback deployment.
func isTLSRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
var in struct {
Password string `json:"password"`
@@ -150,13 +162,13 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
return
}
if s.cfg.LoginPassword != "" {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, Secure: isTLSRequest(r), SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())})
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: isTLSRequest(r), SameSite: http.SameSiteLaxMode, MaxAge: -1})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -283,7 +295,7 @@ func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
return
}
if out.State != "OK" {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": util.FirstNonEmpty(out.ErrorMessage, "验证码发送失败")})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
@@ -323,7 +335,7 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
return
}
if !out.Body.Result {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": util.FirstNonEmpty(out.ErrorMessage, "验证码校验失败")})
return
}
creds, err := decryptPhoneCredentials(out.Body, secret)
@@ -494,7 +506,7 @@ func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
"path": s.cfg.DBPath,
"access_key": mask(c.AccessKey),
"has_api_key": c.APIKey != "",
"model_base": firstNonEmpty(c.ModelBaseURL, c.BaseURL),
"model_base": util.FirstNonEmpty(c.ModelBaseURL, c.BaseURL),
"email": c.Email,
"saved_at": c.SavedAt,
})
@@ -534,8 +546,8 @@ func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
return
}
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
endpoint := util.FirstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
decryptKey := util.FirstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
profile, err := auth.ExchangeCode(s.upstreamHTTPClient(), endpoint, in.Code, decryptKey)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
@@ -619,7 +631,7 @@ func (s *Server) getModels(w http.ResponseWriter, r *http.Request) {
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
@@ -666,7 +678,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
@@ -715,7 +727,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
}
if json.Unmarshal([]byte(payload), &evt) == nil {
if evt.State == "ERROR" {
errMsg = firstNonEmpty(evt.ErrorMessage, "上游返回错误")
errMsg = util.FirstNonEmpty(evt.ErrorMessage, "上游返回错误")
break
}
if !gotStream {
@@ -744,7 +756,7 @@ func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
result["ttft_ms"] = ttft
}
if !available {
result["error"] = firstNonEmpty(errMsg, "未收到响应内容")
result["error"] = util.FirstNonEmpty(errMsg, "未收到响应内容")
}
writeJSON(w, http.StatusOK, result)
}
@@ -764,13 +776,34 @@ func parseLimit(s string) int {
}
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
modelIDs := []string{}
if creds, err := s.currentCredentials(); err == nil && creds.HasAPIKey() {
if client, cerr := s.zhanluClient(); cerr == nil {
if fetched, merr := client.Models(r.Context(), creds.APIKey); merr == nil && len(fetched) > 0 {
modelIDs = fetched
}
creds, err := s.currentCredentials()
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
modelIDs, err := client.Models(r.Context(), creds.APIKey)
if err != nil {
msg := "zhanlu model list failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_models_failed")
return
}
data := make([]map[string]any, 0, len(modelIDs))
for _, model := range modelIDs {
@@ -779,6 +812,55 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
}
// callUpstream performs the shared upstream request sequence for
// /v1/chat/completions and /v1/responses: provision an API key on demand when
// the resolved credentials lack one, build the HTTP/1.1 zhanlu client, POST
// the marshalled chat-completions body to the gateway, and handle upstream
// errors uniformly (OpenAI error + stats record). creds is the credential set
// already resolved by the caller. On success it returns the upstream response
// (caller closes Body); on failure it writes the error and records the failed
// request, returning ok=false.
func (s *Server) callUpstream(w http.ResponseWriter, r *http.Request, creds auth.Credentials, model string, body []byte, clientWantsStream bool, start time.Time) (resp *http.Response, ok bool) {
if !creds.HasAPIKey() {
var err error
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return nil, false
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := util.FirstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return nil, false
}
resp, err = client.ChatCompletions(r.Context(), creds.APIKey, body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
s.record(model, clientWantsStream, nil, "upstream_error", start)
return nil, false
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
s.record(model, clientWantsStream, nil, "upstream_error", start)
return nil, false
}
return resp, true
}
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
creds, err := s.currentCredentials()
if err != nil {
@@ -801,43 +883,11 @@ func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
return
}
if !creds.HasAPIKey() {
creds, err = s.provisionCredentials(r.Context(), creds)
if err != nil {
writeOpenAIError(w, http.StatusBadGateway, "zhanlu api key provisioning failed: "+err.Error(), "auth_error", "zhanlu_provision_failed")
return
}
s.cfg.Credentials = creds
_ = s.st.SaveCredentials(creds)
}
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
client, err := s.zhanluClientWithBase(modelBaseURL)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
return
}
resp, err := client.ChatCompletions(r.Context(), creds.APIKey, body)
if err != nil {
msg := "zhanlu upstream request failed"
if s.cfg.Debug {
msg = redactSensitive(err.Error())
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
resp, ok := s.callUpstream(w, r, creds, req.Model, body, clientWantsStream, start)
if !ok {
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
if s.cfg.Debug && len(b) > 0 {
msg += ": " + string(b)
}
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
s.record(req.Model, clientWantsStream, nil, "upstream_error", start)
return
}
if clientWantsStream {
usage, status := s.proxyStream(w, resp)
s.record(req.Model, true, usage, status, start)
@@ -921,6 +971,33 @@ func sseDataPayload(line string) string {
return strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
}
// chatStreamChunk is one SSE data event from the upstream chat-completions
// stream. It is shared by the non-streaming aggregation paths of
// /v1/chat/completions (aggregateStream) and /v1/responses
// (aggregateResponsesStream), which previously redeclared this anonymous
// struct inline in each function.
type chatStreamChunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, model string) (any, string) {
var content, reasoning, id string
var usage any
@@ -935,27 +1012,7 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
}
toolCalls := map[int]*toolCall{}
err := forEachSSEChunk(resp.Body, func(chunk []byte) error {
var event struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage any `json:"usage"`
}
var event chatStreamChunk
if err := json.Unmarshal(chunk, &event); err != nil {
return err
}
@@ -998,11 +1055,14 @@ func (s *Server) aggregateStream(w http.ResponseWriter, resp *http.Response, mod
}
message := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
indices := make([]int, 0, len(toolCalls))
for i := range toolCalls {
indices = append(indices, i)
}
sort.Ints(indices)
ordered := make([]*toolCall, 0, len(toolCalls))
for i := 0; i < len(toolCalls); i++ {
if call := toolCalls[i]; call != nil {
ordered = append(ordered, call)
}
for _, i := range indices {
ordered = append(ordered, toolCalls[i])
}
message["tool_calls"] = ordered
if content == "" {
@@ -1029,11 +1089,7 @@ func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
payload := sseDataPayload(scanner.Text())
if payload == "" || payload == "[DONE]" {
continue
}
@@ -1041,14 +1097,15 @@ func forEachSSEChunk(r io.Reader, fn func([]byte) error) error {
if json.Unmarshal([]byte(payload), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
}
if !json.Valid([]byte(payload)) {
return errors.New("zhanlu stream contained invalid JSON")
}
if err := fn([]byte(payload)); err != nil {
return err
}
}
return scanner.Err()
err := scanner.Err()
if err != nil && errors.Is(err, bufio.ErrTooLong) {
return errors.New("zhanlu stream chunk exceeded 2MB limit")
}
return err
}
func (s *Server) currentCredentials() (auth.Credentials, error) {
@@ -1116,13 +1173,6 @@ func mask(s string) string {
return s[:4] + "****" + s[len(s)-4:]
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// humanNum renders an integer-like value with K/M/B suffixes for compact,
// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 ->
// "1.5M"). Values below 1000 are shown as plain integers. It accepts int,
@@ -1174,576 +1224,3 @@ func redactQueryValue(s, key string) string {
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
}
}
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理登录</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:40px 36px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:0 0 10px;font-size:24px;font-weight:700;letter-spacing:-.02em}
.lede{margin:0 0 28px;color:var(--body);font-size:14px;line-height:1.6}
.lede code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--ink);background:var(--bg);padding:1px 6px;border-radius:5px;border:1px solid var(--border)}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:11px 18px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;text-decoration:none;color:#fff;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);width:100%}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-primary:disabled{background:#c2c9d4;cursor:not-allowed}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:24px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<main class="card">
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
<h1>湛卢代理登录</h1>
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入管理后台</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
</main>
<script>
const statusEl = document.getElementById('status');
const passwordForm = document.getElementById('password-form');
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) { setStatus('请输入登录密码', 'err'); return; }
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
window.location.href = '/admin';
});
}
</script>
</body>
</html>`))
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢登录结果</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:26px;height:26px}
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
.btn:hover{background:var(--accent-hover)}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
</head>
<body>
<main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/admin">返回管理后台</a>
</main>
</body>
</html>`))
var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
"rate": func(cached, prompt int64) string {
if prompt <= 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
},
"human": humanNum,
}).Parse(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理管理后台</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.btn-ghost:active{transform:translateY(1px)}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
.tab:hover{color:var(--ink)}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.tabpanel{display:none;min-width:0}
.tabpanel.active{display:block;min-width:0}
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
.panel.disabled{background:#eef0f3}
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody tr:last-child td{border-bottom:0}
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.muted{color:var(--muted);font-size:13px;line-height:1.6}
.scroll{overflow-x:auto;min-width:0}
.empty{color:var(--muted);font-size:13px;padding:8px 0}
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.page-btn:disabled{opacity:.4;cursor:not-allowed}
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
.btn-primary.full{width:100%}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
<h1>湛卢代理管理</h1>
</div>
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
</header>
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div>
<section class="tabpanel active" data-tab="stats" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="refresh">刷新</button>
<button class="btn btn-primary" id="reset">重置统计</button>
</div>
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
<div class="panel">
<h2>总览</h2>
<div class="grid4">
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
</div>
</div>
<div class="panel" style="margin-top:20px">
<h2>按模型</h2>
{{if .Stats.PerModel}}
<div class="scroll"><table>
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
<tbody>
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>按日</h2>
{{if .Stats.Daily}}
<div id="daily">
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
</div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>最近请求</h2>
{{if .Stats.Recent}}
<div class="scroll"><table id="recent">
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
<tbody>
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
</tbody>
</table></div>
<div class="pager" id="recent-pager"></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
</section>
<section class="tabpanel" data-tab="models" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
</div>
<div class="panel">
<h2>可用模型</h2>
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
<div class="scroll">
<table id="models-table">
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
</div>
</section>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tabpanel');
function activate(name) {
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.dataset.tab;
activate(name);
if (history.replaceState) history.replaceState(null, '', '#' + name);
});
});
var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models');
else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar');
var max = 1;
for (var i = 0; i < rows.length; i++) {
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
if (t > max) max = t;
}
for (var j = 0; j < rows.length; j++) {
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
}
var refresh = document.getElementById('refresh');
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
var reset = document.getElementById('reset');
if (reset) reset.addEventListener('click', function () {
if (!confirm('确定清空所有统计数据?')) return;
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
});
// paginate the recent-requests table (data is already rendered server-side)
(function () {
var tbody = document.querySelector('#recent tbody');
var pager = document.getElementById('recent-pager');
if (!tbody || !pager) return;
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) { pager.style.display = 'none'; return; }
var pageSize = 10;
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
var page = 1;
function pageList(c, t) {
var p = [];
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
p.push(1);
if (c > 3) p.push('…');
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
for (var m = s; m <= e; m++) p.push(m);
if (c < t - 2) p.push('…');
p.push(t);
return p;
}
function render() {
var start = (page - 1) * pageSize;
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
}
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '></button>';
var list = pageList(page, totalPages);
for (var n = 0; n < list.length; n++) {
var item = list[n];
if (item === '…') html += '<span class="page-btn dots">…</span>';
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
}
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '></button>';
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
pager.innerHTML = html;
}
pager.addEventListener('click', function (ev) {
var btn = ev.target.closest('.page-btn');
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
if (btn.dataset.act === 'prev' && page > 1) page--;
else if (btn.dataset.act === 'next' && page < totalPages) page++;
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
render();
});
render();
})();
var logout = document.getElementById('logout-button');
if (logout) logout.addEventListener('click', async function () {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
})();
(function () {
var statusEl = document.getElementById('status');
var form = document.getElementById('phone-form');
var codeButton = document.getElementById('code-button');
var secret = '';
var countdown = 0;
var countdownTimer = null;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (form) {
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
if (codeButton) codeButton.addEventListener('click', async function () {
var telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
var data = await res.json();
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
if (form) form.addEventListener('submit', async function (event) {
event.preventDefault();
var telephone = form.telephone.value.trim();
var code = form.code.value.trim();
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
setStatus('正在登录并保存凭据...', 'busy');
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
})();
(function () {
var refreshBtn = document.getElementById('models-refresh');
var testAllBtn = document.getElementById('models-test-all');
var statusEl = document.getElementById('models-status');
var tbody = document.querySelector('#models-table tbody');
if (!refreshBtn || !tbody) return;
var loaded = false;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function renderRow(model) {
var tr = document.createElement('tr');
tr.dataset.model = model;
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
'<td class="m-status"><span class="muted">未测试</span></td>' +
'<td class="num m-ttft">—</td>' +
'<td class="num m-total">—</td>' +
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
return tr;
}
async function loadModels() {
setStatus('正在获取模型列表...', 'busy');
refreshBtn.disabled = true;
try {
var res = await fetch('/api/models');
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
var models = data.models || [];
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
tbody.innerHTML = '';
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
} catch (e) {
setStatus('获取模型列表失败:' + e.message, 'err');
} finally {
refreshBtn.disabled = false;
}
}
async function testModel(model, row) {
var statusCell = row.querySelector('.m-status');
var ttftCell = row.querySelector('.m-ttft');
var totalCell = row.querySelector('.m-total');
var btn = row.querySelector('.m-test');
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
ttftCell.textContent = '—';
totalCell.textContent = '—';
btn.disabled = true;
try {
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
var data = await res.json();
if (data.available) {
statusCell.innerHTML = '<span class="badge ok">可用</span>';
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
} else {
statusCell.innerHTML = '<span class="badge err">不可用</span>';
statusCell.title = data.error || '';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
}
} catch (e) {
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
statusCell.title = e.message;
} finally {
btn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadModels);
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
await testModel(rows[i].dataset.model, rows[i]);
}
});
tbody.addEventListener('click', function (ev) {
var btn = ev.target.closest('.m-test');
if (!btn) return;
var row = btn.closest('tr');
if (row && row.dataset.model) testModel(row.dataset.model, row);
});
// lazy-load the model list the first time the tab becomes active
function loadIfActive() {
if (loaded) return;
var panel = document.querySelector('.tabpanel[data-tab="models"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
}
var modelsTab = document.querySelector('.tab[data-tab="models"]');
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
</script>
</body>
</html>`))
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"embed"
"fmt"
"html/template"
)
// templateFS holds the rendered admin/login HTML pages. Keeping them as
// separate files (rather than inline raw-string literals in server.go) gives
// them real syntax highlighting and keeps server.go focused on handlers.
//
//go:embed templates/*.html
var templateFS embed.FS
var (
loginTemplate = mustParseTemplate("login.html", "login")
loginResultTemplate = mustParseTemplate("login_result.html", "login-result")
adminTemplate = mustParseTemplateFuncs("admin.html", "admin", template.FuncMap{
"pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) },
"rate": func(cached, prompt int64) string {
if prompt <= 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100)
},
"human": humanNum,
})
)
// mustParseTemplate reads a single embedded template file and parses it,
// panicking on error (a malformed template is a build-time mistake).
func mustParseTemplate(filename, name string) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Parse(string(data)))
}
// mustParseTemplateFuncs is mustParseTemplate with a FuncMap registered before
// parsing, so the template body may reference the custom functions.
func mustParseTemplateFuncs(filename, name string, funcs template.FuncMap) *template.Template {
data, err := templateFS.ReadFile("templates/" + filename)
if err != nil {
panic("embed template " + filename + ": " + err.Error())
}
return template.Must(template.New(name).Funcs(funcs).Parse(string(data)))
}
+439
View File
@@ -0,0 +1,439 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理管理后台</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626; --stream:#2563eb; --nonstream:#9ca3af;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:32px 20px 64px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.wrap{max-width:1080px;margin:0 auto;display:grid;gap:20px}
header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:6px 0 0;font-size:clamp(24px,3vw,30px);font-weight:700;letter-spacing:-.02em}
.top-actions{display:flex;gap:10px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:9px 16px;font:inherit;font-weight:600;font-size:13.5px;text-decoration:none;cursor:pointer;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-ghost{background:var(--surface);border-color:var(--border-strong);color:var(--ink)}
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.btn-ghost:active{transform:translateY(1px)}
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--border);margin-bottom:24px}
.tab{padding:10px 18px;border:0;background:none;font:inherit;font-weight:600;font-size:14px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s ease,border-color .15s ease;border-radius:8px 8px 0 0}
.tab:hover{color:var(--ink)}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.tabpanel{display:none;min-width:0}
.tabpanel.active{display:block;min-width:0}
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0}
.panel.disabled{background:#eef0f3}
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:12px}
.stat{border:1px solid var(--border);border-radius:12px;padding:16px;background:var(--bg)}
.stat .label{font-size:11.5px;letter-spacing:.04em;color:var(--muted);margin-bottom:8px}
.stat .val{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.stat .sub{font-size:12px;color:var(--body);margin-top:4px;font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody tr:last-child td{border-bottom:0}
th{color:var(--muted);font-weight:600;font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;border:1px solid transparent}
.badge.ok{background:#e7f6ef;color:var(--ok);border-color:#c3e8d6}
.badge.err{background:#fdecec;color:var(--err);border-color:#f7d3d3}
.badge.stream{background:var(--accent-soft);color:var(--stream);border-color:#dbe6fb}
.badge.nonstream{background:#eef0f3;color:var(--nonstream);border-color:#dde1e6}
.barrow{display:grid;grid-template-columns:92px 1fr 72px;align-items:center;gap:12px;padding:5px 0}
.barrow .day{font-size:12.5px;color:var(--body);font-variant-numeric:tabular-nums}
.barrow .track{height:10px;border-radius:6px;background:#eef0f3;overflow:hidden}
.barrow .bar{height:100%;border-radius:6px;background:var(--accent);min-width:2px;width:0;transition:width .4s ease}
.barrow .amt{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.muted{color:var(--muted);font-size:13px;line-height:1.6}
.scroll{overflow-x:auto;min-width:0}
.empty{color:var(--muted);font-size:13px;padding:8px 0}
.pager{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:16px;flex-wrap:wrap}
.page-btn{min-width:32px;height:32px;padding:0 8px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--body);font:inherit;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .15s ease,color .15s ease,background .15s ease;font-variant-numeric:tabular-nums}
.page-btn:hover:not(:disabled):not(.dots){border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.page-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.page-btn[aria-current="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.page-btn:disabled{opacity:.4;cursor:not-allowed}
.page-btn.dots{border:0;background:none;cursor:default;color:var(--muted);min-width:auto;padding:0 2px}
.page-info{font-size:12.5px;color:var(--muted);margin-left:8px;font-variant-numeric:tabular-nums}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:32px;max-width:460px;margin:0 auto}
.lede{margin:0 0 24px;color:var(--body);font-size:14px;line-height:1.6}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:stretch}
.btn-primary.full{width:100%}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy · 管理后台</span></div>
<h1>湛卢代理管理</h1>
</div>
<div class="top-actions">{{if .PasswordEnabled}}<button class="btn btn-ghost" id="logout-button" type="button">退出登录</button>{{end}}</div>
</header>
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div>
<section class="tabpanel active" data-tab="stats" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="refresh">刷新</button>
<button class="btn btn-primary" id="reset">重置统计</button>
</div>
{{if not .Enabled}}<div class="panel disabled"><p class="muted">统计已关闭(ZHANLU_STATS_DISABLED=true)。</p></div>{{end}}
<div class="panel">
<h2>总览</h2>
<div class="grid4">
<div class="stat"><div class="label">请求总数</div><div class="val">{{.Stats.Totals.Requests}}</div><div class="sub">成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}</div></div>
<div class="stat"><div class="label">Prompt Tokens</div><div class="val">{{human .Stats.Totals.PromptTokens}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}}</div></div>
<div class="stat"><div class="label">Completion Tokens</div><div class="val">{{human .Stats.Totals.CompletionTokens}}</div><div class="sub">含思考 {{human .Stats.Totals.ReasoningTokens}}</div></div>
<div class="stat"><div class="label">Total Tokens</div><div class="val">{{human .Stats.Totals.TotalTokens}}</div></div>
<div class="stat"><div class="label">缓存命中率</div><div class="val">{{pct .Stats.Totals.CacheRate}}</div><div class="sub">缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}</div></div>
</div>
</div>
<div class="panel" style="margin-top:20px">
<h2>按模型</h2>
{{if .Stats.PerModel}}
<div class="scroll"><table>
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
<tbody>
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
</tbody>
</table></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>按日</h2>
{{if .Stats.Daily}}
<div id="daily">
{{range .Stats.Daily}}<div class="barrow"><div class="day">{{.Day}}</div><div class="track"><div class="bar" data-token="{{.TotalTokens}}"></div></div><div class="amt">{{human .TotalTokens}}</div></div>{{end}}
</div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
<div class="panel" style="margin-top:20px">
<h2>最近请求</h2>
{{if .Stats.Recent}}
<div class="scroll"><table id="recent">
<thead><tr><th>时间</th><th>模型</th><th>模式</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th>状态</th><th class="num">耗时</th></tr></thead>
<tbody>
{{range .Stats.Recent}}<tr><td>{{.Ts.Format "01-02 15:04:05"}}</td><td>{{.Model}}</td><td>{{if .Stream}}<span class="badge stream">流式</span>{{else}}<span class="badge nonstream">非流式</span>{{end}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td>{{if eq .Status "success"}}<span class="badge ok">成功</span>{{else}}<span class="badge err">失败</span>{{end}}</td><td class="num">{{.LatencyMs}}ms</td></tr>{{end}}
</tbody>
</table></div>
<div class="pager" id="recent-pager"></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
</div>
</section>
<section class="tabpanel" data-tab="models" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
</div>
<div class="panel">
<h2>可用模型</h2>
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
<div class="scroll">
<table id="models-table">
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
<form id="phone-form">
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
<label>验证码
<div class="row">
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
<button class="btn btn-ghost" id="code-button" type="button">获取验证码</button>
</div>
</label>
<button class="btn btn-primary full" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status" data-state="busy">正在检查登录状态...</div>
</div>
</section>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.tabpanel');
function activate(name) {
tabs.forEach(function (t) { t.setAttribute('aria-selected', t.dataset.tab === name ? 'true' : 'false'); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.tab === name); });
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var name = tab.dataset.tab;
activate(name);
if (history.replaceState) history.replaceState(null, '', '#' + name);
});
});
var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models');
else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar');
var max = 1;
for (var i = 0; i < rows.length; i++) {
var t = parseInt(rows[i].getAttribute('data-token') || '0', 10);
if (t > max) max = t;
}
for (var j = 0; j < rows.length; j++) {
var v = parseInt(rows[j].getAttribute('data-token') || '0', 10);
rows[j].style.width = Math.max(2, Math.round(v * 100 / max)) + '%';
}
var refresh = document.getElementById('refresh');
if (refresh) refresh.addEventListener('click', function () { location.reload(); });
var reset = document.getElementById('reset');
if (reset) reset.addEventListener('click', function () {
if (!confirm('确定清空所有统计数据?')) return;
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
});
// paginate the recent-requests table (data is already rendered server-side)
(function () {
var tbody = document.querySelector('#recent tbody');
var pager = document.getElementById('recent-pager');
if (!tbody || !pager) return;
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) { pager.style.display = 'none'; return; }
var pageSize = 10;
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
var page = 1;
function pageList(c, t) {
var p = [];
if (t <= 7) { for (var k = 1; k <= t; k++) p.push(k); return p; }
p.push(1);
if (c > 3) p.push('…');
var s = Math.max(2, c - 1), e = Math.min(t - 1, c + 1);
for (var m = s; m <= e; m++) p.push(m);
if (c < t - 2) p.push('…');
p.push(t);
return p;
}
function render() {
var start = (page - 1) * pageSize;
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = (i >= start && i < start + pageSize) ? '' : 'none';
}
var html = '<button class="page-btn" data-act="prev"' + (page === 1 ? ' disabled' : '') + '></button>';
var list = pageList(page, totalPages);
for (var n = 0; n < list.length; n++) {
var item = list[n];
if (item === '…') html += '<span class="page-btn dots">…</span>';
else html += '<button class="page-btn"' + (item === page ? ' aria-current="true"' : '') + ' data-page="' + item + '">' + item + '</button>';
}
html += '<button class="page-btn" data-act="next"' + (page === totalPages ? ' disabled' : '') + '></button>';
html += '<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 · 共 ' + rows.length + ' 条</span>';
pager.innerHTML = html;
}
pager.addEventListener('click', function (ev) {
var btn = ev.target.closest('.page-btn');
if (!btn || btn.disabled || btn.classList.contains('dots')) return;
if (btn.dataset.act === 'prev' && page > 1) page--;
else if (btn.dataset.act === 'next' && page < totalPages) page++;
else if (btn.dataset.page) page = parseInt(btn.dataset.page, 10);
render();
});
render();
})();
var logout = document.getElementById('logout-button');
if (logout) logout.addEventListener('click', async function () {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
})();
(function () {
var statusEl = document.getElementById('status');
var form = document.getElementById('phone-form');
var codeButton = document.getElementById('code-button');
var secret = '';
var countdown = 0;
var countdownTimer = null;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function startCountdown() {
countdown = 60;
codeButton.disabled = true;
countdownTimer && clearInterval(countdownTimer);
countdownTimer = setInterval(function () {
if (countdown <= 0) {
clearInterval(countdownTimer);
codeButton.disabled = false;
codeButton.textContent = '获取验证码';
return;
}
codeButton.textContent = countdown + 's';
countdown--;
}, 1000);
}
if (form) {
fetch('/api/credentials').then(function (r) { return r.json(); }).then(function (data) {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
statusEl.dataset.state = data.configured ? 'ok' : '';
});
}
if (codeButton) codeButton.addEventListener('click', async function () {
var telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) { setStatus('请输入有效的 11 位手机号', 'err'); return; }
codeButton.disabled = true;
setStatus('正在发送验证码...', 'busy');
var res = await fetch('/api/auth/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone }) });
var data = await res.json();
if (!res.ok || !data.ok) { codeButton.disabled = false; setStatus(data.error || '验证码发送失败', 'err'); return; }
secret = data.secret;
setStatus('验证码已发送', 'ok');
startCountdown();
});
if (form) form.addEventListener('submit', async function (event) {
event.preventDefault();
var telephone = form.telephone.value.trim();
var code = form.code.value.trim();
if (!secret) { setStatus('请先获取验证码', 'err'); return; }
setStatus('正在登录并保存凭据...', 'busy');
var res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ telephone: telephone, code: code, secret: secret }) });
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
});
})();
(function () {
var refreshBtn = document.getElementById('models-refresh');
var testAllBtn = document.getElementById('models-test-all');
var statusEl = document.getElementById('models-status');
var tbody = document.querySelector('#models-table tbody');
if (!refreshBtn || !tbody) return;
var loaded = false;
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function renderRow(model) {
var tr = document.createElement('tr');
tr.dataset.model = model;
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
'<td class="m-status"><span class="muted">未测试</span></td>' +
'<td class="num m-ttft">—</td>' +
'<td class="num m-total">—</td>' +
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
return tr;
}
async function loadModels() {
setStatus('正在获取模型列表...', 'busy');
refreshBtn.disabled = true;
try {
var res = await fetch('/api/models');
var data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
var models = data.models || [];
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
tbody.innerHTML = '';
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
} catch (e) {
setStatus('获取模型列表失败:' + e.message, 'err');
} finally {
refreshBtn.disabled = false;
}
}
async function testModel(model, row) {
var statusCell = row.querySelector('.m-status');
var ttftCell = row.querySelector('.m-ttft');
var totalCell = row.querySelector('.m-total');
var btn = row.querySelector('.m-test');
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
ttftCell.textContent = '—';
totalCell.textContent = '—';
btn.disabled = true;
try {
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
var data = await res.json();
if (data.available) {
statusCell.innerHTML = '<span class="badge ok">可用</span>';
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
} else {
statusCell.innerHTML = '<span class="badge err">不可用</span>';
statusCell.title = data.error || '';
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
}
} catch (e) {
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
statusCell.title = e.message;
} finally {
btn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadModels);
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
await testModel(rows[i].dataset.model, rows[i]);
}
});
tbody.addEventListener('click', function (ev) {
var btn = ev.target.closest('.m-test');
if (!btn) return;
var row = btn.closest('tr');
if (row && row.dataset.model) testModel(row.dataset.model, row);
});
// lazy-load the model list the first time the tab becomes active
function loadIfActive() {
if (loaded) return;
var panel = document.querySelector('.tabpanel[data-tab="models"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
}
var modelsTab = document.querySelector('.tab[data-tab="models"]');
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
</script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢代理登录</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec; --border-strong:#d4d8df;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --accent-soft:#eff4ff;
--ok:#059669; --err:#dc2626;
--radius:16px; --radius-sm:10px;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:40px 36px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dot{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none}
.eyebrow{font-size:12px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
h1{margin:0 0 10px;font-size:24px;font-weight:700;letter-spacing:-.02em}
.lede{margin:0 0 28px;color:var(--body);font-size:14px;line-height:1.6}
.lede code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12.5px;color:var(--ink);background:var(--bg);padding:1px 6px;border-radius:5px;border:1px solid var(--border)}
form{display:grid;gap:18px}
label{display:grid;gap:7px;font-size:13px;font-weight:500;color:var(--body)}
input{width:100%;border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:11px 13px;background:var(--surface);color:var(--ink);outline:none;font:inherit;font-size:14px;transition:border-color .15s ease,box-shadow .15s ease}
input::placeholder{color:var(--muted)}
input:hover{border-color:#bdc2cc}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid transparent;border-radius:var(--radius-sm);padding:11px 18px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;text-decoration:none;color:#fff;transition:background .15s ease,border-color .15s ease,box-shadow .15s ease,transform .05s ease}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);width:100%}
.btn-primary:hover{background:var(--accent-hover)}
.btn-primary:active{transform:translateY(1px)}
.btn-primary:disabled{background:#c2c9d4;cursor:not-allowed}
.status{min-height:20px;font-size:13px;line-height:1.6;color:var(--muted);display:flex;align-items:flex-start;gap:8px;margin-top:4px}
.status::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;margin-top:6px;background:currentColor}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="err"]{color:var(--err)}
.status[data-state="busy"]{color:var(--accent)}
.status[data-state="busy"]::before{animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:24px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
</style>
</head>
<body>
<main class="card">
<div class="brand"><span class="dot"></span><span class="eyebrow">Zhanlu Proxy</span></div>
<h1>湛卢代理登录</h1>
<p class="lede">请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码,验证后进入管理后台。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="btn btn-primary" type="submit">进入管理后台</button>
</form>
<div class="status" id="status" data-state="busy">需要登录后才能管理湛卢凭据。</div>
</main>
<script>
const statusEl = document.getElementById('status');
const passwordForm = document.getElementById('password-form');
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) { setStatus('请输入登录密码', 'err'); return; }
setStatus('正在登录...', 'busy');
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await res.json();
if (!res.ok || !data.ok) { setStatus(data.error || '登录失败', 'err'); return; }
window.location.href = '/admin';
});
}
</script>
</body>
</html>
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>湛卢登录结果</title>
<style>
:root{
color-scheme:light;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;
--bg:#f6f7f9; --surface:#ffffff; --border:#e6e8ec;
--ink:#111827; --body:#4b5563; --muted:#9ca3af;
--accent:#2563eb; --accent-hover:#1d4ed8; --ok:#059669; --err:#dc2626;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:32px 20px;color:var(--ink);background:var(--bg);-webkit-font-smoothing:antialiased}
@media(prefers-reduced-motion:no-preference){body{animation:fade .4s ease both}}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.card{width:min(440px,100%);text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:0 1px 2px rgba(16,24,40,.04),0 12px 32px -12px rgba(16,24,40,.12);padding:44px 36px}
.mark{width:56px;height:56px;margin:0 auto 20px;border-radius:50%;display:grid;place-items:center}
.mark svg{width:26px;height:26px}
.mark.ok{background:#e7f6ef;border:1px solid #c3e8d6}
.mark.err{background:#fdecec;border:1px solid #f7d3d3}
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.02em}
p{margin:14px 0 28px;color:var(--body);line-height:1.7;font-size:14px;word-break:break-word}
.btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:11px 24px;font:inherit;font-weight:600;font-size:14px;text-decoration:none;color:#fff;background:var(--accent);cursor:pointer;transition:background .15s ease}
.btn:hover{background:var(--accent-hover)}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
</head>
<body>
<main class="card">
<div class="mark {{if .Success}}ok{{else}}err{{end}}">
{{if .Success}}<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>{{end}}
</div>
{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}
<p>{{.Message}}</p>
<a class="btn" href="/admin">返回管理后台</a>
</main>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
// Package util holds small shared helpers used across internal packages to
// avoid divergent same-named copies.
package util
import "strings"
// FirstNonEmpty returns the first trimmed-non-empty value, or "" when none of
// the values are non-empty. Callers that need a specific fallback for the
// all-empty case should apply it explicitly at the call site.
func FirstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+4 -12
View File
@@ -13,6 +13,7 @@ import (
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
"git.misaka.ren/M1saka/zhanlu_proxy/internal/util"
)
type Client struct {
@@ -93,8 +94,8 @@ func (c *Client) ProvisionAPIKey(ctx context.Context, email, organization, team
if strings.TrimSpace(email) == "" {
return "", fmt.Errorf("profile email is required to provision an API key")
}
org := firstNonEmpty(organization, "未配置")
tm := firstNonEmpty(team, "未配置")
org := util.FirstNonEmpty(organization, "未配置")
tm := util.FirstNonEmpty(team, "未配置")
// Field order matters: the SM2 signature covers the exact JSON body bytes,
// matching the plugin's JSON.stringify({email, organization, team}).
body, err := json.Marshal(struct {
@@ -195,7 +196,7 @@ func (c *Client) Models(ctx context.Context, apiKey string) ([]string, error) {
seen := map[string]bool{}
models := make([]string, 0, len(out.Data))
for _, m := range out.Data {
id := firstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
id := util.FirstNonEmpty(m.ModelName, m.ID, m.ModelInfo.ID)
if id == "" || seen[id] {
continue
}
@@ -237,15 +238,6 @@ func findString(m map[string]any, keys ...string) string {
return ""
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
const alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
func randomAlnum(n int) string {