feat: 并发分组系统 + 品牌/Logo 上传 + 兑换码开关 + 文档分辨率表
并发分组(新功能): - 新表 concurrency_groups(名称/上限/默认),用户加 concurrency_group_id - 启动自动建「默认并发」组(上限10、默认),老用户回填、新注册自动绑定 - 并发计数改用 Redis(自愈 sorted-set + TTL + fail-open): · 用户并发(画图台 + API key 合计)受其分组上限限制,0=不限制 → 超返回 429 · 账号级并发也从内存 gate 换成同一套 Redis(6 处调用点) · 移除旧的「已有正在生成的任务」单任务锁 - 后台「并发分组」新菜单:增删改、设默认、用户数;默认组不可删(删别的组成员转默认) - 用户管理:并发列 + 新建/编辑可选分组 - 个人设置页:账户信息卡(用户名/邮箱/角色/余额/并发);/me 暴露 concurrency_group/limit 品牌 / Logo(上传到 RustFS): - Logo 改成拖拽/点击上传,点保存才上传;替换自动删旧;branding/ 设为公开且被清理任务 pin 住(永不删) - 有自定义就用:前台左侧 nav + 后台侧栏 + favicon(浏览器标签);没有则默认 V 图标 - 前台页头还原成文字;首页 Hero 子标题用 site.subtitle(默认那句宣传语,设置页预填) - 邮件验证码标题用站点名;新增 POST/DELETE /settings/logo + POST /settings/asset(首页底图上传) 兑换码开关: - 系统设置→积分 新增「开启兑换码」(默认开);关闭后后端拒绝兑换、前台隐藏兑换入口(/site 暴露 cdk_redeem_enabled) 文档 / 分辨率: - 去掉 quality 参数:size(宽x高)同时决定比例 + 分辨率档(长边映射 1K/2K/4K) - 文档加「分辨率对照表」(14 个比例 × 1K/2K/4K → size 该传的值);guessRatio 与自定义模型 RATIO_OPTS 对齐到 14 个 其它: - 删模型时同步清掉各上游账号「支持模型」里的该 id - 首页设置/兑换码弹窗去固定高度滚动条;展示位弹窗浅色主题适配 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -96,12 +97,19 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
apiKeyRepo := repo.NewAPIKeyRepository(db)
|
||||
tokenRepo := repo.NewTokenRepository(db)
|
||||
refreshRepo := repo.NewRefreshProfileRepository(db)
|
||||
cgroupRepo := repo.NewConcurrencyGroupRepository(db)
|
||||
// Seed the "默认并发" group (cap 10) and bind any ungrouped users to it.
|
||||
if err := cgroupRepo.EnsureDefault(ctx); err != nil {
|
||||
log.Printf("ensure default concurrency group: %v", err)
|
||||
}
|
||||
concSvc := service.NewConcurrencyService(rdb)
|
||||
cgroupSvc := service.NewConcurrencyGroupService(cgroupRepo, concSvc)
|
||||
sessionSvc := service.NewSessionService(rdb, cfg.SessionTTL, cfg.SessionSlideAfter)
|
||||
emailCodeSvc := service.NewEmailCodeService(rdb)
|
||||
smtpSvc := service.NewSMTPService()
|
||||
rateLimitSvc := service.NewRateLimitService(rdb)
|
||||
rustfsClient := storage.New(cfg.RustFSEndpoint, cfg.RustFSBucket, cfg.RustFSAccessKey, cfg.RustFSSecretKey)
|
||||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc)
|
||||
authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc, cgroupRepo)
|
||||
appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient)
|
||||
imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc)
|
||||
adobeClient := adobe.NewClient("clio-playground-web", "")
|
||||
@@ -112,12 +120,12 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
imagineClient := imagine.NewClient("")
|
||||
grokClient := grok.NewClient("")
|
||||
customClient := custom.NewClient()
|
||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
|
||||
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)
|
||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo)
|
||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo)
|
||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo)
|
||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||||
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient)
|
||||
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
|
||||
@@ -141,6 +149,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc),
|
||||
UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc),
|
||||
ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc),
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
})
|
||||
|
||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||
|
||||
@@ -34,6 +34,7 @@ func seedDefaults(ctx context.Context, db *gorm.DB) error {
|
||||
{Key: "credits.checkin_reward", Value: "3"},
|
||||
{Key: "credits.invite_enabled", Value: "true"},
|
||||
{Key: "credits.invite_reward", Value: "3"},
|
||||
{Key: "credits.cdk_redeem_enabled", Value: "true"},
|
||||
{Key: "logs.retention_days", Value: "30"},
|
||||
{Key: "media.retention_days", Value: "30"},
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ func userPublic(user model.User) gin.H {
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"notes": user.Notes,
|
||||
"concurrency_group_id": user.ConcurrencyGroupID,
|
||||
"created_at": unixSec(user.CreatedAt),
|
||||
"last_login_at": unixSecPtr(user.LastLoginAt),
|
||||
"last_login_ip": user.LastLoginIP,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,6 +17,87 @@ func NewAppSettingsHandler(settings *service.AppSettingsService) *AppSettingsHan
|
||||
return &AppSettingsHandler{settings: settings}
|
||||
}
|
||||
|
||||
// LogoUpload stores a base64 image as the site logo in RustFS (deleting the old
|
||||
// one) and persists site.logo. Called on 保存 — not on file pick.
|
||||
func (h *AppSettingsHandler) LogoUpload(c *gin.Context) {
|
||||
var body struct {
|
||||
Data string `json:"data"` // base64, optionally a "data:...;base64," URL
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(body.Data)
|
||||
if strings.HasPrefix(raw, "data:") {
|
||||
if i := strings.Index(raw, ","); i >= 0 {
|
||||
if body.ContentType == "" {
|
||||
meta := raw[5:i] // e.g. image/png;base64
|
||||
if j := strings.Index(meta, ";"); j >= 0 {
|
||||
body.ContentType = meta[:j]
|
||||
}
|
||||
}
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片解码失败"})
|
||||
return
|
||||
}
|
||||
url, err := h.settings.UploadLogo(c.Request.Context(), data, body.ContentType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "logo": url})
|
||||
}
|
||||
|
||||
// AssetUpload stores a public image (homepage 底图 etc.) in RustFS and returns
|
||||
// its storage path for the caller to save (e.g. as a showcase card's image).
|
||||
func (h *AppSettingsHandler) AssetUpload(c *gin.Context) {
|
||||
var body struct {
|
||||
Data string `json:"data"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(body.Data)
|
||||
if strings.HasPrefix(raw, "data:") {
|
||||
if i := strings.Index(raw, ","); i >= 0 {
|
||||
if body.ContentType == "" {
|
||||
meta := raw[5:i]
|
||||
if j := strings.Index(meta, ";"); j >= 0 {
|
||||
body.ContentType = meta[:j]
|
||||
}
|
||||
}
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "图片解码失败"})
|
||||
return
|
||||
}
|
||||
path, err := h.settings.UploadAsset(c.Request.Context(), data, body.ContentType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
|
||||
}
|
||||
|
||||
// LogoDelete removes the uploaded logo and falls back to the built-in default.
|
||||
func (h *AppSettingsHandler) LogoDelete(c *gin.Context) {
|
||||
if err := h.settings.RemoveLogo(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to remove logo"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "logo": ""})
|
||||
}
|
||||
|
||||
func (h *AppSettingsHandler) RegistrationGet(c *gin.Context) {
|
||||
data, err := h.settings.Registration(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ConcurrencyGroupHandler struct {
|
||||
svc *service.ConcurrencyGroupService
|
||||
}
|
||||
|
||||
func NewConcurrencyGroupHandler(svc *service.ConcurrencyGroupService) *ConcurrencyGroupHandler {
|
||||
return &ConcurrencyGroupHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *ConcurrencyGroupHandler) List(c *gin.Context) {
|
||||
items, err := h.svc.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load concurrency groups"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ConcurrencyGroupHandler) Create(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
g, err := h.svc.Create(c.Request.Context(), body.Name, body.MaxConcurrency)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": g.ID})
|
||||
}
|
||||
|
||||
func (h *ConcurrencyGroupHandler) Update(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
g, err := h.svc.Update(c.Request.Context(), c.Param("id"), body.Name, body.MaxConcurrency)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": g.ID})
|
||||
}
|
||||
|
||||
func (h *ConcurrencyGroupHandler) SetDefault(c *gin.Context) {
|
||||
if err := h.svc.SetDefault(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *ConcurrencyGroupHandler) Delete(c *gin.Context) {
|
||||
if err := h.svc.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "分组不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func (h *SiteHandler) Public(c *gin.Context) {
|
||||
"title": title,
|
||||
"logo": h.site.Logo(ctx),
|
||||
"subtitle": h.site.Subtitle(ctx),
|
||||
"cdk_redeem_enabled": h.site.CDKRedeemEnabled(ctx),
|
||||
"contact": h.site.Contact(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Logo string `json:"logo"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Contact service.Contact `json:"contact"`
|
||||
}
|
||||
@@ -53,8 +52,8 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"})
|
||||
return
|
||||
}
|
||||
if err := h.site.SetBranding(ctx, body.Logo, body.Subtitle); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save branding"})
|
||||
if err := h.site.SetSubtitle(ctx, body.Subtitle); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save subtitle"})
|
||||
return
|
||||
}
|
||||
if err := h.site.SetContact(ctx, body.Contact); err != nil {
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderExecution):
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()})
|
||||
@@ -136,7 +136,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrNoProviderAccount):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
|
||||
@@ -72,7 +72,6 @@ func (h *V1Handler) ImageGenerations(c *gin.Context) {
|
||||
Prompt: body.Prompt,
|
||||
N: body.N,
|
||||
Size: body.Size,
|
||||
Quality: body.Quality,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -110,7 +109,6 @@ func (h *V1Handler) ImageEdits(c *gin.Context) {
|
||||
Prompt: c.PostForm("prompt"),
|
||||
N: n,
|
||||
Size: c.PostForm("size"),
|
||||
Quality: c.PostForm("quality"),
|
||||
ReferenceImages: refs,
|
||||
BaseURL: requestBaseURL(c),
|
||||
})
|
||||
@@ -336,7 +334,7 @@ func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]a
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderTemporary):
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrConcurrencyFull):
|
||||
case errors.Is(err, service.ErrConcurrencyFull), errors.Is(err, service.ErrUserConcurrencyFull):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrVideoJobNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
|
||||
@@ -25,6 +25,7 @@ type Handlers struct {
|
||||
UserTools *handler.UserToolsHandler
|
||||
UserGen *handler.UserGenerationHandler
|
||||
ProviderAdmin *handler.ProviderAdminHandler
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
@@ -96,6 +97,11 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.POST("/users/:user_id/credits", handlers.AdminWrite.AdjustUserCredits)
|
||||
authed.POST("/users/:user_id/api-keys", handlers.AdminWrite.CreateUserAPIKey)
|
||||
authed.DELETE("/users/:user_id/api-keys/:key_id", handlers.AdminWrite.DeleteUserAPIKey)
|
||||
authed.GET("/concurrency-groups", handlers.ConcGroups.List)
|
||||
authed.POST("/concurrency-groups", handlers.ConcGroups.Create)
|
||||
authed.PATCH("/concurrency-groups/:id", handlers.ConcGroups.Update)
|
||||
authed.POST("/concurrency-groups/:id/default", handlers.ConcGroups.SetDefault)
|
||||
authed.DELETE("/concurrency-groups/:id", handlers.ConcGroups.Delete)
|
||||
authed.GET("/cdks", handlers.CDK.List)
|
||||
authed.POST("/cdks", handlers.CDK.Create)
|
||||
authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk)
|
||||
@@ -135,6 +141,9 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettings.Get)
|
||||
settings.PUT("/site", handlers.SiteSettings.Put)
|
||||
settings.POST("/logo", handlers.AppSettings.LogoUpload)
|
||||
settings.DELETE("/logo", handlers.AppSettings.LogoDelete)
|
||||
settings.POST("/asset", handlers.AppSettings.AssetUpload)
|
||||
settings.GET("/registration", handlers.AppSettings.RegistrationGet)
|
||||
settings.PUT("/registration", handlers.AppSettings.RegistrationPut)
|
||||
settings.GET("/smtp", handlers.AppSettings.SMTPGet)
|
||||
|
||||
@@ -15,6 +15,7 @@ type User struct {
|
||||
Status string `gorm:"size:32;index;not null"`
|
||||
Credits float64 `gorm:"not null;default:0"`
|
||||
Notes string `gorm:"type:text"`
|
||||
ConcurrencyGroupID string `gorm:"size:32;index"`
|
||||
InviteCode string `gorm:"size:32;uniqueIndex"`
|
||||
InvitedBy *string `gorm:"size:32;index"`
|
||||
InviteRewardDone bool `gorm:"not null;default:false"`
|
||||
@@ -203,9 +204,22 @@ func AutoMigrateModels() []any {
|
||||
&RefreshProfile{},
|
||||
&SiteSetting{},
|
||||
&StatCounter{},
|
||||
&ConcurrencyGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
// ConcurrencyGroup caps how many generations a member user may run AT ONCE
|
||||
// (across their API key + 画图台). MaxConcurrency 0 = unlimited. Exactly one
|
||||
// group is IsDefault — new users are bound to it and it can't be deleted.
|
||||
type ConcurrencyGroup struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
MaxConcurrency int `gorm:"not null;default:10"` // 0 = 不限制
|
||||
IsDefault bool `gorm:"not null;default:false;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// StatCounter is a persistent monotonic counter (key → value), independent of the
|
||||
// event_log (which is retention-pruned / clearable). Used for the dashboard
|
||||
// cumulative cards (total/success/failed/image/video/api) so they never reset.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConcurrencyGroupRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConcurrencyGroupRepository(db *gorm.DB) *ConcurrencyGroupRepository {
|
||||
return &ConcurrencyGroupRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ConcurrencyGroupRepository) List(ctx context.Context) ([]model.ConcurrencyGroup, error) {
|
||||
var items []model.ConcurrencyGroup
|
||||
// Default first, then by name — stable ordering for the admin table.
|
||||
err := r.db.WithContext(ctx).Order("is_default desc, created_at asc").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *ConcurrencyGroupRepository) Get(ctx context.Context, id string) (*model.ConcurrencyGroup, error) {
|
||||
var g model.ConcurrencyGroup
|
||||
if err := r.db.WithContext(ctx).First(&g, "id = ?", id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (r *ConcurrencyGroupRepository) GetDefault(ctx context.Context) (*model.ConcurrencyGroup, error) {
|
||||
var g model.ConcurrencyGroup
|
||||
if err := r.db.WithContext(ctx).First(&g, "is_default = ?", true).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (r *ConcurrencyGroupRepository) Create(ctx context.Context, g *model.ConcurrencyGroup) error {
|
||||
now := time.Now()
|
||||
g.CreatedAt = now
|
||||
g.UpdatedAt = now
|
||||
return r.db.WithContext(ctx).Create(g).Error
|
||||
}
|
||||
|
||||
func (r *ConcurrencyGroupRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.ConcurrencyGroup, error) {
|
||||
patch["updated_at"] = time.Now()
|
||||
if err := r.db.WithContext(ctx).Model(&model.ConcurrencyGroup{}).Where("id = ?", id).Updates(patch).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
// SetDefault makes id the sole default (used for new registrations).
|
||||
func (r *ConcurrencyGroupRepository) SetDefault(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.ConcurrencyGroup{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.ConcurrencyGroup{}).Where("id = ?", id).Update("is_default", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a group and reassigns its members to the default group, so no
|
||||
// user is left without a concurrency limit. The default group itself is never
|
||||
// deletable (guarded in the service).
|
||||
func (r *ConcurrencyGroupRepository) Delete(ctx context.Context, id, defaultID string) (int64, error) {
|
||||
var rows int64
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.User{}).Where("concurrency_group_id = ?", id).
|
||||
Update("concurrency_group_id", defaultID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Delete(&model.ConcurrencyGroup{}, "id = ?", id)
|
||||
rows = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// UserCounts returns group_id → number of bound users.
|
||||
func (r *ConcurrencyGroupRepository) UserCounts(ctx context.Context) (map[string]int64, error) {
|
||||
type row struct {
|
||||
GroupID string
|
||||
N int64
|
||||
}
|
||||
var rows []row
|
||||
if err := r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Select("concurrency_group_id as group_id, count(*) as n").
|
||||
Where("concurrency_group_id <> ''").
|
||||
Group("concurrency_group_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, x := range rows {
|
||||
out[x.GroupID] = x.N
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnsureDefault creates the seed "默认并发" group (MaxConcurrency 10) when none
|
||||
// exists, and binds any ungrouped users to the default. Idempotent — safe at boot.
|
||||
func (r *ConcurrencyGroupRepository) EnsureDefault(ctx context.Context) error {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.ConcurrencyGroup{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
now := time.Now()
|
||||
if err := r.db.WithContext(ctx).Create(&model.ConcurrencyGroup{
|
||||
ID: "cg-default", Name: "默认并发", MaxConcurrency: 10, IsDefault: true,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
def, err := r.GetDefault(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Bind ungrouped users to the default group.
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("concurrency_group_id = '' OR concurrency_group_id IS NULL").
|
||||
Update("concurrency_group_id", def.ID).Error
|
||||
}
|
||||
@@ -238,6 +238,15 @@ func (r *EventRepository) CountBetween(ctx context.Context, start, end time.Time
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountPendingByUser returns how many generations the user currently has
|
||||
// in-flight (status=pending) — used to enforce the per-user concurrency limit.
|
||||
func (r *EventRepository) CountPendingByUser(ctx context.Context, userID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
|
||||
Where("user_id = ? AND status = ?", userID, "pending").Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DistinctUsersSince counts distinct (non-empty) user_ids active since `since`.
|
||||
func (r *EventRepository) DistinctUsersSince(ctx context.Context, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
|
||||
@@ -27,15 +27,17 @@ type AdminWriteService struct {
|
||||
models *repo.ModelRepository
|
||||
events *repo.EventRepository
|
||||
apiKeys *repo.APIKeyRepository
|
||||
tokens *repo.TokenRepository
|
||||
}
|
||||
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository) *AdminWriteService {
|
||||
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository) *AdminWriteService {
|
||||
return &AdminWriteService{
|
||||
users: users,
|
||||
showcase: showcase,
|
||||
models: models,
|
||||
events: events,
|
||||
apiKeys: apiKeys,
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any)
|
||||
status := normalizedStatus(stringValue(body["status"]))
|
||||
credits := maxFloat(0, floatValue(body["credits"]))
|
||||
notes := strings.TrimSpace(stringValue(body["notes"]))
|
||||
cgroupID := strings.TrimSpace(stringValue(body["concurrency_group_id"]))
|
||||
|
||||
exists, err := s.users.ExistsEmail(ctx, email, "")
|
||||
if err != nil {
|
||||
@@ -99,6 +102,7 @@ func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any)
|
||||
Status: status,
|
||||
Credits: credits,
|
||||
Notes: notes,
|
||||
ConcurrencyGroupID: cgroupID,
|
||||
InviteCode: randomInviteCode(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
@@ -161,6 +165,9 @@ func (s *AdminWriteService) UpdateUser(ctx context.Context, userID string, body
|
||||
if _, ok := body["notes"]; ok {
|
||||
patch["notes"] = strings.TrimSpace(stringValue(body["notes"]))
|
||||
}
|
||||
if _, ok := body["concurrency_group_id"]; ok {
|
||||
patch["concurrency_group_id"] = strings.TrimSpace(stringValue(body["concurrency_group_id"]))
|
||||
}
|
||||
if _, ok := body["password"]; ok && strings.TrimSpace(stringValue(body["password"])) != "" {
|
||||
if err := ValidatePassword(stringValue(body["password"])); err != nil {
|
||||
return nil, err
|
||||
@@ -458,9 +465,52 @@ func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) err
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Clean up: strip this model id from every custom upstream account's
|
||||
// supported-models list so no orphan reference is left behind.
|
||||
s.removeModelFromUpstreams(ctx, modelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeModelFromUpstreams drops modelID from the CSV in each custom account's
|
||||
// Meta["models"]. Best-effort — a failure here doesn't undo the model delete.
|
||||
func (s *AdminWriteService) removeModelFromUpstreams(ctx context.Context, modelID string) {
|
||||
if s.tokens == nil {
|
||||
return
|
||||
}
|
||||
items, err := s.tokens.ListByPool(ctx, "custom")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
raw, _ := it.Meta["models"].(string)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(strings.Split(raw, ",")))
|
||||
changed := false
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == modelID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
meta := datatypes.JSONMap{}
|
||||
for k, v := range it.Meta {
|
||||
meta[k] = v
|
||||
}
|
||||
meta["models"] = strings.Join(kept, ",")
|
||||
_, _ = s.tokens.Update(ctx, "custom", it.ID, map[string]any{"meta": meta})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) {
|
||||
return s.events.DeleteAll(ctx)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ type CreditSettings struct {
|
||||
CheckinReward int `json:"checkin_reward"`
|
||||
InviteEnabled bool `json:"invite_enabled"`
|
||||
InviteReward int `json:"invite_reward"`
|
||||
CDKRedeemEnabled bool `json:"cdk_redeem_enabled"`
|
||||
}
|
||||
|
||||
type ProxySettings struct {
|
||||
@@ -70,6 +71,75 @@ func NewAppSettingsService(settings *repo.SiteSettingRepository, events *repo.Ev
|
||||
}
|
||||
}
|
||||
|
||||
// UploadLogo stores a new site logo in object storage under branding/, deletes
|
||||
// the previously-uploaded one (if any), persists site.logo, and returns its URL.
|
||||
func (s *AppSettingsService) UploadLogo(ctx context.Context, data []byte, contentType string) (string, error) {
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return "", errors.New("对象存储未配置")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errors.New("空文件")
|
||||
}
|
||||
if len(data) > 4*1024*1024 {
|
||||
return "", errors.New("logo 不能超过 4MB")
|
||||
}
|
||||
key := "branding/logo-" + randomUpper(10) + "." + logoExt(contentType)
|
||||
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
url := "/images/" + key
|
||||
// Delete the previous uploaded logo (best-effort), then point site.logo at the new one.
|
||||
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") {
|
||||
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
|
||||
}
|
||||
if err := s.settings.UpsertValue(ctx, "site.logo", url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// UploadAsset stores a public image (e.g. a 首页内容 底图) under branding/ and
|
||||
// returns its storage path (for form.image). Does NOT touch site settings.
|
||||
func (s *AppSettingsService) UploadAsset(ctx context.Context, data []byte, contentType string) (string, error) {
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return "", errors.New("对象存储未配置")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", errors.New("空文件")
|
||||
}
|
||||
if len(data) > 8*1024*1024 {
|
||||
return "", errors.New("图片不能超过 8MB")
|
||||
}
|
||||
key := "branding/sc-" + randomUpper(10) + "." + logoExt(contentType)
|
||||
if err := s.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// RemoveLogo deletes the uploaded logo and resets site.logo to the built-in default (empty).
|
||||
func (s *AppSettingsService) RemoveLogo(ctx context.Context) error {
|
||||
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") && s.store != nil {
|
||||
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
|
||||
}
|
||||
return s.settings.UpsertValue(ctx, "site.logo", "")
|
||||
}
|
||||
|
||||
func logoExt(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/svg+xml":
|
||||
return "svg"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
default:
|
||||
return "png"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppSettingsService) Registration(ctx context.Context) (*RegistrationSettings, error) {
|
||||
openRaw, err := s.settings.GetValue(ctx, "auth.open")
|
||||
if err != nil {
|
||||
@@ -281,11 +351,13 @@ func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdkRaw, _ := s.settings.GetValue(ctx, "credits.cdk_redeem_enabled")
|
||||
return &CreditSettings{
|
||||
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
|
||||
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
|
||||
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
|
||||
InviteReward: parseIntSetting(inviteRewardRaw, 3),
|
||||
CDKRedeemEnabled: parseBoolSetting(cdkRaw, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -301,6 +373,7 @@ func (s *AppSettingsService) SaveCredits(ctx context.Context, in CreditSettings)
|
||||
"credits.checkin_reward": strconv.Itoa(in.CheckinReward),
|
||||
"credits.invite_enabled": strconv.FormatBool(in.InviteEnabled),
|
||||
"credits.invite_reward": strconv.Itoa(in.InviteReward),
|
||||
"credits.cdk_redeem_enabled": strconv.FormatBool(in.CDKRedeemEnabled),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type AuthService struct {
|
||||
codes *EmailCodeService
|
||||
smtp *SMTPService
|
||||
loginGuard *LoginGuard
|
||||
cgroups *repo.ConcurrencyGroupRepository
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
@@ -39,6 +40,7 @@ func NewAuthService(
|
||||
sessions *SessionService,
|
||||
codes *EmailCodeService,
|
||||
smtp *SMTPService,
|
||||
cgroups *repo.ConcurrencyGroupRepository,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
users: users,
|
||||
@@ -47,6 +49,7 @@ func NewAuthService(
|
||||
codes: codes,
|
||||
smtp: smtp,
|
||||
loginGuard: NewLoginGuard(codes.Redis()),
|
||||
cgroups: cgroups,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +303,12 @@ func (s *AuthService) Register(ctx context.Context, email, username, password, i
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
// Bind new users to the default concurrency group.
|
||||
if s.cgroups != nil {
|
||||
if def, derr := s.cgroups.GetDefault(ctx); derr == nil && def != nil {
|
||||
user.ConcurrencyGroupID = def.ID
|
||||
}
|
||||
}
|
||||
if err := s.users.Create(ctx, user); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
@@ -426,6 +435,20 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Concurrency group + its cap (0 = unlimited) for the profile page.
|
||||
concName, concMax := "", 0
|
||||
if s.cgroups != nil {
|
||||
var g *model.ConcurrencyGroup
|
||||
if user.ConcurrencyGroupID != "" {
|
||||
g, _ = s.cgroups.Get(ctx, user.ConcurrencyGroupID)
|
||||
}
|
||||
if g == nil {
|
||||
g, _ = s.cgroups.GetDefault(ctx)
|
||||
}
|
||||
if g != nil {
|
||||
concName, concMax = g.Name, g.MaxConcurrency
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
@@ -433,6 +456,8 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"credits": user.Credits,
|
||||
"concurrency_group": concName,
|
||||
"concurrency_limit": concMax,
|
||||
"checkin_last": user.CheckinLast,
|
||||
"checkin_streak": user.CheckinStreak,
|
||||
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
type CDKService struct {
|
||||
cdks *repo.CDKRepository
|
||||
users *repo.UserRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
}
|
||||
|
||||
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository) *CDKService {
|
||||
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository) *CDKService {
|
||||
return &CDKService{
|
||||
cdks: cdks,
|
||||
users: users,
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +129,12 @@ func (s *CDKService) DeleteBulk(ctx context.Context, codes []string) (int, error
|
||||
}
|
||||
|
||||
func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[string]any, error) {
|
||||
// Honor the admin "兑换码" switch — when off, no code can be redeemed.
|
||||
if s.settings != nil {
|
||||
if v, _ := s.settings.GetValue(ctx, "credits.cdk_redeem_enabled"); v == "false" {
|
||||
return nil, errors.New("兑换功能已关闭")
|
||||
}
|
||||
}
|
||||
code = strings.TrimSpace(strings.ToUpper(code))
|
||||
if code == "" {
|
||||
return nil, errors.New("请输入兑换码")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ConcurrencyService is a Redis-backed, self-healing concurrency limiter shared
|
||||
// by the per-user gate (画图台 + API key) and the per-account upstream gate.
|
||||
//
|
||||
// Each slot is a member of a sorted set keyed by the subject (user/account),
|
||||
// scored with its expiry time. Acquire prunes expired members first, so a slot
|
||||
// whose Release was lost (crash / missed defer) auto-frees after the TTL — the
|
||||
// count can never leak forever. It's intentionally lossy-tolerant: if Redis is
|
||||
// unavailable it FAILS OPEN (allows the work) rather than blocking generation.
|
||||
type ConcurrencyService struct {
|
||||
redis *redis.Client
|
||||
// ttl is the max lifetime of a slot — the longest a generation can run
|
||||
// (video ~3min) plus head-room, after which a stuck slot self-heals.
|
||||
ttl int
|
||||
}
|
||||
|
||||
func NewConcurrencyService(rdb *redis.Client) *ConcurrencyService {
|
||||
return &ConcurrencyService{redis: rdb, ttl: 900} // 15 min
|
||||
}
|
||||
|
||||
// acquireScript: KEYS[1]=set, ARGV[1]=max (0=unlimited), ARGV[2]=ttl secs,
|
||||
// ARGV[3]=token. Prunes expired members, then admits the token iff under max.
|
||||
// Returns 1 on success, 0 when full.
|
||||
var acquireScript = redis.NewScript(`
|
||||
local t = redis.call('TIME')
|
||||
local now = tonumber(t[1])
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
|
||||
local n = redis.call('ZCARD', KEYS[1])
|
||||
local max = tonumber(ARGV[1])
|
||||
if max > 0 and n >= max then return 0 end
|
||||
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[2]), ARGV[3])
|
||||
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
||||
return 1
|
||||
`)
|
||||
|
||||
// Acquire takes one slot under `key` (capped at max; 0 = unlimited), tagged with
|
||||
// `token`. Returns true if admitted. Fail-open when Redis is down/unset.
|
||||
func (c *ConcurrencyService) Acquire(ctx context.Context, key string, max int, token string) bool {
|
||||
if c == nil || c.redis == nil {
|
||||
return true
|
||||
}
|
||||
res, err := acquireScript.Run(ctx, c.redis, []string{key}, max, c.ttl, token).Int()
|
||||
if err != nil {
|
||||
return true // fail open — never block a generation on Redis trouble
|
||||
}
|
||||
return res == 1
|
||||
}
|
||||
|
||||
// Release frees the slot held by `token` under `key`. Safe to call even if the
|
||||
// slot already expired.
|
||||
func (c *ConcurrencyService) Release(ctx context.Context, key, token string) {
|
||||
if c == nil || c.redis == nil {
|
||||
return
|
||||
}
|
||||
_ = c.redis.ZRem(ctx, key, token).Err()
|
||||
}
|
||||
|
||||
// Count returns the live (non-expired) slot count under `key` — for display.
|
||||
func (c *ConcurrencyService) Count(ctx context.Context, key string) int {
|
||||
if c == nil || c.redis == nil {
|
||||
return 0
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_ = c.redis.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(now, 10)).Err()
|
||||
n, err := c.redis.ZCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// CountUsers returns live concurrency for many users in one round-trip
|
||||
// (group_id display etc. don't need this, but the user list does). Keyed by the
|
||||
// raw subject id passed in.
|
||||
func (c *ConcurrencyService) CountMany(ctx context.Context, prefix string, ids []string) map[string]int {
|
||||
out := make(map[string]int, len(ids))
|
||||
if c == nil || c.redis == nil || len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
pipe := c.redis.Pipeline()
|
||||
cmds := make(map[string]*redis.IntCmd, len(ids))
|
||||
for _, id := range ids {
|
||||
cmds[id] = pipe.ZCard(ctx, prefix+id)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return out
|
||||
}
|
||||
for id, cmd := range cmds {
|
||||
if n, err := cmd.Result(); err == nil && n > 0 {
|
||||
out[id] = int(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"backend/internal/model"
|
||||
"backend/internal/repo"
|
||||
)
|
||||
|
||||
// ConcurrencyGroupService manages the admin-facing concurrency groups: the
|
||||
// definitions live in the DB (repo), the live in-flight counts come from Redis.
|
||||
type ConcurrencyGroupService struct {
|
||||
repo *repo.ConcurrencyGroupRepository
|
||||
conc *ConcurrencyService
|
||||
}
|
||||
|
||||
func NewConcurrencyGroupService(r *repo.ConcurrencyGroupRepository, conc *ConcurrencyService) *ConcurrencyGroupService {
|
||||
return &ConcurrencyGroupService{repo: r, conc: conc}
|
||||
}
|
||||
|
||||
// List returns every group with its bound-user count.
|
||||
func (s *ConcurrencyGroupService) List(ctx context.Context) ([]map[string]any, error) {
|
||||
groups, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := s.repo.UserCounts(ctx)
|
||||
out := make([]map[string]any, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, map[string]any{
|
||||
"id": g.ID,
|
||||
"name": g.Name,
|
||||
"max_concurrency": g.MaxConcurrency,
|
||||
"is_default": g.IsDefault,
|
||||
"user_count": counts[g.ID],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) Create(ctx context.Context, name string, max int) (*model.ConcurrencyGroup, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("名称不能为空")
|
||||
}
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
g := &model.ConcurrencyGroup{
|
||||
ID: "cg-" + randomUpper(10), Name: name, MaxConcurrency: max, IsDefault: false,
|
||||
}
|
||||
if err := s.repo.Create(ctx, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) Update(ctx context.Context, id string, name *string, max *int) (*model.ConcurrencyGroup, error) {
|
||||
patch := map[string]any{}
|
||||
if name != nil {
|
||||
n := strings.TrimSpace(*name)
|
||||
if n == "" {
|
||||
return nil, errors.New("名称不能为空")
|
||||
}
|
||||
patch["name"] = n
|
||||
}
|
||||
if max != nil {
|
||||
m := *max
|
||||
if m < 0 {
|
||||
m = 0
|
||||
}
|
||||
patch["max_concurrency"] = m
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
return s.repo.Update(ctx, id, patch)
|
||||
}
|
||||
|
||||
func (s *ConcurrencyGroupService) SetDefault(ctx context.Context, id string) error {
|
||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.repo.SetDefault(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a group (members fall back to the default group). The default
|
||||
// group itself can never be deleted.
|
||||
func (s *ConcurrencyGroupService) Delete(ctx context.Context, id string) error {
|
||||
def, err := s.repo.GetDefault(ctx)
|
||||
if err != nil || def == nil {
|
||||
return errors.New("缺少默认并发分组")
|
||||
}
|
||||
if id == def.ID {
|
||||
return errors.New("默认并发分组不可删除")
|
||||
}
|
||||
rows, err := s.repo.Delete(ctx, id, def.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,11 @@ func (s *ImageAccessService) Resolve(user, name string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, error) {
|
||||
// Branding assets (the site logo) are public — they render on the homepage /
|
||||
// header for logged-out visitors.
|
||||
if strings.HasPrefix(rel, "branding/") {
|
||||
return true, nil
|
||||
}
|
||||
return s.showcase.IsPublicFile(ctx, rel)
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,14 @@ func (m *MaintenanceService) pruneMedia(ctx context.Context) {
|
||||
pinned = nil
|
||||
}
|
||||
}
|
||||
// The site logo is permanent too — pin it like a showcase image so the
|
||||
// retention sweep never deletes it. site.logo is "/images/<key>".
|
||||
if logo, _ := m.settings.GetValue(ctx, "site.logo"); strings.TrimSpace(logo) != "" {
|
||||
if pinned == nil {
|
||||
pinned = map[string]struct{}{}
|
||||
}
|
||||
pinned[strings.TrimPrefix(strings.TrimLeft(logo, "/"), "images/")] = struct{}{}
|
||||
}
|
||||
removed, skipped := 0, 0
|
||||
var clearedKeys []string
|
||||
for _, o := range objs {
|
||||
|
||||
@@ -51,17 +51,16 @@ func (s *SiteService) get(ctx context.Context, key string) string {
|
||||
func (s *SiteService) Logo(ctx context.Context) string { return s.get(ctx, "site.logo") }
|
||||
func (s *SiteService) Subtitle(ctx context.Context) string { return s.get(ctx, "site.subtitle") }
|
||||
|
||||
// SetBranding persists logo / subtitle (either may be empty).
|
||||
func (s *SiteService) SetBranding(ctx context.Context, logo, subtitle string) error {
|
||||
for k, v := range map[string]string{
|
||||
"site.logo": strings.TrimSpace(logo),
|
||||
"site.subtitle": strings.TrimSpace(subtitle),
|
||||
} {
|
||||
if err := s.settings.UpsertValue(ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// CDKRedeemEnabled reflects the admin "兑换码" switch (default on). The front-end
|
||||
// hides the redeem UI when off.
|
||||
func (s *SiteService) CDKRedeemEnabled(ctx context.Context) bool {
|
||||
return s.get(ctx, "credits.cdk_redeem_enabled") != "false"
|
||||
}
|
||||
|
||||
// SetSubtitle persists the homepage 子标题. (The logo is managed separately via
|
||||
// the upload/delete endpoints so a site-form save never clobbers it.)
|
||||
func (s *SiteService) SetSubtitle(ctx context.Context, subtitle string) error {
|
||||
return s.settings.UpsertValue(ctx, "site.subtitle", strings.TrimSpace(subtitle))
|
||||
}
|
||||
|
||||
// Contact is the admin-editable "联系我们" info shown in the public 关于 section.
|
||||
|
||||
@@ -39,13 +39,8 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User,
|
||||
if user == nil || strings.TrimSpace(user.ID) == "" {
|
||||
return nil, errors.New("未登录或会话已过期")
|
||||
}
|
||||
pending, err := s.events.PendingByUser(ctx, user.ID, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending != nil {
|
||||
return nil, errors.New("已有正在生成的任务,请稍候")
|
||||
}
|
||||
// No single-job lock anymore — concurrent generations are allowed, capped by
|
||||
// the user's concurrency group (enforced in prepareImageExecution/Video).
|
||||
|
||||
modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model))
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,9 @@ var (
|
||||
// ErrConcurrencyFull — every eligible account is busy (each account runs at
|
||||
// most ONE generation at a time). English message: surfaced to API / UI.
|
||||
ErrConcurrencyFull = errors.New("all accounts are busy (1 concurrent job each), please try again shortly")
|
||||
// ErrUserConcurrencyFull — the caller already has their concurrency-group's max
|
||||
// generations in flight (画图台 + API key combined). 0 = unlimited.
|
||||
ErrUserConcurrencyFull = errors.New("too many generations in progress, please wait for one to finish")
|
||||
// ErrVideoJobNotFound / ErrVideoNotReady — /v1/videos async job lookups.
|
||||
ErrVideoJobNotFound = errors.New("video job not found")
|
||||
ErrVideoNotReady = errors.New("video is not ready yet")
|
||||
@@ -66,6 +69,7 @@ type V1Service struct {
|
||||
events *repo.EventRepository
|
||||
tokens *repo.TokenRepository
|
||||
settings *repo.SiteSettingRepository
|
||||
cgroups *repo.ConcurrencyGroupRepository
|
||||
adobe *adobe.Client
|
||||
chatgpt *chatgpt.Client
|
||||
runway *runway.Client
|
||||
@@ -93,48 +97,57 @@ type V1Service struct {
|
||||
// for minutes and surface a late "success" on an already-abandoned event).
|
||||
inflight *InflightRegistry
|
||||
|
||||
// gate enforces 1 concurrent generation PER account: a scheduler skips any
|
||||
// account that's currently busy, and fails with ErrConcurrencyFull when every
|
||||
// eligible account is occupied. In-memory (single process).
|
||||
gate accountGate
|
||||
// conc is the Redis-backed concurrency limiter for BOTH the per-account
|
||||
// upstream gate (1+ jobs per account) and the per-user gate (画图台 + API key,
|
||||
// capped by the user's concurrency group). Self-healing + fail-open.
|
||||
conc *ConcurrencyService
|
||||
}
|
||||
|
||||
// accountGate is a 1-slot-per-account in-flight gate. tryAcquire wins only if the
|
||||
// account isn't already running a generation; release frees it when done.
|
||||
type accountGate struct{ m sync.Map } // accountID -> struct{} held while busy
|
||||
|
||||
// tryAcquireN wins if the account has fewer than max in-flight jobs, atomically
|
||||
// bumping its counter. max=1 is the default 1-job-per-account policy; some
|
||||
// providers (grok) allow more.
|
||||
func (g *accountGate) tryAcquireN(id string, max int) bool {
|
||||
if id == "" {
|
||||
return true
|
||||
}
|
||||
// acctAcquire takes one per-account upstream slot (capped at max; 0/1 = single),
|
||||
// tagged with the generation's eventID (unique per job; a generation only ever
|
||||
// holds one slot on a given account at a time, so failover reuses it cleanly).
|
||||
func (s *V1Service) acctAcquire(ctx context.Context, accountID, eventID string, max int) bool {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
v, _ := g.m.LoadOrStore(id, new(int64))
|
||||
cnt := v.(*int64)
|
||||
for {
|
||||
cur := atomic.LoadInt64(cnt)
|
||||
if cur >= int64(max) {
|
||||
return false
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(cnt, cur, cur+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return s.conc.Acquire(ctx, "conc:a:"+accountID, max, eventID)
|
||||
}
|
||||
|
||||
func (g *accountGate) tryAcquire(id string) bool { return g.tryAcquireN(id, 1) }
|
||||
func (s *V1Service) acctRelease(ctx context.Context, accountID, eventID string) {
|
||||
s.conc.Release(ctx, "conc:a:"+accountID, eventID)
|
||||
}
|
||||
|
||||
func (g *accountGate) release(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
// userAcquire takes one per-user generation slot, capped by the user's
|
||||
// concurrency group (0 = unlimited). Returns false when the user is already at
|
||||
// their limit. `token` is a unique per-generation tag passed back to userRelease.
|
||||
func (s *V1Service) userAcquire(ctx context.Context, user *model.User, token string) bool {
|
||||
if user == nil {
|
||||
return true
|
||||
}
|
||||
if v, ok := g.m.Load(id); ok {
|
||||
atomic.AddInt64(v.(*int64), -1)
|
||||
return s.conc.Acquire(ctx, "conc:u:"+user.ID, s.userConcurrencyLimit(ctx, user), token)
|
||||
}
|
||||
|
||||
func (s *V1Service) userRelease(ctx context.Context, userID, token string) {
|
||||
s.conc.Release(ctx, "conc:u:"+userID, token)
|
||||
}
|
||||
|
||||
// userConcurrencyLimit resolves the user's concurrency-group cap (0 = unlimited),
|
||||
// falling back to the default group when unset/missing.
|
||||
func (s *V1Service) userConcurrencyLimit(ctx context.Context, user *model.User) int {
|
||||
if s.cgroups == nil || user == nil {
|
||||
return 0
|
||||
}
|
||||
var g *model.ConcurrencyGroup
|
||||
if user.ConcurrencyGroupID != "" {
|
||||
g, _ = s.cgroups.Get(ctx, user.ConcurrencyGroupID)
|
||||
}
|
||||
if g == nil {
|
||||
g, _ = s.cgroups.GetDefault(ctx)
|
||||
}
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
return g.MaxConcurrency
|
||||
}
|
||||
|
||||
// InflightRegistry tracks the cancel func of every in-progress generation by
|
||||
@@ -199,7 +212,7 @@ type V1VideoRequest struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
|
||||
func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service {
|
||||
return &V1Service{
|
||||
cfg: cfg,
|
||||
models: models,
|
||||
@@ -207,6 +220,8 @@ func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.
|
||||
events: events,
|
||||
tokens: tokens,
|
||||
settings: settings,
|
||||
cgroups: cgroups,
|
||||
conc: conc,
|
||||
adobe: adobeClient,
|
||||
chatgpt: chatGPTClient,
|
||||
runway: runwayClient,
|
||||
@@ -327,6 +342,16 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
genCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Per-user concurrency gate (画图台 + API key combined). Admin model-tests are
|
||||
// exempt. Held for the whole generation; released on return.
|
||||
if source != "admin" && principal != nil && principal.User != nil {
|
||||
slot := randomUpper(12)
|
||||
if !s.userAcquire(ctx, principal.User, slot) {
|
||||
return nil, ErrUserConcurrencyFull
|
||||
}
|
||||
defer s.userRelease(ctx, principal.User.ID, slot)
|
||||
}
|
||||
|
||||
modelItem, resolution, aspectRatio, price, err := s.prepareImage(ctx, principal, in, charge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -550,6 +575,15 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Per-user concurrency gate (画图台 + API key combined); admin tests exempt.
|
||||
if source != "admin" && principal != nil && principal.User != nil {
|
||||
slot := randomUpper(12)
|
||||
if !s.userAcquire(ctx, principal.User, slot) {
|
||||
return nil, ErrUserConcurrencyFull
|
||||
}
|
||||
defer s.userRelease(ctx, principal.User.ID, slot)
|
||||
}
|
||||
|
||||
modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, charge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -922,12 +956,11 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
|
||||
if err := ensureReferenceSizes(in.ReferenceImages); err != nil {
|
||||
return nil, "", "", 0, err
|
||||
}
|
||||
// `size` (WxH) drives BOTH the aspect ratio AND the resolution tier — its long
|
||||
// edge maps to a tier (<1800→1K, 1800–3499→2K, ≥3500→4K). The web path passes
|
||||
// an explicit resolution; the OpenAI /v1 path derives it from size. There is no
|
||||
// `quality` param — size is the single source of truth for resolution.
|
||||
aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution)
|
||||
// Strict OpenAI path (/v1) sends no resolution — pick the tier from `quality`
|
||||
// (low/medium/high/auto → 1K/2K/4K/default), clamped to the model's tiers.
|
||||
if strings.TrimSpace(in.Resolution) == "" {
|
||||
resolution = resolutionForQuality(modelItem, in.Quality)
|
||||
}
|
||||
// parseImageSize defaults a blank resolution to "2K" (OpenAI-size parity).
|
||||
// For a model that doesn't price that tier — e.g. gpt-image-2 is 1K-only —
|
||||
// fall back to its first supported tier so a missing/stale resolution from
|
||||
@@ -1240,13 +1273,13 @@ func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool strin
|
||||
tempDeadCount := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
// release via defer so a panic in tryAccount can't leak the 1-job slot.
|
||||
data, err, failover, tempDead := func() ([]byte, error, bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
return s.tryAccount(ctx, eventID, pool, token, kind, attempt, classify, refreshOnAuth, tempAsDead)
|
||||
}()
|
||||
if err == nil {
|
||||
@@ -1524,13 +1557,13 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
teamID := ""
|
||||
@@ -1663,13 +1696,13 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
|
||||
var lastErr error
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
baseURL := stringValue(token.Meta["base_url"])
|
||||
@@ -1730,13 +1763,13 @@ func (s *V1Service) generateCustomVideo(ctx context.Context, eventID string, mod
|
||||
var videoURL string
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
if !s.gate.tryAcquireN(token.ID, accountConcurrency(token)) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
baseURL := stringValue(token.Meta["base_url"])
|
||||
@@ -1869,13 +1902,13 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
for _, token := range active {
|
||||
// grok allows 10 concurrent jobs per account (unlike the 1-per-account
|
||||
// default of the other pools).
|
||||
if !s.gate.tryAcquireN(token.ID, grokConcurrencyPerAccount) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, grokConcurrencyPerAccount) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
d, meta, genErr := s.grok.GenerateVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
|
||||
@@ -1970,13 +2003,13 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// 1 concurrent job per account: skip any account already generating.
|
||||
if !s.gate.tryAcquire(token.ID) {
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, 1) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.gate.release(token.ID)
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
teamID := ""
|
||||
@@ -2571,7 +2604,14 @@ func guessRatio(w, h int) string {
|
||||
W int
|
||||
H int
|
||||
}
|
||||
candidates := []candidate{{1, 1}, {16, 9}, {9, 16}, {4, 3}, {3, 4}, {4, 1}, {1, 4}, {8, 1}, {1, 8}}
|
||||
// The 14 ratios actually used across our models. Must stay in sync with the
|
||||
// custom-model picker (CustomModelModal RATIO_OPTS) and the docs 对照表, so a
|
||||
// /v1 `size` maps to exactly one of them.
|
||||
candidates := []candidate{
|
||||
{1, 1},
|
||||
{5, 4}, {4, 3}, {3, 2}, {16, 9}, {2, 1}, {21, 9}, {3, 1}, // 横
|
||||
{4, 5}, {3, 4}, {2, 3}, {9, 16}, {9, 21}, {1, 3}, // 竖
|
||||
}
|
||||
best := candidates[0]
|
||||
bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H))
|
||||
for _, item := range candidates[1:] {
|
||||
|
||||
@@ -6,7 +6,9 @@ import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const RATIO_OPTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '5:4', '4:5', '2:3', '2:1']
|
||||
// 14 ratios — the union of what our models actually support; kept in sync with
|
||||
// the backend guessRatio() and the docs 对照表.
|
||||
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:5', '3:4', '2:3', '9:16', '9:21', '1:3']
|
||||
const IMG_RES = ['1K', '2K', '4K']
|
||||
const VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
|
||||
@@ -13,6 +13,7 @@ const tabs = [
|
||||
{ label: '模型管理', to: '/admin/models', icon: 'models' },
|
||||
{ label: '账号管理', to: '/admin/accounts', icon: 'plug' },
|
||||
{ label: '用户管理', to: '/admin/users', icon: 'accounts' },
|
||||
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
||||
{ label: '兑换码', to: '/admin/cdks', icon: 'spark' },
|
||||
{ label: '邀请日志', to: '/admin/invites', icon: 'accounts' },
|
||||
{ label: '图片管理', to: '/admin/images', icon: 'files' },
|
||||
|
||||
@@ -16,8 +16,8 @@ const nav = computed(() => {
|
||||
const items = [{ to: '/', label: '首页', icon: 'overview' }]
|
||||
if (isAuthed()) {
|
||||
items.push({ to: '/user', label: '画图', icon: 'spark' })
|
||||
items.push({ to: '/logs', label: '记录', icon: 'log' })
|
||||
items.push({ to: '/mylogs', label: '日志', icon: 'files' })
|
||||
items.push({ to: '/logs', label: '图片', icon: 'files' })
|
||||
items.push({ to: '/mylogs', label: '日志', icon: 'log' })
|
||||
items.push({ to: '/invite', label: '邀请', icon: 'accounts' })
|
||||
}
|
||||
// 文档 + 关于 are public — visible to guests too.
|
||||
@@ -53,7 +53,9 @@ const currentLabel = computed(() => {
|
||||
<aside class="fixed inset-y-0 left-0 z-30 w-16 md:w-20 flex flex-col items-center py-5 border-r border-[color:var(--hairline)]">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="mb-8 group transition-transform hover:scale-105">
|
||||
<Logo :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title"
|
||||
class="w-10 h-10 rounded-xl object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
<Logo v-else :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
</router-link>
|
||||
|
||||
<!-- Nav -->
|
||||
@@ -107,11 +109,10 @@ const currentLabel = computed(() => {
|
||||
stamp doesn't jump around. -->
|
||||
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="h-7 w-auto self-center object-contain" />
|
||||
<span v-else class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
|
||||
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
|
||||
{{ site.title }}
|
||||
</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? (site.subtitle || 'AI 生图 · 生视频') : currentLabel }}</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
|
||||
</div>
|
||||
<router-link v-if="showBalance" to="/settings"
|
||||
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
|
||||
|
||||
@@ -20,6 +20,7 @@ import OverviewView from './views/OverviewView.vue'
|
||||
import ModelsView from './views/ModelsView.vue'
|
||||
import AccountsView from './views/AccountsView.vue'
|
||||
import UsersView from './views/UsersView.vue'
|
||||
import ConcurrencyView from './views/ConcurrencyView.vue'
|
||||
import CdksView from './views/CdksView.vue'
|
||||
import InvitesAdminView from './views/InvitesAdminView.vue'
|
||||
import ImagesView from './views/ImagesView.vue'
|
||||
@@ -51,6 +52,7 @@ const routes = [
|
||||
{ path: 'models', component: ModelsView, meta: { label: '模型管理' } },
|
||||
{ path: 'accounts', component: AccountsView, meta: { label: '账号管理' } },
|
||||
{ path: 'users', component: UsersView, meta: { label: '用户管理' } },
|
||||
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
||||
{ path: 'cdks', component: CdksView, meta: { label: '兑换码' } },
|
||||
{ path: 'invites', component: InvitesAdminView, meta: { label: '邀请日志' } },
|
||||
{ path: 'images', component: ImagesView, meta: { label: '图片管理' } },
|
||||
|
||||
@@ -10,6 +10,7 @@ export const site = reactive({
|
||||
title: 'Vivid',
|
||||
logo: '',
|
||||
subtitle: '',
|
||||
cdkRedeemEnabled: true,
|
||||
// Defaults so the 关于 page is never blank even if /site hasn't loaded (or a
|
||||
// cache serves an older payload without `contact`). The backend value, once
|
||||
// fetched, overrides these.
|
||||
@@ -24,6 +25,18 @@ export const site = reactive({
|
||||
ready: false,
|
||||
})
|
||||
|
||||
// Point the browser-tab favicon at a custom logo (or back to the default svg).
|
||||
export function applyFavicon(url) {
|
||||
let link = document.querySelector("link[rel~='icon']")
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.removeAttribute('type') // a png/jpg logo must not be forced as svg
|
||||
link.href = url || '/favicon.svg'
|
||||
}
|
||||
|
||||
export async function loadSite() {
|
||||
try {
|
||||
const r = await fetch(`${BASE}/admin/api/site`)
|
||||
@@ -32,7 +45,10 @@ export async function loadSite() {
|
||||
if (data.title) site.title = String(data.title)
|
||||
site.logo = data.logo ? String(data.logo) : ''
|
||||
site.subtitle = data.subtitle ? String(data.subtitle) : ''
|
||||
site.cdkRedeemEnabled = data.cdk_redeem_enabled !== false
|
||||
if (data.contact) site.contact = { ...site.contact, ...data.contact }
|
||||
// The uploaded logo IS the site icon (favicon / 浏览器标签 / 收藏).
|
||||
if (site.logo) applyFavicon(site.logo)
|
||||
}
|
||||
} catch { /* offline — keep the default. */ }
|
||||
site.ready = true
|
||||
|
||||
@@ -27,7 +27,7 @@ const contact = computed(() => site.contact || {})
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往充值商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const toast = ref('')
|
||||
let toastTimer = null
|
||||
function flash(msg) { toast.value = msg; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toast.value = ''), 1800) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/concurrency-groups')
|
||||
items.value = r.data?.data || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// add / edit modal
|
||||
const editing = ref(null) // { id?, name, max_concurrency }
|
||||
function openNew() { editing.value = { id: '', name: '', max_concurrency: 10 } }
|
||||
function openEdit(g) { editing.value = { id: g.id, name: g.name, max_concurrency: g.max_concurrency } }
|
||||
async function save() {
|
||||
const e = editing.value
|
||||
const body = { name: (e.name || '').trim(), max_concurrency: Math.max(0, Number(e.max_concurrency) || 0) }
|
||||
if (!body.name) { flash('名称不能为空'); return }
|
||||
const r = e.id
|
||||
? await api(`/concurrency-groups/${e.id}`, jsonBody('PATCH', body))
|
||||
: await api('/concurrency-groups', jsonBody('POST', body))
|
||||
if (r.ok) { editing.value = null; flash('已保存'); load() }
|
||||
else flash(r.data?.detail || '保存失败')
|
||||
}
|
||||
|
||||
async function setDefault(g) {
|
||||
if (g.is_default) return
|
||||
const r = await api(`/concurrency-groups/${g.id}/default`, jsonBody('POST', {}))
|
||||
if (r.ok) { flash('已设为默认注册分组'); load() }
|
||||
else flash(r.data?.detail || '操作失败')
|
||||
}
|
||||
|
||||
async function del(g) {
|
||||
if (g.is_default) { flash('默认分组不可删除'); return }
|
||||
if (!confirm(`删除分组「${g.name}」?其下 ${g.user_count} 个用户将转入默认分组。`)) return
|
||||
const r = await api(`/concurrency-groups/${g.id}`, { method: 'DELETE' })
|
||||
if (r.ok) { flash('已删除'); load() }
|
||||
else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="theme-text space-y-4">
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">并发分组</h2>
|
||||
<p class="text-xs text-white/45 mt-0.5">每个分组限制成员用户的<strong class="text-white/70">同时生成数</strong>(画图台 + API key 合计)。<strong class="text-white/70">0 = 不限制</strong>。新用户自动进入「默认注册分组」。</p>
|
||||
</div>
|
||||
<button @click="openNew" class="btn-primary shrink-0">+ 新增分组</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 py-3 font-medium">名称</th>
|
||||
<th class="text-right px-3 py-3 font-medium">并发上限</th>
|
||||
<th class="text-right px-3 py-3 font-medium">用户数</th>
|
||||
<th class="text-left px-3 py-3 font-medium">默认注册</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading"><td colspan="5" class="text-center text-xs text-white/40 py-10">加载中…</td></tr>
|
||||
<tr v-else-if="!items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">还没有分组</td></tr>
|
||||
<tr v-for="g in items" :key="g.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ g.name }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums">
|
||||
<span v-if="g.max_concurrency > 0" class="text-white/85">{{ g.max_concurrency }}</span>
|
||||
<span v-else class="text-emerald-300/90">不限制</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/70">{{ g.user_count }}</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-fuchsia-500/10 text-fuchsia-300 ring-1 ring-fuchsia-400/30">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span> 默认
|
||||
</span>
|
||||
<button v-else @click="setDefault(g)" class="btn-soft text-xs">设为默认</button>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="openEdit(g)" class="act" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="del(g)" :disabled="g.is_default" class="act danger disabled:opacity-30 disabled:cursor-not-allowed" :title="g.is_default ? '默认分组不可删除' : '删除'"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- add / edit modal -->
|
||||
<div v-if="editing" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto" @click.self="editing = 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">{{ editing.id ? '编辑分组' : '新增分组' }}</h2>
|
||||
<button @click="editing = 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="block text-xs text-white/55 mb-1.5">名称</label>
|
||||
<input v-model="editing.name" class="field" placeholder="如:VIP 并发 / 试用" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">并发上限 <span class="text-white/35">(0 = 不限制)</span></label>
|
||||
<input v-model.number="editing.max_concurrency" type="number" min="0" class="field" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button @click="editing = null" class="btn-soft">取消</button>
|
||||
<button @click="save" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">{{ toast }}</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger { color: rgb(253 164 175); background: rgb(244 63 94 / 0.12); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { site } from '../site'
|
||||
import { site, applyFavicon } from '../site'
|
||||
import TagInput from '../components/TagInput.vue'
|
||||
import Logo from '../components/Logo.vue'
|
||||
|
||||
// ---- logs (retention window) ----
|
||||
const logsCfg = reactive({ retention_days: 30 })
|
||||
@@ -37,6 +38,9 @@ async function saveMedia() {
|
||||
}
|
||||
|
||||
// ---- site (branding shown across the app) ----
|
||||
// Default homepage 子标题 — shown on the home Hero when unset, and pre-filled
|
||||
// into the input so the admin edits from it (same idea as 标题 defaulting to Vivid).
|
||||
const DEFAULT_SUBTITLE = '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。'
|
||||
const siteForm = reactive({ title: '', logo: '', subtitle: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
|
||||
const siteBusy = ref(false); const siteSaved = ref(false)
|
||||
async function loadSite() {
|
||||
@@ -44,7 +48,7 @@ async function loadSite() {
|
||||
if (r.ok && r.data) {
|
||||
siteForm.title = r.data.title || ''
|
||||
siteForm.logo = r.data.logo || ''
|
||||
siteForm.subtitle = r.data.subtitle || ''
|
||||
siteForm.subtitle = r.data.subtitle || DEFAULT_SUBTITLE
|
||||
const c = r.data.contact || {}
|
||||
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
||||
siteForm.qq_group = c.qq_group || ''
|
||||
@@ -52,27 +56,53 @@ async function loadSite() {
|
||||
siteForm.email = c.email || ''; siteForm.shop = c.shop || ''
|
||||
}
|
||||
}
|
||||
// ---- logo (drag/click to stage; uploaded to RustFS only on 保存) ----
|
||||
const logoStaged = ref('') // dataUrl of a newly picked logo, pending save
|
||||
const logoRemove = ref(false) // true → delete logo on save (back to default)
|
||||
const logoDragOver = ref(false)
|
||||
const logoInput = ref(null)
|
||||
// What the preview shows: staged file > (removed → none) > current saved logo.
|
||||
const logoPreview = computed(() => logoStaged.value || (logoRemove.value ? '' : (siteForm.logo || '')))
|
||||
function pickLogo() { logoInput.value && logoInput.value.click() }
|
||||
function readLogo(f) {
|
||||
if (!f || !f.type || !f.type.startsWith('image/')) return
|
||||
if (f.size > 4 * 1024 * 1024) { flashSite('logo 不能超过 4MB'); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { logoStaged.value = reader.result; logoRemove.value = false }
|
||||
reader.readAsDataURL(f)
|
||||
}
|
||||
function onLogoInput(ev) { readLogo((ev.target.files || [])[0]); if (ev.target) ev.target.value = '' }
|
||||
function onLogoDrop(ev) { ev.preventDefault(); logoDragOver.value = false; readLogo((ev.dataTransfer?.files || [])[0]) }
|
||||
function clearLogo() { logoStaged.value = ''; logoRemove.value = true } // 恢复默认
|
||||
const siteErr = ref('')
|
||||
function flashSite(msg) { siteErr.value = msg; setTimeout(() => (siteErr.value = ''), 2500) }
|
||||
|
||||
async function saveSite() {
|
||||
siteBusy.value = true; siteSaved.value = false
|
||||
siteBusy.value = true; siteSaved.value = false; siteErr.value = ''
|
||||
// 1) logo first — upload the staged file (or delete) to RustFS, only on 保存.
|
||||
if (logoStaged.value) {
|
||||
const lr = await api('/settings/logo', jsonBody('POST', { data: logoStaged.value }))
|
||||
if (lr.ok && lr.data?.logo != null) { siteForm.logo = lr.data.logo; site.logo = lr.data.logo; applyFavicon(site.logo); logoStaged.value = '' }
|
||||
else { siteBusy.value = false; flashSite(lr.data?.detail || 'Logo 上传失败'); return }
|
||||
} else if (logoRemove.value) {
|
||||
await api('/settings/logo', { method: 'DELETE' })
|
||||
siteForm.logo = ''; site.logo = ''; applyFavicon(''); logoRemove.value = false
|
||||
}
|
||||
// 2) the rest of the site form (logo is managed above, not sent here).
|
||||
const r = await api('/settings/site', jsonBody('PUT', {
|
||||
title: siteForm.title,
|
||||
logo: siteForm.logo,
|
||||
subtitle: siteForm.subtitle,
|
||||
contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop },
|
||||
}))
|
||||
siteBusy.value = false
|
||||
if (r.ok && r.data) {
|
||||
site.logo = r.data.data?.logo ?? siteForm.logo.trim()
|
||||
site.subtitle = r.data.data?.subtitle ?? siteForm.subtitle.trim()
|
||||
// Mirror the change into the shared `site` store so every header /
|
||||
// wordmark / tab title updates without a reload. The PUT response is
|
||||
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
|
||||
// saved value from there — falling back to the input we just submitted.
|
||||
// Mirror into the shared `site` store so headers / wordmark update without a reload.
|
||||
site.title = r.data.data?.title || siteForm.title.trim()
|
||||
site.contact = r.data.data?.contact || site.contact
|
||||
siteSaved.value = true
|
||||
setTimeout(() => (siteSaved.value = false), 2000)
|
||||
}
|
||||
} else flashSite(r.data?.detail || '保存失败')
|
||||
}
|
||||
|
||||
// ---- registration ----
|
||||
@@ -87,7 +117,7 @@ const smtp = reactive({ host: '', port: 587, username: '', password: '', from_ad
|
||||
const smtpBusy = ref(false); const smtpSaved = ref(false)
|
||||
|
||||
// ---- rewards ----
|
||||
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3 })
|
||||
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3, cdk_redeem_enabled: true })
|
||||
const credBusy = ref(false); const credSaved = ref(false)
|
||||
|
||||
// ---- proxy (carried when calling upstream during generation) ----
|
||||
@@ -197,6 +227,7 @@ async function saveCredits() {
|
||||
checkin_reward: Number(credits.checkin_reward) || 0,
|
||||
invite_enabled: credits.invite_enabled,
|
||||
invite_reward: Number(credits.invite_reward) || 0,
|
||||
cdk_redeem_enabled: credits.cdk_redeem_enabled,
|
||||
}))
|
||||
credBusy.value = false
|
||||
if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) }
|
||||
@@ -218,13 +249,23 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy();
|
||||
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span>
|
||||
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
||||
</label>
|
||||
<div class="row">
|
||||
<span><span class="lbl">Logo</span><span class="hint">侧栏 / 公开页头部 / 浏览器标签的 Logo。点击或拖拽图片到下图替换,点「保存设置」后才上传到存储(替换会自动删旧图)。下图当前显示的就是默认 Logo。</span></span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div @click="pickLogo" @drop="onLogoDrop" @dragover.prevent="logoDragOver = true" @dragleave="logoDragOver = false"
|
||||
title="点击或拖拽图片替换"
|
||||
class="w-16 h-16 rounded-xl grid place-items-center overflow-hidden shrink-0 cursor-pointer transition-all"
|
||||
:class="logoDragOver ? 'ring-2 ring-indigo-400 bg-indigo-50/40' : ''">
|
||||
<img v-if="logoPreview" :src="logoPreview" class="w-full h-full object-cover" />
|
||||
<Logo v-else :size="64" class="w-full h-full" />
|
||||
</div>
|
||||
<input ref="logoInput" type="file" accept="image/*" class="hidden" @change="onLogoInput" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="siteErr" class="text-xs text-rose-500 -mt-1">{{ siteErr }}</p>
|
||||
<label class="row">
|
||||
<span><span class="lbl">Logo 图片地址</span><span class="hint">侧栏 / 公开页头部显示的 Logo 图片 URL。留空则用文字主标题。</span></span>
|
||||
<input v-model="siteForm.logo" placeholder="https://.../logo.png" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">子标题</span><span class="hint">主标题下方的副标题 / slogan,公开页展示。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.subtitle" placeholder="如:聚合顶级 AI 模型的生图生视频平台" class="txt" />
|
||||
<span><span class="lbl">子标题</span><span class="hint">首页 Hero 大标题下方那句话。留空则显示默认:「把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。」</span></span>
|
||||
<input v-model="siteForm.subtitle" placeholder="留空 = 默认那句宣传语" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span>
|
||||
@@ -377,6 +418,10 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy();
|
||||
<span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分。</span></span>
|
||||
<input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">开启兑换码</span><span class="hint">关闭后用户无法兑换兑换码,前台也不再显示兑换入口。</span></span>
|
||||
<input type="checkbox" v-model="credits.cdk_redeem_enabled" class="sw" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
|
||||
@@ -43,15 +43,13 @@ function priceOf(m) {
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'],
|
||||
['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'],
|
||||
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型 id(需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例'],
|
||||
['quality', 'string', '可选', '同图像:决定画质档'],
|
||||
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(视频)'],
|
||||
@@ -61,6 +59,25 @@ const videoParams = [
|
||||
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
|
||||
]
|
||||
|
||||
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
|
||||
// size 的长边映射档位:<1800→1K · 1800–3499→2K · ≥3500→4K;宽高比映射比例。
|
||||
const sizeTable = [
|
||||
{ ratio: '1:1 · 方', k1: '1024x1024', k2: '2048x2048', k4: '4096x4096' },
|
||||
{ ratio: '5:4 · 横', k1: '1280x1024', k2: '2560x2048', k4: '3840x3072' },
|
||||
{ ratio: '4:3 · 横', k1: '1024x768', k2: '2048x1536', k4: '4096x3072' },
|
||||
{ ratio: '3:2 · 横', k1: '1200x800', k2: '2400x1600', k4: '3600x2400' },
|
||||
{ ratio: '16:9 · 横', k1: '1280x720', k2: '2048x1152', k4: '4096x2304' },
|
||||
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
|
||||
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
|
||||
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
|
||||
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
|
||||
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
|
||||
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
|
||||
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
|
||||
{ ratio: '9:21 · 竖超宽', k1: '720x1680', k2: '1080x2520', k4: '2160x5040' },
|
||||
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
|
||||
]
|
||||
|
||||
// ---- examples (built in script so refs resolve correctly) ----
|
||||
const examples = computed(() => [
|
||||
{
|
||||
@@ -72,8 +89,7 @@ const examples = computed(() => [
|
||||
-d '{
|
||||
"model": "${sampleImage.value}",
|
||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||||
"size": "1024x1024",
|
||||
"quality": "high"
|
||||
"size": "2048x2048"
|
||||
}'`,
|
||||
},
|
||||
{
|
||||
@@ -87,8 +103,7 @@ client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
resp = client.images.generate(
|
||||
model="${sampleImage.value}",
|
||||
prompt="a corgi running in a golden wheat field, cinematic",
|
||||
size="1024x1024",
|
||||
quality="high",
|
||||
size="2048x2048", # 2K · 1:1,见下方对照表
|
||||
)
|
||||
# 结果是 base64(无 URL)
|
||||
with open("out.png", "wb") as f:
|
||||
@@ -101,7 +116,7 @@ with open("out.png", "wb") as f:
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-F model="${sampleImage.value}" \\
|
||||
-F prompt="把这张图改成赛博朋克风格" \\
|
||||
-F quality="high" \\
|
||||
-F size="2048x2048" \\
|
||||
-F image=@input.png
|
||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||||
},
|
||||
@@ -314,6 +329,38 @@ async function copy(text) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- size 对照表(课时表)—— 解决"传错分辨率" -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-1">分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
|
||||
<p class="text-xs text-white/45 mb-3">
|
||||
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)。
|
||||
没有 <code class="text-white/70">quality</code> 参数,分辨率只看 <code class="text-white/70">size</code> 的长边。
|
||||
档位必须是该模型支持的(见上方「可用模型」的分辨率列),不支持会自动回退到该模型最低档。
|
||||
</p>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">比例</th>
|
||||
<th class="px-4 py-2.5 font-medium">1K</th>
|
||||
<th class="px-4 py-2.5 font-medium">2K</th>
|
||||
<th class="px-4 py-2.5 font-medium">4K</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
例:想要 <strong class="text-white/70">2K 的 16:9 横图</strong> → <code class="text-white/70">"size": "2048x1152"</code>。
|
||||
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- examples -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">调用示例</h2>
|
||||
@@ -340,7 +387,7 @@ async function copy(text) {
|
||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||||
</ol>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>→比例,<code class="text-white/70">quality</code>(low/medium/high)→画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>→视频时长。参数须落在该模型定价表内,否则 400;余额不足 402。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>(宽x高)同时决定<strong class="text-white/90">比例 + 分辨率档</strong>(长边:<1800→1K · 1800–3499→2K · ≥3500→4K),<code class="text-white/70">seconds</code>→视频时长。<strong class="text-white/90">没有 quality 参数</strong>,分辨率只看 size。档位须是该模型支持的(不支持会回退到该模型最低档);参数须落在定价表内否则 400,余额不足 402。</p>
|
||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||||
|
||||
@@ -139,7 +139,7 @@ function useExample(ex) {
|
||||
</h1>
|
||||
|
||||
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
|
||||
把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。
|
||||
{{ site.subtitle || '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
|
||||
@@ -97,6 +97,13 @@ async function changePwd() {
|
||||
// ---- Credits: the REAL server-side balance of the logged-in user ----
|
||||
// (admin adjustments in 用户管理 write this same field). Top-up is via CDK.
|
||||
const balance = computed(() => Number(auth.user?.credits || 0))
|
||||
const roleLabel = computed(() => ({ user: '普通用户', agent: '代理', admin: '管理员' }[auth.user?.role] || '普通用户'))
|
||||
const concurrencyLabel = computed(() => {
|
||||
const n = Number(auth.user?.concurrency_limit || 0)
|
||||
const g = auth.user?.concurrency_group
|
||||
if (n <= 0) return g ? `不限制 · ${g}` : '不限制'
|
||||
return g ? `${n} · ${g}` : String(n)
|
||||
})
|
||||
|
||||
const cdkCode = ref('')
|
||||
const cdkBusy = ref(false)
|
||||
@@ -184,6 +191,36 @@ function toast(m) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ===== Account info ===== -->
|
||||
<section class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Icon name="accounts" class="w-4 h-4 text-violet-300" />
|
||||
<h2 class="text-sm font-semibold">账户信息</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">用户名</div>
|
||||
<div class="text-white/90 font-medium truncate" :title="auth.user?.name || ''">{{ auth.user?.name || '—' }}</div>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">绑定邮箱</div>
|
||||
<div class="text-white/90 font-mono text-xs break-all" :title="auth.user?.email || ''">{{ auth.user?.email || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">角色</div>
|
||||
<div class="text-white/90">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">积分余额</div>
|
||||
<div class="text-amber-300 font-semibold tabular-nums">{{ pointsLabel(balance) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">并发上限</div>
|
||||
<div class="text-white/90 truncate" :title="concurrencyLabel">{{ concurrencyLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Two-column grid ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-5">
|
||||
|
||||
@@ -280,8 +317,8 @@ function toast(m) {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- CDK REDEEM -->
|
||||
<section class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<!-- CDK REDEEM — hidden when the admin turns the 兑换码 switch off -->
|
||||
<section v-if="site.cdkRedeemEnabled" class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
@@ -141,6 +141,28 @@ function pickImage(file) {
|
||||
picking.value = false
|
||||
}
|
||||
|
||||
// Upload a NEW image as 底图 — click/drag the preview. Stored public under
|
||||
// branding/ in RustFS; form.image is set to the returned path immediately.
|
||||
const scImgInput = ref(null)
|
||||
const scDragOver = ref(false)
|
||||
const uploadingImg = ref(false)
|
||||
function pickShowcaseImg() { scImgInput.value && scImgInput.value.click() }
|
||||
async function uploadShowcaseImg(f) {
|
||||
if (!f || !f.type || !f.type.startsWith('image/')) return
|
||||
if (f.size > 8 * 1024 * 1024) { error.value = '图片不能超过 8MB'; return }
|
||||
uploadingImg.value = true; error.value = ''
|
||||
const dataUrl = await new Promise((res, rej) => {
|
||||
const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f)
|
||||
}).catch(() => '')
|
||||
if (!dataUrl) { uploadingImg.value = false; error.value = '读取图片失败'; return }
|
||||
const r = await api('/settings/asset', jsonBody('POST', { data: dataUrl }))
|
||||
uploadingImg.value = false
|
||||
if (r.ok && r.data?.path) form.image = r.data.path
|
||||
else error.value = r.data?.detail || '上传失败'
|
||||
}
|
||||
function onScImgInput(ev) { uploadShowcaseImg((ev.target.files || [])[0]); if (ev.target) ev.target.value = '' }
|
||||
function onScDrop(ev) { ev.preventDefault(); scDragOver.value = false; uploadShowcaseImg((ev.dataTransfer?.files || [])[0]) }
|
||||
|
||||
function bgFor(image) {
|
||||
if (!image) return {}
|
||||
const src = /^https?:\/\//i.test(image) ? image : generatedUrl(image)
|
||||
@@ -240,19 +262,30 @@ onMounted(refresh)
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- live preview -->
|
||||
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
|
||||
:style="bgFor(form.image)">
|
||||
<!-- live preview — click or drag an image here to upload as 底图 -->
|
||||
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04] cursor-pointer transition-all"
|
||||
:class="scDragOver ? 'ring-2 ring-indigo-400' : ''"
|
||||
:style="bgFor(form.image)"
|
||||
@click="pickShowcaseImg" @drop="onScDrop" @dragover.prevent="scDragOver = true" @dragleave="scDragOver = false">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/40">
|
||||
未选择底图
|
||||
<button type="button" @click.stop="openPicker"
|
||||
class="absolute top-2 right-2 z-10 inline-flex items-center gap-1 rounded-lg bg-black/55 ring-1 ring-white/15 hover:bg-black/75 text-white text-[11px] px-2.5 py-1.5 transition-colors">
|
||||
<Icon name="files" class="w-3.5 h-3.5" /> 选择已生成
|
||||
</button>
|
||||
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/60">
|
||||
<div class="text-center">
|
||||
<Icon name="plus" class="w-6 h-6 mx-auto mb-1 opacity-80" />
|
||||
{{ uploadingImg ? '上传中…' : '点击或拖拽图片上传底图' }}
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5">
|
||||
</div>
|
||||
<div v-else-if="uploadingImg" class="absolute inset-0 grid place-items-center bg-black/40 text-xs text-white">上传中…</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 pointer-events-none">
|
||||
<div v-if="form.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ form.subtitle }}</div>
|
||||
<div v-if="form.title" class="text-xl font-bold text-white mt-1">{{ form.title }}</div>
|
||||
<div v-if="form.prompt" class="text-xs text-white/65 mt-1 line-clamp-2">{{ form.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="scImgInput" type="file" accept="image/*" class="hidden" @change="onScImgInput" />
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -269,17 +302,6 @@ onMounted(refresh)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image picker (the central change — admins pick a real image) -->
|
||||
<div>
|
||||
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">底图</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.image" class="field font-mono text-[11px]"
|
||||
placeholder="user/abc.png 或 https://…" />
|
||||
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-[color:var(--fg-faint)] mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL。</p>
|
||||
</div>
|
||||
|
||||
<template v-if="form.kind !== 'work'">
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const showAdd = ref(false)
|
||||
const editing = ref(null)
|
||||
const toast = ref('')
|
||||
|
||||
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '' })
|
||||
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' })
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '正常' },
|
||||
@@ -35,6 +35,26 @@ const ROLE_OPTIONS = [
|
||||
]
|
||||
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户')
|
||||
|
||||
// Concurrency groups — resolve a user's group id → name/limit for the table,
|
||||
// and offer them in the edit form.
|
||||
const cgroups = ref([])
|
||||
const cgroupOptions = computed(() => cgroups.value.map((g) => ({ value: g.id, label: g.name })))
|
||||
const cgroupById = computed(() => Object.fromEntries(cgroups.value.map((g) => [g.id, g])))
|
||||
function cgroupLabel(id) {
|
||||
const g = cgroupById.value[id]
|
||||
if (!g) return '—'
|
||||
return g.max_concurrency > 0 ? `${g.name} · ${g.max_concurrency}` : `${g.name} · 不限`
|
||||
}
|
||||
async function loadGroups() {
|
||||
const r = await api('/concurrency-groups')
|
||||
cgroups.value = r.data?.data || []
|
||||
// Default the 新建用户 form to the default registration group.
|
||||
if (!addForm.value.concurrency_group_id) {
|
||||
const def = cgroups.value.find((g) => g.is_default) || cgroups.value[0]
|
||||
if (def) addForm.value.concurrency_group_id = def.id
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/users')
|
||||
@@ -42,7 +62,7 @@ async function load() {
|
||||
stats.value = r.data?.stats || stats.value
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(load)
|
||||
onMounted(() => { load(); loadGroups() })
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
@@ -100,7 +120,8 @@ async function createUser() {
|
||||
const r = await api('/users', jsonBody('POST', addForm.value))
|
||||
if (r.ok) {
|
||||
showAdd.value = false
|
||||
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '' }
|
||||
const def = cgroups.value.find((g) => g.is_default) || cgroups.value[0]
|
||||
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: def ? def.id : '' }
|
||||
flash('用户已创建')
|
||||
load()
|
||||
} else flash(r.data?.detail || '创建失败')
|
||||
@@ -116,6 +137,7 @@ async function saveEdit() {
|
||||
credits: u.credits,
|
||||
role: u.role,
|
||||
notes: u.notes || '',
|
||||
concurrency_group_id: u.concurrency_group_id || '',
|
||||
}
|
||||
if (u._newPassword) patch.password = u._newPassword
|
||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
|
||||
@@ -246,6 +268,7 @@ async function quickCredits(u, delta) {
|
||||
<col class="w-40" /> <!-- username -->
|
||||
<col /> <!-- email (flex) -->
|
||||
<col class="w-36" /> <!-- notes -->
|
||||
<col class="w-28" /> <!-- concurrency -->
|
||||
<col class="w-20" /> <!-- role -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-24" /> <!-- credits -->
|
||||
@@ -264,6 +287,7 @@ async function quickCredits(u, delta) {
|
||||
<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>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-3 py-3 font-medium">积分</th>
|
||||
@@ -290,6 +314,9 @@ async function quickCredits(u, delta) {
|
||||
<td class="px-3 py-3.5 align-middle text-xs truncate" :class="u.notes ? 'text-white/70' : 'text-white/25'" :title="u.notes || ''">
|
||||
{{ u.notes || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs truncate text-white/70" :title="cgroupLabel(u.concurrency_group_id)">
|
||||
{{ cgroupLabel(u.concurrency_group_id) }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="u.role === 'admin'
|
||||
@@ -396,6 +423,10 @@ async function quickCredits(u, delta) {
|
||||
<label class="lbl">角色</label>
|
||||
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">并发分组</label>
|
||||
<SelectMenu v-model="addForm.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
|
||||
<textarea v-model="addForm.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>
|
||||
@@ -444,6 +475,10 @@ async function quickCredits(u, delta) {
|
||||
<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="选择分组" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
|
||||
<textarea v-model="editing.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>
|
||||
|
||||
Reference in New Issue
Block a user