更新grok

This commit is contained in:
2026-07-13 21:08:30 +08:00
parent 0822628bfc
commit 86658e98a1
6 changed files with 414 additions and 12 deletions
+7
View File
@@ -28,16 +28,23 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/chromedp v0.15.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
+14
View File
@@ -22,6 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -41,6 +47,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -54,6 +62,12 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+33
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"strconv"
"time"
"backend/internal/config"
@@ -122,6 +123,11 @@ func NewApp(ctx context.Context) (*App, error) {
kreaClient := krea.NewClient("")
imagineClient := imagine.NewClient("")
grokClient := grok.NewClient("")
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
// headless-browser signer output. Event-driven: seed from the persisted
// recipe, capture once at startup, then re-capture only on an anti-bot 403
// (a reship made the recipe stale). No polling.
startGrokStatsigRefresh(siteRepo)
customClient := custom.NewClient()
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
@@ -175,6 +181,33 @@ func NewApp(ctx context.Context) (*App, error) {
}, nil
}
// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the
// site-settings store (persisted across restarts) with an app-lifetime context.
func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) {
const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer"
grok.StartStatsigAutoRefresh(context.Background(), 0,
func(ctx context.Context) (string, string, int, bool) {
h, _ := siteRepo.GetValue(ctx, kHeader)
s, _ := siteRepo.GetValue(ctx, kSuffix)
tv, _ := siteRepo.GetValue(ctx, kTrailer)
if h == "" || s == "" {
return "", "", 0, false
}
t, _ := strconv.Atoi(tv)
return h, s, t, true
},
func(ctx context.Context, h, s string, t int) {
if err := siteRepo.UpsertValues(ctx, map[string]string{
kHeader: h,
kSuffix: s,
kTrailer: strconv.Itoa(t),
}); err != nil {
log.Printf("grok statsig: persist recipe failed: %v", err)
}
},
)
}
func (a *App) Close() error {
if a.maintenanceCancel != nil {
a.maintenanceCancel()
@@ -0,0 +1,300 @@
package grok
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
// browser fingerprint "F". grok recomputes F server-side from the homepage
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
// header + trailer), then feed those into statsigID's existing per-request
// computation. Because we read grok's live output we never reverse-engineer the
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
// loop does on a timer and whenever a live request hits an anti-bot 403.
//
// The captured recipe is persisted (site settings) so a restart keeps the last
// good values, and every failure is non-fatal — statsigID keeps using whatever
// recipe is currently live (persisted → last capture → static defaults).
// hookJS is installed before grok's own scripts run. It records (a) every
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
// attaches to its API calls. Both are only produced once grok's signer succeeds
// (after its keyframe animation settles), so a short wait yields valid values.
const statsigHookJS = `
(() => {
window.__statsigDigests = []; window.__statsigCaps = [];
try {
const td = new TextDecoder('utf-8', {fatal:false});
const orig = crypto.subtle.digest.bind(crypto.subtle);
crypto.subtle.digest = function(alg, data) {
try {
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
} catch (e) {}
return orig(alg, data);
};
const of = window.fetch;
window.fetch = function(input, init) {
try {
let hdrs = (init && init.headers); let sid = null;
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
} catch (e) {}
return of.apply(this, arguments);
};
} catch (e) {}
})();
`
var (
statsigRefreshTrigger = make(chan struct{}, 1)
statsigRefreshStarted sync.Once
statsigLastCapture time.Time
statsigLastCaptureMu sync.Mutex
)
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
// statsigID and drops the per-session challenge cache so the next request adopts
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
if err != nil {
return err
}
if len(h) != 49 {
return errors.New("statsig: header must be 49 bytes")
}
if strings.TrimSpace(suffix) == "" {
return errors.New("statsig: empty suffix")
}
if trailer < 0 || trailer > 255 {
return errors.New("statsig: trailer out of range")
}
statsigMu.Lock()
statsigHeader = h
statsigSuffix = suffix
statsigTrailer = byte(trailer)
statsigCache = map[string]statsigChallenge{}
statsigMu.Unlock()
return nil
}
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
func chromeExecPath() string {
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
return p
}
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
return ""
}
type statsigCapResult struct {
Digs []string `json:"digs"`
Caps []string `json:"caps"`
}
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
// signer output, returning the current (headerHex, suffix, trailer). It never
// mutates global state. Returns an error if Chrome is unavailable or grok's
// signer output could not be observed (e.g. blocked before the signer ran).
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
chromePath := chromeExecPath()
if chromePath == "" {
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
}
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(chromePath),
chromedp.Flag("headless", "new"),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.UserAgent(userAgent),
)
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
opts = append(opts, chromedp.ProxyServer(proxy))
}
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
defer cancel()
bctx, cancel2 := chromedp.NewContext(allocCtx)
defer cancel2()
var res statsigCapResult
runErr := chromedp.Run(bctx,
chromedp.ActionFunc(func(ctx context.Context) error {
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
return err
}),
chromedp.Navigate(apiBase+"/"),
chromedp.Sleep(14*time.Second),
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
)
if runErr != nil {
return "", "", 0, runErr
}
return parseStatsigCapture(res)
}
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
// digest plaintexts and x-statsig-id headers of one page load. Both must be
// present and are self-consistent (same per-load fingerprint F).
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
for _, d := range res.Digs {
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
suffix = d[i:]
break
}
}
if suffix == "" {
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
}
var header []byte
for _, sid := range res.Caps {
raw, derr := base64.RawStdEncoding.DecodeString(sid)
if derr != nil || len(raw) != 70 {
continue
}
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
r := make([]byte, 70)
for i := range raw {
r[i] = raw[i] ^ key
}
if r[0] != 0x00 {
continue
}
header = r[0:49]
trailer = int(r[69])
break
}
if header == nil {
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
}
return hex.EncodeToString(header), suffix, trailer, nil
}
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
func TriggerStatsigRefresh() {
select {
case statsigRefreshTrigger <- struct{}{}:
default:
}
}
// StartStatsigAutoRefresh launches the headless capture loop once. It is
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
// values (load), captures once at startup so the process starts fresh, and then
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
// Captured recipes are handed to `save` for persistence. No-op when a manual
// GROK_STATSIG_* override is set or no Chrome binary is available.
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
statsigRefreshStarted.Do(func() {
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
return
}
if chromeExecPath() == "" {
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
return
}
if load != nil {
if h, s, t, ok := load(ctx); ok {
if err := SetStatsigRecipe(h, s, t); err != nil {
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
} else {
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
}
}
}
go statsigRefreshLoop(ctx, safetyInterval, save)
})
}
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
refresh := func(reason string) {
statsigLastCaptureMu.Lock()
if time.Since(statsigLastCapture) < 30*time.Second {
statsigLastCaptureMu.Unlock()
return // debounce bursts (e.g. a 403 storm across concurrent requests)
}
statsigLastCapture = time.Now()
statsigLastCaptureMu.Unlock()
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
h, s, t, err := CaptureStatsigRecipe(cctx)
if err != nil {
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
return
}
if err := SetStatsigRecipe(h, s, t); err != nil {
log.Printf("grok statsig: captured recipe invalid: %v", err)
return
}
if save != nil {
save(ctx, h, s, t)
}
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
}
initial := time.NewTimer(10 * time.Second)
defer initial.Stop()
// Optional slow backstop only; disabled (nil channel blocks forever) when
// safetyInterval<=0 so the loop stays purely event-driven.
var tickC <-chan time.Time
if safetyInterval > 0 {
ticker := time.NewTicker(safetyInterval)
defer ticker.Stop()
tickC = ticker.C
}
for {
select {
case <-ctx.Done():
return
case <-initial.C:
refresh("startup")
case <-tickC:
refresh("safety-interval")
case <-statsigRefreshTrigger:
refresh("anti-bot 403")
}
}
}
func safePrefix(s string, n int) string {
if len(s) < n {
return s
}
return s[:n]
}
+3 -1
View File
@@ -448,7 +448,9 @@ func mapStatus(path string, status int, raw []byte) error {
case status == 403 && isBotChallenge(string(raw)):
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
// NOT a dead token — transient, so a good account isn't killed by an
// IP/anti-bot hiccup.
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
// went stale on a reship; kick the headless refresher to re-capture.
TriggerStatsigRefresh()
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
case status == 401 || status == 403:
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
+57 -11
View File
@@ -18,6 +18,8 @@ const pageSize = ref(20)
const showAdd = ref(false)
const editing = ref(null)
const recharging = ref(null) // the user whose credits are being topped up
const rechargeAmt = ref(0)
const toast = ref('')
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' })
@@ -132,9 +134,11 @@ async function saveEdit() {
// Email + 用户名 are intentionally NOT in the patch — they're displayed
// read-only in the form, and the admin shouldn't be in the habit of
// rewriting a user's identity from this page.
// 积分 intentionally NOT patched here — editing must never touch the live
// balance (a stale snapshot would clobber credits the user spent meanwhile).
// Credits are adjusted only through the atomic 充值 button (/credits).
const patch = {
status: u.status,
credits: u.credits,
role: u.role,
notes: u.notes || '',
concurrency_group_id: u.concurrency_group_id || '',
@@ -192,6 +196,15 @@ async function quickCredits(u, delta) {
if (r.ok) { flash(`${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() }
else flash(r.data?.detail || '调整失败')
}
function openRecharge(u) { recharging.value = u; rechargeAmt.value = 0 }
async function doRecharge() {
const delta = Math.round(Number(rechargeAmt.value) || 0)
if (!delta) { flash('请输入积分数量'); return }
// Atomic increment on the server — never overwrites the live balance.
await quickCredits(recharging.value, delta)
recharging.value = null; rechargeAmt.value = 0
}
</script>
<template>
@@ -263,12 +276,13 @@ async function quickCredits(u, delta) {
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
</div>
<table v-else class="w-full text-sm table-fixed">
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1360px]">
<colgroup>
<col class="w-9" /> <!-- select -->
<col class="w-40" /> <!-- username -->
<col class="w-36" /> <!-- username -->
<col /> <!-- email (flex) -->
<col class="w-36" /> <!-- notes -->
<col class="w-24" /> <!-- notes -->
<col class="w-28" /> <!-- concurrency -->
<col class="w-20" /> <!-- role -->
<col class="w-16" /> <!-- status switch -->
@@ -279,7 +293,7 @@ async function quickCredits(u, delta) {
<col class="w-28" /> <!-- registered -->
<col class="w-28" /> <!-- last login -->
<col class="w-32" /> <!-- login IP -->
<col class="w-24" /> <!-- actions -->
<col class="w-32" /> <!-- actions -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
@@ -287,7 +301,7 @@ async function quickCredits(u, delta) {
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
class="chk" title="全选" />
</th>
<th class="text-left px-5 py-3 font-medium">用户名</th>
<th class="text-left px-3 py-3 font-medium">用户名</th>
<th class="text-left px-3 py-3 font-medium">邮箱</th>
<th class="text-left px-3 py-3 font-medium">备注</th>
<th class="text-left px-3 py-3 font-medium">并发</th>
@@ -310,7 +324,7 @@ async function quickCredits(u, delta) {
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
class="chk" />
</td>
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
<td class="px-3 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
{{ u.name || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
@@ -376,6 +390,9 @@ async function quickCredits(u, delta) {
</td>
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1">
<button @click="openRecharge(u)" class="act" title="充值">
<Icon name="spark" class="w-3.5 h-3.5" />
</button>
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
<Icon name="config" class="w-3.5 h-3.5" />
</button>
@@ -387,6 +404,7 @@ async function quickCredits(u, delta) {
</tr>
</tbody>
</table>
</div>
<!-- pagination -->
<div v-if="!loading && totalPages > 1"
@@ -484,10 +502,6 @@ async function quickCredits(u, delta) {
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
</div>
<div>
<label class="lbl">积分</label>
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
</div>
<div>
<label class="lbl">并发分组 <span class="text-white/35">(限制同时生成数)</span></label>
<SelectMenu v-model="editing.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
@@ -508,6 +522,38 @@ async function quickCredits(u, delta) {
</div>
</div>
<!-- Recharge modal -->
<div v-if="recharging"
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
@click.self="recharging = null">
<div class="card !shadow-2xl my-12 w-full max-w-sm">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">充值积分</h2>
<button @click="recharging = null" class="text-white/40 hover:text-white">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-3">
<div>
<label class="lbl">用户</label>
<input :value="recharging.email || recharging.name || recharging.id" disabled class="field font-mono" />
</div>
<div>
<label class="lbl">当前积分</label>
<input :value="points(recharging.credits).toLocaleString('en-US')" disabled class="field tabular-nums" />
</div>
<div>
<label class="lbl">充值数量 <span class="text-white/35">(正数增加,负数扣除)</span></label>
<input v-model.number="rechargeAmt" type="number" step="1" class="field" placeholder="例如 1000" autofocus />
</div>
<div class="flex justify-end gap-2 pt-2">
<button @click="recharging = null" class="btn-soft">取消</button>
<button @click="doRecharge" class="btn-primary">确认充值</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<transition name="fade">
<div v-if="toast"