diff --git a/backend/internal/bootstrap/seed.go b/backend/internal/bootstrap/seed.go index ba87a4c..0fc6bfb 100644 --- a/backend/internal/bootstrap/seed.go +++ b/backend/internal/bootstrap/seed.go @@ -10,6 +10,8 @@ import ( func seedDefaults(ctx context.Context, db *gorm.DB) error { defaults := []model.SiteSetting{ {Key: "site.title", Value: "Vivid"}, + {Key: "site.logo", Value: ""}, + {Key: "site.subtitle", Value: ""}, {Key: "contact.qq", Value: "1114639355"}, {Key: "contact.qq_link", Value: "https://qm.qq.com/q/ItgCcNA7ac"}, {Key: "contact.qq_group", Value: "1106849765"}, @@ -47,5 +49,35 @@ func seedDefaults(ctx context.Context, db *gorm.DB) error { return err } } + // One-time backfill of the persistent per-model generation counter from + // historical success logs, so the admin "次数" keeps its running total when we + // switch it off the (retention-pruned) event_log. Only touches models still at + // 0, so it never double-counts after the first run; increments take over next. + if err := db.WithContext(ctx).Exec( + `UPDATE model_configs m SET generation_count = COALESCE( + (SELECT COUNT(*) FROM event_logs e WHERE e.model = m.id AND e.status = 'success'), 0) + WHERE m.generation_count = 0`).Error; err != nil { + return err + } + // Same one-time backfill for the per-user generation counter. + if err := db.WithContext(ctx).Exec( + `UPDATE users u SET generation_count = COALESCE( + (SELECT COUNT(*) FROM event_logs e WHERE e.user_id = u.id AND e.status = 'success'), 0) + WHERE u.generation_count = 0`).Error; err != nil { + return err + } + // Seed the dashboard lifetime counters from logs ONCE (only when empty), so the + // all-time cards start from real history then track forward via the hooks. + var counterRows int64 + if err := db.WithContext(ctx).Model(&model.StatCounter{}).Count(&counterRows).Error; err == nil && counterRows == 0 { + _ = db.WithContext(ctx).Exec(`INSERT INTO stat_counters (key, value, updated_at) + SELECT 'total', COUNT(*), now() FROM event_logs + UNION ALL SELECT 'success', COUNT(*) FILTER (WHERE status='success'), now() FROM event_logs + UNION ALL SELECT 'failed', COUNT(*) FILTER (WHERE status='failed'), now() FROM event_logs + UNION ALL SELECT 'image', COUNT(*) FILTER (WHERE kind='image'), now() FROM event_logs + UNION ALL SELECT 'video', COUNT(*) FILTER (WHERE kind='video'), now() FROM event_logs + UNION ALL SELECT 'api', COUNT(*) FILTER (WHERE source='v1'), now() FROM event_logs + ON CONFLICT (key) DO NOTHING`).Error + } return nil } diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index 16761c2..2d7b3ad 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -26,16 +26,11 @@ func (h *AdminReadHandler) Users(c *gin.Context) { } out := make([]gin.H, 0, len(users)) - generationCounts := map[string]int64{} - if raw, ok := stats["generation_counts"].(map[string]int64); ok { - generationCounts = raw - } for _, user := range users { row := userPublic(user) - row["generation_count"] = generationCounts[user.ID] + row["generation_count"] = user.GenerationCount out = append(out, row) } - delete(stats, "generation_counts") c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats}) } @@ -61,7 +56,7 @@ func (h *AdminReadHandler) Logs(c *gin.Context) { } } - items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, 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) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return diff --git a/backend/internal/http/handler/site.go b/backend/internal/http/handler/site.go index 9766f48..d9182a1 100644 --- a/backend/internal/http/handler/site.go +++ b/backend/internal/http/handler/site.go @@ -21,5 +21,11 @@ func (h *SiteHandler) Public(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"}) return } - c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) + ctx := c.Request.Context() + c.JSON(http.StatusOK, gin.H{ + "title": title, + "logo": h.site.Logo(ctx), + "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }) } diff --git a/backend/internal/http/handler/site_settings.go b/backend/internal/http/handler/site_settings.go index eecbd98..0e579ec 100644 --- a/backend/internal/http/handler/site_settings.go +++ b/backend/internal/http/handler/site_settings.go @@ -17,18 +17,27 @@ func NewSiteSettingsHandler(site *service.SiteService) *SiteSettingsHandler { } func (h *SiteSettingsHandler) Get(c *gin.Context) { - title, err := h.site.Title(c.Request.Context()) + ctx := c.Request.Context() + title, err := h.site.Title(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"}) return } - c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) + c.JSON(http.StatusOK, gin.H{ + "title": title, + "logo": h.site.Logo(ctx), + "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }) } func (h *SiteSettingsHandler) Put(c *gin.Context) { + ctx := c.Request.Context() var body struct { - Title string `json:"title"` - Contact service.Contact `json:"contact"` + Title string `json:"title"` + Logo string `json:"logo"` + Subtitle string `json:"subtitle"` + Contact service.Contact `json:"contact"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) @@ -39,14 +48,21 @@ func (h *SiteSettingsHandler) Put(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"}) return } - updated, err := h.site.SetTitle(c.Request.Context(), title) + updated, err := h.site.SetTitle(ctx, title) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"}) return } - if err := h.site.SetContact(c.Request.Context(), body.Contact); err != nil { + if err := h.site.SetBranding(ctx, body.Logo, body.Subtitle); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save branding"}) + return + } + if err := h.site.SetContact(ctx, body.Contact); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save contact info"}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{"title": updated, "contact": h.site.Contact(c.Request.Context())}}) + c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{ + "title": updated, "logo": h.site.Logo(ctx), "subtitle": h.site.Subtitle(ctx), + "contact": h.site.Contact(ctx), + }}) } diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 33b2379..390664b 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -3,6 +3,7 @@ package handler import ( "errors" "net/http" + "strings" "backend/internal/service" "github.com/gin-gonic/gin" @@ -176,6 +177,16 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) { offset := parseInt(c.Query("offset"), 0) kind := c.Query("kind") status := c.Query("status") + // statuses=pending,success → status IN (...). Used by the 画图台 grid so it can + // fetch exactly the rows it shows (进行中 + 成功) in one query, server-side. + var statuses []string + if s := strings.TrimSpace(c.Query("statuses")); s != "" { + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + statuses = append(statuses, p) + } + } + } // Secure-by-default: always scope to the caller's OWN records. This endpoint // serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal // records must NOT see other users' work. Only an admin who explicitly opts @@ -195,7 +206,7 @@ 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, nil, userID, excludeSource, source, hasFile) + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return @@ -430,7 +441,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "type": "video", "ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"}, "resolutions": []string{"720p"}, - "durations": []string{"6s", "10s"}, + "durations": []string{"6s", "10s", "15s"}, "max_reference_images": 6, "reference_mode": "asset", "description": "Grok Imagine video (文/图生视频)", diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 4e5fa0b..c15fc6e 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -21,6 +21,7 @@ type User struct { InviteRewardAt *time.Time CheckinLast string `gorm:"size:32"` CheckinStreak int `gorm:"not null;default:0"` + GenerationCount int64 `gorm:"not null;default:0"` LastLoginAt *time.Time LastLoginIP string `gorm:"size:128"` CreatedAt time.Time @@ -111,6 +112,10 @@ type ModelConfig struct { // weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall // back to created_at desc. Default 0. Weight int `gorm:"not null;default:0;index"` + // GenerationCount is a persistent success counter, incremented once per + // successful generation. Independent of the event_log (which is subject to + // retention / manual clearing), so the admin "次数" is a true running total. + GenerationCount int64 `gorm:"not null;default:0"` CreatedAt time.Time UpdatedAt time.Time } @@ -197,5 +202,15 @@ func AutoMigrateModels() []any { &TokenAccount{}, &RefreshProfile{}, &SiteSetting{}, + &StatCounter{}, } } + +// 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. +type StatCounter struct { + Key string `gorm:"primaryKey;size:64"` + Value int64 `gorm:"not null;default:0"` + UpdatedAt time.Time +} diff --git a/backend/internal/provider/custom/client.go b/backend/internal/provider/custom/client.go index 146f673..e4cdad5 100644 --- a/backend/internal/provider/custom/client.go +++ b/backend/internal/provider/custom/client.go @@ -15,6 +15,7 @@ import ( "io" "mime/multipart" "net/http" + "net/url" "strings" "time" ) @@ -29,6 +30,32 @@ type Client struct{} func NewClient() *Client { return &Client{} } +// sanitizeErr strips the upstream URL/host from a network error so a user's +// private upstream URL never leaks into the event log / API response. +func sanitizeErr(err error) string { + if err == nil { + return "" + } + s := err.Error() + switch { + case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"): + return "request timeout" + case strings.Contains(s, "connection refused"): + return "connection refused" + case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "): + return "cannot reach upstream" + case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"): + return "TLS error" + case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"): + return "connection reset" + } + var ue *url.Error + if errors.As(err, &ue) { + return strings.ToLower(ue.Op) + " upstream failed" + } + return "upstream request failed" +} + func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} } // GenerateImage calls the upstream OpenAI image API. With reference images it @@ -82,7 +109,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) @@ -145,7 +172,7 @@ func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prom case "failed", "error", "canceled", "cancelled": reason := stringValue(job["error"]) if isCreditError(reason) { - return nil, "", fmt.Errorf("%w: %s", ErrQuotaExhausted, clip([]byte(reason), 160)) + return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160)) } return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160)) } @@ -171,7 +198,7 @@ func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body [] } resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) @@ -194,7 +221,7 @@ func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, erro req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -234,7 +261,7 @@ func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) { req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil) resp, err := httpClient().Do(req) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err)) } defer resp.Body.Close() return io.ReadAll(resp.Body) @@ -249,12 +276,14 @@ func mapStatus(status int, body []byte) error { case status == 401 || status == 403: return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160)) case status == 429: - return fmt.Errorf("%w: 429 %s", ErrQuotaExhausted, clip(body, 160)) + // Custom upstreams have NO "quota exhausted" lock — a 429 is just rate + // limiting, treated as a temporary error (fail over, account stays active). + return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160)) case status >= 500: return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160)) default: if isCreditError(string(body)) { - return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(body, 160)) + return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160)) } return fmt.Errorf("custom: %d %s", status, clip(body, 160)) } diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index 3ed89b2..963a356 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -44,7 +44,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, if strings.TrimSpace(resolution) == "" { resolution = "720p" } - if seconds != 6 && seconds != 10 { + if seconds != 6 && seconds != 10 && seconds != 15 { seconds = 10 } diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 0ec5090..036460c 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -18,7 +18,8 @@ type EventListFilter struct { Limit int Offset int Kind string - Status string + Status string // single status (status = ?) + Statuses []string // multiple statuses (status IN (?)) — used by the 画图台 grid Since *time.Time UserID string ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page) @@ -47,6 +48,9 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m if filter.Status != "" { q = q.Where("status = ?", filter.Status) } + if len(filter.Statuses) > 0 { + q = q.Where("status IN ?", filter.Statuses) + } if filter.Since != nil { q = q.Where("ts > ?", *filter.Since) } @@ -443,7 +447,49 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge time.Duration) } func (r *EventRepository) Create(ctx context.Context, item *model.EventLog) error { - return r.db.WithContext(ctx).Create(item).Error + if err := r.db.WithContext(ctx).Create(item).Error; err != nil { + return err + } + // Persistent cumulative counters (survive log retention/clearing): every + // created event bumps total + its kind + (api source). + deltas := map[string]int64{"total": 1} + if item.Kind == "video" { + deltas["video"] = 1 + } else if item.Kind == "image" { + deltas["image"] = 1 + } + if item.Source == "v1" { + deltas["api"] = 1 + } + r.incrCounters(ctx, deltas) + return nil +} + +// incrCounters upserts monotonic counters (stat_counters). Best-effort: a counter +// failure must never fail the generation, so errors are swallowed. +func (r *EventRepository) incrCounters(ctx context.Context, deltas map[string]int64) { + for k, n := range deltas { + if n == 0 { + continue + } + _ = r.db.WithContext(ctx).Exec( + `INSERT INTO stat_counters (key, value, updated_at) VALUES (?, ?, now()) + ON CONFLICT (key) DO UPDATE SET value = stat_counters.value + EXCLUDED.value, updated_at = now()`, + k, n).Error + } +} + +// Counters returns all persistent counters as key→value. +func (r *EventRepository) Counters(ctx context.Context) (map[string]int64, error) { + var rows []model.StatCounter + if err := r.db.WithContext(ctx).Find(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]int64, len(rows)) + for _, x := range rows { + out[x.Key] = x.Value + } + return out, nil } // GetByID fetches a single event (nil, nil when not found). Used by the async @@ -462,16 +508,22 @@ func (r *EventRepository) GetByID(ctx context.Context, id string) (*model.EventL // MarkVideoReady completes an async video job: status=success, file=upstream URL // (proxied on /content — never persisted), elapsed. func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL string, elapsedMS int) error { - return r.db.WithContext(ctx). + // Guard on a real transition (status <> success) so the success counter is + // incremented exactly once even if this fires twice / concurrently. + res := r.db.WithContext(ctx). Model(&model.EventLog{}). - Where("id = ?", eventID). + Where("id = ? AND status <> ?", eventID, "success"). Updates(map[string]any{ "status": "success", "file": fileURL, "error": "", "elapsed_ms": elapsedMS, "updated_at": time.Now(), - }).Error + }) + if res.Error == nil && res.RowsAffected > 0 { + r.incrCounters(ctx, map[string]int64{"success": 1}) + } + return res.Error } func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error { @@ -488,10 +540,20 @@ func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, err // "成功 + abandoned" at once. patch["error"] = "" } - return r.db.WithContext(ctx). + // Guard on a real transition so the success/failed counters increment exactly + // once per event even under a duplicate/concurrent terminal status update. + res := r.db.WithContext(ctx). Model(&model.EventLog{}). - Where("id = ?", eventID). - Updates(patch).Error + Where("id = ? AND status <> ?", eventID, status). + Updates(patch) + if res.Error == nil && res.RowsAffected > 0 { + if status == "success" { + r.incrCounters(ctx, map[string]int64{"success": 1}) + } else if status == "failed" { + r.incrCounters(ctx, map[string]int64{"failed": 1}) + } + } + return res.Error } // MarkRefunded atomically claims the right to refund this event exactly once: diff --git a/backend/internal/repo/model_repo.go b/backend/internal/repo/model_repo.go index c24bd35..ce99fcd 100644 --- a/backend/internal/repo/model_repo.go +++ b/backend/internal/repo/model_repo.go @@ -18,6 +18,14 @@ func NewModelRepository(db *gorm.DB) *ModelRepository { return &ModelRepository{db: db} } +// IncrementGenerationCount bumps a model's persistent success counter by 1. +// Best-effort: a missing model id is a no-op (0 rows affected, no error). +func (r *ModelRepository) IncrementGenerationCount(ctx context.Context, modelID string) error { + return r.db.WithContext(ctx).Model(&model.ModelConfig{}). + Where("id = ?", modelID). + UpdateColumn("generation_count", gorm.Expr("generation_count + 1")).Error +} + func (r *ModelRepository) List(ctx context.Context) ([]model.ModelConfig, error) { var items []model.ModelConfig // Higher weight floats to the top of the dropdown / admin list; ties fall diff --git a/backend/internal/repo/user_repo.go b/backend/internal/repo/user_repo.go index a8348c7..cff3027 100644 --- a/backend/internal/repo/user_repo.go +++ b/backend/internal/repo/user_repo.go @@ -87,6 +87,16 @@ func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*mod return &user, nil } +// IncrementGenerationCount bumps a user's persistent success counter by 1. +func (r *UserRepository) IncrementGenerationCount(ctx context.Context, userID string) error { + if userID == "" { + return nil + } + return r.db.WithContext(ctx).Model(&model.User{}). + Where("id = ?", userID). + UpdateColumn("generation_count", gorm.Expr("generation_count + 1")).Error +} + func (r *UserRepository) List(ctx context.Context) ([]model.User, error) { var users []model.User if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil { diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index ebd5087..b6f62e8 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -41,19 +41,12 @@ func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string] if err != nil { return nil, nil, err } - counts, err := s.events.UserSuccessCounts(ctx) - if err != nil { - return nil, nil, err - } - for i := range users { - meta := users[i].Notes - _ = meta - } stats, err := s.users.Stats(ctx) if err != nil { return nil, nil, err } - stats["generation_counts"] = counts + // Per-user generation count now comes from the persistent users.generation_count + // column (set in the handler from each user object), not a log COUNT. return users, stats, nil } @@ -66,10 +59,6 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er if err != nil { return nil, err } - counts, err := s.events.ModelSuccessCounts(ctx) - if err != nil { - return nil, err - } out := make([]map[string]any, 0, len(items)) for _, item := range items { out = append(out, map[string]any{ @@ -89,7 +78,7 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er "max_reference_images": item.MaxReferenceImages, "reference_mode": item.ReferenceMode, "weight": item.Weight, - "generation_count": counts[item.ID], + "generation_count": item.GenerationCount, "created_at": item.CreatedAt, "updated_at": item.UpdatedAt, }) @@ -97,12 +86,13 @@ 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, 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 bool) ([]model.EventLog, int64, *repo.EventStats, error) { items, total, err := s.events.List(ctx, repo.EventListFilter{ Limit: limit, Offset: offset, Kind: kind, Status: status, + Statuses: statuses, Since: since, UserID: userID, ExcludeSource: excludeSource, @@ -173,7 +163,7 @@ func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) { func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) { now := time.Now() dayCut := now.Add(-24 * time.Hour) - weekCut := now.Add(-7 * 24 * time.Hour) + weekCut := now.Add(-3 * 24 * time.Hour) // "week" key = last 3 days (per admin request) day, err := s.events.WindowStats(ctx, dayCut) if err != nil { @@ -199,6 +189,12 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error if err != nil { return nil, err } + // All-time persistent counters (total/success/failed/image/video/api) — these + // survive log retention/clearing, unlike the windowed day/week stats. + lifetime, err := s.events.Counters(ctx) + if err != nil { + return nil, err + } // Per-window top-N analytics so the frontend can toggle 24h / 7d without a // re-fetch (the lists are small — top 6 / top 5). @@ -263,6 +259,7 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error "dau": dau, "wau": wau, "hourly": hourly, + "lifetime": lifetime, "analytics": map[string]any{ "day": dayAnalytics, "week": weekAnalytics, diff --git a/backend/internal/service/app_settings.go b/backend/internal/service/app_settings.go index 4f837fa..2d5a999 100644 --- a/backend/internal/service/app_settings.go +++ b/backend/internal/service/app_settings.go @@ -358,6 +358,12 @@ func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, er if err != nil { return SMTPConfig{}, err } + // The verification-email subject uses the site title, e.g. " 邮箱验证码". + title, _ := s.settings.GetValue(ctx, "site.title") + title = strings.TrimSpace(title) + if title == "" { + title = "Vivid" + } return SMTPConfig{ Host: current.Host, Port: current.Port, @@ -365,6 +371,7 @@ func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, er Password: password, FromAddr: current.FromAddr, UseTLS: current.UseTLS, + Subject: title + " 邮箱验证码", }, nil } diff --git a/backend/internal/service/site.go b/backend/internal/service/site.go index 3e41fee..f854065 100644 --- a/backend/internal/service/site.go +++ b/backend/internal/service/site.go @@ -42,6 +42,28 @@ func (s *SiteService) SetTitle(ctx context.Context, title string) (string, error return title, nil } +func (s *SiteService) get(ctx context.Context, key string) string { + v, _ := s.settings.GetValue(ctx, key) + return strings.TrimSpace(v) +} + +// Logo / Subtitle are admin-editable branding shown on the public site. +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 +} + // Contact is the admin-editable "联系我们" info shown in the public 关于 section. type Contact struct { QQ string `json:"qq"` diff --git a/backend/internal/service/smtp.go b/backend/internal/service/smtp.go index cacad0a..7e9deaa 100644 --- a/backend/internal/service/smtp.go +++ b/backend/internal/service/smtp.go @@ -18,6 +18,7 @@ type SMTPConfig struct { Password string FromAddr string UseTLS bool + Subject string // verification-code email subject (empty → default) } type SMTPService struct{} @@ -35,7 +36,10 @@ func (s *SMTPService) SendCode(ctx context.Context, cfg SMTPConfig, to, code, pu if purpose == "reset" { action = "找回密码" } - subject := "Vivid AI 邮箱验证码" + subject := strings.TrimSpace(cfg.Subject) + if subject == "" { + subject = "Vivid AI 邮箱验证码" + } body := fmt.Sprintf("你正在进行%s,验证码为:%s\n\n验证码 6 分钟内有效。", action, code) msg := buildSMTPMessage(cfg.FromAddr, to, subject, body) addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index 51cb4c7..f52c2f8 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -906,6 +906,7 @@ func (s *TokenService) ImportGrokToken(ctx context.Context, ssoToken, tokenID st return item, nil } + func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) { defer func() { if r := recover(); r != nil { diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index dc0d873..40cfb22 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -495,6 +495,10 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil { return nil, err } + _ = s.models.IncrementGenerationCount(ctx, modelItem.ID) + if principal != nil && principal.User != nil { + _ = s.users.IncrementGenerationCount(ctx, principal.User.ID) + } if charge { _ = s.maybeGrantInviteReward(ctx, principal) } @@ -615,6 +619,10 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil { return nil, err } + _ = s.models.IncrementGenerationCount(ctx, modelItem.ID) + if principal != nil && principal.User != nil { + _ = s.users.IncrementGenerationCount(ctx, principal.User.ID) + } if charge { _ = s.maybeGrantInviteReward(ctx, principal) } @@ -710,6 +718,10 @@ func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in if err := s.events.MarkVideoReady(ctx, eventID, videoURL, int(time.Since(startedAt).Milliseconds())); err != nil { return } + _ = s.models.IncrementGenerationCount(ctx, modelItem.ID) + if principal != nil && principal.User != nil { + _ = s.users.IncrementGenerationCount(ctx, principal.User.ID) + } _ = s.maybeGrantInviteReward(ctx, principal) } @@ -2810,7 +2822,7 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod // the cookie. chatgpt/runway/leonardo auth means the stored credential is // dead — a raw JWT (chatgpt/runway) or a cookie whose session no longer // authenticates (leonardo) — there's nothing left to refresh from. - if pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" { + if pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" || pool == "grok" { patch["status"] = "disabled" patch["dead"] = true } diff --git a/frontend/src/components/CustomModelModal.vue b/frontend/src/components/CustomModelModal.vue index 37e1b40..e1e2250 100644 --- a/frontend/src/components/CustomModelModal.vue +++ b/frontend/src/components/CustomModelModal.vue @@ -10,7 +10,7 @@ const RATIO_OPTS = ['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '3:2', '5:4', ' const IMG_RES = ['1K', '2K', '4K'] const VID_RES = ['720p', '1080p', '2K', '4K'] const ALL_RES = ['1K', '2K', '4K', '720p', '1080p'] -const DUR_OPTS = ['5s', '6s', '8s', '10s'] +const DUR_OPTS = ['5s', '6s', '8s', '10s', '15s'] const id = ref('') const type = ref('image') @@ -21,9 +21,30 @@ const weight = ref(0) // tier -> { price, agent } — blank price means the tier is NOT supported. const res = ref(Object.fromEntries(ALL_RES.map((r) => [r, { price: '', agent: '' }]))) const dur = ref(Object.fromEntries(DUR_OPTS.map((d) => [d, { price: '', agent: '' }]))) +// Duration rows shown: the presets above + any custom seconds the admin adds. +const durList = ref([...DUR_OPTS]) +const customDurInput = ref('') const error = ref('') const saving = ref(false) +// Add a custom duration (any positive integer seconds, e.g. 12 → "12s"). +function addCustomDur() { + const n = parseInt(customDurInput.value, 10) + if (!(n > 0)) return + const key = n + 's' + if (!durList.value.includes(key)) { + if (!dur.value[key]) dur.value[key] = { price: '', agent: '' } + durList.value.push(key) + } + customDurInput.value = '' +} +// Custom (non-preset) durations can be removed; presets stay. +function removeDur(key) { + if (DUR_OPTS.includes(key)) return + durList.value = durList.value.filter((k) => k !== key) + delete dur.value[key] +} + const isVideo = computed(() => type.value === 'video') // Resolution tiers depend on type: image = 1K/2K/4K, video = 540p/720p/1080p/2K/4K. const resOpts = computed(() => (isVideo.value ? VID_RES : IMG_RES)) @@ -145,12 +166,22 @@ async function save() { <div v-if="isVideo"> <label class="text-xs text-slate-500 block mb-1.5">时长 · 价格(总价 = 分辨率价 + 时长价;<strong class="text-slate-600">留空 = 不支持</strong>)</label> <div class="space-y-1.5"> - <div v-for="d in DUR_OPTS" :key="d" class="flex items-center gap-2"> + <div v-for="d in durList" :key="d" class="flex items-center gap-2"> <span class="w-16 text-xs font-mono text-slate-500">{{ d }}</span> <input v-model="dur[d].price" type="number" class="field !py-1 flex-1" placeholder="普通价(留空=不支持)" /> <input v-model="dur[d].agent" type="number" class="field !py-1 flex-1" placeholder="代理价(留空跟随)" /> + <button v-if="!DUR_OPTS.includes(d)" type="button" @click="removeDur(d)" + class="shrink-0 w-7 h-7 grid place-items-center rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50" title="删除该时长"> + <Icon name="close" class="w-3.5 h-3.5" /> + </button> </div> </div> + <!-- 自定义时长:输入任意秒数,Sora 类上游支持的任意时长都能加 --> + <div class="flex items-center gap-2 mt-2"> + <input v-model="customDurInput" type="number" min="1" @keydown.enter.prevent="addCustomDur" + class="field !py-1 w-32" placeholder="自定义秒数" /> + <button type="button" @click="addCustomDur" class="btn-soft text-xs whitespace-nowrap">+ 添加时长</button> + </div> </div> <div class="flex gap-3"> diff --git a/frontend/src/components/MediaLightbox.vue b/frontend/src/components/MediaLightbox.vue index 4fa9fd6..0fe4bf9 100644 --- a/frontend/src/components/MediaLightbox.vue +++ b/frontend/src/components/MediaLightbox.vue @@ -3,9 +3,11 @@ // admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls // mount via v-if and passes the resolved media URL + meta; the component owns the // overlay shell, image/video element, prompt + meta block, and action buttons. +import { ref } from 'vue' +import { copyText } from '../utils/clipboard' import Icon from './Icon.vue' -defineProps({ +const props = defineProps({ src: { type: String, required: true }, // resolved media URL (generatedUrl) kind: { type: String, default: 'image' }, // 'image' | 'video' prompt: { type: String, default: '' }, @@ -14,12 +16,25 @@ defineProps({ downloadName: { type: String, default: '' }, }) const emit = defineEmits(['close']) + +const toast = ref('') +let toastTimer = null +async function copyPrompt() { + if (!props.prompt) return + toast.value = (await copyText(props.prompt)) ? '指令已复制' : '复制失败' + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 1800) +} </script> <template> <transition name="lb-fade" appear> <div class="media-card fixed inset-0 z-50 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-6" @click.self="emit('close')"> + <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 ring-1 ring-white/10"> + {{ toast }} + </div> <!-- Wrapper shrinks to the media's rendered width, so the info row below lines up flush with the image's left & right edges (one clean column). --> <div class="flex flex-col max-h-full max-w-full"> @@ -29,7 +44,8 @@ const emit = defineEmits(['close']) <div class="mt-3 flex items-start justify-between gap-4 text-white"> <div class="min-w-0 flex-1"> - <div v-if="prompt" class="text-sm font-medium leading-snug line-clamp-3 break-words" :title="prompt">{{ prompt }}</div> + <div v-if="prompt" @click="copyPrompt" title="点击复制提示词" + class="text-sm font-medium leading-snug line-clamp-3 break-words cursor-pointer transition-colors hover:text-white/75">{{ prompt }}</div> <div v-if="meta" class="text-xs text-white/60 mt-1 font-mono break-all">{{ meta }}</div> <div v-if="metaSub" class="text-xs text-white/45 mt-1">{{ metaSub }}</div> </div> diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 26435d9..1000b19 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -29,7 +29,8 @@ const currentLabel = computed(() => route.meta?.label || '') <!-- ===== Sidebar ===== --> <aside class="w-60 shrink-0 border-r border-[color:var(--hairline)] bg-[var(--surface)] backdrop-blur-md flex flex-col"> <router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-[color:var(--hairline)] group"> - <Logo :size="32" class="rounded-[10px] shadow-lg shadow-violet-500/20 ring-1 ring-white/10" /> + <img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 rounded-[10px] object-contain shadow-lg shadow-violet-500/20 ring-1 ring-white/10" /> + <Logo v-else :size="32" class="rounded-[10px] shadow-lg shadow-violet-500/20 ring-1 ring-white/10" /> <div class="leading-tight min-w-0"> <div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div> <div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div> diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index 2062af8..e3f21ac 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -107,10 +107,11 @@ 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"> - <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"> + <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"> {{ site.title }} </span> - <span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span> + <span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? (site.subtitle || '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"> diff --git a/frontend/src/main.js b/frontend/src/main.js index 3cd6e29..fa3c8c4 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -34,7 +34,7 @@ const routes = [ children: [ { path: '', component: HomeView, meta: { label: '首页' } }, { path: 'user', component: PlaygroundView, meta: { label: '画图' } }, - { path: 'logs', component: UserLogsView, meta: { label: '记录' } }, + { path: 'logs', component: UserLogsView, meta: { label: '图片' } }, { path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } }, { path: 'invite', component: InviteView, meta: { label: '邀请' } }, { path: 'docs', component: DocsView, meta: { label: '文档' } }, diff --git a/frontend/src/site.js b/frontend/src/site.js index adc7fc2..cc86b00 100644 --- a/frontend/src/site.js +++ b/frontend/src/site.js @@ -8,6 +8,8 @@ const BASE = import.meta.env.VITE_API_BASE || '' export const site = reactive({ title: 'Vivid', + logo: '', + subtitle: '', // 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. @@ -28,6 +30,8 @@ export async function loadSite() { if (r.ok) { const data = await r.json() if (data.title) site.title = String(data.title) + site.logo = data.logo ? String(data.logo) : '' + site.subtitle = data.subtitle ? String(data.subtitle) : '' if (data.contact) site.contact = { ...site.contact, ...data.contact } } } catch { /* offline — keep the default. */ } diff --git a/frontend/src/style.css b/frontend/src/style.css index 42a57e5..b9f3fbe 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -101,6 +101,11 @@ html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset color: var(--fg); background: var(--hover); } html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) { background: rgb(15 23 42); color: #fff; box-shadow: none; } +/* ShowcaseView marks the selected filter/kind tab with an `.on` modifier on + `.filter-pill`/`.kind-btn` (not the `-on` class above). Without this, the + neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and + the highlight vanishes on a white page. Equal-or-higher specificity, later. */ +html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on { background: rgb(15 23 42); color: #fff; box-shadow: none; } /* Selected COLORED filter pills — their scoped (dark) selected colors get overridden by the neutral `.fp` rescue above, so re-state a light-mode variant (tinted bg + dark-enough text + colored ring) with equal specificity diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js new file mode 100644 index 0000000..5f44c86 --- /dev/null +++ b/frontend/src/utils/clipboard.js @@ -0,0 +1,29 @@ +// Robust clipboard copy. navigator.clipboard only exists in a SECURE context +// (https or localhost) — on http://<ip> it's undefined, so the modern path +// throws and we fall back to the legacy execCommand('copy') via a hidden +// textarea, which works without a secure context. Returns true on success. +export async function copyText(text) { + const s = text == null ? '' : String(text) + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(s) + return true + } + } catch { /* fall through to the legacy path */ } + try { + const ta = document.createElement('textarea') + ta.value = s + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.top = '-9999px' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.focus() + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch { + return false + } +} diff --git a/frontend/src/views/CdksView.vue b/frontend/src/views/CdksView.vue index 52971e1..802751d 100644 --- a/frontend/src/views/CdksView.vue +++ b/frontend/src/views/CdksView.vue @@ -182,7 +182,7 @@ const pageNumbers = computed(() => { <span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 个 — 请复制保存</span> <button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button> </div> - <div class="font-mono text-xs text-white/85 space-y-0.5 max-h-40 overflow-auto"> + <div class="font-mono text-xs text-white/85 space-y-0.5"> <div v-for="code in lastBatch" :key="code">{{ code }}</div> </div> </div> diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index a8c87ab..663c1d9 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -37,12 +37,14 @@ async function saveMedia() { } // ---- site (branding shown across the app) ---- -const siteForm = reactive({ title: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' }) +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() { const r = await api('/settings/site') if (r.ok && r.data) { siteForm.title = r.data.title || '' + siteForm.logo = r.data.logo || '' + siteForm.subtitle = r.data.subtitle || '' const c = r.data.contact || {} siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || '' siteForm.qq_group = c.qq_group || '' @@ -54,10 +56,14 @@ async function saveSite() { siteBusy.value = true; siteSaved.value = false 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 @@ -212,6 +218,14 @@ 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> + <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" /> + </label> <label class="row"> <span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span> <input v-model="siteForm.qq" placeholder="1114639355" class="txt" /> diff --git a/frontend/src/views/ImagesView.vue b/frontend/src/views/ImagesView.vue index dd3f5f9..2ba0ade 100644 --- a/frontend/src/views/ImagesView.vue +++ b/frontend/src/views/ImagesView.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { api, generatedUrl } from '../api' import { fmtTs, fmtSize } from '../utils/format' +import { copyText } from '../utils/clipboard' import Icon from '../components/Icon.vue' import MediaLightbox from '../components/MediaLightbox.vue' @@ -40,12 +41,12 @@ function absUrl(name) { } async function copyLink(name) { - try { - await navigator.clipboard.writeText(absUrl(name)) - flash('链接已复制') - } catch { - flash('复制失败') - } + flash(await copyText(absUrl(name)) ? '链接已复制' : '复制失败') +} + +async function copyPrompt(f) { + if (!f.prompt) return + flash(await copyText(f.prompt) ? '指令已复制' : '复制失败') } let toastTimer = null @@ -173,8 +174,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey)) <!-- caption: prompt (truncated 2 lines) + meta line --> <div class="absolute inset-x-0 bottom-0 p-3 pointer-events-none"> - <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" - :title="f.prompt || f.name"> + <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1 transition-colors" + :class="f.prompt ? 'pointer-events-auto cursor-pointer hover:text-white/75' : ''" + :title="f.prompt ? '点击复制提示词' : f.name" + @click.stop="copyPrompt(f)"> {{ f.prompt || f.name.split('/').pop() }} </div> <div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums"> diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue index 344aec4..e3767e6 100644 --- a/frontend/src/views/LogsView.vue +++ b/frontend/src/views/LogsView.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { api } from '../api' import { fmtTs, fmtDate, fmtClock } from '../utils/format' +import { copyText } from '../utils/clipboard' import { generatedUrl } from '../api' import Icon from '../components/Icon.vue' import MediaLightbox from '../components/MediaLightbox.vue' @@ -16,6 +17,14 @@ const search = ref('') const page = ref(1) const pageSize = ref(15) const total = ref(0) +const toast = ref('') +let toastTimer = null +async function copyPrompt(e) { + if (!e.prompt) return + toast.value = (await copyText(e.prompt)) ? '指令已复制' : '复制失败' + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 1800) +} async function load() { loading.value = true @@ -281,7 +290,10 @@ const sourcePill = (s) => ({ <!-- Prompt with error inline; the error reads as a follow-up rather than wasting a whole column when there's nothing to show. --> <td class="px-3 py-3.5 align-middle min-w-0"> - <div class="text-xs text-white/80 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div> + <div class="text-xs text-white/80 truncate transition-colors" + :class="e.prompt ? 'cursor-pointer hover:text-white' : ''" + :title="e.prompt ? '点击复制提示词' : ''" + @click="e.prompt && copyPrompt(e)">{{ e.prompt || '—' }}</div> <div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5" :title="e.error"> <Icon name="close" class="w-3 h-3 shrink-0" /> {{ e.error }} @@ -337,6 +349,11 @@ const sourcePill = (s) => ({ :meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')" :download-name="previewing.file" @close="closePreview" /> + + <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> </section> </template> diff --git a/frontend/src/views/OverviewView.vue b/frontend/src/views/OverviewView.vue index a731ada..2d45052 100644 --- a/frontend/src/views/OverviewView.vue +++ b/frontend/src/views/OverviewView.vue @@ -37,6 +37,8 @@ async function refreshAll() { const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 } const day = computed(() => dash.value?.day || EMPTY_WINDOW) const week = computed(() => dash.value?.week || EMPTY_WINDOW) +// All-time persistent counters (stat_counters) — independent of log retention. +const lifetime = computed(() => dash.value?.lifetime || {}) const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0)) // Direction vs the previous 24h (24–48h ago) — a quiet day after a busy week is @@ -54,7 +56,7 @@ const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null) // ---- range-toggled top-N analytics (both windows ship in the payload, so the // 24h/7d switch is instant — no re-fetch) ---- -const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h')) +const rangeLabel = computed(() => (range.value === 'week' ? '近 3 天' : '近 24h')) const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] }) const modelUsage = computed(() => analytics.value.models || []) const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count))) @@ -153,7 +155,7 @@ onUnmounted(() => clearInterval(timer)) </div> <!-- ===== KPI strip ===== --> - <div class="grid grid-cols-2 lg:grid-cols-4 gap-3"> + <div class="grid grid-cols-2 lg:grid-cols-5 gap-3"> <!-- 用户 --> <div class="card p-4"> <div class="flex items-center justify-between"> @@ -196,6 +198,24 @@ onUnmounted(() => clearInterval(timer)) </div> </div> + <!-- 累计生成(全部 · 持久计数,不随日志清理变化) --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">累计生成</span> + <span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20"> + <Icon name="overview" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(lifetime.total || 0) }}</div> + <div class="text-[11px] mt-1 flex flex-wrap gap-x-2"> + <span class="text-emerald-300 tabular-nums">{{ lifetime.success || 0 }} 成功</span> + <span v-if="lifetime.failed" class="text-rose-300 tabular-nums">{{ lifetime.failed }} 失败</span> + </div> + <div class="text-[10px] text-white/40 mt-1 tabular-nums"> + API {{ lifetime.api || 0 }} · 图 {{ lifetime.image || 0 }} · 视 {{ lifetime.video || 0 }} + </div> + </div> + <!-- 平均耗时 --> <div class="card p-4"> <div class="flex items-center justify-between"> @@ -247,7 +267,7 @@ onUnmounted(() => clearInterval(timer)) <div class="card p-4"> <div class="text-xs text-white/55">活跃用户 · 24h</div> <div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div> - <div class="text-[11px] text-white/45 mt-1">近 7 天累计生成 {{ fmtInt(week.total) }}</div> + <div class="text-[11px] text-white/45 mt-1">近 3 天累计生成 {{ fmtInt(week.total) }}</div> </div> </div> @@ -342,7 +362,7 @@ onUnmounted(() => clearInterval(timer)) </div> </div> - <div class="card"> + <div class="card flex flex-col"> <div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between"> <h2 class="text-sm font-semibold">24 小时生成趋势</h2> <div class="text-[11px] text-white/45 flex items-center gap-3"> @@ -351,8 +371,8 @@ onUnmounted(() => clearInterval(timer)) <span class="tabular-nums">峰值 {{ hourMax }}/h</span> </div> </div> - <div class="p-5"> - <div class="flex items-end gap-[3px] h-32"> + <div class="p-5 flex-1 flex flex-col"> + <div class="flex items-end gap-[3px] flex-1 min-h-[8rem]"> <div v-for="(b, i) in hourBuckets" :key="i" class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible" :style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }"> @@ -385,7 +405,7 @@ onUnmounted(() => clearInterval(timer)) <button @click="range = 'week'" class="px-3 py-1 rounded-md transition-colors" :class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'"> - 近 7d + 近 3d </button> </div> </div> @@ -472,7 +492,7 @@ onUnmounted(() => clearInterval(timer)) <div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div> </div> <div> - <div class="text-xs text-white/55">近 7 天</div> + <div class="text-xs text-white/55">近 3 天</div> <div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div> <div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div> </div> diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index 8645652..39f4233 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -1,13 +1,13 @@ <script setup> import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { useRoute } from 'vue-router' -import { api, jsonBody } from '../api' +import { api, jsonBody, generatedUrl } from '../api' import { auth, refreshMe } from '../auth' -import { draft, applyJobToDraft } from '../playground' +import { draft } from '../playground' import Icon from '../components/Icon.vue' import SelectMenu from '../components/SelectMenu.vue' import MediaLightbox from '../components/MediaLightbox.vue' -import { points, pointsLabel } from '../credits' +import { pointsLabel } from '../credits' import { sortResolutions } from '../utils/format' const route = useRoute() @@ -38,29 +38,28 @@ watch(duration, (v) => { draft.duration = v }) const refImages = ref([]) // [{ name, dataUrl }] const fileInput = ref(null) -const busy = ref(false) -// submitting = run() owns the busy/current state end-to-end while a /generate -// call is in flight. The 2s poll() must NOT touch busy or current during this -// window, or it races run() and the controls flicker unlocked mid-generation. -const submitting = ref(false) -// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy -// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL -// rendering server-side. Treat these as "still running", NOT a failure: keep the -// controls locked and let poll() follow the live job to completion (出图). +// Concurrent generation: each 生成 click fires an INDEPENDENT /generate and adds +// a card — the UI never locks, so several can run at once. `tasks` holds the +// in-session cards (newest first); `history` fills the grid up to 10 with the +// user's recent finished results from the server. +const tasks = ref([]) +const history = ref([]) +// Gateway-timeout statuses our BACKEND never emits — a CDN/proxy (e.g. EdgeOne +// 524) gave up waiting while the synchronous /generate is STILL rendering. The +// task stays "running" and loadHistory() claims it once the result lands. const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525]) const error = ref('') -const statusText = ref('') - -// Only ever show the latest generation on the right side. Each new run -// replaces it; the persistent history lives at /logs (UserLogsView). -// `current` is restored from the server on mount and refreshed via /jobs/mine -// polling, so a reload, a parallel tab, or a different browser sees the same -// in-flight job and the same final result without re-running anything. -const current = ref(null) const lightbox = ref(null) const toast = ref('') let pollTimer = null +const fileKey = (u) => (u || '').split('?')[0].split('/').pop() +const taskKey = (x) => [x.model, x.kind, (x.prompt || '').trim()].join('|') +// Up to 10 cards (一行五个 × 2): in-session tasks first, then the server's recent +// rows (进行中 + 成功) so the grid stays filled; loadHistory() prunes an optimistic +// task once the server tracks it → never a duplicate. 新的顶掉老的. +const displayItems = computed(() => [...tasks.value, ...history.value].slice(0, 10)) + // ---- derived ---- const models = computed(() => allModels.value.filter((m) => m.enabled !== false && m.type === mode.value), @@ -183,7 +182,13 @@ function openPicker() { fileInput.value && fileInput.value.click() } // of charging + failing upstream after the upload. const MAX_REF_BYTES = 8 * 1024 * 1024 function onFiles(ev) { - const files = Array.from(ev.target.files || []) + addFiles(Array.from(ev.target.files || [])) + if (ev.target) ev.target.value = '' +} +// Shared by the file picker AND drag-and-drop. Filters to images, honors the +// per-model max + 8MB cap, reads each to a data URL. +function addFiles(files) { + files = files.filter((f) => f && f.type && f.type.startsWith('image/')) const room = Math.max(0, maxRefs.value - refImages.value.length) const tooBig = [] let added = 0 @@ -198,7 +203,23 @@ function onFiles(ev) { error.value = tooBig.length ? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)` : '' - if (ev.target) ev.target.value = '' +} +// Drag-and-drop onto the reference area. +const dragOver = ref(false) +function onDrop(ev) { + ev.preventDefault() + dragOver.value = false + if (maxRefs.value <= 0) return + addFiles(Array.from(ev.dataTransfer?.files || [])) +} +function onDragOver(ev) { + ev.preventDefault() + if (maxRefs.value > 0) dragOver.value = true +} +function onDragLeave(ev) { + // ignore leave events bubbling from children + if (ev.currentTarget.contains(ev.relatedTarget)) return + dragOver.value = false } function removeRef(i) { refImages.value.splice(i, 1) } @@ -241,17 +262,11 @@ function flash(msg) { toastTimer = setTimeout(() => (toast.value = ''), 1800) } -async function copyLink(url) { - try { - const abs = url.startsWith('http') ? url : location.origin + url - await navigator.clipboard.writeText(abs) - flash('链接已复制') - } catch { - flash('复制失败') - } -} +// ---- generate (concurrent — no lock) ---- +// 生图 can request 1–4 images at once: each is an independent task/charge. +const count = ref(1) +const batchCount = computed(() => (mode.value === 'image' ? Math.max(1, Math.min(4, count.value)) : 1)) -// ---- generate ---- async function run() { if (!modelId.value) { error.value = '请选择模型'; return } if (!prompt.value.trim()) { error.value = '请输入提示词'; return } @@ -263,11 +278,22 @@ async function run() { error.value = '该参数组合未定价 (留空 = 不支持)' return } - if (!canAfford.value) { - error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}` + const n = batchCount.value + if (price.value != null && credits.value < price.value * n) { + error.value = `积分不足 — 需要 ${pointsLabel(price.value * n)},余额 ${pointsLabel(credits.value)}` return } - const job = { + error.value = '' + // A new generation clears any lingering real-time error cards. + tasks.value = tasks.value.filter((t) => t.status !== 'failed') + // Fire N independent tasks (no await between them → all run concurrently). + for (let i = 0; i < n; i++) fireOne() +} + +async function fireOne() { + // Snapshot the form NOW — concurrent tasks keep their own params even if the + // user edits the form (or fires another batch) while this one runs. + const task = { id: Math.random().toString(36).slice(2, 10), model: modelId.value, kind: mode.value, @@ -275,7 +301,6 @@ async function run() { ratio: ratio.value, resolution: resolution.value, duration: mode.value === 'video' ? duration.value : '', - refs: refImages.value.length, status: 'pending', url: '', error: '', @@ -283,118 +308,144 @@ async function run() { charged: price.value, ts: Date.now(), } - current.value = job + const refsSnapshot = refImages.value.slice() + const chargedPrice = price.value + tasks.value.unshift(task) + if (tasks.value.length > 10) tasks.value = tasks.value.slice(0, 10) - busy.value = true - submitting.value = true - error.value = '' - statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' + // Optimistically deduct the price (server debits before generating; a failure + // refunds + refreshMe reconciles). + if (auth.user && chargedPrice != null) { + auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - chargedPrice) + } + + const payload = { + model: task.model, prompt: task.prompt, ratio: task.ratio, resolution: task.resolution, + } + if (task.kind === 'video') payload.duration = task.duration + if (refsSnapshot.length) { + const refs = await Promise.all(refsSnapshot.map(refToBase64)) + payload.reference_images = refs.filter(Boolean) + } try { - // Optimistically deduct the price from the displayed balance right away. The - // server debits BEFORE generating (which can take minutes for video), so - // otherwise 余额 looks unchanged the whole time. The success response carries - // the authoritative balance (reconciled below); a failure refunds + refreshMe. - if (auth.user && price.value != null) { - auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - price.value) - } - - const payload = { - model: modelId.value, - prompt: prompt.value, - ratio: ratio.value, - resolution: resolution.value, - } - if (mode.value === 'video') payload.duration = duration.value - if (refImages.value.length) { - // Backend accepts raw base64 only — convert each ref (uploaded dataUrl or - // restored /images URL) to base64 at submit time. - const refs = await Promise.all(refImages.value.map(refToBase64)) - payload.reference_images = refs.filter(Boolean) - } - - // Single charged call: the server debits the price atomically BEFORE - // generating and refunds on failure, so the client can't skip the charge. const r = await api('/generate', jsonBody('POST', payload)) - if (r.ok && r.data?.url) { - job.status = 'done' - job.url = r.data.url - job.elapsed_ms = r.data.elapsed_ms - job.charged = r.data.charged ?? price.value + task.status = 'done' + task.url = r.data.url + task.elapsed_ms = r.data.elapsed_ms + task.charged = r.data.charged ?? chargedPrice if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits - statusText.value = `完成 · 扣费 ${pointsLabel(job.charged)} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s · 余额 ${pointsLabel(credits.value)}` - busy.value = false // 出图 → 解锁 } else if (GATEWAY_TIMEOUT.has(r.status)) { - // CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁: - // 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")。 - statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' + // CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持 running, + // loadHistory() 在结果落库后认领它。 + task.status = 'running' } else { - // 真失败:服务端已退款,resync 余额并解锁。 await refreshMe() - job.status = 'failed' - job.error = r.data?.detail || `失败 (${r.status})` - statusText.value = '' - busy.value = false // 真失败 → 解锁 + task.status = 'failed' + task.error = r.data?.detail || `失败 (${r.status})` } - } finally { - // Hand control back to poll(); busy is left as set above (locked when the - // job is still rendering after a gateway timeout). - submitting.value = false + } catch (e) { + await refreshMe() + task.status = 'failed' + task.error = String(e) } - // Sync real server state: poll() picks up the live pending job (replacing our - // optimistic one with the real id) and will unlock + show the result the - // moment it finishes — so a 524 mid-flight never leaves the UI unlocked. - poll() + loadHistory() } -// Recover the current generation from the server: any pending job for this -// user lives in event_log, so reload / parallel tab / parallel browser can -// all see the same in-flight state and the same final result. -async function poll() { - // While run() is mid-submit it fully owns busy/current — don't race it. - if (submitting.value) return - const r = await api('/jobs/mine') +// Fill the grid up to 10 with the user's recent rows (进行中 + 成功). Past +// FAILURES are never shown from history — an error is only relevant for the live +// generation the user just ran. Prune optimistic tasks the server now tracks. +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') if (!r.ok) return - const { pending, latest } = r.data || {} - if (pending) { - busy.value = true - if (!statusText.value) { - statusText.value = pending.kind === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' - } - if (!current.value || current.value.id !== pending.id) { - current.value = { ...pending } - // Replay the pending job's params onto the form so a fresh tab shows - // what's cooking — and writes them into the cross-component draft. - applyJobToDraft(pending) - mode.value = pending.kind === 'video' ? 'video' : 'image' - modelId.value = pending.model || modelId.value - prompt.value = pending.prompt || prompt.value - ratio.value = pending.ratio || ratio.value - resolution.value = pending.resolution || resolution.value - duration.value = pending.duration || duration.value - // Re-display the uploaded reference image(s) after a reload. They're - // served (cookie-authed) from /images; re-fetch into data URLs so the - // thumbnails show AND the refs stay re-submittable if the user regenerates. - restoreRefs(pending.reference_urls) - } - return + history.value = (r.data?.data || []) + .filter((e) => e.status === 'pending' || e.file) + .map((e) => ({ + id: 'srv-' + e.id, + prompt: e.prompt, model: e.model, kind: e.kind, + ratio: e.ratio, resolution: e.resolution, duration: e.duration, + status: e.status === 'success' ? 'done' : 'running', + url: e.file ? generatedUrl(e.file) : '', + error: '', + elapsed_ms: e.elapsed_ms, + })) + // Hand each optimistic task over to the server once it's tracked there: drop a + // pending task when a matching server pending row exists, a done task once its + // file is in the server's rows. A FAILED task is a live error — keep it. + const serverPending = new Set(history.value.filter((h) => h.status === 'running').map(taskKey)) + const serverFiles = new Set(history.value.filter((h) => h.url).map((h) => fileKey(h.url))) + tasks.value = tasks.value.filter((t) => { + if (t.status === 'failed') return true + if (t.status === 'done') return !serverFiles.has(fileKey(t.url)) + return !serverPending.has(taskKey(t)) + }) + if (serverPending.size < prevPending) refreshMe() + prevPending = serverPending.size +} + +// 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) { + if (!item || !item.url || item.status !== 'done') return + if (item.kind === 'video') return + const cap = maxRefs.value + if (cap <= 0) { flash('当前模型不支持参考图'); return } + const ref = { name: 'ref', url: item.url } + if (cap === 1) { + refImages.value = [ref] + } else if (refImages.value.length >= cap) { + refImages.value.splice(cap - 1, 1, ref) + } else { + refImages.value.push(ref) } - // No pending. If our locally-shown job just finished on the server (same id, - // status flipped to success/failed), promote it to the result view — this is - // the live "I'm watching my own generation finish" case and stays. - if (current.value && current.value.status === 'pending' && latest && latest.id === current.value.id) { - current.value = { ...latest, status: latest.status === 'success' ? 'done' : latest.status } - if (latest.url) current.value.url = latest.url - busy.value = false - statusText.value = '' - refreshMe() - return + flash('已加入参考图') +} + +// Grab the LAST frame of a video as a PNG data URL (same-origin → canvas isn't +// tainted). Used to continue a video from where it ended (首尾帧 models). +function lastFrameDataUrl(url) { + return new Promise((resolve) => { + const v = document.createElement('video') + v.crossOrigin = 'anonymous' + v.muted = true + v.preload = 'auto' + v.src = url + const grab = () => { + try { + const c = document.createElement('canvas') + c.width = v.videoWidth; c.height = v.videoHeight + c.getContext('2d').drawImage(v, 0, 0) + resolve(c.toDataURL('image/png')) + } catch { resolve('') } + } + v.addEventListener('loadeddata', () => { + const t = Math.max(0, (v.duration || 0) - 0.05) + if (isFinite(t) && t > 0) v.currentTime = t + else grab() + }) + v.addEventListener('seeked', grab) + v.addEventListener('error', () => resolve('')) + }) +} + +// Click a generated VIDEO. For a 首尾帧 (frame) model, set the video's LAST frame +// as the 首帧 (first reference) — to continue the scene. Otherwise just zoom. +async function onVideoClick(item) { + if (refMode.value === 'frame' && maxRefs.value > 0 && item.url) { + const dataUrl = await lastFrameDataUrl(item.url) + if (dataUrl) { + const ref = { name: 'frame', dataUrl } + if (refImages.value.length === 0) refImages.value = [ref] + else refImages.value.splice(0, 1, ref) // replace the 首帧 slot + flash('已把视频末帧设为首帧') + return + } } - // Intentionally NO restore of an already-finished result on first paint / - // navigation: the playground only ever shows an in-progress job (or the one - // that just completed while watched). Past results live in /记录 (logs), not - // re-echoed onto a freshly opened workspace. + lightbox.value = item } function onKey(e) { if (e.key === 'Escape') lightbox.value = null } @@ -434,10 +485,10 @@ onMounted(async () => { applyModelDefaults() } window.addEventListener('keydown', onKey) - // Restore any in-flight or recently-finished job for this user, then poll - // every 2s so a parallel tab / device sees changes within one tick. - poll() - pollTimer = setInterval(poll, 2000) + // Fill the grid with the user's recent results, then refresh every 3s so + // finished tasks (incl. gateway-timed-out ones) land without a reload. + loadHistory() + pollTimer = setInterval(loadHistory, 3000) }) onUnmounted(() => { window.removeEventListener('keydown', onKey) @@ -447,18 +498,17 @@ onUnmounted(() => { <template> <section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6"> - <!-- LEFT: controls — every interactive element accepts :disabled="busy" - so the form locks the moment a generation kicks off. Reload, parallel - tab and tab-switch all see the same locked state via poll(). --> + <!-- LEFT: controls — never locked. 生成 fires an independent task each click, + so several generations can run at once (concurrent). --> <div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start"> <!-- mode switch --> <div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl"> - <button @click="setMode('image')" type="button" :disabled="busy" + <button @click="setMode('image')" type="button" class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed" :class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"> <Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图 </button> - <button @click="setMode('video')" type="button" :disabled="busy" + <button @click="setMode('video')" type="button" class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed" :class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"> <Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频 @@ -469,7 +519,7 @@ onUnmounted(() => { <div> <label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label> <SelectMenu v-if="models.length" :model-value="modelId" @update:model-value="selectModel" - :options="modelOptions" placeholder="选择模型" mono :disabled="busy" /> + :options="modelOptions" placeholder="选择模型" mono /> <div v-else class="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-xs text-slate-400 text-center"> 还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 · <router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link> @@ -479,7 +529,7 @@ onUnmounted(() => { <!-- prompt --> <div> <label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label> - <textarea v-model="prompt" rows="4" :disabled="busy" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed" + <textarea v-model="prompt" rows="4" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed" placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea> </div> @@ -489,7 +539,7 @@ onUnmounted(() => { <div v-if="ratios.length > 0 && showRatio"> <label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label> <div class="flex flex-wrap gap-1.5"> - <button v-for="r in ratios" :key="r" type="button" @click="ratio = r" :disabled="busy" + <button v-for="r in ratios" :key="r" type="button" @click="ratio = r" class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" :class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> {{ r }} @@ -500,7 +550,7 @@ onUnmounted(() => { <div v-if="resolutions.length > 0"> <label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label> <div class="flex flex-wrap gap-1.5"> - <button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" :disabled="busy" + <button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" :class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> {{ r }} @@ -511,7 +561,7 @@ onUnmounted(() => { <div v-if="mode === 'video' && durations.length > 0"> <label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label> <div class="flex flex-wrap gap-1.5"> - <button v-for="d in durations" :key="d" type="button" @click="duration = d" :disabled="busy" + <button v-for="d in durations" :key="d" type="button" @click="duration = d" class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" :class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> {{ d }} @@ -528,12 +578,13 @@ onUnmounted(() => { </span> <span v-if="refsRequired" class="text-rose-500">*</span> </label> - <div class="flex gap-2 flex-wrap items-start"> + <div class="flex gap-2 flex-wrap items-start rounded-lg transition-colors" + :class="dragOver ? 'ring-2 ring-indigo-400 ring-offset-2 bg-indigo-50/40' : ''" + @drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave"> <div v-for="(img, i) in refImages" :key="i" - class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all" - :class="busy ? 'opacity-60 grayscale pointer-events-none' : ''"> + class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all"> <img :src="img.dataUrl || img.url" class="w-full h-full object-cover" /> - <button type="button" @click="removeRef(i)" :disabled="busy" + <button type="button" @click="removeRef(i)" class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"> <Icon name="close" class="w-3 h-3" /> </button> @@ -542,20 +593,33 @@ onUnmounted(() => { {{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }} </div> </div> - <button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy" - class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"> - <Icon name="plus" class="w-5 h-5" /> + <button v-if="refImages.length < maxRefs" type="button" @click="openPicker" + class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed" + :title="dragOver ? '松开以添加' : '点击或拖拽图片到此'"> + <Icon :name="dragOver ? 'download' : 'plus'" class="w-5 h-5" /> </button> </div> <input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" /> </div> - <button @click="run" :disabled="busy || !models.length || price == null || !canAfford" + <!-- 生图张数 1–4 (image only) — each is a separate concurrent generation. --> + <div v-if="mode === 'image'"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">张数</label> + <div class="flex gap-1.5"> + <button v-for="n in [1, 2, 3, 4]" :key="n" type="button" @click="count = n" + class="flex-1 rounded-lg py-1.5 text-xs font-medium transition-colors" + :class="count === n ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> + {{ n }} + </button> + </div> + </div> + + <button @click="run" :disabled="!models.length || price == null || !canAfford" class="btn-primary w-full !py-3 flex items-center justify-center gap-2 leading-none"> <Icon name="spark" class="w-4 h-4 shrink-0" /> - <span class="leading-none">{{ busy ? (mode === 'video' ? '生成中…请耐心等待' : '生成中…') : '生成' }}</span> - <span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</span> - <span v-if="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span> + <span class="leading-none">生成<span v-if="batchCount > 1"> {{ batchCount }} 张</span></span> + <span v-if="price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ batchCount > 1 ? pointsLabel(price * batchCount) : priceLabel }}</span> + <span v-if="price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span> </button> <!-- Validation / upload errors (model/prompt/ref/price/credits/oversized @@ -565,60 +629,56 @@ onUnmounted(() => { </div> - <!-- RIGHT: single latest result (replaces on each new generation). - min-w-0: the 1fr grid track defaults to min-width:auto, so a long - unbroken prompt would otherwise blow the column wider than the page - (truncate can't shrink a track that won't shrink). --> - <div class="space-y-4 min-w-0"> - <div v-if="!current && !busy" - class="card p-14 grid place-items-center text-slate-400 text-center"> - <span class="w-16 h-16 rounded-2xl bg-slate-100 grid place-items-center mb-4"> - <Icon name="spark" class="w-7 h-7 text-slate-400" /> - </span> - <p class="text-sm">还没有生成过 — 在左侧写提示词,点击「生成」</p> - <router-link to="/logs" class="text-xs text-slate-500 hover:text-white mt-3 transition-colors">查看历史记录 →</router-link> - </div> - - <div v-else-if="current" class="card overflow-hidden"> - <div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between gap-3"> - <div class="min-w-0"> - <div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div> - <div class="text-[11px] text-slate-400 mt-0.5 font-mono"> - {{ current.model }} · {{ current.ratio }} · {{ current.resolution }} - <span v-if="current.kind === 'video'"> · {{ current.duration }}</span> - <span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span> + <!-- RIGHT: concurrent gallery — one card per task, newest first; filled up to + 10 with the user's recent results. No lock: 生成 can be clicked anytime. + min-w-0 keeps a long prompt from blowing the 1fr track wider than the page. --> + <div class="min-w-0"> + <div v-if="displayItems.length" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> + <div v-for="item in displayItems" :key="item.id" + class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]"> + <!-- done: media + caption --> + <template v-if="item.status === 'done' && item.url"> + <video v-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata" + @click="onVideoClick(item)" + :title="refMode === 'frame' && maxRefs > 0 ? '点击:把末帧设为首帧' : '点击放大'" + class="absolute inset-0 w-full h-full object-cover cursor-pointer" + @mouseenter="$event.target.play && $event.target.play()" + @mouseleave="$event.target.pause && $event.target.pause()" /> + <img v-else :src="item.url" loading="lazy" @click="useAsRef(item)" + :title="maxRefs > 0 ? '点击作为参考图' : ''" + class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" + :class="maxRefs > 0 ? 'cursor-pointer' : 'cursor-default'" /> + <div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div> + <!-- hover action: just zoom (clicking the image itself = 参考图) --> + <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"> + <button @click.stop="lightbox = item" title="放大" + 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="open" 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="text-[11px] leading-tight text-white font-medium line-clamp-2" :title="item.prompt">{{ item.prompt }}</div> + <div class="text-[9px] text-white/55 mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div> + </div> + </template> + <!-- pending / running --> + <div v-else-if="item.status === 'pending' || item.status === 'running'" + class="absolute inset-0 grid place-items-center text-slate-400 text-xs px-3 text-center"> + <div class="flex flex-col items-center gap-2"> + <span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"><Icon name="spark" class="w-4 h-4" /></span> + {{ item.kind === 'video' ? '生成视频中…' : '生成中…' }} + <span class="text-[10px] text-slate-400/80 line-clamp-1 max-w-full">{{ item.prompt }}</span> </div> </div> - <!-- only when a finished result exists — hidden while pending/failed --> - <div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'" - class="flex items-center gap-1.5 shrink-0"> - <a :href="current.url" :download="''" class="btn-soft" title="下载"> - <Icon name="download" class="w-3.5 h-3.5" /> - </a> - <button @click="copyLink(current.url)" class="btn-soft" title="复制链接"> - <Icon name="copy" class="w-3.5 h-3.5" /> - </button> + <!-- failed --> + <div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-3 text-center"> + <div> + <Icon name="close" class="w-6 h-6 mx-auto mb-1 opacity-60" /> + <div>生成失败</div> + <div v-if="item.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ item.error }}</div> + </div> </div> </div> - - <div class="bg-slate-50 grid place-items-center min-h-[260px]"> - <div v-if="current.status === 'pending'" class="text-sm text-slate-400 py-12 flex flex-col items-center gap-2"> - <span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"> - <Icon name="spark" class="w-4 h-4 text-slate-400" /> - </span> - {{ statusText || '生成中…' }} - </div> - <div v-else-if="current.status === 'failed'" class="text-sm text-rose-600 py-12 px-5 max-w-xl text-center"> - <div class="font-medium mb-1">生成失败</div> - <div class="text-xs text-rose-500 break-all">{{ current.error }}</div> - </div> - <template v-else> - <video v-if="current.kind === 'video'" :src="current.url" controls - class="max-w-full max-h-[600px] object-contain" /> - <img v-else :src="current.url" @click="lightbox = current" - class="max-w-full max-h-[600px] object-contain cursor-zoom-in" /> - </template> - </div> </div> </div> diff --git a/frontend/src/views/ShowcaseView.vue b/frontend/src/views/ShowcaseView.vue index b3d6b8c..7212398 100644 --- a/frontend/src/views/ShowcaseView.vue +++ b/frontend/src/views/ShowcaseView.vue @@ -174,8 +174,8 @@ onMounted(refresh) </div> <!-- grid --> - <div v-if="loading" class="text-center text-xs text-white/40 py-12">加载中…</div> - <div v-else-if="!filtered.length" class="text-center text-xs text-white/40 py-12">没有条目</div> + <div v-if="loading" class="text-center text-xs text-[color:var(--fg-faint)] py-12">加载中…</div> + <div v-else-if="!filtered.length" class="text-center text-xs text-[color:var(--fg-faint)] py-12">没有条目</div> <div v-else class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> <div v-for="rec in pagedItems" :key="rec.id" class="media-card relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[4/3] group bg-white/[0.04]" @@ -211,13 +211,13 @@ onMounted(refresh) <!-- pagination — shown when there's more than one page worth of entries --> <div v-if="!loading && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3"> - <div class="text-xs text-white/55 tabular-nums px-2"> - <span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span> + <div class="text-xs text-[color:var(--fg-3)] tabular-nums px-2"> + <span class="text-[color:var(--fg)]">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span> / {{ filtered.length }} 条 </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/35">…</span> + <span v-if="n === null" class="px-1 text-[color:var(--fg-faint)]">…</span> <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> </template> </div> @@ -226,20 +226,20 @@ onMounted(refresh) <!-- ======= form modal ======= --> <transition name="fade"> <div v-if="editing" - class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm grid place-items-center p-4" + class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="closeForm"> - <div class="card w-full max-w-2xl !shadow-2xl"> - <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <div class="card w-full max-w-2xl !shadow-2xl my-auto"> + <div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between"> <h2 class="text-sm font-semibold"> {{ form.id ? '编辑' : '新增' }} · {{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }} </h2> - <button @click="closeForm" class="text-white/40 hover:text-white"> + <button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]"> <Icon name="close" class="w-4 h-4" /> </button> </div> - <div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto"> + <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)"> @@ -256,7 +256,7 @@ onMounted(refresh) <div class="grid sm:grid-cols-2 gap-3"> <div> - <label class="block text-xs text-white/55 mb-1.5">类型</label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label> <div class="flex gap-1.5"> <button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button> <button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button> @@ -264,62 +264,62 @@ onMounted(refresh) </div> </div> <div> - <label class="block text-xs text-white/55 mb-1.5">权重 <span class="text-white/35">(越大越靠前)</span></label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">权重 <span class="text-[color:var(--fg-faint)]">(越大越靠前)</span></label> <input v-model.number="form.weight" type="number" class="field" /> </div> </div> <!-- image picker (the central change — admins pick a real image) --> <div> - <label class="block text-xs text-white/55 mb-1.5">底图</label> + <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-white/35 mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL。</p> + <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> - <label class="block text-xs text-white/55 mb-1.5">标题</label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">标题</label> <input v-model="form.title" class="field" placeholder="电影感人物" /> </div> <div> - <label class="block text-xs text-white/55 mb-1.5">副标题</label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">副标题</label> <input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" /> </div> </div> <div> - <label class="block text-xs text-white/55 mb-1.5">提示词 <span class="text-white/35">(点 Bento 后会预填到画图)</span></label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点 Bento 后会预填到画图)</span></label> <textarea v-model="form.prompt" rows="3" class="field resize-none" placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea> </div> </template> <template v-else> <div> - <label class="block text-xs text-white/55 mb-1.5">作品标题 <span class="text-white/35">(可选)</span></label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">作品标题 <span class="text-[color:var(--fg-faint)]">(可选)</span></label> <input v-model="form.title" class="field" placeholder="留空则只展示图片" /> </div> </template> <div v-if="form.kind === 'bento'"> - <label class="block text-xs text-white/55 mb-1.5">网格跨度 <span class="text-white/35">(Tailwind class)</span></label> + <label class="block text-xs text-[color:var(--fg-3)] mb-1.5">网格跨度 <span class="text-[color:var(--fg-faint)]">(Tailwind class)</span></label> <div class="flex gap-1.5 flex-wrap mb-2"> <button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s" - class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-white/10 hover:bg-white/[0.08]" - :class="form.span === s ? 'bg-white text-slate-900' : 'bg-white/[0.04] text-white/70'"> + class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-[color:var(--hairline)] hover:bg-[color:var(--hover)]" + :class="form.span === s ? 'bg-[color:var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)]' : 'bg-[color:var(--surface-2)] text-[color:var(--fg-2)]'"> {{ s || '默认 1×1' }} </button> </div> <input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" /> </div> - <p v-if="error" class="text-xs text-rose-300">{{ error }}</p> + <p v-if="error" class="text-xs text-rose-500">{{ error }}</p> </div> - <div class="px-5 py-3 border-t border-white/[0.06] flex items-center justify-end gap-2"> + <div class="px-5 py-3 border-t border-[color:var(--hairline)] flex items-center justify-end gap-2"> <button @click="closeForm" class="btn-ghost">取消</button> <button @click="save" :disabled="saving" class="btn-primary"> {{ saving ? '保存中…' : '保存' }} @@ -335,14 +335,14 @@ onMounted(refresh) class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4" @click.self="closePicker"> <div class="card w-full max-w-4xl !shadow-2xl"> - <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between"> <h2 class="text-sm font-semibold">选择底图 · 最近生成</h2> - <button @click="closePicker" class="text-white/40 hover:text-white"> + <button @click="closePicker" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]"> <Icon name="close" class="w-4 h-4" /> </button> </div> <div class="p-4 max-h-[70vh] overflow-y-auto"> - <div v-if="!recentFiles.length" class="text-center text-xs text-white/40 py-10">尚未有生成过的图片</div> + <div v-if="!recentFiles.length" class="text-center text-xs text-[color:var(--fg-faint)] py-10">尚未有生成过的图片</div> <div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"> <button v-for="f in recentFiles" :key="f.name" type="button" @click="pickImage(f)" class="relative aspect-square rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all"> @@ -357,41 +357,35 @@ onMounted(refresh) </template> <style scoped> +/* All colors come from the theme vars (:root light / html.dark dark) so the view + adapts to BOTH themes. Selected states use --btn-solid-* which inverts per + theme (light: dark bg/white text · dark: white bg/dark text). */ .filter-pill { padding: 0.375rem 0.75rem; font-size: 0.75rem; border-radius: 0.5rem; - background: rgb(255 255 255 / 0.06); - color: rgb(255 255 255 / 0.65); + background: var(--surface-2); + color: var(--fg-2); transition: background 0.15s, color 0.15s; } -.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; } -.filter-pill.on { background: white; color: rgb(15 23 42); } +.filter-pill:hover { background: var(--hover); color: var(--fg); } +.filter-pill.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); } .kind-btn { flex: 1; padding: 0.5rem 0; border-radius: 0.5rem; font-size: 0.75rem; - background: rgb(255 255 255 / 0.06); - color: rgb(255 255 255 / 0.7); + background: var(--surface-2); + color: var(--fg-2); transition: background 0.15s, color 0.15s; } -.kind-btn:hover { background: rgb(255 255 255 / 0.1); } -.kind-btn.on { background: white; color: rgb(15 23 42); } +.kind-btn:hover { background: var(--hover); } +.kind-btn.on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); } -.field { - width: 100%; - padding: 0.5rem 0.7rem; - border-radius: 0.6rem; - font-size: 0.85rem; - outline: none; - background: rgb(255 255 255 / 0.04); - border: 1px solid rgb(255 255 255 / 0.1); - color: white; - transition: border-color 0.18s, background 0.18s; -} -.field:focus { border-color: rgb(167 139 250 / 0.65); background: rgb(255 255 255 / 0.06); } +/* No scoped .field — use the GLOBAL .field (bg-white + border-slate-200 in light, + .public-dark .field in dark) so inputs match every other modal and the border + is clearly visible. A scoped override here only re-broke the border. */ .fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } @@ -403,15 +397,15 @@ onMounted(refresh) 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); + color: var(--fg-2); + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s; } -.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); } .pg-on { - background: rgb(255 255 255 / 0.92); - color: rgb(15 23 42); + background: var(--btn-solid-bg); + color: var(--btn-solid-fg); box-shadow: none; } </style> diff --git a/frontend/src/views/UserLogsTableView.vue b/frontend/src/views/UserLogsTableView.vue index 4bf8adf..afb678a 100644 --- a/frontend/src/views/UserLogsTableView.vue +++ b/frontend/src/views/UserLogsTableView.vue @@ -7,6 +7,7 @@ import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { api, generatedUrl } from '../api' import { fmtDate, fmtClock, fmtTs } from '../utils/format' +import { copyText } from '../utils/clipboard' import { points } from '../credits' import Icon from '../components/Icon.vue' import MediaLightbox from '../components/MediaLightbox.vue' @@ -23,6 +24,15 @@ const page = ref(1) const pageSize = 20 const lightbox = ref(null) +const toast = ref('') +let toastTimer = null +async function copyPrompt(e) { + if (!e.prompt) return + toast.value = (await copyText(e.prompt)) ? '指令已复制' : '复制失败' + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 1800) +} + // 来源筛选走服务端:画图台 = source "user",API = source "v1"。 const SOURCE_PARAM = { web: 'user', api: 'v1' } @@ -230,7 +240,10 @@ const params = (e) => { </div> </td> <td class="px-3 py-3 align-middle min-w-0"> - <div class="text-xs text-slate-700 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div> + <div class="text-xs text-slate-700 truncate transition-colors" + :class="e.prompt ? 'cursor-pointer hover:text-slate-900' : ''" + :title="e.prompt ? '点击复制提示词' : ''" + @click="e.prompt && copyPrompt(e)">{{ e.prompt || '—' }}</div> <div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error">⚠ {{ e.error }}</div> </td> <td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</td> @@ -265,6 +278,11 @@ const params = (e) => { :meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')" :download-name="lightbox.file" @close="lightbox = null" /> + + <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> </section> </template> diff --git a/frontend/src/views/UserLogsView.vue b/frontend/src/views/UserLogsView.vue index 0eb7569..f78949d 100644 --- a/frontend/src/views/UserLogsView.vue +++ b/frontend/src/views/UserLogsView.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { api, generatedUrl } from '../api' import { fmtTs } from '../utils/format' +import { copyText } from '../utils/clipboard' import Icon from '../components/Icon.vue' import MediaLightbox from '../components/MediaLightbox.vue' @@ -80,12 +81,17 @@ function fmtMs(ms) { async function copyLink(name) { - try { - const u = generatedUrl(name) - await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u) - toast.value = '链接已复制' - setTimeout(() => (toast.value = ''), 1500) - } catch {} + const u = generatedUrl(name) + const ok = await copyText(u.startsWith('http') ? u : location.origin + u) + toast.value = ok ? '链接已复制' : '复制失败' + setTimeout(() => (toast.value = ''), 1500) +} + +async function copyPrompt(e) { + if (!e.prompt) return + const ok = await copyText(e.prompt) + toast.value = ok ? '指令已复制' : '复制失败' + setTimeout(() => (toast.value = ''), 1500) } const toast = ref('') @@ -198,7 +204,10 @@ onUnmounted(() => { <!-- caption (over a real image) --> <div v-if="e.status === 'success' && e.file" class="absolute inset-x-0 bottom-0 p-3 pointer-events-none"> - <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" :title="e.prompt">{{ e.prompt }}</div> + <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1 transition-colors" + :class="e.prompt ? 'pointer-events-auto cursor-pointer hover:text-white/75' : ''" + :title="e.prompt ? '点击复制提示词' : ''" + @click.stop="copyPrompt(e)">{{ e.prompt }}</div> <div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums"> <span class="truncate" :title="e.model || ''">{{ e.model || '—' }}</span> <span class="shrink-0 flex items-center gap-1"> diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue index f64fd69..4f3b38f 100644 --- a/frontend/src/views/UsersView.vue +++ b/frontend/src/views/UsersView.vue @@ -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 }) +const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '' }) const STATUS_OPTIONS = [ { value: 'active', label: '正常' }, @@ -100,7 +100,7 @@ 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 } + addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '' } flash('用户已创建') load() } else flash(r.data?.detail || '创建失败') @@ -115,6 +115,7 @@ async function saveEdit() { status: u.status, credits: u.credits, role: u.role, + notes: u.notes || '', } if (u._newPassword) patch.password = u._newPassword const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch)) @@ -244,6 +245,7 @@ async function quickCredits(u, delta) { <col class="w-9" /> <!-- select --> <col class="w-40" /> <!-- username --> <col /> <!-- email (flex) --> + <col class="w-36" /> <!-- notes --> <col class="w-20" /> <!-- role --> <col class="w-16" /> <!-- status switch --> <col class="w-24" /> <!-- credits --> @@ -261,6 +263,7 @@ async function quickCredits(u, delta) { </th> <th class="text-left px-5 py-3 font-medium">用户名</th> <th class="text-left px-3 py-3 font-medium">邮箱</th> + <th class="text-left px-3 py-3 font-medium">备注</th> <th class="text-left px-3 py-3 font-medium">角色</th> <th class="text-left px-3 py-3 font-medium">状态</th> <th class="text-right px-3 py-3 font-medium">积分</th> @@ -284,6 +287,9 @@ async function quickCredits(u, delta) { <td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email"> {{ u.email || '—' }} </td> + <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"> <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' @@ -390,6 +396,10 @@ async function quickCredits(u, delta) { <label class="lbl">角色</label> <SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" /> </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> + </div> <div class="flex justify-end gap-2 pt-2"> <button @click="showAdd = false" class="btn-soft">取消</button> <button @click="createUser" class="btn-primary">创建</button> @@ -434,6 +444,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> + <textarea v-model="editing.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea> + </div> <div> <label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label> <input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" />