diff --git a/internal/gateway/api.go b/internal/gateway/api.go index ae912b1..36b3254 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -113,4 +113,14 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) { default: writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") } -} \ No newline at end of file +} +// handleStatsAPI returns per-key / per-model / per-source usage aggregates and +// the recent request audit trail. +func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + limit := 500 + writeJSON(w, http.StatusOK, g.stats.Snapshot(limit)) +} diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 311e406..7adee6d 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -147,6 +147,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { effective = firstModel(cands[0]) } ctx := r.Context() + done := g.stats.Begin() + defer done() inner := &types.ChatRequest{ Model: effective, @@ -159,11 +161,19 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { DisableThinking: req.DisableThinking, ExtraBody: req.ExtraBody, } + rec := &Req{ + Key: keyID(reqKey(ctx)), + Type: "chat", + Model: effective, + Source: firstSource(cands), + OK: false, + } if req.Stream { - g.streamChat(w, ctx, cands, inner, effective) + rec.Type = "stream" + g.streamChat(w, ctx, cands, inner, effective, rec) return } - g.singleChat(w, ctx, cands, inner, effective) + g.singleChat(w, ctx, cands, inner, effective, rec) } func normalizeModel(m string) string { @@ -220,12 +230,32 @@ func toolCallsWire(tcs []types.ToolCall) json.RawMessage { return b } -func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) { +func firstSource(cands []*provider.Provider) string { + if len(cands) > 0 { + return cands[0].Name() + } + return "" +} + +func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { + rec.LatMs = 0 + t0 := time.Now() resp, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req) + rec.LatMs = time.Since(t0).Milliseconds() if err != nil { + rec.OK = false + rec.Status = http.StatusBadGateway + rec.Err = err.Error() + g.writeRec(rec) writeError(w, http.StatusBadGateway, "upstream_error", err.Error()) return } + rec.OK = true + rec.Status = http.StatusOK + rec.Prompt = int64(resp.TokenUsage.Prompt) + rec.Compl = int64(resp.TokenUsage.Completion) + rec.Source = firstSource(cands) + g.writeRec(rec) msg := RespMessage{Role: "assistant", Content: resp.Content} if resp.ReasoningContent != "" { msg.ReasoningContent = resp.ReasoningContent @@ -246,9 +276,31 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [ writeJSON(w, http.StatusOK, out) } -func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) { +// writeRec records a finished request (audit + aggregates). +func (g *Gateway) writeRec(rec *Req) { + if rec == nil { + return + } + if rec.Time == 0 { + rec.Time = time.Now().UnixMilli() + } + g.stats.Record(*rec) +} + +func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { + rec.LatMs = 0 + t0 := time.Now() + rec.OK = true + rec.Status = http.StatusOK + defer func() { + rec.LatMs = time.Since(t0).Milliseconds() + g.writeRec(rec) + }() chunks, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req) if err != nil { + rec.OK = false + rec.Status = http.StatusBadGateway + rec.Err = err.Error() writeError(w, http.StatusBadGateway, "upstream_error", err.Error()) return } @@ -297,6 +349,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ choice.FinishReason = &stop } chunk.Choices = []ChunkChoice{choice} + rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3 if !send(chunk) { return } @@ -336,11 +389,23 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured") return } + done := g.stats.Begin() + defer done() + rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false} + t0 := time.Now() resp, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req) + rec.LatMs = time.Since(t0).Milliseconds() if err != nil { + rec.Status = http.StatusBadGateway + rec.Err = err.Error() + g.writeRec(rec) writeError(w, http.StatusBadGateway, "upstream_error", err.Error()) return } + rec.OK = true + rec.Status = http.StatusOK + rec.Compl = int64(len(resp.ImageData)) + g.writeRec(rec) writeJSON(w, http.StatusOK, types.ImageGenResponse{ Created: time.Now().Unix(), Data: resp.ImageData, diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 6b1caad..8462ce0 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -5,6 +5,7 @@ package gateway import ( + "context" "embed" "encoding/json" "fmt" @@ -25,6 +26,7 @@ type Gateway struct { core *core.Core apiKeys map[string]bool ui http.Handler + stats *Stats } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { @@ -42,6 +44,7 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { core: c, apiKeys: keys, ui: http.FileServer(http.FS(sub)), + stats: NewStats(3000), }, nil } @@ -72,6 +75,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleChat(w, r) case r.URL.Path == "/api/status": g.handleStatusAPI(w, r) + case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"): + g.handleStatsAPI(w, r) case r.URL.Path == "/login": g.handleLogin(w, r) case r.URL.Path == "/api/login": @@ -128,10 +133,31 @@ func (g *Gateway) auth(next http.Handler) http.Handler { http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(withKey(r.Context(), key))) }) } +// keyCtxKey is the context key carrying the authenticated gateway key. +type keyCtxKey struct{} + +func withKey(ctx context.Context, key string) context.Context { + return context.WithValue(ctx, keyCtxKey{}, key) +} + +// reqKey returns the authenticated gateway key id (masked suffix for display). +func reqKey(ctx context.Context) string { + k, _ := ctx.Value(keyCtxKey{}).(string) + return k +} + +// keyID returns a short stable id for a gateway key (last 6 chars). +func keyID(k string) string { + if len(k) <= 6 { + return k + } + return "***" + k[len(k)-6:] +} + // isAPIPath reports whether the request targets a JSON API endpoint that // should answer 401 instead of redirecting to the login page. func isAPIPath(p string) bool { diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go new file mode 100644 index 0000000..cfe8108 --- /dev/null +++ b/internal/gateway/stats.go @@ -0,0 +1,167 @@ +package gateway + +import ( + "sync" +) + +// Req is one recorded gateway request (audit trail + per-key/per-model stats). +type Req struct { + Time int64 `json:"time"` // unix milliseconds + Key string `json:"key"` // gateway key id + Type string `json:"type"` // chat | stream | image + Model string `json:"model"` // effective model used upstream + // Source provider/source name + Source string `json:"source"` + // Prompt prompt tokens + Prompt int64 `json:"prompt_tokens"` + // Compl completion tokens + Compl int64 `json:"completion_tokens"` + // LatMs total handling time ms + LatMs int64 `json:"latency_ms"` + OK bool `json:"ok"` + // Status http status code + Status int `json:"status"` + // Err short error message + Err string `json:"error,omitempty"` +} + +// Stat aggregates counters for one dimension row. +type Stat struct { + Reqs int64 `json:"reqs"` + OK int64 `json:"ok"` + Err int64 `json:"err"` + Tokens int64 `json:"tokens"` + Prompt int64 `json:"prompt_tokens"` + Compl int64 `json:"completion_tokens"` + LatSum int64 `json:"latency_sum_ms"` + LatMax int64 `json:"latency_max_ms"` +} + +type agrRow struct { + Name string `json:"name"` + Stat +} + +// Stats collects per-key / per-model / per-source aggregates plus a bounded +// ring of raw request records, all guarded by one mutex. +type Stats struct { + mu sync.Mutex + active int64 + byKey map[string]*Stat + byModel map[string]*Stat + bySrc map[string]*Stat + recs []Req + maxRecs int +} + +func NewStats(maxRecords int) *Stats { + if maxRecords <= 0 { + maxRecords = 3000 + } + return &Stats{ + byKey: map[string]*Stat{}, + byModel: map[string]*Stat{}, + bySrc: map[string]*Stat{}, + maxRecs: maxRecords, + } +} + +// Begin accounts an in-flight request; the returned func must be called once +// the request finished (defer ok). +func (s *Stats) Begin() func() { + s.mu.Lock() + s.active++ + s.mu.Unlock() + return func() { + s.mu.Lock() + s.active-- + s.mu.Unlock() + } +} + +func inc(m map[string]*Stat, name string, r Req) { + a := m[name] + if a == nil { + a = &Stat{} + m[name] = a + } + a.Reqs++ + if r.OK { + a.OK++ + } else { + a.Err++ + } + a.Tokens += r.Prompt + r.Compl + a.Prompt += r.Prompt + a.Compl += r.Compl + a.LatSum += r.LatMs + if r.LatMs > a.LatMax { + a.LatMax = r.LatMs + } +} + +// Record appends a finished request to the aggregates and ring buffer. +func (s *Stats) Record(r Req) { + s.mu.Lock() + defer s.mu.Unlock() + inc(s.byKey, r.Key, r) + inc(s.byModel, r.Model, r) + inc(s.bySrc, r.Source, r) + s.recs = append(s.recs, r) + if len(s.recs) > s.maxRecs { + s.recs = s.recs[len(s.recs)-s.maxRecs:] + } +} + +func rows(m map[string]*Stat) []StatsRow { + out := make([]StatsRow, 0, len(m)) + for k, v := range m { + out = append(out, StatsRow{Name: k, Stat: *v}) + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Reqs > out[j-1].Reqs; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +// StatsRow is one aggregated row for the dashboard. +type StatsRow struct { + Name string `json:"name"` + Stat +} + +// Snapshot returns the whole dashboard payload. +func (s *Stats) Snapshot(limit int) map[string]interface{} { + s.mu.Lock() + defer s.mu.Unlock() + if limit <= 0 { + limit = s.maxRecs + } + start := 0 + if len(s.recs) > limit { + start = len(s.recs) - limit + } + var total Stat + for _, a := range s.byModel { + total.Reqs += a.Reqs + total.OK += a.OK + total.Err += a.Err + total.Tokens += a.Tokens + total.Prompt += a.Prompt + total.Compl += a.Compl + total.LatSum += a.LatSum + if a.LatMax > total.LatMax { + total.LatMax = a.LatMax + } + } + return map[string]interface{}{ + "active": s.active, + "total": total, + "by_key": rows(s.byKey), + "by_model": rows(s.byModel), + "by_source": rows(s.bySrc), + "records": append([]Req(nil), s.recs[start:]...), + } +} \ No newline at end of file diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 4aab48b..5fec9da 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -35,11 +35,11 @@ header { display:flex; align-items:center; gap:14px; padding:14px 24px; border-b .hd-actions button { background:var(--card2); border:1px solid var(--line); color:var(--muted); padding:6px 12px; border-radius:8px; cursor:pointer; font:inherit; transition:border .15s,color .15s; } .hd-actions button:hover { border-color:var(--accent); color:var(--accent); } -nav { display:flex; gap:6px; padding:14px 24px 0; max-width:1240px; margin:0 auto; } +nav { display:flex; gap:6px; padding:0; } nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:7px 16px; cursor:pointer; border-radius:9px; font:inherit; font-weight:500; transition:all .15s; } -nav button:hover { color:var(--fg); background:var(--card); } -nav button.active { background:var(--card); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); } +nav button:hover { color:var(--fg); background:var(--card2); } +nav button.active { background:var(--card2); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); } main { padding:20px 24px 48px; max-width:1240px; margin:0 auto; } .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:18px; box-shadow:var(--shadow); transition:background .2s; } @@ -157,58 +157,90 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho justify-content:center; overflow:auto; padding:48px 20px; z-index:50; } .twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; } -/* ---------- scratch-style canvas & jigsaw blocks ---------- */ -.sort-canvas { position:relative; min-height:360px; padding:26px 10px 30px; margin:4px 0 10px; +/* ---------- scratch-style priority canvas (no numbers, tier lanes) ---------- */ +.sort-canvas { position:relative; min-height:420px; padding:22px 14px 30px; margin:4px 0 10px; background-color:var(--card2); background-image:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px); - background-size:26px 26px; - border:1px dashed var(--line); border-radius:12px; } -.scr-block { position:relative; display:flex; align-items:center; gap:12px; margin:10px 24px; - padding:16px 20px 15px 26px; color:#fff; font-weight:700; font-size:13.5px; cursor:grab; - user-select:none; touch-action:none; border-radius:12px 12px 7px 7px; - box-shadow:0 7px 0 rgba(0,0,0,.13), inset 0 2px 0 rgba(255,255,255,.28), - inset 0 -6px 0 rgba(0,0,0,.07); } -/* 顶部凸榫:像拼图块上缘伸出的插头 */ -.scr-block::before { content:''; position:absolute; left:50%; transform:translateX(-50%); - top:-8px; width:56px; height:14px; border-radius:9px 9px 4px 4px; background:inherit; } -/* 底部凹槽:被下一块凸榫咬合 */ -.scr-block::after { content:''; position:absolute; left:50%; transform:translateX(-50%); - bottom:-6px; width:66px; height:11px; background:var(--card2); - border-radius:4px 4px 9px 9px; box-shadow:inset 0 1px 0 var(--line); } -/* 链首:没有顶部榫头,块顶圆润 + 大扭扣帽 */ -.scr-block.cap::before { display:none; } -.scr-block.cap { border-radius:16px 16px 8px 8px; box-shadow:0 9px 0 rgba(0,0,0,.14), - inset 0 2px 0 rgba(255,255,255,.25), inset 0 -6px 0 rgba(0,0,0,.07); } -.scr-block .scr-ico { width:28px; height:28px; border-radius:50%; flex:0 0 auto; display:flex; - align-items:center; justify-content:center; font-size:12px; font-weight:800; letter-spacing:.5px; - background:rgba(0,0,0,.2); box-shadow:inset 0 2px 3px rgba(0,0,0,.15), 0 1px 0 rgba(255,255,255,.2); } -.scr-block .scr-src { font-size:11px; opacity:.92; font-weight:600; padding:3px 11px; border-radius:20px; - background:rgba(255,255,255,.22); white-space:nowrap; } -.scr-block .scr-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; - font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px; } -/* 优先级 = 积木上的参数槽(Scratch 椭圆参数口样式) */ -.scr-block .scr-prio { width:86px; flex:0 0 auto; text-align:center; color:var(--accent); background:#fff; - border:2px solid rgba(255,255,255,.65); border-radius:14px; padding:6px 10px; - font:700 13px ui-monospace,Menlo,Consolas,monospace; outline:none; box-shadow:inset 0 1px 4px rgba(0,0,0,.12); } -.scr-block .scr-prio:focus { border-color:var(--accent); } -.scr-block .scr-grip { flex:0 0 auto; font-size:15px; opacity:.65; letter-spacing:2px; color:rgba(255,255,255,.9); - text-shadow:0 1px 2px rgba(0,0,0,.3); } -.scr-block.drag-src { opacity:.30; } -.scr-ghost { position:fixed; z-index:200; pointer-events:none; margin:0!important; padding-right:0!important; - transform:rotate(1.2deg) scale(1.045); filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; } -.scr-ghost .scr-prio, .scr-ghost .scr-grip { visibility:hidden; } -.sort-dropline { height:10px; margin:2px 26px; border-radius:7px; background:var(--accent); - box-shadow:0 1px 6px var(--accent); } -.sortbar { display:flex; align-items:center; gap:12px; margin-bottom:18px; flex-wrap:wrap; } -.sortbar .grow { flex:1; } -.scr-hint { font-size:12px; color:var(--muted); margin-top:10px; display:flex; gap:12px; flex-wrap:wrap; } -.scr-hint span { display:flex; align-items:center; gap:5px; } + background-size:28px 28px; + border:1px dashed var(--line); border-radius:14px; overflow-x:auto; } +.scr-lane { position:relative; display:flex; align-items:stretch; margin:0; } +.scr-tier { flex:0 0 auto; width:22px; display:flex; flex-direction:column; align-items:center; + justify-content:center; gap:3px; padding:4px 2px; margin-right:10px; } +.scr-tier span { display:block; width:9px; height:4px; border-radius:2px; background:var(--accent); + opacity:.28; transition:opacity .15s; } +.scr-lane.scr-top .scr-tier span { opacity:.95; } +.scr-row { flex:1 1 auto; display:flex; align-items:center; padding:2px 18px; + border-radius:8px; position:relative; transition:background .15s; } +.scr-row.hover { background:rgba(63,110,245,.14); } +.scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; } +/* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */ +.scr-block { position:relative; display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px; + color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:7px; + border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13), + inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; } +/* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */ +.scr-block::before { content:''; position:absolute; left:-7px; top:50%; transform:translateY(-50%); + width:7px; height:30px; border-radius:7px 0 0 7px; background:inherit; z-index:2; } +/* 右侧凹槽:露出底层色,供右邻块的凸榫咬合(全圆角,视觉内凹不割裂) */ +.scr-block::after { content:''; position:absolute; right:-7px; top:50%; transform:translateY(-50%); + width:14px; height:30px; background:rgba(0,0,0,.14); border-radius:7px; z-index:1; + box-shadow: inset 2px 0 4px rgba(0,0,0,.22); } +.scr-row .scr-block:first-child::before { display:none; } +.scr-row .scr-block:last-child { margin-right:0; } +.scr-row .scr-block:last-child::after { display:none; } +/* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */ +.scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; } +@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } } +.scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; } +@keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } } +/* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */ +.scr-block.grow-t .scr-knob { animation:growtT .22s ease-out .24s both; } +@keyframes growtT { from { transform: scaleY(0); transform-origin:50% 100%; } } +.scr-block.grow-b .scr-slot { animation:growbB .22s ease-out .24s both; } +@keyframes growbB { from { transform: scaleY(0); transform-origin:50% 0; } } +.scr-block .scr-ico { width:26px; height:26px; border-radius:50%; flex:0 0 auto; display:flex; + align-items:center; justify-content:center; font-size:11px; font-weight:800; letter-spacing:.5px; + background:rgba(0,0,0,.2); } +.scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px; + max-width:200px; overflow:hidden; text-overflow:ellipsis; } +.scr-block .scr-grip { flex:0 0 auto; display:flex; flex-direction:column; gap:2px; padding:6px 4px; + margin-left:2px; border-radius:6px; cursor:grab; background:rgba(255,255,255,.18); + box-shadow:inset 0 1px 2px rgba(0,0,0,.18); transition:background .12s; touch-action:none; } +.scr-block .scr-grip:hover { background:rgba(255,255,255,.32); } +.scr-block .scr-grip i { width:12px; height:2px; border-radius:1px; background:rgba(255,255,255,.9); + display:block; } +/* 上下嵌合:仅行首块带上下卡扣(行间嵌合),位置相对块左缘固定偏移, + 各行首块因左对齐而卡扣精准对齐;同行的其余块只做左右嵌合 */ +.scr-block .scr-knob { position:absolute; left:24px; top:-11px; + width:30px; height:12px; border-radius:8px 8px 4px 4px; background:inherit; z-index:2; } +.scr-block .scr-slot { position:absolute; left:24px; bottom:-9px; + width:30px; height:11px; background:rgba(0,0,0,.14); border-radius:4px 4px 12px 12px; z-index:1; + box-shadow: inset 0 3px 5px rgba(0,0,0,.18); } +.scr-lane.scr-top .scr-block .scr-knob, +.scr-lane.scr-last .scr-block .scr-slot { display:none; } +.scr-block.drag-src { opacity:.28; } +.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot { visibility:hidden; } +.scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05); + filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; } +.scr-ghost .scr-knob, .scr-ghost .scr-slot { display:none; } +.scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s; + display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px; + letter-spacing:2px; } +.scr-gap.hover { border-color:var(--accent); background:rgba(63,110,245,.10); + box-shadow:0 0 0 3px rgba(63,110,245,.12); color:var(--accent); } +.scr-gap.hover::after { content:'+'; font-size:14px; font-weight:800; } +.scr-gap.drophere { border-color:var(--accent); background:var(--accent); color:#fff; height:16px; } +.scr-gap.drophere::after { content:'+'; font-size:16px; font-weight:800; } +.scr-blockline { position:fixed; width:3px; border-radius:2px; background:var(--accent); + box-shadow:0 0 8px var(--accent); z-index:150; pointer-events:none; } +.scr-hint { font-size:12px; color:var(--muted); margin-top:12px; display:flex; gap:16px; flex-wrap:wrap; } +.scr-hint span { display:flex; align-items:center; gap:6px; } .scr-hint i { font-style:normal; width:10px; height:10px; border-radius:3px; display:inline-block; } /* ---------- responsive / narrow screens ---------- */ @media (max-width: 900px) { header { padding:12px 16px; } - nav { padding:12px 16px 0; overflow-x:auto; } + nav { overflow-x:auto; max-width:1240px; margin:0 auto; } nav button { padding:7px 12px; white-space:nowrap; } main { padding:16px 16px 40px; } .card { padding:16px; } @@ -255,6 +287,13 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho