增加违禁词管理 增加多选操作
This commit is contained in:
@@ -126,7 +126,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
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)
|
||||
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
|
||||
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo)
|
||||
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo)
|
||||
apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
|
||||
@@ -135,6 +135,8 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
// Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token
|
||||
// from the cookie). Wired post-construction to avoid a ctor init cycle.
|
||||
v1Svc.SetRefresh(refreshSvc)
|
||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||
v1Svc.SetBannedWords(bannedWordRepo)
|
||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||
|
||||
engine := router.New(cfg, authSvc, router.Handlers{
|
||||
@@ -155,6 +157,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
||||
ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc),
|
||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||
})
|
||||
|
||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||
|
||||
@@ -29,6 +29,7 @@ func (h *AdminReadHandler) Users(c *gin.Context) {
|
||||
for _, user := range users {
|
||||
row := userPublic(user)
|
||||
row["generation_count"] = user.GenerationCount
|
||||
row["banned_word_hits"] = user.BannedWordHits
|
||||
out = append(out, row)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats})
|
||||
@@ -56,7 +57,7 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false)
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false, false, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
@@ -158,6 +159,16 @@ func (h *AdminReadHandler) Invites(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items, "stats": stats})
|
||||
}
|
||||
|
||||
// DeleteImage removes one generated file (plus derived stills) and blanks the
|
||||
// log rows referencing it. Admin 图片管理 delete; ?name= is the storage key.
|
||||
func (h *AdminReadHandler) DeleteImage(c *gin.Context) {
|
||||
if err := h.admin.DeleteFile(c.Request.Context(), c.Query("name")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminReadHandler) Providers(c *gin.Context) {
|
||||
items, err := h.admin.Providers(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"backend/internal/repo"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BannedWordsHandler — admin 违禁词管理: list / add / delete prompt blocklist
|
||||
// entries. The generation path (V1Service.checkBannedPrompt) enforces them.
|
||||
type BannedWordsHandler struct {
|
||||
words *repo.BannedWordRepository
|
||||
}
|
||||
|
||||
func NewBannedWordsHandler(words *repo.BannedWordRepository) *BannedWordsHandler {
|
||||
return &BannedWordsHandler{words: words}
|
||||
}
|
||||
|
||||
func (h *BannedWordsHandler) List(c *gin.Context) {
|
||||
items, err := h.words.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load banned words"})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, w := range items {
|
||||
out = append(out, gin.H{
|
||||
"id": w.ID,
|
||||
"word": w.Word,
|
||||
"hits": w.Hits,
|
||||
"created_at": w.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h *BannedWordsHandler) Create(c *gin.Context) {
|
||||
var body struct {
|
||||
Word string `json:"word"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
item, err := h.words.Create(c.Request.Context(), body.Word)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": item.ID, "word": item.Word, "hits": item.Hits, "created_at": item.CreatedAt}})
|
||||
}
|
||||
|
||||
func (h *BannedWordsHandler) Delete(c *gin.Context) {
|
||||
n, err := h.words.Delete(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "delete failed"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -38,6 +38,22 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
// DeleteMyFile removes ONE of the caller's own generated files (plus its
|
||||
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
|
||||
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
|
||||
func (h *UserGenerationHandler) DeleteMyFile(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
if err := h.admin.DeleteOwnedFile(c.Request.Context(), service.OwnerDir(user), c.Query("file")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
@@ -70,7 +86,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": "积分不足"})
|
||||
@@ -132,7 +148,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrProviderQuota):
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
@@ -206,7 +222,14 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) {
|
||||
// rows with real media (success + stored file), not failed/pending events.
|
||||
hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true"
|
||||
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile)
|
||||
// Media views hide homepage showcase files — those belong to the public
|
||||
// landing page, not to the caller's personal works. Galleries imply it via
|
||||
// has_file; the 画图台 grid opts in with exclude_showcase=1.
|
||||
excludeShowcase := hasFile || c.Query("exclude_showcase") == "1"
|
||||
// media=1 (画图台 grid): only pending rows or rows with a stored file, so a
|
||||
// deleted work's blanked row doesn't consume one of the grid's slots.
|
||||
mediaOnly := c.Query("media") == "1"
|
||||
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile, excludeShowcase, mediaOnly)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
|
||||
return
|
||||
|
||||
@@ -316,7 +316,7 @@ func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]a
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnknownModel):
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrUnsupportedParams):
|
||||
case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
case errors.Is(err, service.ErrInsufficientFunds):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()})
|
||||
|
||||
@@ -28,6 +28,7 @@ type Handlers struct {
|
||||
ConcGroups *handler.ConcurrencyGroupHandler
|
||||
Announcement *handler.AnnouncementHandler
|
||||
Payment *handler.PaymentHandler
|
||||
BannedWords *handler.BannedWordsHandler
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
||||
@@ -87,6 +88,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
userAuthed.POST("/test", handlers.UserGen.Test)
|
||||
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
|
||||
userAuthed.GET("/my-images", handlers.UserGen.MyImages)
|
||||
userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile)
|
||||
userAuthed.GET("/announcement", handlers.Announcement.Get)
|
||||
userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen)
|
||||
userAuthed.GET("/pay/config", handlers.Payment.Config)
|
||||
@@ -137,6 +139,10 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
||||
authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail)
|
||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||
authed.GET("/images", handlers.AdminRead.Images)
|
||||
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
|
||||
authed.GET("/banned-words", handlers.BannedWords.List)
|
||||
authed.POST("/banned-words", handlers.BannedWords.Create)
|
||||
authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete)
|
||||
authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles)
|
||||
authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow)
|
||||
authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate)
|
||||
|
||||
@@ -26,6 +26,7 @@ type User struct {
|
||||
CheckinLast string `gorm:"size:32"`
|
||||
CheckinStreak int `gorm:"not null;default:0"`
|
||||
GenerationCount int64 `gorm:"not null;default:0"`
|
||||
BannedWordHits int64 `gorm:"not null;default:0"` // 提示词命中违禁词被拦截的累计次数
|
||||
LastLoginAt *time.Time
|
||||
LastLoginIP string `gorm:"size:128"`
|
||||
CreatedAt time.Time
|
||||
@@ -33,6 +34,17 @@ type User struct {
|
||||
APIKeys []APIKey `gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// BannedWord is an admin-managed prompt blocklist entry. Generation requests
|
||||
// whose prompt contains Word (case-insensitive substring) are rejected before
|
||||
// reaching any provider; Hits counts how many requests each word blocked.
|
||||
type BannedWord struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Word string `gorm:"size:255;uniqueIndex;not null"`
|
||||
Hits int64 `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
@@ -206,6 +218,7 @@ type SiteSetting struct {
|
||||
func AutoMigrateModels() []any {
|
||||
return []any{
|
||||
&User{},
|
||||
&BannedWord{},
|
||||
&APIKey{},
|
||||
&ShowcaseItem{},
|
||||
&EventLog{},
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BannedWordRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBannedWordRepository(db *gorm.DB) *BannedWordRepository {
|
||||
return &BannedWordRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *BannedWordRepository) List(ctx context.Context) ([]model.BannedWord, error) {
|
||||
var items []model.BannedWord
|
||||
err := r.db.WithContext(ctx).Order("hits DESC, created_at DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *BannedWordRepository) Create(ctx context.Context, word string) (*model.BannedWord, error) {
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
return nil, errors.New("违禁词不能为空")
|
||||
}
|
||||
item := &model.BannedWord{
|
||||
ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:32],
|
||||
Word: word,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(item).Error; err != nil {
|
||||
return nil, errors.New("添加失败(可能已存在)")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *BannedWordRepository) Delete(ctx context.Context, id string) (int64, error) {
|
||||
res := r.db.WithContext(ctx).Delete(&model.BannedWord{}, "id = ?", id)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// RecordHit bumps the word's block counter and, when userID is set, the user's
|
||||
// 违禁词触发次数 shown on the admin users table. Best-effort bookkeeping.
|
||||
func (r *BannedWordRepository) RecordHit(ctx context.Context, wordID, userID string) {
|
||||
_ = r.db.WithContext(ctx).Model(&model.BannedWord{}).Where("id = ?", wordID).
|
||||
UpdateColumn("hits", gorm.Expr("hits + 1")).Error
|
||||
if userID != "" {
|
||||
_ = r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
|
||||
UpdateColumn("banned_word_hits", gorm.Expr("banned_word_hits + 1")).Error
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ type EventListFilter struct {
|
||||
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
|
||||
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
|
||||
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
|
||||
ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries)
|
||||
MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot
|
||||
}
|
||||
|
||||
type EventStats struct {
|
||||
@@ -66,6 +68,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
|
||||
if filter.HasFile {
|
||||
q = q.Where("file <> ''")
|
||||
}
|
||||
if len(filter.ExcludeFiles) > 0 {
|
||||
q = q.Where("file NOT IN ?", filter.ExcludeFiles)
|
||||
}
|
||||
if filter.MediaOnly {
|
||||
q = q.Where("(status = 'pending' OR file <> '')")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,9 +22,10 @@ type AdminReadService struct {
|
||||
tokens *repo.TokenRepository
|
||||
cdks *repo.CDKRepository
|
||||
store *storage.Client
|
||||
showcase *repo.ShowcaseRepository
|
||||
}
|
||||
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService {
|
||||
func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService {
|
||||
return &AdminReadService{
|
||||
cfg: cfg,
|
||||
users: users,
|
||||
@@ -33,9 +35,28 @@ func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models
|
||||
tokens: tokens,
|
||||
cdks: cdks,
|
||||
store: store,
|
||||
showcase: showcase,
|
||||
}
|
||||
}
|
||||
|
||||
// showcaseFileList returns the homepage showcase image keys (no leading slash).
|
||||
// User-facing galleries and the admin image manager hide these files — they
|
||||
// belong to the public landing page, not to anyone's personal works.
|
||||
func (s *AdminReadService) showcaseFileList(ctx context.Context) []string {
|
||||
if s.showcase == nil {
|
||||
return nil
|
||||
}
|
||||
set, err := s.showcase.PublicFileSet(ctx)
|
||||
if err != nil || len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) {
|
||||
users, err := s.users.List(ctx)
|
||||
if err != nil {
|
||||
@@ -91,7 +112,11 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
|
||||
var excludeFiles []string
|
||||
if excludeShowcase {
|
||||
excludeFiles = s.showcaseFileList(ctx)
|
||||
}
|
||||
items, total, err := s.events.List(ctx, repo.EventListFilter{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
@@ -103,6 +128,8 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st
|
||||
ExcludeSource: excludeSource,
|
||||
Source: source,
|
||||
HasFile: hasFile,
|
||||
ExcludeFiles: excludeFiles,
|
||||
MediaOnly: mediaOnly,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
@@ -406,8 +433,19 @@ func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind s
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
// Homepage showcase media never shows in the image manager — it's public
|
||||
// landing-page content, managed on the 首页内容 page instead.
|
||||
pinned := map[string]struct{}{}
|
||||
if s.showcase != nil {
|
||||
if set, perr := s.showcase.PublicFileSet(ctx); perr == nil {
|
||||
pinned = set
|
||||
}
|
||||
}
|
||||
filtered := make([]generatedFile, 0, len(allFiles))
|
||||
for _, item := range allFiles {
|
||||
if _, ok := pinned[strings.TrimLeft(item.Name, "/")]; ok {
|
||||
continue
|
||||
}
|
||||
if kind == "" || item.Kind == kind {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
@@ -523,6 +561,44 @@ func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteOwnedFile removes a generated media object (and its thumbnail) that
|
||||
// lives under the given owner directory, then blanks the file reference on the
|
||||
// matching log rows so galleries and the 画图台 grid stop showing it. The owner
|
||||
// prefix check keeps a user from deleting anyone else's files.
|
||||
func (s *AdminReadService) DeleteOwnedFile(ctx context.Context, owner, rel string) error {
|
||||
owner = strings.TrimSpace(owner)
|
||||
rel = strings.TrimLeft(strings.TrimSpace(rel), "/")
|
||||
if owner == "" {
|
||||
return errors.New("invalid file")
|
||||
}
|
||||
if !strings.HasPrefix(rel, owner+"/") {
|
||||
return errors.New("file not owned by caller")
|
||||
}
|
||||
return s.DeleteFile(ctx, rel)
|
||||
}
|
||||
|
||||
// DeleteFile removes any generated media object (and its derived stills) and
|
||||
// blanks the log rows referencing it. Admin 图片管理 delete — no owner check.
|
||||
func (s *AdminReadService) DeleteFile(ctx context.Context, rel string) error {
|
||||
rel = strings.TrimLeft(strings.TrimSpace(rel), "/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return errors.New("invalid file")
|
||||
}
|
||||
if s.store == nil || !s.store.Configured() {
|
||||
return errors.New("storage not configured")
|
||||
}
|
||||
if err := s.store.Delete(ctx, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort derived stills; old files may not have them.
|
||||
_ = s.store.Delete(ctx, ThumbKey(rel))
|
||||
_ = s.store.Delete(ctx, LastFrameKey(rel))
|
||||
if _, err := s.events.ClearFiles(ctx, []string{rel}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) {
|
||||
items, err := s.events.RecentByFile(ctx, 10000)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,6 +37,7 @@ var (
|
||||
ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
ErrUnknownModel = errors.New("unknown model")
|
||||
ErrUnsupportedParams = errors.New("unsupported or unpriced parameters for this model")
|
||||
ErrBannedPrompt = errors.New("prompt contains banned content")
|
||||
ErrInsufficientFunds = errors.New("insufficient credits")
|
||||
ErrGenerationPending = errors.New("generation executor not implemented yet")
|
||||
ErrProviderAuth = errors.New("provider token invalid or expired")
|
||||
@@ -83,6 +84,9 @@ type V1Service struct {
|
||||
// 401 mid-flight (set via SetRefresh — wired after construction to avoid an
|
||||
// init cycle). nil for deployments without cookie refresh.
|
||||
refresh *RefreshProfileService
|
||||
// banned is the admin-managed prompt blocklist (set via SetBannedWords).
|
||||
// nil disables the check.
|
||||
banned *repo.BannedWordRepository
|
||||
|
||||
// tokenCursors holds one strict round-robin cursor per pool (key: pool name,
|
||||
// value: *uint64). Each pick advances the pool's cursor by one so accounts
|
||||
@@ -244,6 +248,36 @@ func (s *V1Service) Inflight() *InflightRegistry { return s.inflight }
|
||||
// without reordering). Enables refresh-then-retry on a mid-request 401.
|
||||
func (s *V1Service) SetRefresh(r *RefreshProfileService) { s.refresh = r }
|
||||
|
||||
// SetBannedWords wires the prompt blocklist in after construction.
|
||||
func (s *V1Service) SetBannedWords(r *repo.BannedWordRepository) { s.banned = r }
|
||||
|
||||
// checkBannedPrompt rejects the request when the prompt contains any banned
|
||||
// word (case-insensitive substring). A hit bumps the word's counter and the
|
||||
// user's 违禁词触发次数 before rejecting.
|
||||
func (s *V1Service) checkBannedPrompt(ctx context.Context, principal *APIPrincipal, prompt string) error {
|
||||
if s.banned == nil || strings.TrimSpace(prompt) == "" {
|
||||
return nil
|
||||
}
|
||||
words, err := s.banned.List(ctx)
|
||||
if err != nil || len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(prompt)
|
||||
for _, w := range words {
|
||||
term := strings.ToLower(strings.TrimSpace(w.Word))
|
||||
if term == "" || !strings.Contains(lower, term) {
|
||||
continue
|
||||
}
|
||||
userID := ""
|
||||
if principal != nil && principal.User != nil {
|
||||
userID = principal.User.ID
|
||||
}
|
||||
s.banned.RecordHit(ctx, w.ID, userID)
|
||||
return fmt.Errorf("%w: banned word \"%s\"", ErrBannedPrompt, w.Word)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAdobeToken re-mints an Adobe account's access token from its cookie
|
||||
// (RefreshNow) and returns the updated row. Used to retry a 401 with a fresh
|
||||
// token instead of replaying the stale one. Returns false if refresh is
|
||||
@@ -339,6 +373,11 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
// generation from running on for minutes and surfacing a late "success" on an
|
||||
// already-abandoned event.
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
if source != "admin" {
|
||||
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -577,6 +616,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
// context (12-min backstop — video polls up to 10 min — and registered so the
|
||||
// maintenance sweep can cancel a stuck render when it abandons the row).
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
if source != "admin" {
|
||||
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -712,6 +756,9 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
// the background, and returns the OpenAI video object (status "queued").
|
||||
func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) {
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "vivid-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"marked": "^18.0.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.13",
|
||||
@@ -1497,6 +1498,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"marked": "^18.0.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
@@ -21,6 +21,7 @@ const PATHS = {
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
receipt: '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const tabs = [
|
||||
{ label: '账号管理', to: '/admin/accounts', icon: 'plug' },
|
||||
{ label: '用户管理', to: '/admin/users', icon: 'accounts' },
|
||||
{ label: '并发分组', to: '/admin/concurrency', icon: 'shield' },
|
||||
{ label: '违禁词管理', to: '/admin/banned-words', icon: 'ban' },
|
||||
{ label: '订单管理', to: '/admin/orders', icon: 'receipt' },
|
||||
{ label: '兑换码管理', to: '/admin/cdks', icon: 'spark' },
|
||||
{ label: '邀请日志', to: '/admin/invites', icon: 'accounts' },
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 BannedWordsView from './views/BannedWordsView.vue'
|
||||
import CdksView from './views/CdksView.vue'
|
||||
import InvitesAdminView from './views/InvitesAdminView.vue'
|
||||
import ImagesView from './views/ImagesView.vue'
|
||||
@@ -56,6 +57,7 @@ const routes = [
|
||||
{ path: 'accounts', component: AccountsView, meta: { label: '账号管理' } },
|
||||
{ path: 'users', component: UsersView, meta: { label: '用户管理' } },
|
||||
{ path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } },
|
||||
{ path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词管理' } },
|
||||
{ path: 'orders', component: AdminOrdersView, meta: { label: '订单管理' } },
|
||||
{ path: 'cdks', component: CdksView, meta: { label: '兑换码管理' } },
|
||||
{ path: 'invites', component: InvitesAdminView, meta: { label: '邀请日志' } },
|
||||
|
||||
@@ -279,13 +279,13 @@ function toggleSelect(id) {
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox controls the whole filtered set (not just the visible page).
|
||||
// Header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id)))
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id))
|
||||
else filtered.value.forEach((a) => s.add(a.id))
|
||||
if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id))
|
||||
else pagedItems.value.forEach((a) => s.add(a.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function deleteSelected() {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(false)
|
||||
const newWord = ref('')
|
||||
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('/banned-words')
|
||||
items.value = r.data?.data || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const word = newWord.value.trim()
|
||||
if (!word) { flash('违禁词不能为空'); return }
|
||||
const r = await api('/banned-words', jsonBody('POST', { word }))
|
||||
if (r.ok) { newWord.value = ''; flash('已添加'); load() }
|
||||
else flash(r.data?.detail || '添加失败')
|
||||
}
|
||||
|
||||
async function del(w) {
|
||||
if (!confirm(`删除违禁词「${w.word}」?`)) return
|
||||
const r = await api(`/banned-words/${w.id}`, { method: 'DELETE' })
|
||||
if (r.ok) { flash('已删除'); selected.value.delete(w.id); load() }
|
||||
else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
// multi-select — header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
const allSelected = computed(() =>
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((w) => selected.value.has(w.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) pagedItems.value.forEach((w) => s.delete(w.id))
|
||||
else pagedItems.value.forEach((w) => s.add(w.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个违禁词?`)) return
|
||||
let ok = 0
|
||||
for (const id of ids) {
|
||||
const r = await api(`/banned-words/${id}`, { method: 'DELETE' })
|
||||
if (r.ok) ok++
|
||||
}
|
||||
selected.value = new Set()
|
||||
flash(`已删除 ${ok} 个`)
|
||||
load()
|
||||
}
|
||||
|
||||
// pagination (client-side; the full list arrives in one payload)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (Math.min(page.value, totalPages.value) - 1) * pageSize
|
||||
return items.value.slice(start, start + pageSize)
|
||||
})
|
||||
function goPage(n) {
|
||||
const t = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (t !== page.value) page.value = t
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value, cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
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">提示词包含违禁词的生成请求(画图台 + API)会被<strong class="text-white/70">直接拦截</strong>,并累计触发次数(见用户管理)。匹配不区分大小写。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="selected.size" @click="delSelected" class="btn-soft danger shrink-0" title="删除选中的违禁词">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<input v-model="newWord" @keyup.enter="add" class="field !py-1.5 text-xs w-52" placeholder="输入违禁词后回车" />
|
||||
<button @click="add" class="btn-primary shrink-0">+ 添加</button>
|
||||
</div>
|
||||
</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-center px-3 py-3 font-medium w-9">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" class="chk" title="全选本页" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">违禁词</th>
|
||||
<th class="text-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 && !items.length"><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="w in pagedItems" :key="w.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(w.id)" @change="toggleSelect(w.id)" @click.stop class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ w.word }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="w.hits > 0 ? 'text-rose-300' : 'text-white/50'">{{ w.hits }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/50">{{ new Date(w.created_at).toLocaleString() }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right">
|
||||
<button @click="del(w)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-5 py-3 border-t border-white/[0.06] text-xs text-white/45">
|
||||
<div><span class="tabular-nums text-white/75">{{ items.length }}</span><span class="ml-1">个违禁词</span></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/30">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</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); }
|
||||
.btn-soft.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);
|
||||
}
|
||||
.btn-soft.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
.chk { accent-color: rgb(217 70 239); width: 0.9rem; height: 0.9rem; cursor: pointer; }
|
||||
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; 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; }
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -75,12 +75,13 @@ function toggleSelect(code) {
|
||||
s.has(code) ? s.delete(code) : s.add(code)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code)))
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((c) => selected.value.has(c.code)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code))
|
||||
else filtered.value.forEach((c) => s.add(c.code))
|
||||
if (allSelected.value) pagedItems.value.forEach((c) => s.delete(c.code))
|
||||
else pagedItems.value.forEach((c) => s.add(c.code))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
|
||||
@@ -234,8 +234,9 @@ function useExample(ex) {
|
||||
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
|
||||
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
|
||||
@click="go('/logs')">
|
||||
<img :src="imgSrc(w.image)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||
<div :style="{ backgroundImage: `url(${imgSrc(w.image)})` }"
|
||||
class="w-full h-full bg-cover bg-center"></div>
|
||||
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, generatedUrl, thumbUrl } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import { zipSync } from 'fflate'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
@@ -38,6 +39,92 @@ async function load() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Admin delete: remove the file (+derived stills) from storage, then reload
|
||||
// so pagination and the KPI strip stay accurate.
|
||||
async function deleteFile(f) {
|
||||
if (!f || !f.name) return
|
||||
if (!confirm('确定删除这个文件?删除后不可恢复')) return
|
||||
const r = await api('/images?name=' + encodeURIComponent(f.name), { method: 'DELETE' })
|
||||
flash(r.ok ? '已删除' : (r.data?.detail || '删除失败'))
|
||||
if (r.ok) load()
|
||||
}
|
||||
|
||||
// multi-select (keyed by file name) — bulk delete/download from the toolbar.
|
||||
const picked = ref(new Set())
|
||||
function togglePick(f) {
|
||||
const s = new Set(picked.value)
|
||||
s.has(f.name) ? s.delete(f.name) : s.add(f.name)
|
||||
picked.value = s
|
||||
}
|
||||
const pageAllPicked = computed(() =>
|
||||
items.value.length > 0 && items.value.every((f) => picked.value.has(f.name)))
|
||||
function togglePickAll() {
|
||||
const s = new Set(picked.value)
|
||||
if (pageAllPicked.value) items.value.forEach((f) => s.delete(f.name))
|
||||
else items.value.forEach((f) => s.add(f.name))
|
||||
picked.value = s
|
||||
}
|
||||
async function deletePicked() {
|
||||
const names = [...picked.value]
|
||||
if (!names.length) return
|
||||
if (!confirm(`确定删除选中的 ${names.length} 个文件?删除后不可恢复`)) return
|
||||
let ok = 0
|
||||
for (const n of names) {
|
||||
const r = await api('/images?name=' + encodeURIComponent(n), { method: 'DELETE' })
|
||||
if (r.ok) ok++
|
||||
}
|
||||
picked.value = new Set()
|
||||
flash(`已删除 ${ok} 个`)
|
||||
load()
|
||||
}
|
||||
// Single pick → direct file download; multiple → bundle into one zip.
|
||||
const zipping = ref(false)
|
||||
async function downloadPicked() {
|
||||
const names = [...picked.value]
|
||||
if (!names.length) return
|
||||
if (names.length === 1) {
|
||||
const a = document.createElement('a')
|
||||
a.href = generatedUrl(names[0])
|
||||
a.download = names[0].split('/').pop()
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
return
|
||||
}
|
||||
zipping.value = true
|
||||
flash('打包中…')
|
||||
try {
|
||||
// Fetch concurrently (10 at a time) so large batches pack fast.
|
||||
const bufs = []
|
||||
let next = 0
|
||||
await Promise.all(Array.from({ length: Math.min(10, names.length) }, async () => {
|
||||
while (next < names.length) {
|
||||
const i = next++
|
||||
bufs[i] = await (await fetch(generatedUrl(names[i]))).arrayBuffer()
|
||||
}
|
||||
}))
|
||||
const entries = {}
|
||||
names.forEach((n, i) => {
|
||||
let name = n.split('/').pop()
|
||||
while (entries[name]) name = '_' + name
|
||||
entries[name] = [new Uint8Array(bufs[i]), { level: 0 }]
|
||||
})
|
||||
const zipped = zipSync(entries)
|
||||
const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `图片-${names.length}个-${Date.now()}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
||||
flash('已打包下载')
|
||||
} catch {
|
||||
flash('打包失败')
|
||||
}
|
||||
zipping.value = false
|
||||
}
|
||||
|
||||
function absUrl(name) {
|
||||
const u = generatedUrl(name)
|
||||
return u.startsWith('http') ? u : location.origin + u
|
||||
@@ -148,10 +235,23 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<button @click="setKind('image')" class="fp" :class="kind === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="setKind('video')" class="fp" :class="kind === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="togglePickAll" class="btn-soft" :class="pageAllPicked && '!bg-white/90 !text-slate-900'">
|
||||
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
|
||||
</button>
|
||||
<template v-if="picked.size">
|
||||
<button @click="downloadPicked" :disabled="zipping" class="btn-soft disabled:opacity-50">
|
||||
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
|
||||
</button>
|
||||
<button @click="deletePicked" class="btn-soft danger">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
|
||||
</button>
|
||||
</template>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grid -->
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
@@ -186,11 +286,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
<!-- select + kind chip -->
|
||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
||||
<button @click.stop.prevent="togglePick(f)" :title="picked.has(f.name) ? '取消选择' : '选择'"
|
||||
class="pick" :class="picked.has(f.name) && 'pick-on'">
|
||||
<Icon name="check" class="w-3 h-3" />
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ f.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- quick actions, hover-revealed; same style as 首页内容 -->
|
||||
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -202,6 +308,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop.prevent="deleteFile(f)" title="删除"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- caption: prompt (truncated 2 lines) + meta line -->
|
||||
@@ -304,4 +414,29 @@ html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
|
||||
.btn-soft.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);
|
||||
}
|
||||
.btn-soft.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
|
||||
/* card select toggle — always visible rounded-square check button */
|
||||
.pick {
|
||||
width: 1.4rem; height: 1.4rem; border-radius: 0.375rem;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75);
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pick svg { opacity: 0; transition: opacity 0.15s; }
|
||||
.pick:hover { background: rgb(0 0 0 / 0.65); }
|
||||
.pick:hover svg { opacity: 0.6; }
|
||||
.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
|
||||
.pick-on svg { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -396,7 +396,7 @@ let prevPending = 0
|
||||
async function loadHistory() {
|
||||
// Server-side filter: status IN (pending, success), newest 12 — exactly the
|
||||
// rows the grid shows, in one query (no client over-fetch).
|
||||
const r = await api('/logs?limit=10&statuses=pending,success&source=user')
|
||||
const r = await api('/logs?limit=10&statuses=pending,success&source=user&exclude_showcase=1&media=1')
|
||||
if (!r.ok) return
|
||||
history.value = (r.data?.data || [])
|
||||
.filter((e) => e.status === 'pending' || e.file)
|
||||
@@ -423,6 +423,22 @@ async function loadHistory() {
|
||||
prevPending = serverPending.size
|
||||
}
|
||||
|
||||
// Delete one of my works: remove the stored file (+thumb) server-side, then
|
||||
// drop the card locally so it disappears before the next history poll.
|
||||
async function deleteItem(item) {
|
||||
if (!item || !item.url) return
|
||||
if (!confirm('确定删除这个作品?删除后不可恢复')) return
|
||||
const rel = (item.url || '').split('?')[0].split('/images/').pop()
|
||||
const r = await api('/my-files?file=' + encodeURIComponent(rel), { method: 'DELETE' })
|
||||
if (r.ok) {
|
||||
tasks.value = tasks.value.filter((t) => t.id !== item.id)
|
||||
history.value = history.value.filter((h) => h.id !== item.id)
|
||||
flash('已删除')
|
||||
} else {
|
||||
flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// Click a generated IMAGE → use it as a reference. Single-ref model: replace the
|
||||
// existing ref. Multi-ref: append if there's room, else replace the last one.
|
||||
function useAsRef(item) {
|
||||
@@ -726,6 +742,10 @@ onUnmounted(() => {
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click.stop.prevent="deleteItem(item)" title="删除"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-2.5 pointer-events-none">
|
||||
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl, thumbUrl } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import { zipSync } from 'fflate'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
@@ -123,6 +124,99 @@ async function copyPrompt(e) {
|
||||
}
|
||||
|
||||
const toast = ref('')
|
||||
|
||||
// Delete one of my works (file + thumb server-side), then reload the page so
|
||||
// pagination stays accurate.
|
||||
async function deleteEntry(e) {
|
||||
if (!e || !e.file) return
|
||||
if (!confirm('确定删除这个作品?删除后不可恢复')) return
|
||||
const r = await api('/my-files?file=' + encodeURIComponent(e.file), { method: 'DELETE' })
|
||||
toast.value = r.ok ? '已删除' : (r.data?.detail || '删除失败')
|
||||
setTimeout(() => (toast.value = ''), 1500)
|
||||
if (r.ok) load()
|
||||
}
|
||||
// multi-select (keyed by file path) — bulk delete/download from the filter bar.
|
||||
const picked = ref(new Set())
|
||||
function togglePick(e) {
|
||||
if (!e.file) return
|
||||
const s = new Set(picked.value)
|
||||
s.has(e.file) ? s.delete(e.file) : s.add(e.file)
|
||||
picked.value = s
|
||||
}
|
||||
const pageAllPicked = computed(() => {
|
||||
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
|
||||
return files.length > 0 && files.every((e) => picked.value.has(e.file))
|
||||
})
|
||||
function togglePickAll() {
|
||||
const s = new Set(picked.value)
|
||||
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
|
||||
if (pageAllPicked.value) files.forEach((e) => s.delete(e.file))
|
||||
else files.forEach((e) => s.add(e.file))
|
||||
picked.value = s
|
||||
}
|
||||
async function deletePicked() {
|
||||
const files = [...picked.value]
|
||||
if (!files.length) return
|
||||
if (!confirm(`确定删除选中的 ${files.length} 个作品?删除后不可恢复`)) return
|
||||
let ok = 0
|
||||
for (const f of files) {
|
||||
const r = await api('/my-files?file=' + encodeURIComponent(f), { method: 'DELETE' })
|
||||
if (r.ok) ok++
|
||||
}
|
||||
picked.value = new Set()
|
||||
toast.value = `已删除 ${ok} 个`
|
||||
setTimeout(() => (toast.value = ''), 1500)
|
||||
load()
|
||||
}
|
||||
// Single pick → direct file download; multiple → bundle into one zip.
|
||||
const zipping = ref(false)
|
||||
async function downloadPicked() {
|
||||
const files = [...picked.value]
|
||||
if (!files.length) return
|
||||
if (files.length === 1) {
|
||||
const a = document.createElement('a')
|
||||
a.href = generatedUrl(files[0])
|
||||
a.download = files[0].split('/').pop()
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
return
|
||||
}
|
||||
zipping.value = true
|
||||
toast.value = '打包中…'
|
||||
try {
|
||||
// Fetch concurrently (10 at a time) so large batches pack fast.
|
||||
const bufs = []
|
||||
let next = 0
|
||||
await Promise.all(Array.from({ length: Math.min(10, files.length) }, async () => {
|
||||
while (next < files.length) {
|
||||
const i = next++
|
||||
bufs[i] = await (await fetch(generatedUrl(files[i]))).arrayBuffer()
|
||||
}
|
||||
}))
|
||||
const entries = {}
|
||||
files.forEach((f, i) => {
|
||||
let name = f.split('/').pop()
|
||||
while (entries[name]) name = '_' + name
|
||||
entries[name] = [new Uint8Array(bufs[i]), { level: 0 }]
|
||||
})
|
||||
const zipped = zipSync(entries)
|
||||
const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `作品-${files.length}个-${Date.now()}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000)
|
||||
toast.value = '已打包下载'
|
||||
} catch {
|
||||
toast.value = '打包失败'
|
||||
}
|
||||
zipping.value = false
|
||||
setTimeout(() => (toast.value = ''), 1500)
|
||||
}
|
||||
|
||||
const lightbox = ref(null)
|
||||
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||
// the muted <video> preview for those cards.
|
||||
@@ -168,6 +262,18 @@ onUnmounted(() => {
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
|
||||
</div>
|
||||
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
|
||||
:class="pageAllPicked ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
|
||||
</button>
|
||||
<template v-if="picked.size">
|
||||
<button @click="downloadPicked" :disabled="zipping" class="text-xs rounded-lg px-2.5 py-1.5 bg-slate-900 text-white hover:bg-slate-700 inline-flex items-center gap-1 disabled:opacity-50">
|
||||
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
|
||||
</button>
|
||||
<button @click="deletePicked" class="text-xs rounded-lg px-2.5 py-1.5 bg-rose-600 text-white hover:bg-rose-500 inline-flex items-center gap-1">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
@@ -217,11 +323,18 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
<!-- select + kind chip -->
|
||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
||||
<button v-if="e.status === 'success' && e.file" @click.stop.prevent="togglePick(e)"
|
||||
:title="picked.has(e.file) ? '取消选择' : '选择'"
|
||||
class="pick" :class="picked.has(e.file) && 'pick-on'">
|
||||
<Icon name="check" class="w-3 h-3" />
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- hover actions (only when there's a file) -->
|
||||
<div v-if="e.status === 'success' && e.file"
|
||||
@@ -234,6 +347,10 @@ onUnmounted(() => {
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop.prevent="deleteEntry(e)" title="删除"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- caption (over a real image) -->
|
||||
@@ -309,4 +426,19 @@ onUnmounted(() => {
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
|
||||
/* card select toggle — always visible rounded-square check button */
|
||||
.pick {
|
||||
width: 1.4rem; height: 1.4rem; border-radius: 0.375rem;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: rgb(255 255 255 / 0.85);
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75);
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pick svg { opacity: 0; transition: opacity 0.15s; }
|
||||
.pick:hover { background: rgb(0 0 0 / 0.65); }
|
||||
.pick:hover svg { opacity: 0.6; }
|
||||
.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
|
||||
.pick-on svg { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -169,12 +169,13 @@ function toggleSelect(id) {
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox selects/deselects the CURRENT PAGE only.
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id)))
|
||||
pagedItems.value.length > 0 && pagedItems.value.every((u) => selected.value.has(u.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id))
|
||||
else filtered.value.forEach((u) => s.add(u.id))
|
||||
if (allSelected.value) pagedItems.value.forEach((u) => s.delete(u.id))
|
||||
else pagedItems.value.forEach((u) => s.add(u.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
@@ -274,6 +275,7 @@ async function quickCredits(u, delta) {
|
||||
<col class="w-24" /> <!-- credits -->
|
||||
<col class="w-24" /> <!-- recharge total -->
|
||||
<col class="w-20" /> <!-- generation count -->
|
||||
<col class="w-20" /> <!-- banned word hits -->
|
||||
<col class="w-28" /> <!-- registered -->
|
||||
<col class="w-28" /> <!-- last login -->
|
||||
<col class="w-32" /> <!-- login IP -->
|
||||
@@ -294,6 +296,7 @@ async function quickCredits(u, delta) {
|
||||
<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-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-left px-3 py-3 font-medium">最近登录</th>
|
||||
<th class="text-left px-3 py-3 font-medium">登录 IP</th>
|
||||
@@ -350,6 +353,10 @@ async function quickCredits(u, delta) {
|
||||
:class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
|
||||
{{ (u.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="u.banned_word_hits > 0 ? 'text-rose-300' : 'text-white/25'">
|
||||
{{ (u.banned_word_hits || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div>
|
||||
|
||||
Reference in New Issue
Block a user